Compare commits

..

1 Commits

Author SHA1 Message Date
Vinícius Lourenço
7d21577e4f feat(global-time-store): add support to context, url persistence, store persistence, drift handle 2026-04-23 16:36:31 -03:00
83 changed files with 6194 additions and 6479 deletions

View File

@@ -614,9 +614,8 @@
"signoz/no-navigator-clipboard": "off",
// Tests can use navigator.clipboard directly,
"signoz/no-raw-absolute-path":"off",
"no-restricted-globals": "off",
"no-restricted-globals": "off"
// Tests need raw localStorage/sessionStorage to seed DOM state for isolation
"signoz/no-zustand-getstate-in-hooks": "off"
}
},
{

View File

@@ -1,70 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 142.5 145.6" style="enable-background:new 0 0 142.5 145.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#565656;}
.st1{fill:url(#SVGID_1_);}
</style>
<g>
<path class="st0" d="M28.7,131.5c-0.3,7.9-6.6,14.1-14.4,14.1C6.1,145.6,0,139,0,130.9s6.6-14.7,14.7-14.7c3.6,0,7.2,1.6,10.2,4.4
l-2.3,2.9c-2.3-2-5.1-3.4-7.9-3.4c-5.9,0-10.8,4.8-10.8,10.8c0,6.1,4.6,10.8,10.4,10.8c5.2,0,9.3-3.8,10.2-8.8H12.6v-3.5h16.1
V131.5z"/>
<path class="st0" d="M42.3,129.5h-2.2c-2.4,0-4.4,2-4.4,4.4v11.4h-3.9v-19.6H35v1.6c1.1-1.1,2.7-1.6,4.6-1.6h4.2L42.3,129.5z"/>
<path class="st0" d="M63.7,145.3h-3.4v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4V145.3z M59.7,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C57.1,141.2,59.1,139.3,59.7,137z"/>
<path class="st0" d="M71.5,124.7v1.1h6.2v3.4h-6.2v16.1h-3.8v-20.5c0-4.3,3.1-6.8,7-6.8h4.7l-1.6,3.7h-3.1
C72.9,121.6,71.5,123,71.5,124.7z"/>
<path class="st0" d="M98.5,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H98.5z M94.5,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C92,141.2,93.9,139.3,94.5,137z"/>
<path class="st0" d="M119.4,133.8v11.5h-3.9v-11.6c0-2.4-2-4.4-4.4-4.4c-2.5,0-4.4,2-4.4,4.4v11.6h-3.9v-19.6h3.2v1.7
c1.4-1.3,3.3-2,5.2-2C115.8,125.5,119.4,129.2,119.4,133.8z"/>
<path class="st0" d="M142.4,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H142.4z M138.4,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C135.9,141.2,137.8,139.3,138.4,137z"/>
</g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="71.25" y1="10.4893" x2="71.25" y2="113.3415" gradientTransform="matrix(1 0 0 -1 0 148.6)">
<stop offset="0" style="stop-color:#FCEE1F"/>
<stop offset="1" style="stop-color:#F15B2A"/>
</linearGradient>
<path class="st1" d="M122.9,49.9c-0.2-1.9-0.5-4.1-1.1-6.5c-0.6-2.4-1.6-5-2.9-7.8c-1.4-2.7-3.1-5.6-5.4-8.3
c-0.9-1.1-1.9-2.1-2.9-3.2c1.6-6.3-1.9-11.8-1.9-11.8c-6.1-0.4-9.9,1.9-11.3,2.9c-0.2-0.1-0.5-0.2-0.7-0.3c-1-0.4-2.1-0.8-3.2-1.2
c-1.1-0.3-2.2-0.7-3.3-0.9c-1.1-0.3-2.3-0.5-3.5-0.7c-0.2,0-0.4-0.1-0.6-0.1C83.5,3.6,75.9,0,75.9,0c-8.7,5.6-10.4,13.1-10.4,13.1
s0,0.2-0.1,0.4c-0.5,0.1-0.9,0.3-1.4,0.4c-0.6,0.2-1.3,0.4-1.9,0.7c-0.6,0.3-1.3,0.5-1.9,0.8c-1.3,0.6-2.5,1.2-3.8,1.9
c-1.2,0.7-2.4,1.4-3.5,2.2c-0.2-0.1-0.3-0.2-0.3-0.2c-11.7-4.5-22.1,0.9-22.1,0.9c-0.9,12.5,4.7,20.3,5.8,21.7
c-0.3,0.8-0.5,1.5-0.8,2.3c-0.9,2.8-1.5,5.7-1.9,8.7c-0.1,0.4-0.1,0.9-0.2,1.3c-10.8,5.3-14,16.3-14,16.3c9,10.4,19.6,11,19.6,11
l0,0c1.3,2.4,2.9,4.7,4.6,6.8c0.7,0.9,1.5,1.7,2.3,2.6c-3.3,9.4,0.5,17.3,0.5,17.3c10.1,0.4,16.7-4.4,18.1-5.5c1,0.3,2,0.6,3,0.9
c3.1,0.8,6.3,1.3,9.4,1.4c0.8,0,1.6,0,2.4,0h0.4H80h0.5H81l0,0c4.7,6.8,13.1,7.7,13.1,7.7c5.9-6.3,6.3-12.4,6.3-13.8l0,0
c0,0,0,0,0-0.1s0-0.2,0-0.2l0,0c0-0.1,0-0.2,0-0.3c1.2-0.9,2.4-1.8,3.6-2.8c2.4-2.1,4.4-4.6,6.2-7.2c0.2-0.2,0.3-0.5,0.5-0.7
c6.7,0.4,11.4-4.2,11.4-4.2c-1.1-7-5.1-10.4-5.9-11l0,0c0,0,0,0-0.1-0.1l-0.1-0.1l0,0l-0.1-0.1c0-0.4,0.1-0.8,0.1-1.3
c0.1-0.8,0.1-1.5,0.1-2.3v-0.6v-0.3v-0.1c0-0.2,0-0.1,0-0.2v-0.5v-0.6c0-0.2,0-0.4,0-0.6s0-0.4-0.1-0.6l-0.1-0.6l-0.1-0.6
c-0.1-0.8-0.3-1.5-0.4-2.3c-0.7-3-1.9-5.9-3.4-8.4c-1.6-2.6-3.5-4.8-5.7-6.8c-2.2-1.9-4.6-3.5-7.2-4.6c-2.6-1.2-5.2-1.9-7.9-2.2
c-1.3-0.2-2.7-0.2-4-0.2h-0.5h-0.1h-0.2h-0.2h-0.5c-0.2,0-0.4,0-0.5,0c-0.7,0.1-1.4,0.2-2,0.3c-2.7,0.5-5.2,1.5-7.4,2.8
c-2.2,1.3-4.1,3-5.7,4.9s-2.8,3.9-3.6,6.1c-0.8,2.1-1.3,4.4-1.4,6.5c0,0.5,0,1.1,0,1.6c0,0.1,0,0.3,0,0.4v0.4c0,0.3,0,0.5,0.1,0.8
c0.1,1.1,0.3,2.1,0.6,3.1c0.6,2,1.5,3.8,2.7,5.4s2.5,2.8,4,3.8s3,1.7,4.6,2.2c1.6,0.5,3.1,0.7,4.5,0.6c0.2,0,0.4,0,0.5,0
c0.1,0,0.2,0,0.3,0s0.2,0,0.3,0c0.2,0,0.3,0,0.5,0h0.1h0.1c0.1,0,0.2,0,0.3,0c0.2,0,0.4-0.1,0.5-0.1c0.2,0,0.3-0.1,0.5-0.1
c0.3-0.1,0.7-0.2,1-0.3c0.6-0.2,1.2-0.5,1.8-0.7c0.6-0.3,1.1-0.6,1.5-0.9c0.1-0.1,0.3-0.2,0.4-0.3c0.5-0.4,0.6-1.1,0.2-1.6
c-0.4-0.4-1-0.5-1.5-0.3C88,74,87.9,74,87.7,74.1c-0.4,0.2-0.9,0.4-1.3,0.5c-0.5,0.1-1,0.3-1.5,0.4c-0.3,0-0.5,0.1-0.8,0.1
c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0s-0.3,0-0.4,0c-0.2,0-0.3,0-0.5,0c0,0-0.1,0,0,0h-0.1h-0.1c-0.1,0-0.1,0-0.2,0
s-0.3,0-0.4-0.1c-1.1-0.2-2.3-0.5-3.4-1c-1.1-0.5-2.2-1.2-3.1-2.1c-1-0.9-1.8-1.9-2.5-3.1c-0.7-1.2-1.1-2.5-1.3-3.8
c-0.1-0.7-0.2-1.4-0.1-2.1c0-0.2,0-0.4,0-0.6c0,0.1,0,0,0,0v-0.1v-0.1c0-0.1,0-0.2,0-0.3c0-0.4,0.1-0.7,0.2-1.1c0.5-3,2-5.9,4.3-8.1
c0.6-0.6,1.2-1.1,1.9-1.5c0.7-0.5,1.4-0.9,2.1-1.2c0.7-0.3,1.5-0.6,2.3-0.8s1.6-0.4,2.4-0.4c0.4,0,0.8-0.1,1.2-0.1
c0.1,0,0.2,0,0.3,0h0.3h0.2c0.1,0,0,0,0,0h0.1h0.3c0.9,0.1,1.8,0.2,2.6,0.4c1.7,0.4,3.4,1,5,1.9c3.2,1.8,5.9,4.5,7.5,7.8
c0.8,1.6,1.4,3.4,1.7,5.3c0.1,0.5,0.1,0.9,0.2,1.4v0.3V66c0,0.1,0,0.2,0,0.3c0,0.1,0,0.2,0,0.3v0.3v0.3c0,0.2,0,0.6,0,0.8
c0,0.5-0.1,1-0.1,1.5c-0.1,0.5-0.1,1-0.2,1.5s-0.2,1-0.3,1.5c-0.2,1-0.6,1.9-0.9,2.9c-0.7,1.9-1.7,3.7-2.9,5.3
c-2.4,3.3-5.7,6-9.4,7.7c-1.9,0.8-3.8,1.5-5.8,1.8c-1,0.2-2,0.3-3,0.3H81h-0.2h-0.3H80h-0.3c0.1,0,0,0,0,0h-0.1
c-0.5,0-1.1,0-1.6-0.1c-2.2-0.2-4.3-0.6-6.4-1.2c-2.1-0.6-4.1-1.4-6-2.4c-3.8-2-7.2-4.9-9.9-8.2c-1.3-1.7-2.5-3.5-3.5-5.4
s-1.7-3.9-2.3-5.9c-0.6-2-0.9-4.1-1-6.2v-0.4v-0.1v-0.1v-0.2V60v-0.1v-0.1v-0.2v-0.5V59l0,0v-0.2c0-0.3,0-0.5,0-0.8
c0-1,0.1-2.1,0.3-3.2c0.1-1.1,0.3-2.1,0.5-3.2c0.2-1.1,0.5-2.1,0.8-3.2c0.6-2.1,1.3-4.1,2.2-6c1.8-3.8,4.1-7.2,6.8-9.9
c0.7-0.7,1.4-1.3,2.2-1.9c0.3-0.3,1-0.9,1.8-1.4c0.8-0.5,1.6-1,2.5-1.4c0.4-0.2,0.8-0.4,1.3-0.6c0.2-0.1,0.4-0.2,0.7-0.3
c0.2-0.1,0.4-0.2,0.7-0.3c0.9-0.4,1.8-0.7,2.7-1c0.2-0.1,0.5-0.1,0.7-0.2c0.2-0.1,0.5-0.1,0.7-0.2c0.5-0.1,0.9-0.2,1.4-0.4
c0.2-0.1,0.5-0.1,0.7-0.2c0.2,0,0.5-0.1,0.7-0.1c0.2,0,0.5-0.1,0.7-0.1l0.4-0.1l0.4-0.1c0.2,0,0.5-0.1,0.7-0.1
c0.3,0,0.5-0.1,0.8-0.1c0.2,0,0.6-0.1,0.8-0.1c0.2,0,0.3,0,0.5-0.1h0.3h0.2h0.2c0.3,0,0.5,0,0.8-0.1h0.4c0,0,0.1,0,0,0h0.1h0.2
c0.2,0,0.5,0,0.7,0c0.9,0,1.8,0,2.7,0c1.8,0.1,3.6,0.3,5.3,0.6c3.4,0.6,6.7,1.7,9.6,3.2c2.9,1.4,5.6,3.2,7.8,5.1
c0.1,0.1,0.3,0.2,0.4,0.4c0.1,0.1,0.3,0.2,0.4,0.4c0.3,0.2,0.5,0.5,0.8,0.7c0.3,0.2,0.5,0.5,0.8,0.7c0.2,0.3,0.5,0.5,0.7,0.8
c1,1,1.9,2.1,2.7,3.1c1.6,2.1,2.9,4.2,3.9,6.2c0.1,0.1,0.1,0.2,0.2,0.4c0.1,0.1,0.1,0.2,0.2,0.4s0.2,0.5,0.4,0.7
c0.1,0.2,0.2,0.5,0.3,0.7c0.1,0.2,0.2,0.5,0.3,0.7c0.4,0.9,0.7,1.8,1,2.7c0.5,1.4,0.8,2.6,1.1,3.6c0.1,0.4,0.5,0.7,0.9,0.7
c0.5,0,0.8-0.4,0.8-0.9C123,52.7,123,51.4,122.9,49.9z"/>
</svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 142.5 145.6" style="enable-background:new 0 0 142.5 145.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#565656;}
.st1{fill:url(#SVGID_1_);}
</style>
<g>
<path class="st0" d="M28.7,131.5c-0.3,7.9-6.6,14.1-14.4,14.1C6.1,145.6,0,139,0,130.9s6.6-14.7,14.7-14.7c3.6,0,7.2,1.6,10.2,4.4
l-2.3,2.9c-2.3-2-5.1-3.4-7.9-3.4c-5.9,0-10.8,4.8-10.8,10.8c0,6.1,4.6,10.8,10.4,10.8c5.2,0,9.3-3.8,10.2-8.8H12.6v-3.5h16.1
V131.5z"/>
<path class="st0" d="M42.3,129.5h-2.2c-2.4,0-4.4,2-4.4,4.4v11.4h-3.9v-19.6H35v1.6c1.1-1.1,2.7-1.6,4.6-1.6h4.2L42.3,129.5z"/>
<path class="st0" d="M63.7,145.3h-3.4v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4V145.3z M59.7,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C57.1,141.2,59.1,139.3,59.7,137z"/>
<path class="st0" d="M71.5,124.7v1.1h6.2v3.4h-6.2v16.1h-3.8v-20.5c0-4.3,3.1-6.8,7-6.8h4.7l-1.6,3.7h-3.1
C72.9,121.6,71.5,123,71.5,124.7z"/>
<path class="st0" d="M98.5,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H98.5z M94.5,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C92,141.2,93.9,139.3,94.5,137z"/>
<path class="st0" d="M119.4,133.8v11.5h-3.9v-11.6c0-2.4-2-4.4-4.4-4.4c-2.5,0-4.4,2-4.4,4.4v11.6h-3.9v-19.6h3.2v1.7
c1.4-1.3,3.3-2,5.2-2C115.8,125.5,119.4,129.2,119.4,133.8z"/>
<path class="st0" d="M142.4,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H142.4z M138.4,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C135.9,141.2,137.8,139.3,138.4,137z"/>
</g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="71.25" y1="10.4893" x2="71.25" y2="113.3415" gradientTransform="matrix(1 0 0 -1 0 148.6)">
<stop offset="0" style="stop-color:#FCEE1F"/>
<stop offset="1" style="stop-color:#F15B2A"/>
</linearGradient>
<path class="st1" d="M122.9,49.9c-0.2-1.9-0.5-4.1-1.1-6.5c-0.6-2.4-1.6-5-2.9-7.8c-1.4-2.7-3.1-5.6-5.4-8.3
c-0.9-1.1-1.9-2.1-2.9-3.2c1.6-6.3-1.9-11.8-1.9-11.8c-6.1-0.4-9.9,1.9-11.3,2.9c-0.2-0.1-0.5-0.2-0.7-0.3c-1-0.4-2.1-0.8-3.2-1.2
c-1.1-0.3-2.2-0.7-3.3-0.9c-1.1-0.3-2.3-0.5-3.5-0.7c-0.2,0-0.4-0.1-0.6-0.1C83.5,3.6,75.9,0,75.9,0c-8.7,5.6-10.4,13.1-10.4,13.1
s0,0.2-0.1,0.4c-0.5,0.1-0.9,0.3-1.4,0.4c-0.6,0.2-1.3,0.4-1.9,0.7c-0.6,0.3-1.3,0.5-1.9,0.8c-1.3,0.6-2.5,1.2-3.8,1.9
c-1.2,0.7-2.4,1.4-3.5,2.2c-0.2-0.1-0.3-0.2-0.3-0.2c-11.7-4.5-22.1,0.9-22.1,0.9c-0.9,12.5,4.7,20.3,5.8,21.7
c-0.3,0.8-0.5,1.5-0.8,2.3c-0.9,2.8-1.5,5.7-1.9,8.7c-0.1,0.4-0.1,0.9-0.2,1.3c-10.8,5.3-14,16.3-14,16.3c9,10.4,19.6,11,19.6,11
l0,0c1.3,2.4,2.9,4.7,4.6,6.8c0.7,0.9,1.5,1.7,2.3,2.6c-3.3,9.4,0.5,17.3,0.5,17.3c10.1,0.4,16.7-4.4,18.1-5.5c1,0.3,2,0.6,3,0.9
c3.1,0.8,6.3,1.3,9.4,1.4c0.8,0,1.6,0,2.4,0h0.4H80h0.5H81l0,0c4.7,6.8,13.1,7.7,13.1,7.7c5.9-6.3,6.3-12.4,6.3-13.8l0,0
c0,0,0,0,0-0.1s0-0.2,0-0.2l0,0c0-0.1,0-0.2,0-0.3c1.2-0.9,2.4-1.8,3.6-2.8c2.4-2.1,4.4-4.6,6.2-7.2c0.2-0.2,0.3-0.5,0.5-0.7
c6.7,0.4,11.4-4.2,11.4-4.2c-1.1-7-5.1-10.4-5.9-11l0,0c0,0,0,0-0.1-0.1l-0.1-0.1l0,0l-0.1-0.1c0-0.4,0.1-0.8,0.1-1.3
c0.1-0.8,0.1-1.5,0.1-2.3v-0.6v-0.3v-0.1c0-0.2,0-0.1,0-0.2v-0.5v-0.6c0-0.2,0-0.4,0-0.6s0-0.4-0.1-0.6l-0.1-0.6l-0.1-0.6
c-0.1-0.8-0.3-1.5-0.4-2.3c-0.7-3-1.9-5.9-3.4-8.4c-1.6-2.6-3.5-4.8-5.7-6.8c-2.2-1.9-4.6-3.5-7.2-4.6c-2.6-1.2-5.2-1.9-7.9-2.2
c-1.3-0.2-2.7-0.2-4-0.2h-0.5h-0.1h-0.2h-0.2h-0.5c-0.2,0-0.4,0-0.5,0c-0.7,0.1-1.4,0.2-2,0.3c-2.7,0.5-5.2,1.5-7.4,2.8
c-2.2,1.3-4.1,3-5.7,4.9s-2.8,3.9-3.6,6.1c-0.8,2.1-1.3,4.4-1.4,6.5c0,0.5,0,1.1,0,1.6c0,0.1,0,0.3,0,0.4v0.4c0,0.3,0,0.5,0.1,0.8
c0.1,1.1,0.3,2.1,0.6,3.1c0.6,2,1.5,3.8,2.7,5.4s2.5,2.8,4,3.8s3,1.7,4.6,2.2c1.6,0.5,3.1,0.7,4.5,0.6c0.2,0,0.4,0,0.5,0
c0.1,0,0.2,0,0.3,0s0.2,0,0.3,0c0.2,0,0.3,0,0.5,0h0.1h0.1c0.1,0,0.2,0,0.3,0c0.2,0,0.4-0.1,0.5-0.1c0.2,0,0.3-0.1,0.5-0.1
c0.3-0.1,0.7-0.2,1-0.3c0.6-0.2,1.2-0.5,1.8-0.7c0.6-0.3,1.1-0.6,1.5-0.9c0.1-0.1,0.3-0.2,0.4-0.3c0.5-0.4,0.6-1.1,0.2-1.6
c-0.4-0.4-1-0.5-1.5-0.3C88,74,87.9,74,87.7,74.1c-0.4,0.2-0.9,0.4-1.3,0.5c-0.5,0.1-1,0.3-1.5,0.4c-0.3,0-0.5,0.1-0.8,0.1
c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0s-0.3,0-0.4,0c-0.2,0-0.3,0-0.5,0c0,0-0.1,0,0,0h-0.1h-0.1c-0.1,0-0.1,0-0.2,0
s-0.3,0-0.4-0.1c-1.1-0.2-2.3-0.5-3.4-1c-1.1-0.5-2.2-1.2-3.1-2.1c-1-0.9-1.8-1.9-2.5-3.1c-0.7-1.2-1.1-2.5-1.3-3.8
c-0.1-0.7-0.2-1.4-0.1-2.1c0-0.2,0-0.4,0-0.6c0,0.1,0,0,0,0v-0.1v-0.1c0-0.1,0-0.2,0-0.3c0-0.4,0.1-0.7,0.2-1.1c0.5-3,2-5.9,4.3-8.1
c0.6-0.6,1.2-1.1,1.9-1.5c0.7-0.5,1.4-0.9,2.1-1.2c0.7-0.3,1.5-0.6,2.3-0.8s1.6-0.4,2.4-0.4c0.4,0,0.8-0.1,1.2-0.1
c0.1,0,0.2,0,0.3,0h0.3h0.2c0.1,0,0,0,0,0h0.1h0.3c0.9,0.1,1.8,0.2,2.6,0.4c1.7,0.4,3.4,1,5,1.9c3.2,1.8,5.9,4.5,7.5,7.8
c0.8,1.6,1.4,3.4,1.7,5.3c0.1,0.5,0.1,0.9,0.2,1.4v0.3V66c0,0.1,0,0.2,0,0.3c0,0.1,0,0.2,0,0.3v0.3v0.3c0,0.2,0,0.6,0,0.8
c0,0.5-0.1,1-0.1,1.5c-0.1,0.5-0.1,1-0.2,1.5s-0.2,1-0.3,1.5c-0.2,1-0.6,1.9-0.9,2.9c-0.7,1.9-1.7,3.7-2.9,5.3
c-2.4,3.3-5.7,6-9.4,7.7c-1.9,0.8-3.8,1.5-5.8,1.8c-1,0.2-2,0.3-3,0.3H81h-0.2h-0.3H80h-0.3c0.1,0,0,0,0,0h-0.1
c-0.5,0-1.1,0-1.6-0.1c-2.2-0.2-4.3-0.6-6.4-1.2c-2.1-0.6-4.1-1.4-6-2.4c-3.8-2-7.2-4.9-9.9-8.2c-1.3-1.7-2.5-3.5-3.5-5.4
s-1.7-3.9-2.3-5.9c-0.6-2-0.9-4.1-1-6.2v-0.4v-0.1v-0.1v-0.2V60v-0.1v-0.1v-0.2v-0.5V59l0,0v-0.2c0-0.3,0-0.5,0-0.8
c0-1,0.1-2.1,0.3-3.2c0.1-1.1,0.3-2.1,0.5-3.2c0.2-1.1,0.5-2.1,0.8-3.2c0.6-2.1,1.3-4.1,2.2-6c1.8-3.8,4.1-7.2,6.8-9.9
c0.7-0.7,1.4-1.3,2.2-1.9c0.3-0.3,1-0.9,1.8-1.4c0.8-0.5,1.6-1,2.5-1.4c0.4-0.2,0.8-0.4,1.3-0.6c0.2-0.1,0.4-0.2,0.7-0.3
c0.2-0.1,0.4-0.2,0.7-0.3c0.9-0.4,1.8-0.7,2.7-1c0.2-0.1,0.5-0.1,0.7-0.2c0.2-0.1,0.5-0.1,0.7-0.2c0.5-0.1,0.9-0.2,1.4-0.4
c0.2-0.1,0.5-0.1,0.7-0.2c0.2,0,0.5-0.1,0.7-0.1c0.2,0,0.5-0.1,0.7-0.1l0.4-0.1l0.4-0.1c0.2,0,0.5-0.1,0.7-0.1
c0.3,0,0.5-0.1,0.8-0.1c0.2,0,0.6-0.1,0.8-0.1c0.2,0,0.3,0,0.5-0.1h0.3h0.2h0.2c0.3,0,0.5,0,0.8-0.1h0.4c0,0,0.1,0,0,0h0.1h0.2
c0.2,0,0.5,0,0.7,0c0.9,0,1.8,0,2.7,0c1.8,0.1,3.6,0.3,5.3,0.6c3.4,0.6,6.7,1.7,9.6,3.2c2.9,1.4,5.6,3.2,7.8,5.1
c0.1,0.1,0.3,0.2,0.4,0.4c0.1,0.1,0.3,0.2,0.4,0.4c0.3,0.2,0.5,0.5,0.8,0.7c0.3,0.2,0.5,0.5,0.8,0.7c0.2,0.3,0.5,0.5,0.7,0.8
c1,1,1.9,2.1,2.7,3.1c1.6,2.1,2.9,4.2,3.9,6.2c0.1,0.1,0.1,0.2,0.2,0.4c0.1,0.1,0.1,0.2,0.2,0.4s0.2,0.5,0.4,0.7
c0.1,0.2,0.2,0.5,0.3,0.7c0.1,0.2,0.2,0.5,0.3,0.7c0.4,0.9,0.7,1.8,1,2.7c0.5,1.4,0.8,2.6,1.1,3.6c0.1,0.4,0.5,0.7,0.9,0.7
c0.5,0,0.8-0.4,0.8-0.9C123,52.7,123,51.4,122.9,49.9z"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -1,21 +1,21 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<polygon style="fill:#FFD500;" points="382.395,228.568 291.215,228.568 330.762,10.199 129.603,283.43 220.785,283.43
181.238,501.799 "/>
<g>
<path style="fill:#3D3D3D;" d="M181.234,512c-1.355,0-2.726-0.271-4.033-0.833c-4.357-1.878-6.845-6.514-5.999-11.184
l37.371-206.353h-78.969c-3.846,0-7.367-2.164-9.103-5.597c-1.735-3.433-1.391-7.55,0.889-10.648L322.548,4.153
c2.814-3.822,7.891-5.196,12.25-3.32c4.357,1.878,6.845,6.514,5.999,11.184L303.427,218.37h78.969c3.846,0,7.367,2.164,9.103,5.597
c1.735,3.433,1.391,7.55-0.889,10.648L189.451,507.846C187.481,510.523,184.399,512,181.234,512z M149.777,273.231h71.007
c3.023,0,5.89,1.341,7.828,3.662c1.938,2.32,2.747,5.38,2.208,8.355l-31.704,175.065l163.105-221.545h-71.007
c-3.023,0-5.89-1.341-7.828-3.661c-1.938-2.32-2.747-5.38-2.208-8.355l31.704-175.065L149.777,273.231z"/>
<path style="fill:#3D3D3D;" d="M267.666,171.348c-0.604,0-1.215-0.054-1.829-0.165c-5.543-1.004-9.223-6.31-8.22-11.853l0.923-5.1
c1.003-5.543,6.323-9.225,11.852-8.219c5.543,1.004,9.223,6.31,8.22,11.853l-0.923,5.1
C276.797,167.892,272.503,171.348,267.666,171.348z"/>
<path style="fill:#3D3D3D;" d="M255.455,238.77c-0.604,0-1.215-0.054-1.83-0.165c-5.543-1.004-9.222-6.31-8.218-11.853
l7.037-38.864c1.004-5.543,6.317-9.225,11.854-8.219c5.543,1.004,9.222,6.31,8.219,11.853l-7.037,38.864
C264.587,235.314,260.293,238.77,255.455,238.77z"/>
</g>
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 512 512" xml:space="preserve">
<polygon style="fill:#FFD500;" points="382.395,228.568 291.215,228.568 330.762,10.199 129.603,283.43 220.785,283.43
181.238,501.799 "/>
<g>
<path style="fill:#3D3D3D;" d="M181.234,512c-1.355,0-2.726-0.271-4.033-0.833c-4.357-1.878-6.845-6.514-5.999-11.184
l37.371-206.353h-78.969c-3.846,0-7.367-2.164-9.103-5.597c-1.735-3.433-1.391-7.55,0.889-10.648L322.548,4.153
c2.814-3.822,7.891-5.196,12.25-3.32c4.357,1.878,6.845,6.514,5.999,11.184L303.427,218.37h78.969c3.846,0,7.367,2.164,9.103,5.597
c1.735,3.433,1.391,7.55-0.889,10.648L189.451,507.846C187.481,510.523,184.399,512,181.234,512z M149.777,273.231h71.007
c3.023,0,5.89,1.341,7.828,3.662c1.938,2.32,2.747,5.38,2.208,8.355l-31.704,175.065l163.105-221.545h-71.007
c-3.023,0-5.89-1.341-7.828-3.661c-1.938-2.32-2.747-5.38-2.208-8.355l31.704-175.065L149.777,273.231z"/>
<path style="fill:#3D3D3D;" d="M267.666,171.348c-0.604,0-1.215-0.054-1.829-0.165c-5.543-1.004-9.223-6.31-8.22-11.853l0.923-5.1
c1.003-5.543,6.323-9.225,11.852-8.219c5.543,1.004,9.223,6.31,8.22,11.853l-0.923,5.1
C276.797,167.892,272.503,171.348,267.666,171.348z"/>
<path style="fill:#3D3D3D;" d="M255.455,238.77c-0.604,0-1.215-0.054-1.83-0.165c-5.543-1.004-9.222-6.31-8.218-11.853
l7.037-38.864c1.004-5.543,6.317-9.225,11.854-8.219c5.543,1.004,9.222,6.31,8.219,11.853l-7.037,38.864
C264.587,235.314,260.293,238.77,255.455,238.77z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,4 +1,6 @@
.log-state-indicator {
padding-left: 8px;
.line {
margin: 0 8px;
min-height: 24px;

View File

@@ -1,43 +0,0 @@
import { Color } from '@signozhq/design-tokens';
import { LogType } from './LogStateIndicator';
export function getRowBackgroundColor(
isDarkMode: boolean,
logType?: string,
): string {
if (isDarkMode) {
switch (logType) {
case LogType.INFO:
return `${Color.BG_ROBIN_500}40`;
case LogType.WARN:
return `${Color.BG_AMBER_500}40`;
case LogType.ERROR:
return `${Color.BG_CHERRY_500}40`;
case LogType.TRACE:
return `${Color.BG_FOREST_400}40`;
case LogType.DEBUG:
return `${Color.BG_AQUA_500}40`;
case LogType.FATAL:
return `${Color.BG_SAKURA_500}40`;
default:
return `${Color.BG_ROBIN_500}40`;
}
}
switch (logType) {
case LogType.INFO:
return Color.BG_ROBIN_100;
case LogType.WARN:
return Color.BG_AMBER_100;
case LogType.ERROR:
return Color.BG_CHERRY_100;
case LogType.TRACE:
return Color.BG_FOREST_200;
case LogType.DEBUG:
return Color.BG_AQUA_100;
case LogType.FATAL:
return Color.BG_SAKURA_100;
default:
return Color.BG_VANILLA_300;
}
}

View File

@@ -1,114 +0,0 @@
import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import type { TableColumnDef } from '../../TanStackTableView/types';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
type UseLogsTableColumnsProps = {
fields: IField[];
fontSize: FontSize;
appendTo?: 'center' | 'end';
};
export function useLogsTableColumns({
fields,
fontSize,
appendTo = 'center',
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return useMemo<TableColumnDef<ILog>[]>(() => {
const stateIndicatorCol: TableColumnDef<ILog> = {
id: 'state-indicator',
header: '',
pin: 'left',
enableMove: false,
enableResize: false,
enableRemove: false,
canBeHidden: false,
width: { fixed: 24 },
cell: ({ row }): ReactElement => (
<LogStateIndicator
fontSize={fontSize}
severityText={row.severity_text as string}
severityNumber={row.severity_number as number}
/>
),
};
const fieldColumns: TableColumnDef<ILog>[] = fields
.filter((f): boolean => !['id', 'body', 'timestamp'].includes(f.name))
.map(
(f): TableColumnDef<ILog> => ({
id: f.name,
header: f.name,
accessorFn: (log): unknown => FlatLogData(log)[f.name],
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
}),
);
const timestampCol: TableColumnDef<ILog> | null = fields.some(
(f) => f.name === 'timestamp',
)
? {
id: 'timestamp',
header: 'Timestamp',
accessorFn: (log): unknown => log.timestamp,
width: { default: 170, min: 170 },
cell: ({ value }): ReactElement => {
const ts = value as string | number;
const formatted =
typeof ts === 'string'
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
: formatTimezoneAdjustedTimestamp(
ts / 1e6,
DATE_TIME_FORMATS.ISO_DATETIME_MS,
);
return <TanStackTable.Text>{formatted}</TanStackTable.Text>;
},
}
: null;
const bodyCol: TableColumnDef<ILog> | null = fields.some(
(f) => f.name === 'body',
)
? {
id: 'body',
header: 'Body',
accessorFn: (log): string => log.body,
canBeHidden: false,
width: { default: '100%', min: 300 },
cell: ({ value, isActive }): ReactElement => (
<TanStackTable.Text
dangerouslySetInnerHTML={{
__html: getSanitizedLogBody(value as string, {
shouldEscapeHtml: true,
}),
}}
data-active={isActive}
/>
),
}
: null;
return [
stateIndicatorCol,
...(timestampCol ? [timestampCol] : []),
...(appendTo === 'center' ? fieldColumns : []),
...(bodyCol ? [bodyCol] : []),
...(appendTo === 'end' ? fieldColumns : []),
];
}, [fields, appendTo, fontSize, formatTimezoneAdjustedTimestamp]);
}

View File

@@ -1,135 +0,0 @@
import { ComponentProps, memo } from 'react';
import { TableComponents } from 'react-virtuoso';
import cx from 'classnames';
import TanStackRowCells from './TanStackRow';
import {
useClearRowHovered,
useSetRowHovered,
} from './TanStackTableStateContext';
import { FlatItem, TableRowContext } from './types';
import tableStyles from './TanStackTable.module.scss';
type VirtuosoTableRowProps<TData> = ComponentProps<
NonNullable<
TableComponents<FlatItem<TData>, TableRowContext<TData>>['TableRow']
>
>;
function TanStackCustomTableRow<TData>({
item,
context,
...props
}: VirtuosoTableRowProps<TData>): JSX.Element {
const rowId = item.row.id;
const rowData = item.row.original;
// Stable callbacks for hover state management
const setHovered = useSetRowHovered(rowId);
const clearHovered = useClearRowHovered(rowId);
if (item.kind === 'expansion') {
return (
<tr {...props} className={tableStyles.tableRowExpansion}>
<TanStackRowCells
row={item.row}
itemKind={item.kind}
context={context}
hasSingleColumn={context?.hasSingleColumn ?? false}
columnOrderKey={context?.columnOrderKey ?? ''}
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
/>
</tr>
);
}
const isActive = context?.isRowActive?.(rowData) ?? false;
const extraClass = context?.getRowClassName?.(rowData) ?? '';
const rowStyle = context?.getRowStyle?.(rowData);
const rowClassName = cx(
tableStyles.tableRow,
isActive && tableStyles.tableRowActive,
extraClass,
);
return (
<tr
{...props}
className={rowClassName}
style={rowStyle}
onMouseEnter={setHovered}
onMouseLeave={clearHovered}
>
<TanStackRowCells
row={item.row}
itemKind={item.kind}
context={context}
hasSingleColumn={context?.hasSingleColumn ?? false}
columnOrderKey={context?.columnOrderKey ?? ''}
columnVisibilityKey={context?.columnVisibilityKey ?? ''}
/>
</tr>
);
}
// Custom comparison - only re-render when row identity or computed values change
// This looks overkill but ensures the table is stable and doesn't re-render on every change
// If you add any new prop to context, remember to update this function
// eslint-disable-next-line sonarjs/cognitive-complexity
function areTableRowPropsEqual<TData>(
prev: Readonly<VirtuosoTableRowProps<TData>>,
next: Readonly<VirtuosoTableRowProps<TData>>,
): boolean {
if (prev.item.row.id !== next.item.row.id) {
return false;
}
if (prev.item.kind !== next.item.kind) {
return false;
}
const prevData = prev.item.row.original;
const nextData = next.item.row.original;
if (prevData !== nextData) {
return false;
}
if (prev.context?.hasSingleColumn !== next.context?.hasSingleColumn) {
return false;
}
if (prev.context?.columnOrderKey !== next.context?.columnOrderKey) {
return false;
}
if (prev.context?.columnVisibilityKey !== next.context?.columnVisibilityKey) {
return false;
}
if (prev.context !== next.context) {
const prevActive = prev.context?.isRowActive?.(prevData) ?? false;
const nextActive = next.context?.isRowActive?.(nextData) ?? false;
if (prevActive !== nextActive) {
return false;
}
const prevClass = prev.context?.getRowClassName?.(prevData) ?? '';
const nextClass = next.context?.getRowClassName?.(nextData) ?? '';
if (prevClass !== nextClass) {
return false;
}
const prevStyle = prev.context?.getRowStyle?.(prevData);
const nextStyle = next.context?.getRowStyle?.(nextData);
if (prevStyle !== nextStyle) {
return false;
}
}
return true;
}
export default memo(
TanStackCustomTableRow,
areTableRowPropsEqual,
) as typeof TanStackCustomTableRow;

View File

@@ -1,260 +0,0 @@
import type {
CSSProperties,
MouseEvent as ReactMouseEvent,
TouchEvent as ReactTouchEvent,
} from 'react';
import { useCallback, useMemo } from 'react';
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
import { useSortable } from '@dnd-kit/sortable';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui';
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
import cx from 'classnames';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { SortState, TableColumnDef } from './types';
import headerStyles from './TanStackHeaderRow.module.scss';
import tableStyles from './TanStackTable.module.scss';
type TanStackHeaderRowProps<TData = unknown> = {
column: TableColumnDef<TData>;
header?: TanStackHeader<TData, unknown>;
isDarkMode: boolean;
hasSingleColumn: boolean;
canRemoveColumn?: boolean;
onRemoveColumn?: (columnId: string) => void;
orderBy?: SortState | null;
onSort?: (sort: SortState | null) => void;
/** Last column cannot be resized */
isLastColumn?: boolean;
};
const GRIP_ICON_SIZE = 12;
const SORT_ICON_SIZE = 14;
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackHeaderRow<TData>({
column,
header,
isDarkMode,
hasSingleColumn,
canRemoveColumn = false,
onRemoveColumn,
orderBy,
onSort,
isLastColumn = false,
}: TanStackHeaderRowProps<TData>): JSX.Element {
const columnId = column.id;
const isDragColumn = column.enableMove !== false && column.pin == null;
const isResizableColumn =
!isLastColumn &&
column.enableResize !== false &&
Boolean(header?.column.getCanResize());
const isColumnRemovable = Boolean(
canRemoveColumn && onRemoveColumn && column.enableRemove,
);
const isSortable = column.enableSort === true && Boolean(onSort);
const currentSortDirection =
orderBy?.columnName === columnId ? orderBy.order : null;
const isResizing = Boolean(header?.column.getIsResizing());
const resizeHandler = header?.getResizeHandler();
const headerText =
typeof column.header === 'string' && column.header
? column.header
: String(header?.id ?? columnId);
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
const handleSortClick = useCallback((): void => {
if (!isSortable || !onSort) {
return;
}
if (currentSortDirection === null) {
onSort({ columnName: columnId, order: 'asc' });
} else if (currentSortDirection === 'asc') {
onSort({ columnName: columnId, order: 'desc' });
} else {
onSort(null);
}
}, [isSortable, onSort, currentSortDirection, columnId]);
const handleResizeStart = (
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
): void => {
event.preventDefault();
event.stopPropagation();
resizeHandler?.(event);
};
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: columnId,
disabled: !isDragColumn,
});
const headerCellStyle = useMemo(
() =>
({
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
}) as CSSProperties,
[isResizing, transform?.x, transform?.y, transition],
);
const headerCellClassName = cx(
headerStyles.tanstackHeaderCell,
isDragging && headerStyles.isDragging,
isResizing && headerStyles.isResizing,
);
const headerContentClassName = cx(
headerStyles.tanstackHeaderContent,
isResizableColumn && headerStyles.hasResizeControl,
isColumnRemovable && headerStyles.hasActionControl,
isSortable && headerStyles.isSortable,
);
const thClassName = cx(
tableStyles.tableHeaderCell,
headerCellClassName,
column.id,
);
return (
<th
ref={setNodeRef}
className={thClassName}
key={columnId}
style={headerCellStyle}
data-dark-mode={isDarkMode}
data-single-column={hasSingleColumn || undefined}
>
<span className={headerContentClassName}>
{isDragColumn ? (
<span className={headerStyles.tanstackGripSlot}>
<span
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
role="button"
aria-label={`Drag ${String(
(typeof column.header === 'string' && column.header) ||
header?.id ||
columnId,
)} column`}
className={headerStyles.tanstackGripActivator}
>
<GripVertical size={GRIP_ICON_SIZE} />
</span>
</span>
) : null}
{isSortable ? (
<button
type="button"
className={cx(
'tanstack-header-title',
headerStyles.tanstackSortButton,
currentSortDirection && headerStyles.isSorted,
)}
title={headerTitleAttr}
onClick={handleSortClick}
data-sort={
currentSortDirection === 'asc'
? 'ascending'
: currentSortDirection === 'desc'
? 'descending'
: 'none'
}
>
<span className={headerStyles.tanstackSortLabel}>
{header?.column?.columnDef
? flexRender(header.column.columnDef.header, header.getContext())
: typeof column.header === 'function'
? column.header()
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
<span className={headerStyles.tanstackSortIndicator}>
{currentSortDirection === 'asc' ? (
<ChevronUp size={SORT_ICON_SIZE} />
) : currentSortDirection === 'desc' ? (
<ChevronDown size={SORT_ICON_SIZE} />
) : null}
</span>
</button>
) : (
<span
className={cx('tanstack-header-title', headerStyles.tanstackHeaderTitle)}
title={headerTitleAttr}
>
{header?.column?.columnDef
? flexRender(header.column.columnDef.header, header.getContext())
: typeof column.header === 'function'
? column.header()
: String(column.header || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
)}
{isColumnRemovable && (
<Popover>
<PopoverTrigger asChild>
<span
role="button"
aria-label={`Column actions for ${headerTitleAttr}`}
className={headerStyles.tanstackHeaderActionTrigger}
onMouseDown={(event): void => {
event.stopPropagation();
}}
>
<MoreOutlined />
</span>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={6}
className={headerStyles.tanstackColumnActionsContent}
>
<button
type="button"
className={headerStyles.tanstackRemoveColumnAction}
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
onRemoveColumn?.(column.id);
}}
>
<CloseOutlined
className={headerStyles.tanstackRemoveColumnActionIcon}
/>
Remove column
</button>
</PopoverContent>
</Popover>
)}
</span>
{isResizableColumn && (
<span
role="presentation"
className={headerStyles.cursorColResize}
title="Drag to resize column"
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event): void => {
handleResizeStart(event);
}}
onTouchStart={(event): void => {
handleResizeStart(event);
}}
>
<span className={headerStyles.tanstackResizeHandleLine} />
</span>
)}
</th>
);
}
export default TanStackHeaderRow;

View File

@@ -1,136 +0,0 @@
import type { MouseEvent } from 'react';
import { memo, useCallback } from 'react';
import { Row as TanStackRowModel } from '@tanstack/react-table';
import { TanStackRowCell } from './TanStackRowCell';
import { useIsRowHovered } from './TanStackTableStateContext';
import { TableRowContext } from './types';
import tableStyles from './TanStackTable.module.scss';
type TanStackRowCellsProps<TData> = {
row: TanStackRowModel<TData>;
context: TableRowContext<TData> | undefined;
itemKind: 'row' | 'expansion';
hasSingleColumn: boolean;
columnOrderKey: string;
columnVisibilityKey: string;
};
function TanStackRowCellsInner<TData>({
row,
context,
itemKind,
hasSingleColumn,
columnOrderKey: _columnOrderKey,
columnVisibilityKey: _columnVisibilityKey,
}: TanStackRowCellsProps<TData>): JSX.Element {
const hasHovered = useIsRowHovered(row.id);
const rowData = row.original;
const visibleCells = row.getVisibleCells();
const lastCellIndex = visibleCells.length - 1;
// Stable references via destructuring, keep them as is
const onRowClick = context?.onRowClick;
const onRowClickNewTab = context?.onRowClickNewTab;
const onRowDeactivate = context?.onRowDeactivate;
const isRowActive = context?.isRowActive;
const getRowKeyData = context?.getRowKeyData;
const rowIndex = row.index;
const handleClick = useCallback(
(event: MouseEvent<HTMLTableCellElement>) => {
const keyData = getRowKeyData?.(rowIndex);
const itemKey = keyData?.itemKey ?? '';
// Handle ctrl+click or cmd+click (open in new tab)
if ((event.ctrlKey || event.metaKey) && onRowClickNewTab) {
onRowClickNewTab(rowData, itemKey);
return;
}
const isActive = isRowActive?.(rowData) ?? false;
if (isActive && onRowDeactivate) {
onRowDeactivate();
} else {
onRowClick?.(rowData, itemKey);
}
},
[
isRowActive,
onRowDeactivate,
onRowClick,
onRowClickNewTab,
rowData,
getRowKeyData,
rowIndex,
],
);
if (itemKind === 'expansion') {
const keyData = getRowKeyData?.(rowIndex);
return (
<td
colSpan={context?.colCount ?? 1}
className={tableStyles.tableCellExpansion}
>
{context?.renderExpandedRow?.(
rowData,
keyData?.finalKey ?? '',
keyData?.groupMeta,
)}
</td>
);
}
return (
<>
{visibleCells.map((cell, index) => {
const isLastCell = index === lastCellIndex;
return (
<TanStackRowCell
key={cell.id}
cell={cell}
hasSingleColumn={hasSingleColumn}
isLastCell={isLastCell}
hasHovered={hasHovered}
rowData={rowData}
onClick={handleClick}
renderRowActions={context?.renderRowActions}
/>
);
})}
</>
);
}
// Custom comparison - only re-render when row data changes
// If you add any new prop to context, remember to update this function
function areRowCellsPropsEqual<TData>(
prev: Readonly<TanStackRowCellsProps<TData>>,
next: Readonly<TanStackRowCellsProps<TData>>,
): boolean {
return (
prev.row.id === next.row.id &&
prev.itemKind === next.itemKind &&
prev.hasSingleColumn === next.hasSingleColumn &&
prev.columnOrderKey === next.columnOrderKey &&
prev.columnVisibilityKey === next.columnVisibilityKey &&
prev.context?.onRowClick === next.context?.onRowClick &&
prev.context?.onRowClickNewTab === next.context?.onRowClickNewTab &&
prev.context?.onRowDeactivate === next.context?.onRowDeactivate &&
prev.context?.isRowActive === next.context?.isRowActive &&
prev.context?.getRowKeyData === next.context?.getRowKeyData &&
prev.context?.renderRowActions === next.context?.renderRowActions &&
prev.context?.renderExpandedRow === next.context?.renderExpandedRow &&
prev.context?.colCount === next.context?.colCount
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const TanStackRowCells = memo(
TanStackRowCellsInner,
areRowCellsPropsEqual as any,
) as <T>(props: TanStackRowCellsProps<T>) => JSX.Element;
export default TanStackRowCells;

View File

@@ -1,88 +0,0 @@
import type { MouseEvent, ReactNode } from 'react';
import { memo } from 'react';
import type { Cell } from '@tanstack/react-table';
import { flexRender } from '@tanstack/react-table';
import { Skeleton } from 'antd';
import cx from 'classnames';
import { useShouldShowCellSkeleton } from './TanStackTableStateContext';
import tableStyles from './TanStackTable.module.scss';
import skeletonStyles from './TanStackTableSkeleton.module.scss';
export type TanStackRowCellProps<TData> = {
cell: Cell<TData, unknown>;
hasSingleColumn: boolean;
isLastCell: boolean;
hasHovered: boolean;
rowData: TData;
onClick: (event: MouseEvent<HTMLTableCellElement>) => void;
renderRowActions?: (row: TData) => ReactNode;
};
function TanStackRowCellInner<TData>({
cell,
hasSingleColumn,
isLastCell,
hasHovered,
rowData,
onClick,
renderRowActions,
}: TanStackRowCellProps<TData>): JSX.Element {
const showSkeleton = useShouldShowCellSkeleton();
return (
<td
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
data-single-column={hasSingleColumn || undefined}
onClick={onClick}
>
{showSkeleton ? (
<Skeleton.Input
active
size="small"
className={skeletonStyles.cellSkeleton}
/>
) : (
flexRender(cell.column.columnDef.cell, cell.getContext())
)}
{isLastCell && hasHovered && renderRowActions && !showSkeleton && (
<span className={tableStyles.tableViewRowActions}>
{renderRowActions(rowData)}
</span>
)}
</td>
);
}
// Custom comparison - only re-render when row data changes
// If you add any new prop to context, remember to update this function
function areTanStackRowCellPropsEqual<TData>(
prev: Readonly<TanStackRowCellProps<TData>>,
next: Readonly<TanStackRowCellProps<TData>>,
): boolean {
if (next.cell.id.startsWith('skeleton-')) {
return false;
}
return (
prev.cell.id === next.cell.id &&
prev.cell.column.id === next.cell.column.id &&
Object.is(prev.cell.getValue(), next.cell.getValue()) &&
prev.hasSingleColumn === next.hasSingleColumn &&
prev.isLastCell === next.isLastCell &&
prev.hasHovered === next.hasHovered &&
prev.onClick === next.onClick &&
prev.renderRowActions === next.renderRowActions &&
prev.rowData === next.rowData
);
}
const TanStackRowCellMemo = memo(
TanStackRowCellInner,
areTanStackRowCellPropsEqual,
);
TanStackRowCellMemo.displayName = 'TanStackRowCell';
export const TanStackRowCell = TanStackRowCellMemo as typeof TanStackRowCellInner;

View File

@@ -1,100 +0,0 @@
.tanStackTable {
width: 100%;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
& td,
& th {
overflow: hidden;
min-width: 0;
box-sizing: border-box;
vertical-align: middle;
}
}
.tableCellText {
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
width: auto;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--tanstack-plain-body-line-clamp, 1);
line-clamp: var(--tanstack-plain-body-line-clamp, 1);
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--l2-foreground);
max-width: 100%;
word-break: break-all;
}
.tableViewRowActions {
position: absolute;
top: 50%;
right: 8px;
left: auto;
transform: translateY(-50%);
margin: 0;
z-index: 5;
}
.tableCell {
padding: 0.3rem;
font-style: normal;
font-weight: 400;
letter-spacing: -0.07px;
font-size: var(--tanstack-plain-cell-font-size, 14px);
line-height: var(--tanstack-plain-cell-line-height, 18px);
color: var(--l2-foreground);
}
.tableRow {
cursor: pointer;
position: relative;
overflow-anchor: none;
&:hover {
.tableCell {
background-color: var(--row-hover-bg) !important;
}
}
&.tableRowActive {
.tableCell {
background-color: var(--row-active-bg) !important;
}
}
}
.tableHeaderCell {
padding: 0.3rem;
height: 36px;
text-align: left;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
color: var(--l1-foreground);
// TODO: Remove this once background color (l1) is matching the actual background color of the page
&[data-dark-mode='true'] {
background: #0b0c0d;
}
&[data-dark-mode='false'] {
background: #fdfdfd;
}
}
.tableRowExpansion {
display: table-row;
}
.tableCellExpansion {
padding: 0.5rem;
vertical-align: top;
}

View File

@@ -1,572 +0,0 @@
import type { ComponentProps, CSSProperties } from 'react';
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import type { TableComponents } from 'react-virtuoso';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { LoadingOutlined } from '@ant-design/icons';
import { DndContext, pointerWithin } from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import {
ComboboxSimple,
ComboboxSimpleItem,
TooltipProvider,
} from '@signozhq/ui';
import { Pagination } from '@signozhq/ui';
import type { Row } from '@tanstack/react-table';
import {
ColumnDef,
ColumnPinningState,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { Spin } from 'antd';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import TanStackCustomTableRow from './TanStackCustomTableRow';
import TanStackHeaderRow from './TanStackHeaderRow';
import {
ColumnVisibilitySync,
TableLoadingSync,
TanStackTableStateProvider,
} from './TanStackTableStateContext';
import {
FlatItem,
TableRowContext,
TanStackTableHandle,
TanStackTableProps,
} from './types';
import { useColumnDnd } from './useColumnDnd';
import { useColumnHandlers } from './useColumnHandlers';
import { useColumnState } from './useColumnState';
import { useEffectiveData } from './useEffectiveData';
import { useFlatItems } from './useFlatItems';
import { useRowKeyData } from './useRowKeyData';
import { useTableParams } from './useTableParams';
import { buildTanstackColumnDef } from './utils';
import { VirtuosoTableColGroup } from './VirtuosoTableColGroup';
import tableStyles from './TanStackTable.module.scss';
import viewStyles from './TanStackTableView.module.scss';
const COLUMN_DND_AUTO_SCROLL = {
layoutShiftCompensation: false as const,
threshold: { x: 0.2, y: 0 },
};
const INCREASE_VIEWPORT_BY = { top: 500, bottom: 500 };
const noopColumnVisibility = (): void => {};
const paginationPageSizeItems: ComboboxSimpleItem[] = [10, 20, 30, 50, 100].map(
(value) => ({
value: value.toString(),
label: value.toString(),
displayValue: value.toString(),
}),
);
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackTableInner<TData>(
{
data,
columns,
columnStorageKey,
columnSizing: columnSizingProp,
onColumnSizingChange,
onColumnOrderChange,
onColumnRemove,
isLoading = false,
skeletonRowCount = 10,
enableQueryParams,
pagination,
onEndReached,
getRowKey,
getItemKey,
groupBy,
getGroupKey,
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
activeRowIndex,
renderExpandedRow,
getRowCanExpand,
tableScrollerProps,
plainTextCellLineClamp,
cellTypographySize,
className,
testId,
prefixPaginationContent,
suffixPaginationContent,
}: TanStackTableProps<TData>,
forwardedRef: React.ForwardedRef<TanStackTableHandle>,
): JSX.Element {
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
const isDarkMode = useIsDarkMode();
const {
page,
limit,
setPage,
setLimit,
orderBy,
setOrderBy,
expanded,
setExpanded,
} = useTableParams(enableQueryParams, {
page: pagination?.defaultPage,
limit: pagination?.defaultLimit,
});
const isGrouped = (groupBy?.length ?? 0) > 0;
const {
columnVisibility: storeVisibility,
columnSizing: storeSizing,
sortedColumns,
hideColumn,
setColumnSizing: storeSetSizing,
setColumnOrder: storeSetOrder,
} = useColumnState({
storageKey: columnStorageKey,
columns,
isGrouped,
});
// Use store values when columnStorageKey is provided, otherwise fall back to props/defaults
const effectiveColumns = columnStorageKey ? sortedColumns : columns;
const effectiveVisibility = columnStorageKey ? storeVisibility : {};
const effectiveSizing = columnStorageKey
? storeSizing
: columnSizingProp ?? {};
const effectiveData = useEffectiveData<TData>({
data,
isLoading,
limit,
skeletonRowCount,
});
const { rowKeyData, getRowKeyData } = useRowKeyData({
data: effectiveData,
isLoading,
getRowKey,
getItemKey,
groupBy,
getGroupKey,
});
const {
handleColumnSizingChange,
handleColumnOrderChange,
handleRemoveColumn,
} = useColumnHandlers({
columnStorageKey,
effectiveSizing,
storeSetSizing,
storeSetOrder,
hideColumn,
onColumnSizingChange,
onColumnOrderChange,
onColumnRemove,
});
const columnPinning = useMemo<ColumnPinningState>(
() => ({
left: effectiveColumns.filter((c) => c.pin === 'left').map((c) => c.id),
right: effectiveColumns.filter((c) => c.pin === 'right').map((c) => c.id),
}),
[effectiveColumns],
);
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
() =>
effectiveColumns.map((colDef) =>
buildTanstackColumnDef(colDef, isRowActive, getRowKeyData),
),
[effectiveColumns, isRowActive, getRowKeyData],
);
const getRowId = useCallback(
(row: TData, index: number): string => {
if (rowKeyData) {
return rowKeyData[index]?.finalKey ?? String(index);
}
const r = row as Record<string, unknown>;
if (r != null && typeof r.id !== 'undefined') {
return String(r.id);
}
return String(index);
},
[rowKeyData],
);
const tableGetRowCanExpand = useCallback(
(row: Row<TData>): boolean =>
getRowCanExpand ? getRowCanExpand(row.original) : true,
[getRowCanExpand],
);
const table = useReactTable({
data: effectiveData,
columns: tanstackColumns,
enableColumnResizing: true,
enableColumnPinning: true,
columnResizeMode: 'onChange',
getCoreRowModel: getCoreRowModel(),
getRowId,
enableExpanding: Boolean(renderExpandedRow),
getRowCanExpand: renderExpandedRow ? tableGetRowCanExpand : undefined,
onColumnSizingChange: handleColumnSizingChange,
onColumnVisibilityChange: noopColumnVisibility,
onExpandedChange: setExpanded,
state: {
columnSizing: effectiveSizing,
columnVisibility: effectiveVisibility,
columnPinning,
expanded,
},
});
// Keep refs to avoid recreating virtuosoComponents on every resize/render
const tableRef = useRef(table);
tableRef.current = table;
const columnsRef = useRef(effectiveColumns);
columnsRef.current = effectiveColumns;
const tableRows = table.getRowModel().rows;
const { flatItems, flatIndexForActiveRow } = useFlatItems({
tableRows,
renderExpandedRow,
expanded,
activeRowIndex,
});
// keep previous count just to avoid flashing the pagination component
const prevTotalCountRef = useRef(pagination?.total || 0);
if (pagination?.total && pagination?.total > 0) {
prevTotalCountRef.current = pagination?.total;
}
const effectiveTotalCount = !isLoading
? pagination?.total || 0
: prevTotalCountRef.current;
useEffect(() => {
if (flatIndexForActiveRow < 0) {
return;
}
virtuosoRef.current?.scrollToIndex({
index: flatIndexForActiveRow,
align: 'center',
behavior: 'auto',
});
}, [flatIndexForActiveRow]);
const { sensors, columnIds, handleDragEnd } = useColumnDnd({
columns: effectiveColumns,
onColumnOrderChange: handleColumnOrderChange,
});
const hasSingleColumn = useMemo(
() =>
effectiveColumns.filter((c) => !c.pin && c.enableRemove !== false).length <=
1,
[effectiveColumns],
);
const canRemoveColumn = !hasSingleColumn;
const flatHeaders = useMemo(
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tanstackColumns, columnPinning, effectiveVisibility],
);
const columnsById = useMemo(
() => new Map(effectiveColumns.map((c) => [c.id, c] as const)),
[effectiveColumns],
);
const visibleColumnsCount = table.getVisibleFlatColumns().length;
const columnOrderKey = useMemo(() => columnIds.join(','), [columnIds]);
const columnVisibilityKey = useMemo(
() =>
table
.getVisibleFlatColumns()
.map((c) => c.id)
.join(','),
// we want to explicitly have table out of this deps
// eslint-disable-next-line react-hooks/exhaustive-deps
[effectiveVisibility, columnIds],
);
const virtuosoContext = useMemo<TableRowContext<TData>>(
() => ({
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
colCount: visibleColumnsCount,
isDarkMode,
plainTextCellLineClamp,
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
}),
[
getRowStyle,
getRowClassName,
isRowActive,
renderRowActions,
onRowClick,
onRowClickNewTab,
onRowDeactivate,
renderExpandedRow,
getRowKeyData,
visibleColumnsCount,
isDarkMode,
plainTextCellLineClamp,
hasSingleColumn,
columnOrderKey,
columnVisibilityKey,
],
);
const tableHeader = useCallback(() => {
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragEnd={handleDragEnd}
autoScroll={COLUMN_DND_AUTO_SCROLL}
>
<SortableContext items={columnIds} strategy={horizontalListSortingStrategy}>
<tr>
{flatHeaders.map((header, index) => {
const column = columnsById.get(header.id);
if (!column) {
return null;
}
return (
<TanStackHeaderRow
key={header.id}
column={column}
header={header}
isDarkMode={isDarkMode}
hasSingleColumn={hasSingleColumn}
onRemoveColumn={handleRemoveColumn}
canRemoveColumn={canRemoveColumn}
orderBy={orderBy}
onSort={setOrderBy}
isLastColumn={index === flatHeaders.length - 1}
/>
);
})}
</tr>
</SortableContext>
</DndContext>
);
}, [
sensors,
handleDragEnd,
columnIds,
flatHeaders,
columnsById,
isDarkMode,
hasSingleColumn,
handleRemoveColumn,
canRemoveColumn,
orderBy,
setOrderBy,
]);
const handleEndReached = useCallback(
(index: number): void => {
onEndReached?.(index);
},
[onEndReached],
);
const isInfiniteScrollMode = Boolean(onEndReached);
const showInfiniteScrollLoader = isInfiniteScrollMode && isLoading;
useImperativeHandle(
forwardedRef,
(): TanStackTableHandle =>
new Proxy(
{
goToPage: (p: number): void => {
setPage(p);
virtuosoRef.current?.scrollToIndex({
index: 0,
align: 'start',
});
},
} as TanStackTableHandle,
{
get(target, prop): unknown {
if (prop in target) {
return Reflect.get(target, prop);
}
const v = (virtuosoRef.current as unknown) as Record<string, unknown>;
const value = v?.[prop as string];
if (typeof value === 'function') {
return (value as (...a: unknown[]) => unknown).bind(virtuosoRef.current);
}
return value;
},
},
),
[setPage],
);
const showPagination = Boolean(pagination && !onEndReached);
const { className: tableScrollerClassName, ...restTableScrollerProps } =
tableScrollerProps ?? {};
const cellTypographyClass = useMemo((): string | undefined => {
if (cellTypographySize === 'small') {
return viewStyles.cellTypographySmall;
}
if (cellTypographySize === 'medium') {
return viewStyles.cellTypographyMedium;
}
if (cellTypographySize === 'large') {
return viewStyles.cellTypographyLarge;
}
return undefined;
}, [cellTypographySize]);
const virtuosoClassName = useMemo(
() =>
cx(
viewStyles.tanstackTableVirtuosoScroll,
cellTypographyClass,
tableScrollerClassName,
),
[cellTypographyClass, tableScrollerClassName],
);
const virtuosoTableStyle = useMemo(
() =>
({
'--tanstack-plain-body-line-clamp': plainTextCellLineClamp,
} as CSSProperties),
[plainTextCellLineClamp],
);
type VirtuosoTableComponentProps = ComponentProps<
NonNullable<TableComponents<FlatItem<TData>, TableRowContext<TData>>['Table']>
>;
// Use refs in virtuosoComponents to keep the component reference stable during resize
// This prevents Virtuoso from re-rendering all rows when columns are resized
const virtuosoComponents = useMemo(
() => ({
Table: ({ style, children }: VirtuosoTableComponentProps): JSX.Element => (
<table className={tableStyles.tanStackTable} style={style}>
<VirtuosoTableColGroup
columns={columnsRef.current}
table={tableRef.current}
/>
{children}
</table>
),
TableRow: TanStackCustomTableRow,
}),
[],
);
return (
<div className={cx(viewStyles.tanstackTableViewWrapper, className)}>
<TanStackTableStateProvider>
<TableLoadingSync
isLoading={isLoading}
isInfiniteScrollMode={isInfiniteScrollMode}
/>
<ColumnVisibilitySync visibility={effectiveVisibility} />
<TooltipProvider>
<TableVirtuoso<FlatItem<TData>, TableRowContext<TData>>
className={virtuosoClassName}
ref={virtuosoRef}
{...restTableScrollerProps}
data={flatItems}
totalCount={flatItems.length}
context={virtuosoContext}
increaseViewportBy={INCREASE_VIEWPORT_BY}
initialTopMostItemIndex={
flatIndexForActiveRow >= 0 ? flatIndexForActiveRow : 0
}
fixedHeaderContent={tableHeader}
style={virtuosoTableStyle}
components={virtuosoComponents}
endReached={onEndReached ? handleEndReached : undefined}
data-testid={testId}
/>
{showInfiniteScrollLoader && (
<div
className={viewStyles.tanstackLoadingOverlay}
data-testid="tanstack-infinite-loader"
>
<Spin indicator={<LoadingOutlined spin />} tip="Loading more..." />
</div>
)}
{showPagination && pagination && (
<div className={viewStyles.paginationContainer}>
{prefixPaginationContent}
<Pagination
current={page}
pageSize={limit}
total={effectiveTotalCount}
onPageChange={(p): void => {
setPage(p);
}}
/>
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => setLimit(+value)}
items={paginationPageSizeItems}
/>
</div>
{suffixPaginationContent}
</div>
)}
</TooltipProvider>
</TanStackTableStateProvider>
</div>
);
}
const TanStackTableForward = forwardRef(TanStackTableInner) as <TData>(
props: TanStackTableProps<TData> & {
ref?: React.Ref<TanStackTableHandle>;
},
) => JSX.Element;
export const TanStackTableBase = memo(
TanStackTableForward,
) as typeof TanStackTableForward;

View File

@@ -1,21 +0,0 @@
.headerSkeleton {
width: 60% !important;
min-width: 50px !important;
height: 16px !important;
:global(.ant-skeleton-input) {
min-width: 50px !important;
height: 16px !important;
}
}
.cellSkeleton {
width: 80% !important;
min-width: 40px !important;
height: 14px !important;
:global(.ant-skeleton-input) {
min-width: 40px !important;
height: 14px !important;
}
}

View File

@@ -1,72 +0,0 @@
import { useMemo } from 'react';
import type { ColumnSizingState } from '@tanstack/react-table';
import { Skeleton } from 'antd';
import { TableColumnDef } from './types';
import { getColumnWidthStyle } from './utils';
import tableStyles from './TanStackTable.module.scss';
import styles from './TanStackTableSkeleton.module.scss';
type TanStackTableSkeletonProps<TData> = {
columns: TableColumnDef<TData>[];
rowCount: number;
isDarkMode: boolean;
columnSizing?: ColumnSizingState;
};
export function TanStackTableSkeleton<TData>({
columns,
rowCount,
isDarkMode,
columnSizing,
}: TanStackTableSkeletonProps<TData>): JSX.Element {
const rows = useMemo(() => Array.from({ length: rowCount }, (_, i) => i), [
rowCount,
]);
return (
<table className={tableStyles.tanStackTable}>
<colgroup>
{columns.map((column, index) => (
<col
key={column.id}
style={getColumnWidthStyle(
column,
columnSizing?.[column.id],
index === columns.length - 1,
)}
/>
))}
</colgroup>
<thead>
<tr>
{columns.map((column) => (
<th
key={column.id}
className={tableStyles.tableHeaderCell}
data-dark-mode={isDarkMode}
>
{typeof column.header === 'function' ? (
<Skeleton.Input active size="small" className={styles.headerSkeleton} />
) : (
column.header
)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((rowIndex) => (
<tr key={rowIndex} className={tableStyles.tableRow}>
{columns.map((column) => (
<td key={column.id} className={tableStyles.tableCell}>
<Skeleton.Input active size="small" className={styles.cellSkeleton} />
</td>
))}
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -1,206 +0,0 @@
/* eslint-disable no-restricted-imports */
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
} from 'react';
/* eslint-enable no-restricted-imports */
import { VisibilityState } from '@tanstack/react-table';
import { createStore, StoreApi, useStore } from 'zustand';
const CLEAR_HOVER_DELAY_MS = 100;
type TanStackTableState = {
hoveredRowId: string | null;
clearTimeoutId: ReturnType<typeof setTimeout> | null;
setHoveredRowId: (id: string | null) => void;
scheduleClearHover: (rowId: string) => void;
isLoading: boolean;
setIsLoading: (loading: boolean) => void;
isInfiniteScrollMode: boolean;
setIsInfiniteScrollMode: (enabled: boolean) => void;
columnVisibility: VisibilityState;
setColumnVisibility: (visibility: VisibilityState) => void;
};
const createTableStateStore = (): StoreApi<TanStackTableState> =>
createStore<TanStackTableState>((set, get) => ({
hoveredRowId: null,
clearTimeoutId: null,
setHoveredRowId: (id: string | null): void => {
const { clearTimeoutId } = get();
if (clearTimeoutId) {
clearTimeout(clearTimeoutId);
set({ clearTimeoutId: null });
}
set({ hoveredRowId: id });
},
scheduleClearHover: (rowId: string): void => {
const { clearTimeoutId } = get();
if (clearTimeoutId) {
clearTimeout(clearTimeoutId);
}
const timeoutId = setTimeout(() => {
const current = get().hoveredRowId;
if (current === rowId) {
set({ hoveredRowId: null, clearTimeoutId: null });
}
}, CLEAR_HOVER_DELAY_MS);
set({ clearTimeoutId: timeoutId });
},
isLoading: false,
setIsLoading: (loading: boolean): void => {
set({ isLoading: loading });
},
isInfiniteScrollMode: false,
setIsInfiniteScrollMode: (enabled: boolean): void => {
set({ isInfiniteScrollMode: enabled });
},
columnVisibility: {},
setColumnVisibility: (visibility: VisibilityState): void => {
set({ columnVisibility: visibility });
},
}));
type TableStateStore = StoreApi<TanStackTableState>;
const TanStackTableStateContext = createContext<TableStateStore | null>(null);
export function TanStackTableStateProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const storeRef = useRef<TableStateStore | null>(null);
if (!storeRef.current) {
storeRef.current = createTableStateStore();
}
return (
<TanStackTableStateContext.Provider value={storeRef.current}>
{children}
</TanStackTableStateContext.Provider>
);
}
const defaultStore = createTableStateStore();
export const useIsRowHovered = (rowId: string): boolean => {
const store = useContext(TanStackTableStateContext);
const isHovered = useStore(
store ?? defaultStore,
(s) => s.hoveredRowId === rowId,
);
return store ? isHovered : false;
};
export const useSetRowHovered = (rowId: string): (() => void) => {
const store = useContext(TanStackTableStateContext);
return useCallback(() => {
if (store) {
const current = store.getState().hoveredRowId;
if (current !== rowId) {
store.getState().setHoveredRowId(rowId);
}
}
}, [store, rowId]);
};
export const useClearRowHovered = (rowId: string): (() => void) => {
const store = useContext(TanStackTableStateContext);
return useCallback(() => {
if (store) {
store.getState().scheduleClearHover(rowId);
}
}, [store, rowId]);
};
export const useIsTableLoading = (): boolean => {
const store = useContext(TanStackTableStateContext);
return useStore(store ?? defaultStore, (s) => s.isLoading);
};
export const useSetTableLoading = (): ((loading: boolean) => void) => {
const store = useContext(TanStackTableStateContext);
return useCallback(
(loading: boolean) => {
if (store) {
store.getState().setIsLoading(loading);
}
},
[store],
);
};
export function TableLoadingSync({
isLoading,
isInfiniteScrollMode,
}: {
isLoading: boolean;
isInfiniteScrollMode: boolean;
}): null {
const store = useContext(TanStackTableStateContext);
// Sync on mount and when props change
useEffect(() => {
if (store) {
store.getState().setIsLoading(isLoading);
store.getState().setIsInfiniteScrollMode(isInfiniteScrollMode);
}
}, [isLoading, isInfiniteScrollMode, store]);
return null;
}
export const useShouldShowCellSkeleton = (): boolean => {
const store = useContext(TanStackTableStateContext);
return useStore(
store ?? defaultStore,
(s) => s.isLoading && !s.isInfiniteScrollMode,
);
};
export const useColumnVisibility = (): VisibilityState => {
const store = useContext(TanStackTableStateContext);
return useStore(store ?? defaultStore, (s) => s.columnVisibility);
};
export const useIsColumnVisible = (columnId: string): boolean => {
const store = useContext(TanStackTableStateContext);
return useStore(
store ?? defaultStore,
(s) => s.columnVisibility[columnId] !== false,
);
};
export const useSetColumnVisibility = (): ((
visibility: VisibilityState,
) => void) => {
const store = useContext(TanStackTableStateContext);
return useCallback(
(visibility: VisibilityState) => {
if (store) {
store.getState().setColumnVisibility(visibility);
}
},
[store],
);
};
export function ColumnVisibilitySync({
visibility,
}: {
visibility: VisibilityState;
}): null {
const setVisibility = useSetColumnVisibility();
useEffect(() => {
setVisibility(visibility);
}, [visibility, setVisibility]);
return null;
}
export default TanStackTableStateContext;

View File

@@ -1,42 +0,0 @@
import type { HTMLAttributes, ReactNode } from 'react';
import cx from 'classnames';
import tableStyles from './TanStackTable.module.scss';
type BaseProps = Omit<
HTMLAttributes<HTMLSpanElement>,
'children' | 'dangerouslySetInnerHTML'
> & {
className?: string;
};
type WithChildren = BaseProps & {
children: ReactNode;
dangerouslySetInnerHTML?: never;
};
type WithDangerousHtml = BaseProps & {
children?: never;
dangerouslySetInnerHTML: { __html: string };
};
export type TanStackTableTextProps = WithChildren | WithDangerousHtml;
function TanStackTableText({
children,
className,
dangerouslySetInnerHTML,
...rest
}: TanStackTableTextProps): JSX.Element {
return (
<span
className={cx(tableStyles.tableCellText, className)}
dangerouslySetInnerHTML={dangerouslySetInnerHTML}
{...rest}
>
{children}
</span>
);
}
export default TanStackTableText;

View File

@@ -1,152 +0,0 @@
.tanstackTableViewWrapper {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
position: relative;
min-height: 0;
}
.tanstackFixedCol {
width: 32px;
min-width: 32px;
max-width: 32px;
}
.tanstackFillerCol {
width: 100%;
min-width: 0;
}
.tanstackActionsCol {
width: 0;
min-width: 0;
max-width: 0;
}
.tanstackLoadMoreContainer {
width: 100%;
min-height: 56px;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0 12px;
flex-shrink: 0;
}
.tanstackTableVirtuoso {
width: 100%;
overflow-x: auto;
}
.tanstackTableFootLoaderCell {
text-align: center;
padding: 8px 0;
}
.tanstackTableVirtuosoScroll {
flex: 1;
width: 100%;
overflow-x: auto;
scrollbar-width: thin;
scrollbar-color: var(--bg-slate-300) transparent;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
border-radius: 9999px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
&.cellTypographySmall {
--tanstack-plain-cell-font-size: 11px;
--tanstack-plain-cell-line-height: 16px;
:global(table tr td),
:global(table thead th) {
font-size: 11px;
line-height: 16px;
letter-spacing: -0.07px;
}
}
&.cellTypographyMedium {
--tanstack-plain-cell-font-size: 13px;
--tanstack-plain-cell-line-height: 20px;
:global(table tr td),
:global(table thead th) {
font-size: 13px;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&.cellTypographyLarge {
--tanstack-plain-cell-font-size: 14px;
--tanstack-plain-cell-line-height: 24px;
:global(table tr td),
:global(table thead th) {
font-size: 14px;
line-height: 24px;
letter-spacing: -0.07px;
}
}
}
.paginationContainer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
}
.paginationPageSize {
width: 80px;
--combobox-trigger-height: 2rem;
}
.tanstackLoadingOverlay {
position: absolute;
left: 50%;
bottom: 2rem;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 3;
border-radius: 8px;
padding: 8px 16px;
background: var(--l1-background);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
:global(.lightMode) .tanstackTableVirtuosoScroll {
scrollbar-color: var(--bg-vanilla-300) transparent;
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-100);
}
}

View File

@@ -1,35 +0,0 @@
import type { Table } from '@tanstack/react-table';
import type { TableColumnDef } from './types';
import { getColumnWidthStyle } from './utils';
export function VirtuosoTableColGroup<TData>({
columns,
table,
}: {
columns: TableColumnDef<TData>[];
table: Table<TData>;
}): JSX.Element {
const visibleTanstackColumns = table.getVisibleFlatColumns();
const columnDefsById = new Map(columns.map((c) => [c.id, c]));
const columnSizing = table.getState().columnSizing;
return (
<colgroup>
{visibleTanstackColumns.map((tanstackCol, index) => {
const colDef = columnDefsById.get(tanstackCol.id);
if (!colDef) {
return <col key={tanstackCol.id} />;
}
const persistedWidth = columnSizing[tanstackCol.id];
const isLastColumn = index === visibleTanstackColumns.length - 1;
return (
<col
key={tanstackCol.id}
style={getColumnWidthStyle(colDef, persistedWidth, isLastColumn)}
/>
);
})}
</colgroup>
);
}

View File

@@ -1,253 +0,0 @@
jest.mock('../TanStackTable.module.scss', () => ({
__esModule: true,
default: {
tableRow: 'tableRow',
tableRowActive: 'tableRowActive',
tableRowExpansion: 'tableRowExpansion',
},
}));
jest.mock('../TanStackRow', () => ({
__esModule: true,
default: (): JSX.Element => (
<td data-testid="mocked-row-cells">mocked cells</td>
),
}));
const mockSetRowHovered = jest.fn();
const mockClearRowHovered = jest.fn();
jest.mock('../TanStackTableStateContext', () => ({
useSetRowHovered: (_rowId: string): (() => void) => mockSetRowHovered,
useClearRowHovered: (_rowId: string): (() => void) => mockClearRowHovered,
}));
import { fireEvent, render, screen } from '@testing-library/react';
import TanStackCustomTableRow from '../TanStackCustomTableRow';
import type { FlatItem, TableRowContext } from '../types';
const makeItem = (id: string): FlatItem<{ id: string }> => ({
kind: 'row',
row: { original: { id }, id } as never,
});
const virtuosoAttrs = {
'data-index': 0,
'data-item-index': 0,
'data-known-size': 40,
} as const;
const baseContext: TableRowContext<{ id: string }> = {
colCount: 1,
hasSingleColumn: false,
columnOrderKey: 'col1',
columnVisibilityKey: 'col1',
};
describe('TanStackCustomTableRow', () => {
beforeEach(() => {
mockSetRowHovered.mockClear();
mockClearRowHovered.mockClear();
});
it('renders cells via TanStackRowCells', async () => {
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
expect(await screen.findByTestId('mocked-row-cells')).toBeInTheDocument();
});
it('applies active class when isRowActive returns true', () => {
const ctx: TableRowContext<{ id: string }> = {
...baseContext,
isRowActive: (row) => row.id === '1',
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx}
/>
</tbody>
</table>,
);
expect(container.querySelector('tr')).toHaveClass('tableRowActive');
});
it('does not apply active class when isRowActive returns false', () => {
const ctx: TableRowContext<{ id: string }> = {
...baseContext,
isRowActive: (row) => row.id === 'other',
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx}
/>
</tbody>
</table>,
);
expect(container.querySelector('tr')).not.toHaveClass('tableRowActive');
});
it('renders expansion row with expansion class', () => {
const item: FlatItem<{ id: string }> = {
kind: 'expansion',
row: { original: { id: '1' }, id: '1' } as never,
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={item}
context={baseContext}
/>
</tbody>
</table>,
);
expect(container.querySelector('tr')).toHaveClass('tableRowExpansion');
});
describe('hover state management', () => {
it('calls setRowHovered on mouse enter', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
fireEvent.mouseEnter(row);
expect(mockSetRowHovered).toHaveBeenCalled();
});
it('calls clearRowHovered on mouse leave', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
fireEvent.mouseLeave(row);
expect(mockClearRowHovered).toHaveBeenCalled();
});
});
describe('virtuoso integration', () => {
it('forwards data-index attribute to tr element', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
expect(row).toHaveAttribute('data-index', '0');
});
it('forwards data-item-index attribute to tr element', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
expect(row).toHaveAttribute('data-item-index', '0');
});
it('forwards data-known-size attribute to tr element', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={baseContext}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
expect(row).toHaveAttribute('data-known-size', '40');
});
});
describe('row interaction', () => {
it('applies custom style from getRowStyle in context', () => {
const ctx: TableRowContext<{ id: string }> = {
...baseContext,
getRowStyle: () => ({ backgroundColor: 'red' }),
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
expect(row).toHaveStyle({ backgroundColor: 'red' });
});
it('applies custom className from getRowClassName in context', () => {
const ctx: TableRowContext<{ id: string }> = {
...baseContext,
getRowClassName: () => 'custom-row-class',
};
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoAttrs}
item={makeItem('1')}
context={ctx}
/>
</tbody>
</table>,
);
const row = container.querySelector('tr')!;
expect(row).toHaveClass('custom-row-class');
});
});
});

View File

@@ -1,368 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TanStackHeaderRow from '../TanStackHeaderRow';
import type { TableColumnDef } from '../types';
jest.mock('@dnd-kit/sortable', () => ({
useSortable: (): any => ({
attributes: {},
listeners: {},
setNodeRef: jest.fn(),
setActivatorNodeRef: jest.fn(),
transform: null,
transition: null,
isDragging: false,
}),
}));
const col = (
id: string,
overrides?: Partial<TableColumnDef<unknown>>,
): TableColumnDef<unknown> => ({
id,
header: id,
cell: (): null => null,
...overrides,
});
const header = {
id: 'col',
column: {
getCanResize: () => true,
getIsResizing: () => false,
columnDef: { header: 'col' },
},
getResizeHandler: () => jest.fn(),
getContext: () => ({}),
} as never;
describe('TanStackHeaderRow', () => {
it('renders column title', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('timestamp', { header: 'timestamp' })}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(screen.getByTitle('Timestamp')).toBeInTheDocument();
});
it('shows grip icon when enableMove is not false and pin is not set', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('body')}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.getByRole('button', { name: /drag body/i }),
).toBeInTheDocument();
});
it('does NOT show grip icon when pin is set', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('indicator', { pin: 'left' })}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.queryByRole('button', { name: /drag/i }),
).not.toBeInTheDocument();
});
it('shows remove button when enableRemove and canRemoveColumn are true', async () => {
const user = userEvent.setup();
const onRemoveColumn = jest.fn();
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('name', { enableRemove: true })}
header={header}
isDarkMode={false}
hasSingleColumn={false}
canRemoveColumn
onRemoveColumn={onRemoveColumn}
/>
</tr>
</thead>
</table>,
);
await user.click(screen.getByRole('button', { name: /column actions/i }));
await user.click(await screen.findByText(/remove column/i));
expect(onRemoveColumn).toHaveBeenCalledWith('name');
});
it('does NOT show remove button when enableRemove is absent', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={col('name')}
header={header}
isDarkMode={false}
hasSingleColumn={false}
canRemoveColumn
onRemoveColumn={jest.fn()}
/>
</tr>
</thead>
</table>,
);
expect(
screen.queryByRole('button', { name: /column actions/i }),
).not.toBeInTheDocument();
});
describe('sorting', () => {
const sortableCol = col('sortable', { enableSort: true, header: 'Sortable' });
const sortableHeader = {
id: 'sortable',
column: {
id: 'sortable',
getCanResize: (): boolean => true,
getIsResizing: (): boolean => false,
columnDef: { header: 'Sortable', enableSort: true },
},
getResizeHandler: (): jest.Mock => jest.fn(),
getContext: (): Record<string, unknown> => ({}),
} as never;
it('calls onSort with asc when clicking unsorted column', async () => {
const user = userEvent.setup();
const onSort = jest.fn();
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={sortableCol}
header={sortableHeader}
isDarkMode={false}
hasSingleColumn={false}
onSort={onSort}
/>
</tr>
</thead>
</table>,
);
// Sort button uses the column header as title
const sortButton = screen.getByTitle('Sortable');
await user.click(sortButton);
expect(onSort).toHaveBeenCalledWith({
columnName: 'sortable',
order: 'asc',
});
});
it('calls onSort with desc when clicking asc-sorted column', async () => {
const user = userEvent.setup();
const onSort = jest.fn();
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={sortableCol}
header={sortableHeader}
isDarkMode={false}
hasSingleColumn={false}
onSort={onSort}
orderBy={{ columnName: 'sortable', order: 'asc' }}
/>
</tr>
</thead>
</table>,
);
const sortButton = screen.getByTitle('Sortable');
await user.click(sortButton);
expect(onSort).toHaveBeenCalledWith({
columnName: 'sortable',
order: 'desc',
});
});
it('calls onSort with null when clicking desc-sorted column', async () => {
const user = userEvent.setup();
const onSort = jest.fn();
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={sortableCol}
header={sortableHeader}
isDarkMode={false}
hasSingleColumn={false}
onSort={onSort}
orderBy={{ columnName: 'sortable', order: 'desc' }}
/>
</tr>
</thead>
</table>,
);
const sortButton = screen.getByTitle('Sortable');
await user.click(sortButton);
expect(onSort).toHaveBeenCalledWith(null);
});
it('shows ascending indicator when orderBy matches column with asc', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={sortableCol}
header={sortableHeader}
isDarkMode={false}
hasSingleColumn={false}
onSort={jest.fn()}
orderBy={{ columnName: 'sortable', order: 'asc' }}
/>
</tr>
</thead>
</table>,
);
const sortButton = screen.getByTitle('Sortable');
expect(sortButton).toHaveAttribute('data-sort', 'ascending');
});
it('shows descending indicator when orderBy matches column with desc', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={sortableCol}
header={sortableHeader}
isDarkMode={false}
hasSingleColumn={false}
onSort={jest.fn()}
orderBy={{ columnName: 'sortable', order: 'desc' }}
/>
</tr>
</thead>
</table>,
);
const sortButton = screen.getByTitle('Sortable');
expect(sortButton).toHaveAttribute('data-sort', 'descending');
});
it('does not show sort button when enableSort is false', () => {
const nonSortableCol = col('nonsort', {
enableSort: false,
header: 'Nonsort',
});
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={nonSortableCol}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
// When enableSort is false, the header text is rendered as a span, not a button
// The title 'Nonsort' exists on the span, not on a button
const titleElement = screen.getByTitle('Nonsort');
expect(titleElement.tagName.toLowerCase()).not.toBe('button');
});
});
describe('resizing', () => {
it('shows resize handle when enableResize is not false', () => {
const resizableCol = col('resizable', { enableResize: true });
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={resizableCol}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
// Resize handle has title "Drag to resize column"
expect(screen.getByTitle('Drag to resize column')).toBeInTheDocument();
});
it('hides resize handle when enableResize is false', () => {
const nonResizableCol = col('noresize', { enableResize: false });
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={nonResizableCol}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(screen.queryByTitle('Drag to resize column')).not.toBeInTheDocument();
});
});
describe('column movement', () => {
it('does not show grip when enableMove is false', () => {
const noMoveCol = col('nomove', { enableMove: false });
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={noMoveCol}
header={header}
isDarkMode={false}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.queryByRole('button', { name: /drag/i }),
).not.toBeInTheDocument();
});
});
});

View File

@@ -1,288 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TanStackRowCells from '../TanStackRow';
import type { TableRowContext } from '../types';
const flexRenderMock = jest.fn((def: unknown) =>
typeof def === 'function' ? def({}) : def,
);
jest.mock('@tanstack/react-table', () => ({
flexRender: (def: unknown, _ctx?: unknown): unknown => flexRenderMock(def),
}));
type Row = { id: string };
function buildMockRow(
cells: { id: string }[],
rowData: Row = { id: 'r1' },
): Parameters<typeof TanStackRowCells>[0]['row'] {
return {
original: rowData,
getVisibleCells: () =>
cells.map((c, i) => ({
id: `cell-${i}`,
column: {
id: c.id,
columnDef: { cell: (): string => `content-${c.id}` },
},
getContext: (): Record<string, unknown> => ({}),
getValue: (): string => `content-${c.id}`,
})),
} as never;
}
describe('TanStackRowCells', () => {
beforeEach(() => flexRenderMock.mockClear());
it('renders a cell per visible column', () => {
const row = buildMockRow([{ id: 'col-a' }, { id: 'col-b' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={undefined}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
expect(screen.getAllByRole('cell')).toHaveLength(2);
});
it('calls onRowClick when a cell is clicked', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
// onRowClick receives (rowData, itemKey) - itemKey is empty when getRowKeyData not provided
expect(onRowClick).toHaveBeenCalledWith({ id: 'r1' }, '');
});
it('calls onRowDeactivate instead of onRowClick when row is active', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowDeactivate,
isRowActive: () => true,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
});
it('does not render renderRowActions before hover', () => {
const ctx: TableRowContext<Row> = {
colCount: 1,
renderRowActions: () => <button type="button">action</button>,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
// Row actions are not rendered until hover (useIsRowHovered returns false by default)
expect(
screen.queryByRole('button', { name: 'action' }),
).not.toBeInTheDocument();
});
it('renders expansion cell with renderExpandedRow content', async () => {
const row = {
original: { id: 'r1' },
getVisibleCells: () => [],
} as never;
const ctx: TableRowContext<Row> = {
colCount: 3,
renderExpandedRow: (r) => <div>expanded-{r.id}</div>,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="expansion"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
expect(await screen.findByText('expanded-r1')).toBeInTheDocument();
});
describe('new tab click', () => {
it('calls onRowClickNewTab on ctrl+click', () => {
const onRowClick = jest.fn();
const onRowClickNewTab = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowClickNewTab,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
expect(onRowClick).not.toHaveBeenCalled();
});
it('calls onRowClickNewTab on meta+click (cmd)', () => {
const onRowClick = jest.fn();
const onRowClickNewTab = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowClickNewTab,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0], { metaKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith({ id: 'r1' }, '');
expect(onRowClick).not.toHaveBeenCalled();
});
it('does not call onRowClick when modifier key is pressed', () => {
const onRowClick = jest.fn();
const onRowClickNewTab = jest.fn();
const ctx: TableRowContext<Row> = {
colCount: 1,
onRowClick,
onRowClickNewTab,
hasSingleColumn: false,
columnOrderKey: '',
columnVisibilityKey: '',
};
const row = buildMockRow([{ id: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells<Row>
row={row as never}
context={ctx}
itemKind="row"
hasSingleColumn={false}
columnOrderKey=""
columnVisibilityKey=""
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0], { ctrlKey: true });
expect(onRowClick).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,440 +0,0 @@
import { fireEvent, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
import { renderTanStackTable } from './testUtils';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('../TanStackTable.module.scss', () => ({
__esModule: true,
default: {
tanStackTable: 'tanStackTable',
tableRow: 'tableRow',
tableRowActive: 'tableRowActive',
tableRowExpansion: 'tableRowExpansion',
tableCell: 'tableCell',
tableCellExpansion: 'tableCellExpansion',
tableHeaderCell: 'tableHeaderCell',
tableCellText: 'tableCellText',
tableViewRowActions: 'tableViewRowActions',
},
}));
describe('TanStackTableView Integration', () => {
describe('rendering', () => {
it('renders all data rows', async () => {
renderTanStackTable({});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
expect(screen.getByText('Item 2')).toBeInTheDocument();
expect(screen.getByText('Item 3')).toBeInTheDocument();
});
});
it('renders column headers', async () => {
renderTanStackTable({});
await waitFor(() => {
expect(screen.getByText('ID')).toBeInTheDocument();
expect(screen.getByText('Name')).toBeInTheDocument();
expect(screen.getByText('Value')).toBeInTheDocument();
});
});
it('renders empty state when data is empty and not loading', async () => {
renderTanStackTable({
props: { data: [], isLoading: false },
});
// Table should still render but with no data rows
await waitFor(() => {
expect(screen.queryByText('Item 1')).not.toBeInTheDocument();
});
});
it('renders table structure when loading with no previous data', async () => {
renderTanStackTable({
props: { data: [], isLoading: true },
});
// Table should render with skeleton rows
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
});
});
describe('loading states', () => {
it('keeps table mounted when loading with no data', () => {
renderTanStackTable({
props: { data: [], isLoading: true },
});
// Table should still be in the DOM for skeleton rows
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('shows loading spinner for infinite scroll when loading', () => {
renderTanStackTable({
props: { isLoading: true, onEndReached: jest.fn() },
});
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
});
it('does not show loading spinner for infinite scroll when not loading', () => {
renderTanStackTable({
props: { isLoading: false, onEndReached: jest.fn() },
});
expect(
screen.queryByTestId('tanstack-infinite-loader'),
).not.toBeInTheDocument();
});
it('does not show loading spinner when not in infinite scroll mode', () => {
renderTanStackTable({
props: { isLoading: true },
});
expect(
screen.queryByTestId('tanstack-infinite-loader'),
).not.toBeInTheDocument();
});
});
describe('pagination', () => {
it('renders pagination when pagination prop is provided', async () => {
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
},
});
await waitFor(() => {
// Look for pagination navigation or page number text
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
});
it('updates page when clicking page number', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
enableQueryParams: true,
},
onUrlUpdate,
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
// Find page 2 button/link within pagination navigation
const nav = screen.getByRole('navigation');
const page2 = Array.from(nav.querySelectorAll('button')).find(
(btn) => btn.textContent?.trim() === '2',
);
if (!page2) {
throw new Error('Page 2 button not found in pagination');
}
await user.click(page2);
await waitFor(() => {
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('2');
});
});
it('does not render pagination in infinite scroll mode', async () => {
renderTanStackTable({
props: {
pagination: { total: 100 },
onEndReached: jest.fn(), // This enables infinite scroll mode
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Pagination should not be visible in infinite scroll mode
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
it('renders prefixPaginationContent before pagination', async () => {
renderTanStackTable({
props: {
pagination: { total: 100 },
prefixPaginationContent: <span data-testid="prefix-content">Prefix</span>,
},
});
await waitFor(() => {
expect(screen.getByTestId('prefix-content')).toBeInTheDocument();
});
});
it('renders suffixPaginationContent after pagination', async () => {
renderTanStackTable({
props: {
pagination: { total: 100 },
suffixPaginationContent: <span data-testid="suffix-content">Suffix</span>,
},
});
await waitFor(() => {
expect(screen.getByTestId('suffix-content')).toBeInTheDocument();
});
});
});
describe('sorting', () => {
it('updates orderBy URL param when clicking sortable header', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
renderTanStackTable({
props: { enableQueryParams: true },
onUrlUpdate,
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Find the sortable column header's sort button (ID column has enableSort: true)
const sortButton = screen.getByTitle('ID');
await user.click(sortButton);
await waitFor(() => {
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('order_by'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
const parsed = JSON.parse(lastOrderBy!);
expect(parsed.columnName).toBe('id');
expect(parsed.order).toBe('asc');
});
});
it('toggles sort order on subsequent clicks', async () => {
const user = userEvent.setup();
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
renderTanStackTable({
props: { enableQueryParams: true },
onUrlUpdate,
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
const sortButton = screen.getByTitle('ID');
// First click - asc
await user.click(sortButton);
// Second click - desc
await user.click(sortButton);
await waitFor(() => {
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('order_by'))
.filter(Boolean)
.pop();
if (lastOrderBy) {
const parsed = JSON.parse(lastOrderBy);
expect(parsed.order).toBe('desc');
}
});
});
});
describe('row selection', () => {
it('calls onRowClick with row data and itemKey', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
renderTanStackTable({
props: {
onRowClick,
getRowKey: (row) => row.id,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
await user.click(screen.getByText('Item 1'));
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: '1', name: 'Item 1' }),
'1',
);
});
it('applies active class when isRowActive returns true', async () => {
renderTanStackTable({
props: {
isRowActive: (row) => row.id === '1',
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Find the row containing Item 1 and check for active class
const cell = screen.getByText('Item 1');
const row = cell.closest('tr');
expect(row).toHaveClass('tableRowActive');
});
it('calls onRowDeactivate when clicking active row', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
const onRowDeactivate = jest.fn();
renderTanStackTable({
props: {
onRowClick,
onRowDeactivate,
isRowActive: (row) => row.id === '1',
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
await user.click(screen.getByText('Item 1'));
expect(onRowDeactivate).toHaveBeenCalled();
expect(onRowClick).not.toHaveBeenCalled();
});
it('opens in new tab on ctrl+click', async () => {
const onRowClick = jest.fn();
const onRowClickNewTab = jest.fn();
renderTanStackTable({
props: {
onRowClick,
onRowClickNewTab,
getRowKey: (row) => row.id,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Item 1'), { ctrlKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith(
expect.objectContaining({ id: '1' }),
'1',
);
expect(onRowClick).not.toHaveBeenCalled();
});
it('opens in new tab on meta+click', async () => {
const onRowClick = jest.fn();
const onRowClickNewTab = jest.fn();
renderTanStackTable({
props: {
onRowClick,
onRowClickNewTab,
getRowKey: (row) => row.id,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Item 1'), { metaKey: true });
expect(onRowClickNewTab).toHaveBeenCalledWith(
expect.objectContaining({ id: '1' }),
'1',
);
expect(onRowClick).not.toHaveBeenCalled();
});
});
describe('row expansion', () => {
it('renders expanded content below the row when expanded', async () => {
renderTanStackTable({
props: {
renderExpandedRow: (row) => (
<div data-testid="expanded-content">Expanded: {row.name}</div>
),
getRowCanExpand: () => true,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Find and click expand button (if available in the row)
// The expansion is controlled by TanStack Table's expanded state
// For now, just verify the renderExpandedRow prop is wired correctly
// by checking the table renders without errors
expect(screen.getByRole('table')).toBeInTheDocument();
});
});
describe('infinite scroll', () => {
it('calls onEndReached when provided', async () => {
const onEndReached = jest.fn();
renderTanStackTable({
props: {
onEndReached,
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// Virtuoso will call onEndReached based on scroll position
// In mock context, we verify the prop is wired correctly
expect(onEndReached).toBeDefined();
});
it('shows loading spinner at bottom when loading in infinite scroll mode', () => {
renderTanStackTable({
props: {
isLoading: true,
onEndReached: jest.fn(),
},
});
expect(screen.getByTestId('tanstack-infinite-loader')).toBeInTheDocument();
});
it('hides pagination in infinite scroll mode', async () => {
renderTanStackTable({
props: {
pagination: { total: 100 },
onEndReached: jest.fn(),
},
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
// When onEndReached is provided, pagination should not render
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
});
});

View File

@@ -1,94 +0,0 @@
import { ReactNode } from 'react';
import { VirtuosoMockContext } from 'react-virtuoso';
import { TooltipProvider } from '@signozhq/ui';
import { render, RenderResult } from '@testing-library/react';
import { NuqsTestingAdapter, OnUrlUpdateFunction } from 'nuqs/adapters/testing';
import TanStackTable from '../index';
import type { TableColumnDef, TanStackTableProps } from '../types';
// NOTE: Test files importing this utility must add this mock at the top of their file:
// jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: (): boolean => false }));
// Default test data types
export type TestRow = { id: string; name: string; value: number };
export const defaultColumns: TableColumnDef<TestRow>[] = [
{
id: 'id',
header: 'ID',
accessorKey: 'id',
enableSort: true,
cell: ({ value }): string => String(value),
},
{
id: 'name',
header: 'Name',
accessorKey: 'name',
cell: ({ value }): string => String(value),
},
{
id: 'value',
header: 'Value',
accessorKey: 'value',
enableSort: true,
cell: ({ value }): string => String(value),
},
];
export const defaultData: TestRow[] = [
{ id: '1', name: 'Item 1', value: 100 },
{ id: '2', name: 'Item 2', value: 200 },
{ id: '3', name: 'Item 3', value: 300 },
];
export type RenderTanStackTableOptions<T> = {
props?: Partial<TanStackTableProps<T>>;
queryParams?: Record<string, string>;
onUrlUpdate?: OnUrlUpdateFunction;
};
export function renderTanStackTable<T = TestRow>(
options: RenderTanStackTableOptions<T> = {},
): RenderResult {
const { props = {}, queryParams, onUrlUpdate } = options;
const mergedProps = {
data: (defaultData as unknown) as T[],
columns: (defaultColumns as unknown) as TableColumnDef<T>[],
...props,
} as TanStackTableProps<T>;
return render(
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 500, itemHeight: 50 }}
>
<TooltipProvider>
<TanStackTable<T> {...mergedProps} />
</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>,
);
}
// Helper to wrap any component with test providers (for unit tests)
export function renderWithProviders(
ui: ReactNode,
options: {
queryParams?: Record<string, string>;
onUrlUpdate?: OnUrlUpdateFunction;
} = {},
): RenderResult {
const { queryParams, onUrlUpdate } = options;
return render(
<NuqsTestingAdapter searchParams={queryParams} onUrlUpdate={onUrlUpdate}>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 500, itemHeight: 50 }}
>
<TooltipProvider>{ui}</TooltipProvider>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>,
);
}

View File

@@ -1,247 +0,0 @@
/* eslint-disable no-restricted-syntax */
import { act, renderHook } from '@testing-library/react';
import { TableColumnDef } from '../types';
import { useColumnState } from '../useColumnState';
import { useColumnStore } from '../useColumnStore';
const TEST_KEY = 'test-state';
type TestRow = { id: string; name: string };
const col = (
id: string,
overrides: Partial<TableColumnDef<TestRow>> = {},
): TableColumnDef<TestRow> => ({
id,
header: id,
cell: (): null => null,
...overrides,
});
describe('useColumnState', () => {
beforeEach(() => {
useColumnStore.setState({ tables: {} });
localStorage.clear();
});
describe('initialization', () => {
it('initializes store from column defaults on mount', () => {
const columns = [
col('a', { defaultVisibility: true }),
col('b', { defaultVisibility: false }),
col('c'),
];
renderHook(() => useColumnState({ storageKey: TEST_KEY, columns }));
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['b']);
});
it('does not initialize without storageKey', () => {
const columns = [col('a', { defaultVisibility: false })];
renderHook(() => useColumnState({ columns }));
expect(useColumnStore.getState().tables[TEST_KEY]).toBeUndefined();
});
});
describe('columnVisibility', () => {
it('returns visibility state from hidden columns', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.columnVisibility).toEqual({ b: false });
});
it('applies visibilityBehavior for grouped state', () => {
const columns = [
col('ungrouped', { visibilityBehavior: 'hidden-on-expand' }),
col('grouped', { visibilityBehavior: 'hidden-on-collapse' }),
col('always'),
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
// Not grouped
const { result: notGrouped } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: false }),
);
expect(notGrouped.current.columnVisibility).toEqual({ grouped: false });
// Grouped
const { result: grouped } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
);
expect(grouped.current.columnVisibility).toEqual({ ungrouped: false });
});
it('combines store hidden + visibilityBehavior', () => {
const columns = [
col('a', { visibilityBehavior: 'hidden-on-expand' }),
col('b'),
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns, isGrouped: true }),
);
expect(result.current.columnVisibility).toEqual({ a: false, b: false });
});
});
describe('sortedColumns', () => {
it('returns columns in original order when no order set', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'a',
'b',
'c',
]);
});
it('returns columns sorted by stored order', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'a', 'b']);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'c',
'a',
'b',
]);
});
it('keeps pinned columns at the start', () => {
const columns = [col('a'), col('pinned', { pin: 'left' }), col('b')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'pinned',
'b',
'a',
]);
});
});
describe('actions', () => {
it('hideColumn hides a column', () => {
const columns = [col('a'), col('b')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
act(() => {
result.current.hideColumn('a');
});
expect(result.current.columnVisibility).toEqual({ a: false });
});
it('showColumn shows a column', () => {
const columns = [col('a', { defaultVisibility: false })];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
expect(result.current.columnVisibility).toEqual({ a: false });
act(() => {
result.current.showColumn('a');
});
expect(result.current.columnVisibility).toEqual({});
});
it('setColumnSizing updates sizing', () => {
const columns = [col('a')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
act(() => {
result.current.setColumnSizing({ a: 200 });
});
expect(result.current.columnSizing).toEqual({ a: 200 });
});
it('setColumnOrder updates order from column array', () => {
const columns = [col('a'), col('b'), col('c')];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns);
});
const { result } = renderHook(() =>
useColumnState({ storageKey: TEST_KEY, columns }),
);
act(() => {
result.current.setColumnOrder([col('c'), col('a'), col('b')]);
});
expect(result.current.sortedColumns.map((c) => c.id)).toEqual([
'c',
'a',
'b',
]);
});
});
});

View File

@@ -1,296 +0,0 @@
/* eslint-disable no-restricted-syntax */
import { act, renderHook } from '@testing-library/react';
import {
useColumnOrder,
useColumnSizing,
useColumnStore,
useHiddenColumnIds,
} from '../useColumnStore';
const TEST_KEY = 'test-table';
describe('useColumnStore', () => {
beforeEach(() => {
useColumnStore.getState().tables = {};
localStorage.clear();
});
describe('initializeFromDefaults', () => {
it('initializes hidden columns from defaultVisibility: false', () => {
const columns = [
{ id: 'a', defaultVisibility: true },
{ id: 'b', defaultVisibility: false },
{ id: 'c' }, // defaults to visible
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['b']);
expect(state.columnOrder).toEqual([]);
expect(state.columnSizing).toEqual({});
});
it('does not reinitialize if already exists', () => {
act(() => {
useColumnStore
.getState()
.initializeFromDefaults(TEST_KEY, [
{ id: 'a', defaultVisibility: false },
] as any);
useColumnStore.getState().hideColumn(TEST_KEY, 'x');
useColumnStore
.getState()
.initializeFromDefaults(TEST_KEY, [
{ id: 'b', defaultVisibility: false },
] as any);
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toContain('a');
expect(state.hiddenColumnIds).toContain('x');
expect(state.hiddenColumnIds).not.toContain('b');
});
});
describe('hideColumn / showColumn / toggleColumn', () => {
beforeEach(() => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
});
});
it('hideColumn adds to hiddenColumnIds', () => {
act(() => {
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
});
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
'col1',
);
});
it('hideColumn is idempotent', () => {
act(() => {
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
});
expect(
useColumnStore
.getState()
.tables[TEST_KEY].hiddenColumnIds.filter((id) => id === 'col1'),
).toHaveLength(1);
});
it('showColumn removes from hiddenColumnIds', () => {
act(() => {
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().showColumn(TEST_KEY, 'col1');
});
expect(
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
).not.toContain('col1');
});
it('toggleColumn toggles visibility', () => {
act(() => {
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
});
expect(useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds).toContain(
'col1',
);
act(() => {
useColumnStore.getState().toggleColumn(TEST_KEY, 'col1');
});
expect(
useColumnStore.getState().tables[TEST_KEY].hiddenColumnIds,
).not.toContain('col1');
});
});
describe('setColumnSizing', () => {
beforeEach(() => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
});
});
it('updates column sizing', () => {
act(() => {
useColumnStore
.getState()
.setColumnSizing(TEST_KEY, { col1: 200, col2: 300 });
});
expect(useColumnStore.getState().tables[TEST_KEY].columnSizing).toEqual({
col1: 200,
col2: 300,
});
});
});
describe('setColumnOrder', () => {
beforeEach(() => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
});
});
it('updates column order', () => {
act(() => {
useColumnStore
.getState()
.setColumnOrder(TEST_KEY, ['col2', 'col1', 'col3']);
});
expect(useColumnStore.getState().tables[TEST_KEY].columnOrder).toEqual([
'col2',
'col1',
'col3',
]);
});
});
describe('resetToDefaults', () => {
it('resets to column defaults', () => {
const columns = [
{ id: 'a', defaultVisibility: false },
{ id: 'b', defaultVisibility: true },
];
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, columns as any);
useColumnStore.getState().showColumn(TEST_KEY, 'a');
useColumnStore.getState().hideColumn(TEST_KEY, 'b');
useColumnStore.getState().setColumnOrder(TEST_KEY, ['b', 'a']);
useColumnStore.getState().setColumnSizing(TEST_KEY, { a: 100 });
});
act(() => {
useColumnStore.getState().resetToDefaults(TEST_KEY, columns as any);
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['a']);
expect(state.columnOrder).toEqual([]);
expect(state.columnSizing).toEqual({});
});
});
describe('cleanupStaleHiddenColumns', () => {
it('removes hidden column IDs that are not in validColumnIds', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
useColumnStore.getState().hideColumn(TEST_KEY, 'col3');
});
// Only col1 and col3 are valid now
act(() => {
useColumnStore
.getState()
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col3']));
});
const state = useColumnStore.getState().tables[TEST_KEY];
expect(state.hiddenColumnIds).toEqual(['col1', 'col3']);
expect(state.hiddenColumnIds).not.toContain('col2');
});
it('does nothing when all hidden columns are valid', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().hideColumn(TEST_KEY, 'col1');
useColumnStore.getState().hideColumn(TEST_KEY, 'col2');
});
const stateBefore = useColumnStore.getState().tables[TEST_KEY];
const hiddenBefore = [...stateBefore.hiddenColumnIds];
act(() => {
useColumnStore
.getState()
.cleanupStaleHiddenColumns(TEST_KEY, new Set(['col1', 'col2', 'col3']));
});
const stateAfter = useColumnStore.getState().tables[TEST_KEY];
expect(stateAfter.hiddenColumnIds).toEqual(hiddenBefore);
});
it('does nothing for unknown storage key', () => {
act(() => {
useColumnStore
.getState()
.cleanupStaleHiddenColumns('unknown-key', new Set(['col1']));
});
// Should not throw or create state
expect(useColumnStore.getState().tables['unknown-key']).toBeUndefined();
});
});
describe('selector hooks', () => {
it('useHiddenColumnIds returns hidden columns', () => {
act(() => {
useColumnStore
.getState()
.initializeFromDefaults(TEST_KEY, [
{ id: 'a', defaultVisibility: false },
] as any);
});
const { result } = renderHook(() => useHiddenColumnIds(TEST_KEY));
expect(result.current).toEqual(['a']);
});
it('useHiddenColumnIds returns a stable snapshot for persisted state', () => {
localStorage.setItem(
'@signoz/table-columns/test-table',
JSON.stringify({
hiddenColumnIds: ['persisted'],
columnOrder: [],
columnSizing: {},
}),
);
const { result, rerender } = renderHook(() => useHiddenColumnIds(TEST_KEY));
const firstSnapshot = result.current;
rerender();
expect(result.current).toBe(firstSnapshot);
});
it('useColumnSizing returns sizing', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().setColumnSizing(TEST_KEY, { col1: 150 });
});
const { result } = renderHook(() => useColumnSizing(TEST_KEY));
expect(result.current).toEqual({ col1: 150 });
});
it('useColumnOrder returns order', () => {
act(() => {
useColumnStore.getState().initializeFromDefaults(TEST_KEY, []);
useColumnStore.getState().setColumnOrder(TEST_KEY, ['c', 'b', 'a']);
});
const { result } = renderHook(() => useColumnOrder(TEST_KEY));
expect(result.current).toEqual(['c', 'b', 'a']);
});
it('returns empty defaults for unknown storageKey', () => {
const { result: hidden } = renderHook(() => useHiddenColumnIds('unknown'));
const { result: sizing } = renderHook(() => useColumnSizing('unknown'));
const { result: order } = renderHook(() => useColumnOrder('unknown'));
expect(hidden.current).toEqual([]);
expect(sizing.current).toEqual({});
expect(order.current).toEqual([]);
});
});
});

View File

@@ -1,239 +0,0 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import {
NuqsTestingAdapter,
OnUrlUpdateFunction,
UrlUpdateEvent,
} from 'nuqs/adapters/testing';
import { useTableParams } from '../useTableParams';
function createNuqsWrapper(
queryParams?: Record<string, string>,
onUrlUpdate?: OnUrlUpdateFunction,
): ({ children }: { children: ReactNode }) => JSX.Element {
return function NuqsWrapper({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<NuqsTestingAdapter
searchParams={queryParams}
onUrlUpdate={onUrlUpdate}
hasMemory
>
{children}
</NuqsTestingAdapter>
);
};
}
describe('useTableParams (local mode — enableQueryParams not set)', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('returns default page=1 and limit=50', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
expect(result.current.page).toBe(1);
expect(result.current.limit).toBe(50);
expect(result.current.orderBy).toBeNull();
});
it('respects custom defaults', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(
() => useTableParams(undefined, { page: 2, limit: 25 }),
{ wrapper },
);
expect(result.current.page).toBe(2);
expect(result.current.limit).toBe(25);
});
it('setPage updates page', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
act(() => {
result.current.setPage(3);
});
expect(result.current.page).toBe(3);
});
it('setLimit updates limit', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
act(() => {
result.current.setLimit(100);
});
expect(result.current.limit).toBe(100);
});
it('setOrderBy updates orderBy', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(), { wrapper });
act(() => {
result.current.setOrderBy({ columnName: 'cpu', order: 'desc' });
});
expect(result.current.orderBy).toEqual({ columnName: 'cpu', order: 'desc' });
});
});
describe('useTableParams (URL mode — enableQueryParams set)', () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('uses nuqs state when enableQueryParams=true', () => {
const wrapper = createNuqsWrapper();
const { result } = renderHook(() => useTableParams(true), { wrapper });
expect(result.current.page).toBe(1);
act(() => {
result.current.setPage(5);
jest.runAllTimers();
});
expect(result.current.page).toBe(5);
});
it('uses prefixed keys when enableQueryParams is a string', () => {
const wrapper = createNuqsWrapper({ pods_page: '2' });
const { result } = renderHook(() => useTableParams('pods', { page: 2 }), {
wrapper,
});
expect(result.current.page).toBe(2);
act(() => {
result.current.setPage(4);
jest.runAllTimers();
});
expect(result.current.page).toBe(4);
});
it('local state is ignored when enableQueryParams is set', () => {
const localWrapper = createNuqsWrapper();
const urlWrapper = createNuqsWrapper();
const { result: local } = renderHook(() => useTableParams(), {
wrapper: localWrapper,
});
const { result: url } = renderHook(() => useTableParams(true), {
wrapper: urlWrapper,
});
act(() => {
local.current.setPage(99);
});
// URL mode hook in a separate wrapper should still have its own state
expect(url.current.page).toBe(1);
});
it('reads initial page from URL params', () => {
const wrapper = createNuqsWrapper({ page: '3' });
const { result } = renderHook(() => useTableParams(true), { wrapper });
expect(result.current.page).toBe(3);
});
it('reads initial orderBy from URL params', () => {
const orderBy = JSON.stringify({ columnName: 'name', order: 'desc' });
const wrapper = createNuqsWrapper({ order_by: orderBy });
const { result } = renderHook(() => useTableParams(true), { wrapper });
expect(result.current.orderBy).toEqual({ columnName: 'name', order: 'desc' });
});
it('updates URL when setPage is called', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams(true), { wrapper });
act(() => {
result.current.setPage(5);
jest.runAllTimers();
});
const lastPage = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('page'))
.filter(Boolean)
.pop();
expect(lastPage).toBe('5');
});
it('updates URL when setOrderBy is called', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams(true), { wrapper });
act(() => {
result.current.setOrderBy({ columnName: 'value', order: 'asc' });
jest.runAllTimers();
});
const lastOrderBy = onUrlUpdate.mock.calls
.map((call) => call[0].searchParams.get('order_by'))
.filter(Boolean)
.pop();
expect(lastOrderBy).toBeDefined();
expect(JSON.parse(lastOrderBy!)).toEqual({
columnName: 'value',
order: 'asc',
});
});
it('uses custom param names from config object', () => {
const config = {
page: 'listPage',
limit: 'listLimit',
orderBy: 'listOrderBy',
expanded: 'listExpanded',
};
const wrapper = createNuqsWrapper({ listPage: '3' });
const { result } = renderHook(() => useTableParams(config, { page: 3 }), {
wrapper,
});
expect(result.current.page).toBe(3);
});
it('manages expanded state for row expansion', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams(true), { wrapper });
act(() => {
result.current.setExpanded({ 'row-1': true });
});
expect(result.current.expanded).toEqual({ 'row-1': true });
});
it('toggles sort order correctly: null → asc → desc → null', () => {
const onUrlUpdate = jest.fn<void, [UrlUpdateEvent]>();
const wrapper = createNuqsWrapper({}, onUrlUpdate);
const { result } = renderHook(() => useTableParams(true), { wrapper });
// Initial state
expect(result.current.orderBy).toBeNull();
// First click: null → asc
act(() => {
result.current.setOrderBy({ columnName: 'id', order: 'asc' });
});
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'asc' });
// Second click: asc → desc
act(() => {
result.current.setOrderBy({ columnName: 'id', order: 'desc' });
});
expect(result.current.orderBy).toEqual({ columnName: 'id', order: 'desc' });
// Third click: desc → null
act(() => {
result.current.setOrderBy(null);
});
expect(result.current.orderBy).toBeNull();
});
});

View File

@@ -1,179 +0,0 @@
import { TanStackTableBase } from './TanStackTable';
import TanStackTableText from './TanStackTableText';
export * from './TanStackTableStateContext';
export * from './types';
export * from './useColumnState';
export * from './useColumnStore';
export * from './useTableParams';
/**
* Virtualized data table built on TanStack Table and `react-virtuoso`: resizable and pinnable columns,
* optional drag-to-reorder headers, expandable rows, and pagination or infinite scroll.
*
* @example Minimal usage
* ```tsx
* import TanStackTable from 'components/TanStackTableView';
* import type { TableColumnDef } from 'components/TanStackTableView';
*
* type Row = { id: string; name: string };
*
* const columns: TableColumnDef<Row>[] = [
* {
* id: 'name',
* header: 'Name',
* accessorKey: 'name',
* cell: ({ value }) => <TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>,
* },
* ];
*
* function Example(): JSX.Element {
* return <TanStackTable<Row> data={rows} columns={columns} />;
* }
* ```
*
* @example Column definitions — `accessorFn`, custom header, pinned column, sortable
* ```tsx
* const columns: TableColumnDef<Row>[] = [
* {
* id: 'id',
* header: 'ID',
* accessorKey: 'id',
* pin: 'left',
* width: { min: 80, default: 120 },
* enableSort: true,
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
* },
* {
* id: 'computed',
* header: () => <span>Computed</span>,
* accessorFn: (row) => row.first + row.last,
* enableMove: false,
* enableRemove: false,
* cell: ({ value }) => <TanStackTable.Text>{String(value)}</TanStackTable.Text>,
* },
* ];
* ```
*
* @example Column state persistence with store (recommended)
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* columnStorageKey="my-table-columns"
* />
* ```
*
* @example Pagination with query params. Use `enableQueryParams` object to customize param names.
* ```tsx
* <TanStackTable
* data={pageRows}
* columns={columns}
* pagination={{ total: totalCount, defaultPage: 1, defaultLimit: 20 }}
* enableQueryParams={{
* page: 'listPage',
* limit: 'listPageSize',
* orderBy: 'orderBy',
* expanded: 'listExpanded',
* }}
* prefixPaginationContent={<span>Custom prefix</span>}
* suffixPaginationContent={<span>Custom suffix</span>}
* />
* ```
*
* @example Infinite scroll — use `onEndReached` (pagination UI is hidden when set).
* ```tsx
* <TanStackTable
* data={accumulatedRows}
* columns={columns}
* onEndReached={(lastIndex) => fetchMore(lastIndex)}
* isLoading={isFetching}
* />
* ```
*
* @example Loading state and typography for plain string/number cells
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* isLoading={isFetching}
* skeletonRowCount={15}
* cellTypographySize="small"
* plainTextCellLineClamp={2}
* />
* ```
*
* @example Row styling, selection, and actions. `onRowClick` receives `(row, itemKey)`.
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* getRowKey={(row) => row.id}
* getItemKey={(row) => row.id}
* isRowActive={(row) => row.id === selectedId}
* activeRowIndex={selectedIndex}
* onRowClick={(row, itemKey) => setSelectedId(itemKey)}
* onRowClickNewTab={(row, itemKey) => openInNewTab(itemKey)}
* onRowDeactivate={() => setSelectedId(undefined)}
* getRowClassName={(row) => (row.severity === 'error' ? 'row-error' : '')}
* getRowStyle={(row) => (row.dimmed ? { opacity: 0.5 } : {})}
* renderRowActions={(row) => <Button size="small">Open</Button>}
* />
* ```
*
* @example Expandable rows. `renderExpandedRow` receives `(row, rowKey, groupMeta?)`.
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* getRowKey={(row) => row.id}
* renderExpandedRow={(row, rowKey, groupMeta) => (
* <pre>{JSON.stringify({ rowKey, groupMeta, raw: row.raw }, null, 2)}</pre>
* )}
* getRowCanExpand={(row) => Boolean(row.raw)}
* />
* ```
*
* @example Grouped rows — use `groupBy` + `getGroupKey` for group-aware key generation.
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* getRowKey={(row) => row.id}
* groupBy={[{ key: 'namespace' }, { key: 'cluster' }]}
* getGroupKey={(row) => row.meta ?? {}}
* renderExpandedRow={(row, rowKey, groupMeta) => (
* <ExpandedDetails groupMeta={groupMeta} />
* )}
* getRowCanExpand={() => true}
* />
* ```
*
* @example Imperative handle — `goToPage` plus Virtuoso methods (e.g. `scrollToIndex`)
* ```tsx
* import type { TanStackTableHandle } from 'components/TanStackTableView';
*
* const ref = useRef<TanStackTableHandle>(null);
*
* <TanStackTable ref={ref} data={data} columns={columns} pagination={{ total, defaultLimit: 20 }} />;
*
* ref.current?.goToPage(2);
* ref.current?.scrollToIndex({ index: 0, align: 'start' });
* ```
*
* @example Scroll container props and testing
* ```tsx
* <TanStackTable
* data={data}
* columns={columns}
* className="my-table-wrapper"
* testId="logs-table"
* tableScrollerProps={{ className: 'my-table-scroll', 'data-testid': 'logs-scroller' }}
* />
* ```
*/
const TanStackTable = Object.assign(TanStackTableBase, {
Text: TanStackTableText,
});
export default TanStackTable;

View File

@@ -1,191 +0,0 @@
import {
CSSProperties,
Dispatch,
HTMLAttributes,
ReactNode,
SetStateAction,
} from 'react';
import type { TableVirtuosoHandle } from 'react-virtuoso';
import type {
ColumnSizingState,
Row as TanStackRowType,
} from '@tanstack/react-table';
export type SortState = { columnName: string; order: 'asc' | 'desc' };
/** Sets `--tanstack-plain-cell-*` on the scroll root via CSS module classes (no data attributes). */
export type CellTypographySize = 'small' | 'medium' | 'large';
export type TableCellContext<TData, TValue> = {
row: TData;
value: TValue;
isActive: boolean;
rowIndex: number;
isExpanded: boolean;
canExpand: boolean;
toggleExpanded: () => void;
/** Business/selection key for the row */
itemKey: string;
/** Group metadata when row is part of a grouped view */
groupMeta?: Record<string, string>;
};
export type RowKeyData = {
/** Final unique key (with duplicate suffix if needed) */
finalKey: string;
/** Business/selection key */
itemKey: string;
/** Group metadata */
groupMeta?: Record<string, string>;
};
export type TableColumnDef<
TData,
TKey extends keyof TData = any,
TValue = TData[TKey]
> = {
id: string;
header: string | (() => ReactNode);
cell: (context: TableCellContext<TData, TValue>) => ReactNode;
accessorKey?: TKey;
accessorFn?: (row: TData) => TValue;
pin?: 'left' | 'right';
enableMove?: boolean;
enableResize?: boolean;
enableRemove?: boolean;
enableSort?: boolean;
/** Default visibility when no persisted state exists. Default: true */
defaultVisibility?: boolean;
/** Whether user can hide this column. Default: true */
canBeHidden?: boolean;
/**
* Visibility behavior for grouped views:
* - 'hidden-on-expand': Hide when rows are expanded (grouped view)
* - 'hidden-on-collapse': Hide when rows are collapsed (ungrouped view)
* - 'always-visible': Always show regardless of grouping
* Default: 'always-visible'
*/
visibilityBehavior?:
| 'hidden-on-expand'
| 'hidden-on-collapse'
| 'always-visible';
width?: {
fixed?: number | string;
min?: number | string;
default?: number | string;
max?: number | string;
};
};
export type FlatItem<TData> =
| { kind: 'row'; row: TanStackRowType<TData> }
| { kind: 'expansion'; row: TanStackRowType<TData> };
export type TableRowContext<TData> = {
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: string) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: string) => void;
onRowDeactivate?: () => void;
renderExpandedRow?: (
row: TData,
rowKey: string,
groupMeta?: Record<string, string>,
) => ReactNode;
/** Get key data for a row by index */
getRowKeyData?: (index: number) => RowKeyData | undefined;
colCount: number;
isDarkMode?: boolean;
/** When set, primitive cell output (string/number/boolean) is wrapped with typography + line-clamp (see `plainTextCellLineClamp` on the table). */
plainTextCellLineClamp?: number;
/** Whether there's only one non-pinned column that can be removed */
hasSingleColumn: boolean;
/** Column order key for memo invalidation on reorder */
columnOrderKey: string;
/** Column visibility key for memo invalidation on visibility change */
columnVisibilityKey: string;
};
export type PaginationProps = {
total: number;
defaultPage?: number;
defaultLimit?: number;
};
export type TanstackTableQueryParamsConfig = {
page: string;
limit: string;
orderBy: string;
expanded: string;
};
export type TanStackTableProps<TData> = {
data: TData[];
columns: TableColumnDef<TData>[];
/** Storage key for column state persistence (visibility, sizing, ordering). When set, enables unified column management. */
columnStorageKey?: string;
columnSizing?: ColumnSizingState;
onColumnSizingChange?: Dispatch<SetStateAction<ColumnSizingState>>;
onColumnOrderChange?: (cols: TableColumnDef<TData>[]) => void;
/** Called when a column is removed via the header menu. Use this to sync with external column preferences. */
onColumnRemove?: (columnId: string) => void;
isLoading?: boolean;
/** Number of skeleton rows to show when loading with no data. Default: 10 */
skeletonRowCount?: number;
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
pagination?: PaginationProps;
onEndReached?: (index: number) => void;
/** Function to get the unique key for a row (before duplicate handling).
* When set, enables automatic duplicate key detection and group-aware key composition. */
getRowKey?: (row: TData) => string;
/** Function to get the business/selection key. Defaults to getRowKey result. */
getItemKey?: (row: TData) => string;
/** When set, enables group-aware key generation (prefixes rowKey with group values). */
groupBy?: Array<{ key: string }>;
/** Extract group metadata from a row. Required when groupBy is set. */
getGroupKey?: (row: TData) => Record<string, string>;
getRowStyle?: (row: TData) => CSSProperties;
getRowClassName?: (row: TData) => string;
isRowActive?: (row: TData) => boolean;
renderRowActions?: (row: TData) => ReactNode;
onRowClick?: (row: TData, itemKey: string) => void;
/** Called when ctrl+click or cmd+click on a row */
onRowClickNewTab?: (row: TData, itemKey: string) => void;
onRowDeactivate?: () => void;
activeRowIndex?: number;
renderExpandedRow?: (
row: TData,
rowKey: string,
groupMeta?: Record<string, string>,
) => ReactNode;
getRowCanExpand?: (row: TData) => boolean;
/**
* Primitive cell values use `--tanstack-plain-cell-*` from the scroll container when `cellTypographySize` is set.
*/
plainTextCellLineClamp?: number;
/** Optional CSS-module typography tier for the scroll root (`--tanstack-plain-cell-font-size` / line-height + header `th`). */
cellTypographySize?: CellTypographySize;
/** Spread onto the Virtuoso scroll container. `data` is omitted — reserved by Virtuoso. */
tableScrollerProps?: Omit<HTMLAttributes<HTMLDivElement>, 'data'>;
className?: string;
testId?: string;
/** Content rendered before the pagination controls */
prefixPaginationContent?: ReactNode;
/** Content rendered after the pagination controls */
suffixPaginationContent?: ReactNode;
};
export type TanStackTableHandle = TableVirtuosoHandle & {
goToPage: (page: number) => void;
};
export type TableColumnsState<TData> = {
columns: TableColumnDef<TData>[];
columnSizing: ColumnSizingState;
onColumnSizingChange: Dispatch<SetStateAction<ColumnSizingState>>;
onColumnOrderChange: (cols: TableColumnDef<TData>[]) => void;
onRemoveColumn: (id: string) => void;
};

View File

@@ -1,64 +0,0 @@
import { useCallback, useMemo } from 'react';
import {
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
import { TableColumnDef } from './types';
export interface UseColumnDndOptions<TData> {
columns: TableColumnDef<TData>[];
onColumnOrderChange: (columns: TableColumnDef<TData>[]) => void;
}
export interface UseColumnDndResult {
sensors: ReturnType<typeof useSensors>;
columnIds: string[];
handleDragEnd: (event: DragEndEvent) => void;
}
/**
* Sets up drag-and-drop for column reordering.
*/
export function useColumnDnd<TData>({
columns,
onColumnOrderChange,
}: UseColumnDndOptions<TData>): UseColumnDndResult {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
);
const columnIds = useMemo(() => columns.map((c) => c.id), [columns]);
const handleDragEnd = useCallback(
(event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
const activeCol = columns.find((c) => c.id === String(active.id));
const overCol = columns.find((c) => c.id === String(over.id));
if (
!activeCol ||
!overCol ||
activeCol.pin != null ||
overCol.pin != null ||
activeCol.enableMove === false
) {
return;
}
const oldIndex = columns.findIndex((c) => c.id === String(active.id));
const newIndex = columns.findIndex((c) => c.id === String(over.id));
if (oldIndex === -1 || newIndex === -1) {
return;
}
onColumnOrderChange(arrayMove(columns, oldIndex, newIndex));
},
[columns, onColumnOrderChange],
);
return { sensors, columnIds, handleDragEnd };
}

View File

@@ -1,76 +0,0 @@
import type { SetStateAction } from 'react';
import { useCallback } from 'react';
import type { ColumnSizingState } from '@tanstack/react-table';
import { TableColumnDef } from './types';
export interface UseColumnHandlersOptions<TData> {
/** Storage key for persisting column state (enables store mode) */
columnStorageKey?: string;
effectiveSizing: ColumnSizingState;
storeSetSizing: (sizing: ColumnSizingState) => void;
storeSetOrder: (columns: TableColumnDef<TData>[]) => void;
hideColumn: (columnId: string) => void;
onColumnSizingChange?: (sizing: ColumnSizingState) => void;
onColumnOrderChange?: (columns: TableColumnDef<TData>[]) => void;
onColumnRemove?: (columnId: string) => void;
}
export interface UseColumnHandlersResult<TData> {
handleColumnSizingChange: (updater: SetStateAction<ColumnSizingState>) => void;
handleColumnOrderChange: (columns: TableColumnDef<TData>[]) => void;
handleRemoveColumn: (columnId: string) => void;
}
/**
* Creates handlers for column state changes that delegate to either
* the store (when columnStorageKey is provided) or prop callbacks.
*/
export function useColumnHandlers<TData>({
columnStorageKey,
effectiveSizing,
storeSetSizing,
storeSetOrder,
hideColumn,
onColumnSizingChange,
onColumnOrderChange,
onColumnRemove,
}: UseColumnHandlersOptions<TData>): UseColumnHandlersResult<TData> {
const handleColumnSizingChange = useCallback(
(updater: SetStateAction<ColumnSizingState>) => {
const next =
typeof updater === 'function' ? updater(effectiveSizing) : updater;
if (columnStorageKey) {
storeSetSizing(next);
}
onColumnSizingChange?.(next);
},
[columnStorageKey, effectiveSizing, storeSetSizing, onColumnSizingChange],
);
const handleColumnOrderChange = useCallback(
(cols: TableColumnDef<TData>[]) => {
if (columnStorageKey) {
storeSetOrder(cols);
}
onColumnOrderChange?.(cols);
},
[columnStorageKey, storeSetOrder, onColumnOrderChange],
);
const handleRemoveColumn = useCallback(
(columnId: string) => {
if (columnStorageKey) {
hideColumn(columnId);
}
onColumnRemove?.(columnId);
},
[columnStorageKey, hideColumn, onColumnRemove],
);
return {
handleColumnSizingChange,
handleColumnOrderChange,
handleRemoveColumn,
};
}

View File

@@ -1,226 +0,0 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ColumnSizingState, VisibilityState } from '@tanstack/react-table';
import { TableColumnDef } from './types';
import {
cleanupStaleHiddenColumns as storeCleanupStaleHiddenColumns,
hideColumn as storeHideColumn,
initializeFromDefaults as storeInitializeFromDefaults,
resetToDefaults as storeResetToDefaults,
setColumnOrder as storeSetColumnOrder,
setColumnSizing as storeSetColumnSizing,
showColumn as storeShowColumn,
toggleColumn as storeToggleColumn,
useColumnOrder as useStoreOrder,
useColumnSizing as useStoreSizing,
useHiddenColumnIds,
} from './useColumnStore';
type UseColumnStateOptions<TData> = {
storageKey?: string;
columns: TableColumnDef<TData>[];
isGrouped?: boolean;
};
type UseColumnStateResult<TData> = {
columnVisibility: VisibilityState;
columnSizing: ColumnSizingState;
/** Columns sorted by persisted order (pinned first) */
sortedColumns: TableColumnDef<TData>[];
hiddenColumnIds: string[];
hideColumn: (columnId: string) => void;
showColumn: (columnId: string) => void;
toggleColumn: (columnId: string) => void;
setColumnSizing: (sizing: ColumnSizingState) => void;
setColumnOrder: (columns: TableColumnDef<TData>[]) => void;
resetToDefaults: () => void;
};
export function useColumnState<TData>({
storageKey,
columns,
isGrouped = false,
}: UseColumnStateOptions<TData>): UseColumnStateResult<TData> {
useEffect(() => {
if (storageKey) {
storeInitializeFromDefaults(storageKey, columns);
}
// Only run on mount, not when columns change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [storageKey]);
const rawHiddenColumnIds = useHiddenColumnIds(storageKey ?? '');
useEffect(
function cleanupHiddenColumnIdsNoLongerInDefinitions(): void {
if (!storageKey) {
return;
}
const validColumnIds = new Set(columns.map((c) => c.id));
storeCleanupStaleHiddenColumns(storageKey, validColumnIds);
},
[storageKey, columns],
);
const columnSizing = useStoreSizing(storageKey ?? '');
const prevColumnIdsRef = useRef<Set<string> | null>(null);
useEffect(
function autoShowNewlyAddedColumns(): void {
if (!storageKey) {
return;
}
const currentIds = new Set(columns.map((c) => c.id));
// Skip first render - just record the initial columns
if (prevColumnIdsRef.current === null) {
prevColumnIdsRef.current = currentIds;
return;
}
const prevIds = prevColumnIdsRef.current;
// Find columns that are new (in current but not in previous)
for (const id of currentIds) {
if (!prevIds.has(id) && rawHiddenColumnIds.includes(id)) {
// Column was just added and is hidden - show it
storeShowColumn(storageKey, id);
}
}
prevColumnIdsRef.current = currentIds;
},
[storageKey, columns, rawHiddenColumnIds],
);
const columnOrder = useStoreOrder(storageKey ?? '');
const columnMap = useMemo(() => new Map(columns.map((c) => [c.id, c])), [
columns,
]);
const hiddenColumnIds = useMemo(
() =>
rawHiddenColumnIds.filter((id) => {
const col = columnMap.get(id);
return col && col.canBeHidden !== false;
}),
[rawHiddenColumnIds, columnMap],
);
const columnVisibility = useMemo((): VisibilityState => {
const visibility: VisibilityState = {};
for (const id of hiddenColumnIds) {
visibility[id] = false;
}
for (const column of columns) {
if (column.visibilityBehavior === 'hidden-on-expand' && isGrouped) {
visibility[column.id] = false;
}
if (column.visibilityBehavior === 'hidden-on-collapse' && !isGrouped) {
visibility[column.id] = false;
}
}
return visibility;
}, [hiddenColumnIds, columns, isGrouped]);
const sortedColumns = useMemo((): TableColumnDef<TData>[] => {
if (columnOrder.length === 0) {
return columns;
}
const orderMap = new Map(columnOrder.map((id, i) => [id, i]));
const pinned = columns.filter((c) => c.pin != null);
const rest = columns.filter((c) => c.pin == null);
const sortedRest = [...rest].sort((a, b) => {
const ai = orderMap.get(a.id) ?? Infinity;
const bi = orderMap.get(b.id) ?? Infinity;
return ai - bi;
});
return [...pinned, ...sortedRest];
}, [columns, columnOrder]);
const hideColumn = useCallback(
(columnId: string) => {
if (!storageKey) {
return;
}
// Prevent hiding columns with canBeHidden: false
const col = columnMap.get(columnId);
if (col && col.canBeHidden === false) {
return;
}
storeHideColumn(storageKey, columnId);
},
[storageKey, columnMap],
);
const showColumn = useCallback(
(columnId: string) => {
if (storageKey) {
storeShowColumn(storageKey, columnId);
}
},
[storageKey],
);
const toggleColumn = useCallback(
(columnId: string) => {
if (!storageKey) {
return;
}
const col = columnMap.get(columnId);
const isCurrentlyHidden = hiddenColumnIds.includes(columnId);
if (col && col.canBeHidden === false && !isCurrentlyHidden) {
return;
}
storeToggleColumn(storageKey, columnId);
},
[storageKey, columnMap, hiddenColumnIds],
);
const setColumnSizing = useCallback(
(sizing: ColumnSizingState) => {
if (storageKey) {
storeSetColumnSizing(storageKey, sizing);
}
},
[storageKey],
);
const setColumnOrder = useCallback(
(cols: TableColumnDef<TData>[]) => {
if (storageKey) {
storeSetColumnOrder(
storageKey,
cols.map((c) => c.id),
);
}
},
[storageKey],
);
const resetToDefaults = useCallback(() => {
if (storageKey) {
storeResetToDefaults(storageKey, columns);
}
}, [storageKey, columns]);
return {
columnVisibility,
columnSizing,
sortedColumns,
hiddenColumnIds,
hideColumn,
showColumn,
toggleColumn,
setColumnSizing,
setColumnOrder,
resetToDefaults,
};
}

View File

@@ -1,330 +0,0 @@
import { ColumnSizingState } from '@tanstack/react-table';
import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import { create } from 'zustand';
import { TableColumnDef } from './types';
const STORAGE_PREFIX = '@signoz/table-columns/';
const persistedTableCache = new Map<
string,
{ raw: string; parsed: ColumnState }
>();
type ColumnState = {
hiddenColumnIds: string[];
columnOrder: string[];
columnSizing: ColumnSizingState;
};
const EMPTY_STATE: ColumnState = {
hiddenColumnIds: [],
columnOrder: [],
columnSizing: {},
};
type ColumnStoreState = {
tables: Record<string, ColumnState>;
hideColumn: (storageKey: string, columnId: string) => void;
showColumn: (storageKey: string, columnId: string) => void;
toggleColumn: (storageKey: string, columnId: string) => void;
setColumnSizing: (storageKey: string, sizing: ColumnSizingState) => void;
setColumnOrder: (storageKey: string, order: string[]) => void;
initializeFromDefaults: <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
) => void;
resetToDefaults: <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
) => void;
cleanupStaleHiddenColumns: (
storageKey: string,
validColumnIds: Set<string>,
) => void;
};
const getDefaultHiddenIds = <TData>(
columns: TableColumnDef<TData>[],
): string[] =>
columns.filter((c) => c.defaultVisibility === false).map((c) => c.id);
const getStorageKeyForTable = (tableKey: string): string =>
`${STORAGE_PREFIX}${tableKey}`;
const loadTableFromStorage = (tableKey: string): ColumnState | null => {
try {
const raw = get(getStorageKeyForTable(tableKey));
if (!raw) {
persistedTableCache.delete(tableKey);
return null;
}
const cached = persistedTableCache.get(tableKey);
if (cached && cached.raw === raw) {
return cached.parsed;
}
const parsed = JSON.parse(raw) as ColumnState;
persistedTableCache.set(tableKey, { raw, parsed });
return parsed;
} catch {
persistedTableCache.delete(tableKey);
return null;
}
};
const saveTableToStorage = (tableKey: string, state: ColumnState): void => {
try {
const raw = JSON.stringify(state);
set(getStorageKeyForTable(tableKey), raw);
persistedTableCache.set(tableKey, { raw, parsed: state });
} catch {
// Ignore storage errors (e.g., private browsing quota exceeded)
}
};
export const useColumnStore = create<ColumnStoreState>()((set, get) => {
return {
tables: {},
hideColumn: (storageKey, columnId): void => {
const state = get();
let table = state.tables[storageKey];
// Lazy load from storage if not in memory
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
table = persisted;
set({ tables: { ...state.tables, [storageKey]: table } });
} else {
return;
}
}
if (table.hiddenColumnIds.includes(columnId)) {
return;
}
const nextTable = {
...table,
hiddenColumnIds: [...table.hiddenColumnIds, columnId],
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
showColumn: (storageKey, columnId): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
table = persisted;
set({ tables: { ...state.tables, [storageKey]: table } });
} else {
return;
}
}
if (!table.hiddenColumnIds.includes(columnId)) {
return;
}
const nextTable = {
...table,
hiddenColumnIds: table.hiddenColumnIds.filter((id) => id !== columnId),
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
toggleColumn: (storageKey, columnId): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
table = persisted;
set({ tables: { ...state.tables, [storageKey]: table } });
}
}
if (!table) {
return;
}
const isHidden = table.hiddenColumnIds.includes(columnId);
if (isHidden) {
get().showColumn(storageKey, columnId);
} else {
get().hideColumn(storageKey, columnId);
}
},
setColumnSizing: (storageKey, sizing): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
table = persisted ?? { ...EMPTY_STATE };
}
const nextTable = {
...table,
columnSizing: sizing,
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
setColumnOrder: (storageKey, order): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
table = persisted ?? { ...EMPTY_STATE };
}
const nextTable = {
...table,
columnOrder: order,
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
initializeFromDefaults: (storageKey, columns): void => {
const state = get();
if (state.tables[storageKey]) {
return;
}
const persisted = loadTableFromStorage(storageKey);
if (persisted) {
set({ tables: { ...state.tables, [storageKey]: persisted } });
return;
}
const newTable: ColumnState = {
hiddenColumnIds: getDefaultHiddenIds(columns),
columnOrder: [],
columnSizing: {},
};
set({ tables: { ...state.tables, [storageKey]: newTable } });
saveTableToStorage(storageKey, newTable);
},
resetToDefaults: (storageKey, columns): void => {
const newTable: ColumnState = {
hiddenColumnIds: getDefaultHiddenIds(columns),
columnOrder: [],
columnSizing: {},
};
set({ tables: { ...get().tables, [storageKey]: newTable } });
saveTableToStorage(storageKey, newTable);
},
cleanupStaleHiddenColumns: (storageKey, validColumnIds): void => {
const state = get();
let table = state.tables[storageKey];
if (!table) {
const persisted = loadTableFromStorage(storageKey);
if (!persisted) {
return;
}
table = persisted;
}
const filteredHiddenIds = table.hiddenColumnIds.filter((id) =>
validColumnIds.has(id),
);
// Only update if something changed
if (filteredHiddenIds.length === table.hiddenColumnIds.length) {
return;
}
const nextTable = {
...table,
hiddenColumnIds: filteredHiddenIds,
};
set({ tables: { ...get().tables, [storageKey]: nextTable } });
saveTableToStorage(storageKey, nextTable);
},
};
});
// Stable empty references to avoid `Object.is` false-negatives when a key
// does not exist yet (returning a new `[]` / `{}` on every selector call
// would trigger React's useSyncExternalStore tearing detection).
const EMPTY_ARRAY: string[] = [];
const EMPTY_SIZING: ColumnSizingState = {};
export const useHiddenColumnIds = (storageKey: string): string[] =>
useColumnStore((s) => {
const table = s.tables[storageKey];
if (table) {
return table.hiddenColumnIds;
}
const persisted = loadTableFromStorage(storageKey);
return persisted?.hiddenColumnIds ?? EMPTY_ARRAY;
});
export const useColumnSizing = (storageKey: string): ColumnSizingState =>
useColumnStore((s) => {
const table = s.tables[storageKey];
if (table) {
return table.columnSizing;
}
const persisted = loadTableFromStorage(storageKey);
return persisted?.columnSizing ?? EMPTY_SIZING;
});
export const useColumnOrder = (storageKey: string): string[] =>
useColumnStore((s) => {
const table = s.tables[storageKey];
if (table) {
return table.columnOrder;
}
const persisted = loadTableFromStorage(storageKey);
return persisted?.columnOrder ?? EMPTY_ARRAY;
});
export const initializeFromDefaults = <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
): void =>
useColumnStore.getState().initializeFromDefaults(storageKey, columns);
export const hideColumn = (storageKey: string, columnId: string): void =>
useColumnStore.getState().hideColumn(storageKey, columnId);
export const showColumn = (storageKey: string, columnId: string): void =>
useColumnStore.getState().showColumn(storageKey, columnId);
export const toggleColumn = (storageKey: string, columnId: string): void =>
useColumnStore.getState().toggleColumn(storageKey, columnId);
export const setColumnSizing = (
storageKey: string,
sizing: ColumnSizingState,
): void => useColumnStore.getState().setColumnSizing(storageKey, sizing);
export const setColumnOrder = (storageKey: string, order: string[]): void =>
useColumnStore.getState().setColumnOrder(storageKey, order);
export const resetToDefaults = <TData>(
storageKey: string,
columns: TableColumnDef<TData>[],
): void => useColumnStore.getState().resetToDefaults(storageKey, columns);
export const cleanupStaleHiddenColumns = (
storageKey: string,
validColumnIds: Set<string>,
): void =>
useColumnStore
.getState()
.cleanupStaleHiddenColumns(storageKey, validColumnIds);

View File

@@ -1,43 +0,0 @@
import { useMemo, useRef } from 'react';
export interface UseEffectiveDataOptions {
data: unknown[];
isLoading: boolean;
limit?: number;
skeletonRowCount?: number;
}
/**
* Manages effective data for the table, handling loading states gracefully.
*/
export function useEffectiveData<TData>({
data,
isLoading,
limit,
skeletonRowCount = 10,
}: UseEffectiveDataOptions): TData[] {
const prevDataRef = useRef<TData[]>(data as TData[]);
const prevDataSizeRef = useRef(data.length || limit || skeletonRowCount);
// Update refs when we have real data (not loading)
if (!isLoading && data.length > 0) {
prevDataRef.current = data as TData[];
prevDataSizeRef.current = data.length;
}
return useMemo((): TData[] => {
if (data.length > 0) {
return data as TData[];
}
if (prevDataRef.current.length > 0) {
return prevDataRef.current;
}
if (isLoading) {
const fakeCount = prevDataSizeRef.current || limit || skeletonRowCount;
return Array.from({ length: fakeCount }, (_, i) => ({
id: `skeleton-${i}`,
})) as TData[];
}
return data as TData[];
}, [isLoading, data, limit, skeletonRowCount]);
}

View File

@@ -1,60 +0,0 @@
import { useMemo } from 'react';
import type { ExpandedState, Row } from '@tanstack/react-table';
import { FlatItem } from './types';
export interface UseFlatItemsOptions<TData> {
tableRows: Row<TData>[];
/** Whether row expansion is enabled, needs to be unknown since it will be a function that can be updated/modified, boolean does not work well here */
renderExpandedRow?: unknown;
expanded: ExpandedState;
/** Index of the active row (for scroll-to behavior) */
activeRowIndex?: number;
}
export interface UseFlatItemsResult<TData> {
flatItems: FlatItem<TData>[];
/** Index of active row in flatItems (-1 if not found) */
flatIndexForActiveRow: number;
}
/**
* Flattens table rows with their expansion rows into a single list.
*
* When a row is expanded, an expansion item is inserted immediately after it.
* Also computes the flat index for the active row (used for scroll-to).
*/
export function useFlatItems<TData>({
tableRows,
renderExpandedRow,
expanded,
activeRowIndex,
}: UseFlatItemsOptions<TData>): UseFlatItemsResult<TData> {
const flatItems = useMemo<FlatItem<TData>[]>(() => {
const result: FlatItem<TData>[] = [];
for (const row of tableRows) {
result.push({ kind: 'row', row });
if (renderExpandedRow && row.getIsExpanded()) {
result.push({ kind: 'expansion', row });
}
}
return result;
// expanded needs to be here, otherwise the rows are not updated when you click to expand
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableRows, renderExpandedRow, expanded]);
const flatIndexForActiveRow = useMemo(() => {
if (activeRowIndex == null || activeRowIndex < 0) {
return -1;
}
for (let i = 0; i < flatItems.length; i++) {
const item = flatItems[i];
if (item.kind === 'row' && item.row.index === activeRowIndex) {
return i;
}
}
return -1;
}, [activeRowIndex, flatItems]);
return { flatItems, flatIndexForActiveRow };
}

View File

@@ -1,79 +0,0 @@
import { useCallback, useMemo } from 'react';
export interface RowKeyDataItem {
/** Final unique key for the row (with dedup suffix if needed) */
finalKey: string;
/** Item key for tracking (may differ from finalKey) */
itemKey: string;
/** Group metadata when grouped */
groupMeta: Record<string, string> | undefined;
}
export interface UseRowKeyDataOptions<TData> {
data: TData[];
isLoading: boolean;
getRowKey?: (item: TData) => string;
getItemKey?: (item: TData) => string;
groupBy?: Array<{ key: string }>;
getGroupKey?: (item: TData) => Record<string, string>;
}
export interface UseRowKeyDataResult {
/** Array of key data for each row, undefined if getRowKey not provided or loading */
rowKeyData: RowKeyDataItem[] | undefined;
getRowKeyData: (index: number) => RowKeyDataItem | undefined;
}
/**
* Computes unique row keys with duplicate handling and group prefixes.
*/
export function useRowKeyData<TData>({
data,
isLoading,
getRowKey,
getItemKey,
groupBy,
getGroupKey,
}: UseRowKeyDataOptions<TData>): UseRowKeyDataResult {
// eslint-disable-next-line sonarjs/cognitive-complexity
const rowKeyData = useMemo((): RowKeyDataItem[] | undefined => {
if (!getRowKey || isLoading) {
return undefined;
}
const keyCount = new Map<string, number>();
return data.map(
(item, index): RowKeyDataItem => {
const itemIdentifier = getRowKey(item);
const itemKey = getItemKey?.(item) ?? itemIdentifier;
const groupMeta = groupBy?.length ? getGroupKey?.(item) : undefined;
// Build rowKey with group prefix when grouped
let rowKey: string;
if (groupBy?.length && groupMeta) {
const groupKeyStr = Object.values(groupMeta).join('-');
if (groupKeyStr && itemIdentifier) {
rowKey = `${groupKeyStr}-${itemIdentifier}`;
} else {
rowKey = groupKeyStr || itemIdentifier || String(index);
}
} else {
rowKey = itemIdentifier || String(index);
}
const count = keyCount.get(rowKey) || 0;
keyCount.set(rowKey, count + 1);
const finalKey = count > 0 ? `${rowKey}-${count}` : rowKey;
return { finalKey, itemKey, groupMeta };
},
);
}, [data, getRowKey, getItemKey, groupBy, getGroupKey, isLoading]);
const getRowKeyData = useCallback((index: number) => rowKeyData?.[index], [
rowKeyData,
]);
return { rowKeyData, getRowKeyData };
}

View File

@@ -1,194 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { ExpandedState, Updater } from '@tanstack/react-table';
import { parseAsInteger, useQueryState } from 'nuqs';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { SortState, TanstackTableQueryParamsConfig } from './types';
const NUQS_OPTIONS = { history: 'push' as const };
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 50;
type Defaults = {
page?: number;
limit?: number;
orderBy?: SortState | null;
expanded?: ExpandedState;
};
type TableParamsResult = {
page: number;
limit: number;
orderBy: SortState | null;
expanded: ExpandedState;
setPage: (p: number) => void;
setLimit: (l: number) => void;
setOrderBy: (s: SortState | null) => void;
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
};
function expandedStateToArray(state: ExpandedState): string[] {
if (typeof state === 'boolean') {
return [];
}
return Object.entries(state)
.filter(([, v]) => v)
.map(([k]) => k);
}
function arrayToExpandedState(arr: string[]): ExpandedState {
const result: Record<string, boolean> = {};
for (const id of arr) {
result[id] = true;
}
return result;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function useTableParams(
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig,
defaults?: Defaults,
): TableParamsResult {
const pageQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_page`
: typeof enableQueryParams === 'object'
? enableQueryParams.page
: 'page';
const limitQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_limit`
: typeof enableQueryParams === 'object'
? enableQueryParams.limit
: 'limit';
const orderByQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_order_by`
: typeof enableQueryParams === 'object'
? enableQueryParams.orderBy
: 'order_by';
const expandedQueryParam =
typeof enableQueryParams === 'string'
? `${enableQueryParams}_expanded`
: typeof enableQueryParams === 'object'
? enableQueryParams.expanded
: 'expanded';
const pageDefault = defaults?.page ?? DEFAULT_PAGE;
const limitDefault = defaults?.limit ?? DEFAULT_LIMIT;
const orderByDefault = defaults?.orderBy ?? null;
const expandedDefault = defaults?.expanded ?? {};
const expandedDefaultArray = useMemo(
() => expandedStateToArray(expandedDefault),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const [localPage, setLocalPage] = useState(pageDefault);
const [localLimit, setLocalLimit] = useState(limitDefault);
const [localOrderBy, setLocalOrderBy] = useState<SortState | null>(
orderByDefault,
);
const [localExpanded, setLocalExpanded] = useState<ExpandedState>(
expandedDefault,
);
const [urlPage, setUrlPage] = useQueryState(
pageQueryParam,
parseAsInteger.withDefault(pageDefault).withOptions(NUQS_OPTIONS),
);
const [urlLimit, setUrlLimit] = useQueryState(
limitQueryParam,
parseAsInteger.withDefault(limitDefault).withOptions(NUQS_OPTIONS),
);
const [urlOrderBy, setUrlOrderBy] = useQueryState(
orderByQueryParam,
parseAsJsonNoValidate<SortState | null>()
.withDefault(orderByDefault as never)
.withOptions(NUQS_OPTIONS),
);
const [urlExpandedArray, setUrlExpandedArray] = useQueryState(
expandedQueryParam,
parseAsJsonNoValidate<string[]>()
.withDefault(expandedDefaultArray as never)
.withOptions(NUQS_OPTIONS),
);
// Convert URL array to ExpandedState
const urlExpanded = useMemo(
() => arrayToExpandedState(urlExpandedArray ?? []),
[urlExpandedArray],
);
// Keep ref for updater function access
const urlExpandedRef = useRef(urlExpanded);
urlExpandedRef.current = urlExpanded;
// Wrapper to convert ExpandedState to array when setting URL state
// Supports both direct values and updater functions (TanStack pattern)
const setUrlExpanded = useCallback(
(updaterOrValue: Updater<ExpandedState>): void => {
const newState =
typeof updaterOrValue === 'function'
? updaterOrValue(urlExpandedRef.current)
: updaterOrValue;
setUrlExpandedArray(expandedStateToArray(newState));
},
[setUrlExpandedArray],
);
// Wrapper for local expanded to match TanStack's Updater pattern
const handleSetLocalExpanded = useCallback(
(updaterOrValue: Updater<ExpandedState>): void => {
setLocalExpanded((prev) =>
typeof updaterOrValue === 'function'
? updaterOrValue(prev)
: updaterOrValue,
);
},
[],
);
const orderByDefaultMemoKey = `${orderByDefault?.columnName}${orderByDefault?.order}`;
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
const isEnabledQueryParams =
typeof enableQueryParams === 'string' ||
typeof enableQueryParams === 'object';
useEffect(() => {
if (isEnabledQueryParams) {
setUrlPage(pageDefault);
} else {
setLocalPage(pageDefault);
}
}, [
isEnabledQueryParams,
orderByDefaultMemoKey,
orderByUrlMemoKey,
pageDefault,
setUrlPage,
]);
if (enableQueryParams) {
return {
page: urlPage,
limit: urlLimit,
orderBy: urlOrderBy as SortState | null,
expanded: urlExpanded,
setPage: setUrlPage,
setLimit: setUrlLimit,
setOrderBy: setUrlOrderBy,
setExpanded: setUrlExpanded,
};
}
return {
page: localPage,
limit: localLimit,
orderBy: localOrderBy,
expanded: localExpanded,
setPage: setLocalPage,
setLimit: setLocalLimit,
setOrderBy: setLocalOrderBy,
setExpanded: handleSetLocalExpanded,
};
}

View File

@@ -1,135 +0,0 @@
import type { CSSProperties, ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { RowKeyData, TableColumnDef } from './types';
export const getColumnId = <TData>(column: TableColumnDef<TData>): string =>
column.id;
const DEFAULT_MIN_WIDTH = 192; // 12rem * 16px
/**
* Parse a numeric pixel value from a number or string (e.g., 200 or "200px").
* Returns undefined for non-pixel strings like "100%" or "10rem".
*/
const parsePixelValue = (
value: number | string | undefined,
): number | undefined => {
if (value == null) {
return undefined;
}
if (typeof value === 'number') {
return value;
}
// Only parse pixel values, ignore %, rem, em, etc.
const match = /^(\d+(?:\.\d+)?)px$/.exec(value);
return match ? parseFloat(match[1]) : undefined;
};
export const getColumnWidthStyle = <TData>(
column: TableColumnDef<TData>,
/** Persisted width from user resizing (overrides defined width) */
persistedWidth?: number,
/** Last column always gets width: 100% and ignores other width settings */
isLastColumn?: boolean,
): CSSProperties => {
// Last column always fills remaining space
if (isLastColumn) {
return { width: '100%' };
}
const { width } = column;
if (!width) {
return {
width: persistedWidth ?? DEFAULT_MIN_WIDTH,
minWidth: DEFAULT_MIN_WIDTH,
};
}
if (width.fixed != null) {
return {
width: width.fixed,
minWidth: width.fixed,
maxWidth: width.fixed,
};
}
return {
width: persistedWidth ?? width.default ?? width.min,
minWidth: width.min ?? DEFAULT_MIN_WIDTH,
maxWidth: width.max,
};
};
const buildAccessorFn = <TData>(
colDef: TableColumnDef<TData>,
): ((row: TData) => unknown) => {
return (row: TData): unknown => {
if (colDef.accessorFn) {
return colDef.accessorFn(row);
}
if (colDef.accessorKey) {
return (row as Record<string, unknown>)[colDef.accessorKey];
}
return undefined;
};
};
export function buildTanstackColumnDef<TData>(
colDef: TableColumnDef<TData>,
isRowActive?: (row: TData) => boolean,
getRowKeyData?: (index: number) => RowKeyData | undefined,
): ColumnDef<TData> {
const isFixed = colDef.width?.fixed != null;
const headerFn =
typeof colDef.header === 'function' ? colDef.header : undefined;
// Extract numeric size/minSize/maxSize for TanStack's resize behavior
// This ensures TanStack's internal resize state stays in sync with CSS constraints
let size: number | undefined;
let minSize: number | undefined;
let maxSize: number | undefined;
const fixedWidth = parsePixelValue(colDef.width?.fixed);
if (isFixed && fixedWidth != null) {
size = fixedWidth;
minSize = fixedWidth;
maxSize = fixedWidth;
} else {
// Match the logic in getColumnWidthStyle for initial size
const defaultSize = parsePixelValue(colDef.width?.default);
const minWidth = parsePixelValue(colDef.width?.min) ?? DEFAULT_MIN_WIDTH;
size = defaultSize ?? minWidth;
minSize = minWidth;
maxSize = parsePixelValue(colDef.width?.max);
}
return {
id: colDef.id,
size,
minSize,
maxSize,
header:
typeof colDef.header === 'string'
? colDef.header
: (): ReactNode => headerFn?.() ?? null,
accessorFn: buildAccessorFn(colDef),
enableResizing: colDef.enableResize !== false && !isFixed,
enableSorting: colDef.enableSort === true,
cell: ({ row, getValue }): ReactNode => {
const rowData = row.original;
const keyData = getRowKeyData?.(row.index);
return colDef.cell({
row: rowData,
value: getValue() as TData[any],
isActive: isRowActive?.(rowData) ?? false,
rowIndex: row.index,
isExpanded: row.getIsExpanded(),
canExpand: row.getCanExpand(),
toggleExpanded: (): void => {
row.toggleExpanded();
},
itemKey: keyData?.itemKey ?? '',
groupMeta: keyData?.groupMeta,
});
},
};
}

View File

@@ -1,41 +1,27 @@
import type { CSSProperties, MouseEvent, ReactNode } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { toast } from '@signozhq/ui';
import { Card, Typography } from 'antd';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import ListLogView from 'components/Logs/ListLogView';
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
import { getRowBackgroundColor } from 'components/Logs/LogStateIndicator/getRowBackgroundColor';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import RawLogView from 'components/Logs/RawLogView';
import { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import type { TanStackTableHandle } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OptionFormatTypes } from 'constants/optionsFormatTypes';
import { QueryParams } from 'constants/query';
import { InfinityWrapperStyled } from 'container/LogsExplorerList/styles';
import TanStackTableView from 'container/LogsExplorerList/TanStackTableView';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useEventSource } from 'providers/EventSource';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
// interfaces
import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import { getAbsoluteUrl } from '@/utils/basePath';
import { LiveLogsListProps } from './types';
@@ -46,16 +32,11 @@ function LiveLogsList({
isLoading,
handleChangeSelectedView,
}: LiveLogsListProps): JSX.Element {
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const ref = useRef<VirtuosoHandle>(null);
const { isConnectionLoading } = useEventSource();
const { activeLogId } = useCopyLogLink();
const { logs: logsPreferences } = usePreferenceContext();
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const hasReconciledHiddenColumnsRef = useRef(false);
const {
activeLog,
@@ -71,7 +52,7 @@ function LiveLogsList({
[logs],
);
const { options } = useOptionsMenu({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator: StringOperators.NOOP,
@@ -87,65 +68,9 @@ function LiveLogsList({
...options.selectColumns,
]);
const syncedSelectedColumns = useMemo(
() =>
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
[options.selectColumns, hiddenColumnIds],
);
const logsColumns = useLogsTableColumns({
fields: selectedFields,
fontSize: options.fontSize,
appendTo: 'end',
});
useEffect(() => {
if (hasReconciledHiddenColumnsRef.current) {
return;
}
hasReconciledHiddenColumnsRef.current = true;
if (syncedSelectedColumns.length === options.selectColumns.length) {
return;
}
logsPreferences.updateColumns(syncedSelectedColumns);
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
const handleColumnRemove = useCallback(
(columnId: string) => {
const updatedColumns = options.selectColumns.filter(
({ name }) => name !== columnId,
);
logsPreferences.updateColumns(updatedColumns);
},
[options.selectColumns, logsPreferences],
);
const makeOnLogCopy = useCallback(
(log: ILog) => (event: MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
const urlQuery = new URLSearchParams(window.location.search);
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${log.id}"`);
const link = getAbsoluteUrl(
`${window.location.pathname}?${urlQuery.toString()}`,
);
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });
},
[setCopy],
);
const handleScrollToLog = useScrollToLog({
logs: formattedLogs,
virtuosoRef: ref as React.RefObject<Pick<
VirtuosoHandle,
'scrollToIndex'
> | null>,
virtuosoRef: ref,
});
const getItemContent = useCallback(
@@ -231,49 +156,29 @@ function LiveLogsList({
{formattedLogs.length !== 0 && (
<InfinityWrapperStyled>
{options.format === OptionFormatTypes.TABLE ? (
<TanStackTable<ILog>
ref={ref as React.Ref<TanStackTableHandle>}
columns={logsColumns}
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
onColumnRemove={handleColumnRemove}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
data={formattedLogs}
<TanStackTableView
ref={ref}
isLoading={false}
isRowActive={(log): boolean => log.id === activeLog?.id}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
'--row-hover-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
} as CSSProperties)
}
onRowClick={(log): void => {
handleSetActiveLog(log);
tableViewProps={{
logs: formattedLogs,
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
activeLogIndex,
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
handleShowContext={(e): void => {
e.preventDefault();
e.stopPropagation();
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
}}
onLogCopy={makeOnLogCopy(log)}
/>
)}
handleChangeSelectedView={handleChangeSelectedView}
logs={formattedLogs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
onRemoveColumn={config.addColumn?.onRemove}
/>
) : (
<Card style={{ width: '100%' }} bodyStyle={CARD_BODY_STYLE}>
<OverlayScrollbar isVirtuoso>
<Virtuoso
ref={ref as React.Ref<VirtuosoHandle>}
ref={ref}
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
data={formattedLogs}
totalCount={formattedLogs.length}

View File

@@ -0,0 +1,276 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ColumnDef, DataTable, Row } from '@signozhq/ui';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import { useTableView } from 'components/Logs/TableView/useTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { FontSize } from 'container/OptionsMenu/types';
import dayjs from 'dayjs';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import useDragColumns from 'hooks/useDragColumns';
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { isEmpty, isEqual } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { ILog } from 'types/api/logs/log';
interface ColumnViewProps {
logs: ILog[];
onLoadMore: () => void;
selectedFields: any[];
isLoading: boolean;
isFetching: boolean;
isFrequencyChartVisible: boolean;
options: {
maxLinesPerRow: number;
fontSize: FontSize;
};
}
function ColumnView({
logs,
onLoadMore,
selectedFields,
isLoading,
isFetching,
isFrequencyChartVisible,
options,
}: ColumnViewProps): JSX.Element {
const {
activeLog,
onSetActiveLog: handleSetActiveLog,
onClearActiveLog: handleClearActiveLog,
onAddToQuery: handleAddToQuery,
} = useActiveLog();
const [showActiveLog, setShowActiveLog] = useState<boolean>(false);
const { queryData: activeLogId } = useUrlQueryData<string | null>(
QueryParams.activeLogId,
null,
);
const scrollToIndexRef = useRef<
| ((
rowIndex: number,
options?: { align?: 'start' | 'center' | 'end' },
) => void)
| undefined
>();
const { timezone } = useTimezone();
useEffect(() => {
if (activeLogId) {
const log = logs.find(({ id }) => id === activeLogId);
if (log) {
handleSetActiveLog(log);
setShowActiveLog(true);
}
}
}, []);
const tableViewProps = {
logs,
fields: selectedFields,
linesPerRow: options.maxLinesPerRow as number,
fontSize: options.fontSize as FontSize,
appendTo: 'end' as const,
activeLogIndex: 0,
};
const { dataSource, columns } = useTableView({
...tableViewProps,
onClickExpand: handleSetActiveLog,
});
const { draggedColumns, onColumnOrderChange } = useDragColumns<
Record<string, unknown>
>(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const tableColumns = useMemo(
() => getDraggedColumns<Record<string, unknown>>(columns, draggedColumns),
[columns, draggedColumns],
);
const scrollToLog = useCallback(
(logId: string): void => {
const logIndex = logs.findIndex((log) => log.id === logId);
if (logIndex !== -1 && scrollToIndexRef.current) {
scrollToIndexRef.current(logIndex, { align: 'center' });
}
},
[logs],
);
useEffect(() => {
if (activeLogId) {
scrollToLog(activeLogId);
}
}, [activeLogId]);
const args = {
columns,
tableId: 'virtualized-infinite-reorder-resize',
enableSorting: false,
enableFiltering: false,
enableGlobalFilter: false,
enableColumnReordering: true,
enableColumnResizing: true,
enableColumnPinning: false,
enableRowSelection: false,
enablePagination: false,
showHeaders: true,
defaultColumnWidth: 180,
minColumnWidth: 80,
maxColumnWidth: 480,
// Virtualization + Infinite Scroll
enableVirtualization: true,
estimateRowSize: 56,
overscan: 50,
rowHeight: 56,
enableInfiniteScroll: true,
enableScrollRestoration: false,
fixedHeight: isFrequencyChartVisible ? 560 : 760,
enableDynamicRowHeight: true,
};
const selectedColumns = useMemo(
() =>
tableColumns.map((field) => ({
id: field.key?.toString().toLowerCase().replace(/\./g, '_'), // IMP - Replace dots with underscores as reordering does not work well for accessorKey with dots
// accessorKey: field.name,
accessorFn: (row: Record<string, string>): string =>
row[field.key as string] as string,
header: field.title as string,
size: field.key === 'state-indicator' ? 4 : 180,
minSize: field.key === 'state-indicator' ? 4 : 120,
maxSize: field.key === 'state-indicator' ? 4 : Number.MAX_SAFE_INTEGER,
disableReorder: field.key === 'state-indicator',
disableDropBefore: field.key === 'state-indicator',
disableResizing: field.key === 'state-indicator',
cell: ({
row,
getValue,
}: {
row: Row<Record<string, string>>;
getValue: () => string | JSX.Element;
}): string | JSX.Element => {
if (field.key === 'state-indicator') {
const fontSize = options.fontSize as FontSize;
return (
<LogStateIndicator
severityText={row.original?.severity_text}
fontSize={fontSize}
/>
);
}
const isTimestamp = field.key === 'timestamp';
const cellContent = getValue();
if (isTimestamp) {
const formattedTimestamp = dayjs(cellContent as string).tz(
timezone.value,
);
return (
<div className="table-cell-content">
{formattedTimestamp.format(DATE_TIME_FORMATS.ISO_DATETIME_MS)}
</div>
);
}
return (
<div
className={`table-cell-content ${
row.original.id === activeLog?.id ? 'active-log' : ''
}`}
>
{cellContent}
</div>
);
},
})),
[tableColumns, options.fontSize, activeLog?.id],
);
const handleColumnOrderChange = (newColumns: ColumnDef<any>[]): void => {
if (isEmpty(newColumns) || isEqual(newColumns, selectedColumns)) {
return;
}
const formattedColumns = newColumns.map((column) => ({
id: column.id,
header: column.header,
size: column.size,
minSize: column.minSize,
maxSize: column.maxSize,
key: column.id,
title: column.header as string,
dataIndex: column.id,
}));
onColumnOrderChange(formattedColumns);
};
const handleRowClick = (row: Row<Record<string, unknown>>): void => {
const currentLog = logs.find(({ id }) => id === row.original.id);
setShowActiveLog(true);
handleSetActiveLog(currentLog as ILog);
};
const removeQueryParam = (key: string): void => {
const url = new URL(window.location.href);
url.searchParams.delete(key);
window.history.replaceState({}, '', url);
};
const handleLogDetailClose = (): void => {
removeQueryParam(QueryParams.activeLogId);
handleClearActiveLog();
setShowActiveLog(false);
};
return (
<div
className={`logs-list-table-view-container ${
options.fontSize as FontSize
} max-lines-${options.maxLinesPerRow as number}`}
data-max-lines-per-row={options.maxLinesPerRow}
data-font-size={options.fontSize}
>
<DataTable
{...args}
columns={selectedColumns as ColumnDef<Record<string, string>, unknown>[]}
data={dataSource}
hasMore
onLoadMore={onLoadMore}
loadingMore={isLoading || isFetching}
onColumnOrderChange={handleColumnOrderChange}
onRowClick={handleRowClick}
scrollToIndexRef={scrollToIndexRef}
/>
{showActiveLog && activeLog && (
<LogDetail
selectedTab={VIEW_TYPES.OVERVIEW}
log={activeLog}
onClose={handleLogDetailClose}
onAddToQuery={handleAddToQuery}
onClickActionItem={handleAddToQuery}
/>
)}
</div>
);
}
export default ColumnView;

View File

@@ -0,0 +1,8 @@
// eslint-disable-next-line no-restricted-imports
import { createContext, useContext } from 'react';
const RowHoverContext = createContext(false);
export const useRowHover = (): boolean => useContext(RowHoverContext);
export default RowHoverContext;

View File

@@ -0,0 +1,84 @@
import { ComponentProps, memo, useCallback, useState } from 'react';
import { TableComponents } from 'react-virtuoso';
import {
getLogIndicatorType,
getLogIndicatorTypeForTable,
} from 'components/Logs/LogStateIndicator/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { ILog } from 'types/api/logs/log';
import { TableRowStyled } from '../InfinityTableView/styles';
import RowHoverContext from '../RowHoverContext';
import { TanStackTableRowData } from './types';
export type TableRowContext = {
activeLog?: ILog | null;
activeContextLog?: ILog | null;
logsById: Map<string, ILog>;
};
type VirtuosoTableRowProps = ComponentProps<
NonNullable<TableComponents<TanStackTableRowData, TableRowContext>['TableRow']>
>;
type TanStackCustomTableRowProps = VirtuosoTableRowProps;
function TanStackCustomTableRow({
children,
item,
context,
...props
}: TanStackCustomTableRowProps): JSX.Element {
const { isHighlighted } = useCopyLogLink(item.currentLog.id);
const isDarkMode = useIsDarkMode();
const [hasHovered, setHasHovered] = useState(false);
const rowId = String(item.currentLog.id ?? '');
const activeLog = context?.activeLog;
const activeContextLog = context?.activeContextLog;
const logsById = context?.logsById;
const rowLog = logsById?.get(rowId) || item.currentLog;
const logType = rowLog
? getLogIndicatorType(rowLog)
: getLogIndicatorTypeForTable(item.log);
const handleMouseEnter = useCallback(() => {
if (!hasHovered) {
setHasHovered(true);
}
}, [hasHovered]);
return (
<RowHoverContext.Provider value={hasHovered}>
<TableRowStyled
{...props}
$isDarkMode={isDarkMode}
$isActiveLog={
isHighlighted ||
rowId === String(activeLog?.id ?? '') ||
rowId === String(activeContextLog?.id ?? '')
}
$logType={logType}
onMouseEnter={handleMouseEnter}
>
{children}
</TableRowStyled>
</RowHoverContext.Provider>
);
}
export default memo(TanStackCustomTableRow, (prev, next) => {
const prevId = String(prev.item.currentLog.id ?? '');
const nextId = String(next.item.currentLog.id ?? '');
if (prevId !== nextId) {
return false;
}
const prevIsActive =
prevId === String(prev.context?.activeLog?.id ?? '') ||
prevId === String(prev.context?.activeContextLog?.id ?? '');
const nextIsActive =
nextId === String(next.context?.activeLog?.id ?? '') ||
nextId === String(next.context?.activeContextLog?.id ?? '');
return prevIsActive === nextIsActive;
});

View File

@@ -0,0 +1,193 @@
import type {
CSSProperties,
MouseEvent as ReactMouseEvent,
TouchEvent as ReactTouchEvent,
} from 'react';
import { useMemo } from 'react';
import { CloseOutlined, MoreOutlined } from '@ant-design/icons';
import { useSortable } from '@dnd-kit/sortable';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui';
import { flexRender, Header as TanStackHeader } from '@tanstack/react-table';
import { GripVertical } from 'lucide-react';
import { TableHeaderCellStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import { OrderedColumn, TanStackTableRowData } from './types';
import { getColumnId } from './utils';
import './styles/TanStackHeaderRow.styles.scss';
type TanStackHeaderRowProps = {
column: OrderedColumn;
header?: TanStackHeader<TanStackTableRowData, unknown>;
isDarkMode: boolean;
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
hasSingleColumn: boolean;
canRemoveColumn?: boolean;
onRemoveColumn?: (columnKey: string) => void;
};
const GRIP_ICON_SIZE = 12;
// eslint-disable-next-line sonarjs/cognitive-complexity
function TanStackHeaderRow({
column,
header,
isDarkMode,
fontSize,
hasSingleColumn,
canRemoveColumn = false,
onRemoveColumn,
}: TanStackHeaderRowProps): JSX.Element {
const columnId = getColumnId(column);
const isDragColumn =
column.key !== 'expand' && column.key !== 'state-indicator';
const isResizableColumn = Boolean(header?.column.getCanResize());
const isColumnRemovable = Boolean(
canRemoveColumn &&
onRemoveColumn &&
column.key !== 'expand' &&
column.key !== 'state-indicator',
);
const isResizing = Boolean(header?.column.getIsResizing());
const resizeHandler = header?.getResizeHandler();
const headerText =
typeof column.title === 'string' && column.title
? column.title
: String(header?.id ?? columnId);
const headerTitleAttr = headerText.replace(/^\w/, (c) => c.toUpperCase());
const handleResizeStart = (
event: ReactMouseEvent<HTMLElement> | ReactTouchEvent<HTMLElement>,
): void => {
event.preventDefault();
event.stopPropagation();
resizeHandler?.(event);
};
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: columnId,
disabled: !isDragColumn,
});
const headerCellStyle = useMemo(
() =>
({
'--tanstack-header-translate-x': `${Math.round(transform?.x ?? 0)}px`,
'--tanstack-header-translate-y': `${Math.round(transform?.y ?? 0)}px`,
'--tanstack-header-transition': isResizing ? 'none' : transition || 'none',
} as CSSProperties),
[isResizing, transform?.x, transform?.y, transition],
);
const headerCellClassName = [
'tanstack-header-cell',
isDragging ? 'is-dragging' : '',
isResizing ? 'is-resizing' : '',
]
.filter(Boolean)
.join(' ');
const headerContentClassName = [
'tanstack-header-content',
isResizableColumn ? 'has-resize-control' : '',
isColumnRemovable ? 'has-action-control' : '',
]
.filter(Boolean)
.join(' ');
return (
<TableHeaderCellStyled
ref={setNodeRef}
$isLogIndicator={column.key === 'state-indicator'}
$isDarkMode={isDarkMode}
$isDragColumn={false}
className={headerCellClassName}
key={columnId}
fontSize={fontSize}
$hasSingleColumn={hasSingleColumn}
style={headerCellStyle}
>
<span className={headerContentClassName}>
{isDragColumn ? (
<span className="tanstack-grip-slot">
<span
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
role="button"
aria-label={`Drag ${String(
column.title || header?.id || columnId,
)} column`}
className="tanstack-grip-activator"
>
<GripVertical size={GRIP_ICON_SIZE} />
</span>
</span>
) : null}
<span className="tanstack-header-title" title={headerTitleAttr}>
{header
? flexRender(header.column.columnDef.header, header.getContext())
: String(column.title || '').replace(/^\w/, (c) => c.toUpperCase())}
</span>
{isColumnRemovable && (
<Popover>
<PopoverTrigger asChild>
<span
role="button"
aria-label={`Column actions for ${headerTitleAttr}`}
className="tanstack-header-action-trigger"
onMouseDown={(event): void => {
event.stopPropagation();
}}
>
<MoreOutlined />
</span>
</PopoverTrigger>
<PopoverContent
align="end"
sideOffset={6}
className="tanstack-column-actions-content"
>
<button
type="button"
className="tanstack-remove-column-action"
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
onRemoveColumn?.(String(column.key));
}}
>
<CloseOutlined className="tanstack-remove-column-action-icon" />
Remove column
</button>
</PopoverContent>
</Popover>
)}
</span>
{isResizableColumn && (
<span
role="presentation"
className="cursor-col-resize"
title="Drag to resize column"
onClick={(event): void => {
event.preventDefault();
event.stopPropagation();
}}
onMouseDown={(event): void => {
handleResizeStart(event);
}}
onTouchStart={(event): void => {
handleResizeStart(event);
}}
>
<span className="tanstack-resize-handle-line" />
</span>
)}
</TableHeaderCellStyled>
);
}
export default TanStackHeaderRow;

View File

@@ -0,0 +1,95 @@
import {
MouseEvent as ReactMouseEvent,
MouseEventHandler,
useCallback,
} from 'react';
import { flexRender, Row as TanStackRowModel } from '@tanstack/react-table';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
import { TableCellStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import { useRowHover } from '../RowHoverContext';
import { TanStackTableRowData } from './types';
type TanStackRowCellsProps = {
row: TanStackRowModel<TanStackTableRowData>;
fontSize: InfinityTableProps['tableViewProps']['fontSize'];
onSetActiveLog?: InfinityTableProps['onSetActiveLog'];
onClearActiveLog?: InfinityTableProps['onClearActiveLog'];
isActiveLog?: boolean;
isDarkMode: boolean;
onLogCopy: (logId: string, event: ReactMouseEvent<HTMLElement>) => void;
isLogsExplorerPage: boolean;
};
function TanStackRowCells({
row,
fontSize,
onSetActiveLog,
onClearActiveLog,
isActiveLog = false,
isDarkMode,
onLogCopy,
isLogsExplorerPage,
}: TanStackRowCellsProps): JSX.Element {
const { currentLog } = row.original;
const hasHovered = useRowHover();
const handleShowContext: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
event.preventDefault();
event.stopPropagation();
onSetActiveLog?.(currentLog, VIEW_TYPES.CONTEXT);
},
[currentLog, onSetActiveLog],
);
const handleShowLogDetails = useCallback(() => {
if (!currentLog) {
return;
}
if (isActiveLog && onClearActiveLog) {
onClearActiveLog();
return;
}
onSetActiveLog?.(currentLog);
}, [currentLog, isActiveLog, onClearActiveLog, onSetActiveLog]);
const visibleCells = row.getVisibleCells();
const lastCellIndex = visibleCells.length - 1;
return (
<>
{visibleCells.map((cell, index) => {
const columnKey = cell.column.id;
const isLastCell = index === lastCellIndex;
return (
<TableCellStyled
$isDragColumn={false}
$isLogIndicator={columnKey === 'state-indicator'}
$hasSingleColumn={visibleCells.length <= 2}
$isDarkMode={isDarkMode}
key={cell.id}
fontSize={fontSize}
className={columnKey}
onClick={handleShowLogDetails}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{isLastCell && isLogsExplorerPage && hasHovered && (
<LogLinesActionButtons
handleShowContext={handleShowContext}
onLogCopy={(event): void => onLogCopy(currentLog.id, event)}
customClassName="table-view-log-actions"
/>
)}
</TableCellStyled>
);
})}
</>
);
}
export default TanStackRowCells;

View File

@@ -0,0 +1,105 @@
import { render, screen } from '@testing-library/react';
import TanStackCustomTableRow, {
TableRowContext,
} from '../TanStackCustomTableRow';
import type { TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableRowStyled: 'tr',
}));
jest.mock('hooks/logs/useCopyLogLink', () => ({
useCopyLogLink: (): { isHighlighted: boolean } => ({ isHighlighted: false }),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('components/Logs/LogStateIndicator/utils', () => ({
getLogIndicatorType: (): string => 'info',
getLogIndicatorTypeForTable: (): string => 'info',
}));
const item: TanStackTableRowData = {
log: {},
currentLog: { id: 'row-1' } as TanStackTableRowData['currentLog'],
rowIndex: 0,
};
const virtuosoTableRowAttrs = {
'data-index': 0,
'data-item-index': 0,
'data-known-size': 40,
} as const;
const defaultContext: TableRowContext = {
activeLog: null,
activeContextLog: null,
logsById: new Map(),
};
describe('TanStackCustomTableRow', () => {
it('renders children inside TableRowStyled', () => {
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={defaultContext}
>
<td>cell</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(screen.getByText('cell')).toBeInTheDocument();
});
it('marks row active when activeLog matches item id', () => {
const { container } = render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={{
...defaultContext,
activeLog: { id: 'row-1' } as never,
}}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
const row = container.querySelector('tr');
expect(row).toBeTruthy();
});
it('uses logsById entry when present for indicator type', () => {
const logFromMap = { id: 'row-1', severity_text: 'error' } as never;
render(
<table>
<tbody>
<TanStackCustomTableRow
{...virtuosoTableRowAttrs}
item={item}
context={{
...defaultContext,
logsById: new Map([['row-1', logFromMap]]),
}}
>
<td>x</td>
</TanStackCustomTableRow>
</tbody>
</table>,
);
expect(screen.getByText('x')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,152 @@
import type { Header } from '@tanstack/react-table';
import { render, screen } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import TanStackHeaderRow from '../TanStackHeaderRow';
import type { OrderedColumn, TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableHeaderCellStyled: 'th',
}));
const mockUseSortable = jest.fn((_args?: unknown) => ({
attributes: {},
listeners: {},
setNodeRef: jest.fn(),
setActivatorNodeRef: jest.fn(),
transform: null,
transition: undefined,
isDragging: false,
}));
jest.mock('@dnd-kit/sortable', () => ({
useSortable: (args: unknown): ReturnType<typeof mockUseSortable> =>
mockUseSortable(args),
}));
jest.mock('@tanstack/react-table', () => ({
flexRender: (def: unknown, ctx: unknown): unknown => {
if (typeof def === 'string') {
return def;
}
if (typeof def === 'function') {
return (def as (c: unknown) => unknown)(ctx);
}
return def;
},
}));
const column = (key: string): OrderedColumn =>
({ key, title: key } as OrderedColumn);
const mockHeader = (
id: string,
canResize = true,
): Header<TanStackTableRowData, unknown> =>
(({
id,
column: {
getCanResize: (): boolean => canResize,
getIsResizing: (): boolean => false,
columnDef: { header: id },
},
getContext: (): unknown => ({}),
getResizeHandler: (): (() => void) => jest.fn(),
flexRender: undefined,
} as unknown) as Header<TanStackTableRowData, unknown>);
describe('TanStackHeaderRow', () => {
beforeEach(() => {
mockUseSortable.mockClear();
});
it('renders column title when header is undefined', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('timestamp')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(screen.getByText('Timestamp')).toBeInTheDocument();
});
it('enables useSortable for draggable columns', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('body')}
header={mockHeader('body')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(mockUseSortable).toHaveBeenCalledWith(
expect.objectContaining({
id: 'body',
disabled: false,
}),
);
});
it('disables sortable for expand column', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('expand')}
header={mockHeader('expand', false)}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(mockUseSortable).toHaveBeenCalledWith(
expect.objectContaining({
disabled: true,
}),
);
});
it('shows drag grip for draggable columns', () => {
render(
<table>
<thead>
<tr>
<TanStackHeaderRow
column={column('body')}
header={mockHeader('body')}
isDarkMode={false}
fontSize={FontSize.SMALL}
hasSingleColumn={false}
/>
</tr>
</thead>
</table>,
);
expect(
screen.getByRole('button', { name: /Drag body column/i }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,193 @@
import { fireEvent, render, screen } from '@testing-library/react';
import RowHoverContext from 'container/LogsExplorerList/RowHoverContext';
import { FontSize } from 'container/OptionsMenu/types';
import TanStackRowCells from '../TanStackRow';
import type { TanStackTableRowData } from '../types';
jest.mock('../../InfinityTableView/styles', () => ({
TableCellStyled: 'td',
}));
jest.mock(
'components/Logs/LogLinesActionButtons/LogLinesActionButtons',
() => ({
__esModule: true,
default: ({
onLogCopy,
}: {
onLogCopy: (e: React.MouseEvent) => void;
}): JSX.Element => (
<button type="button" data-testid="copy-btn" onClick={onLogCopy}>
copy
</button>
),
}),
);
const flexRenderMock = jest.fn((def: unknown, _ctx?: unknown) =>
typeof def === 'function' ? def({}) : def,
);
jest.mock('@tanstack/react-table', () => ({
flexRender: (def: unknown, ctx: unknown): unknown => flexRenderMock(def, ctx),
}));
function buildMockRow(
visibleCells: Array<{ columnId: string }>,
): Parameters<typeof TanStackRowCells>[0]['row'] {
return {
original: {
currentLog: { id: 'log-1' } as TanStackTableRowData['currentLog'],
log: {},
rowIndex: 0,
},
getVisibleCells: () =>
visibleCells.map((cell, index) => ({
id: `cell-${index}`,
column: {
id: cell.columnId,
columnDef: {
cell: (): string => `content-${cell.columnId}`,
},
},
getContext: (): Record<string, unknown> => ({}),
})),
} as never;
}
describe('TanStackRowCells', () => {
beforeEach(() => {
flexRenderMock.mockClear();
});
it('renders a cell per visible column and calls flexRender', () => {
const row = buildMockRow([
{ columnId: 'state-indicator' },
{ columnId: 'body' },
]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
expect(screen.getAllByRole('cell')).toHaveLength(2);
expect(flexRenderMock).toHaveBeenCalled();
});
it('applies state-indicator styling class on the indicator cell', () => {
const row = buildMockRow([{ columnId: 'state-indicator' }]);
const { container } = render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
expect(container.querySelector('td.state-indicator')).toBeInTheDocument();
});
it('renders row actions on logs explorer page after hover', () => {
const row = buildMockRow([{ columnId: 'body' }]);
render(
<RowHoverContext.Provider value>
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onLogCopy={jest.fn()}
isLogsExplorerPage
/>
</tr>
</tbody>
</table>
</RowHoverContext.Provider>,
);
expect(screen.getByTestId('copy-btn')).toBeInTheDocument();
});
it('click on a data cell calls onSetActiveLog with current log', () => {
const onSetActiveLog = jest.fn();
const row = buildMockRow([{ columnId: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
onSetActiveLog={onSetActiveLog}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0]);
expect(onSetActiveLog).toHaveBeenCalledWith(
expect.objectContaining({ id: 'log-1' }),
);
});
it('when row is active log, click on cell clears active log', () => {
const onSetActiveLog = jest.fn();
const onClearActiveLog = jest.fn();
const row = buildMockRow([{ columnId: 'body' }]);
render(
<table>
<tbody>
<tr>
<TanStackRowCells
row={row}
fontSize={FontSize.SMALL}
isDarkMode={false}
isActiveLog
onSetActiveLog={onSetActiveLog}
onClearActiveLog={onClearActiveLog}
onLogCopy={jest.fn()}
isLogsExplorerPage={false}
/>
</tr>
</tbody>
</table>,
);
fireEvent.click(screen.getAllByRole('cell')[0]);
expect(onClearActiveLog).toHaveBeenCalled();
expect(onSetActiveLog).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,105 @@
import { forwardRef } from 'react';
import { render, screen } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import type { InfinityTableProps } from '../../InfinityTableView/types';
import TanStackTableView from '../index';
jest.mock('react-virtuoso', () => ({
TableVirtuoso: forwardRef<
unknown,
{
fixedHeaderContent?: () => JSX.Element;
itemContent: (i: number) => JSX.Element;
}
>(function MockVirtuoso({ fixedHeaderContent, itemContent }, _ref) {
return (
<div data-testid="virtuoso">
{fixedHeaderContent?.()}
{itemContent(0)}
</div>
);
}),
}));
jest.mock('components/Logs/TableView/useTableView', () => ({
useTableView: (): {
dataSource: Record<string, string>[];
columns: unknown[];
} => ({
dataSource: [{ id: '1' }],
columns: [
{ key: 'body', title: 'body', render: (): string => 'x' },
{ key: 'state-indicator', title: 's', render: (): string => 'y' },
],
}),
}));
jest.mock('hooks/useDragColumns', () => ({
__esModule: true,
default: (): {
draggedColumns: unknown[];
onColumnOrderChange: () => void;
} => ({
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
}));
jest.mock('hooks/logs/useActiveLog', () => ({
useActiveLog: (): { activeLog: null } => ({ activeLog: null }),
}));
jest.mock('hooks/logs/useCopyLogLink', () => ({
useCopyLogLink: (): { activeLogId: null } => ({ activeLogId: null }),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('react-router-dom', () => ({
useLocation: (): { pathname: string } => ({ pathname: '/logs' }),
}));
jest.mock('react-use', () => ({
useCopyToClipboard: (): [unknown, () => void] => [null, jest.fn()],
}));
jest.mock('@signozhq/ui', () => ({
...jest.requireActual('@signozhq/ui'),
toast: { success: jest.fn() },
}));
jest.mock('components/Spinner', () => ({
__esModule: true,
default: ({ tip }: { tip: string }): JSX.Element => (
<div data-testid="spinner">{tip}</div>
),
}));
const baseProps: InfinityTableProps = {
isLoading: false,
tableViewProps: {
logs: [{ id: '1' } as never],
fields: [],
linesPerRow: 3,
fontSize: FontSize.SMALL,
appendTo: 'end',
activeLogIndex: 0,
},
};
describe('TanStackTableView', () => {
it('shows spinner while loading', () => {
render(<TanStackTableView {...baseProps} isLoading />);
expect(screen.getByTestId('spinner')).toHaveTextContent('Getting Logs');
});
it('renders virtuoso when not loading', () => {
render(<TanStackTableView {...baseProps} />);
expect(screen.getByTestId('virtuoso')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,173 @@
import { act, renderHook } from '@testing-library/react';
import { LOCALSTORAGE } from 'constants/localStorage';
import type { OrderedColumn } from '../types';
import { useColumnSizingPersistence } from '../useColumnSizingPersistence';
const mockGet = jest.fn();
const mockSet = jest.fn();
jest.mock('api/browser/localstorage/get', () => ({
__esModule: true,
default: (key: string): string | null => mockGet(key),
}));
jest.mock('api/browser/localstorage/set', () => ({
__esModule: true,
default: (key: string, value: string): void => {
mockSet(key, value);
},
}));
const col = (key: string): OrderedColumn =>
({ key, title: key } as OrderedColumn);
describe('useColumnSizingPersistence', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGet.mockReturnValue(null);
jest.useFakeTimers();
});
afterEach(() => {
jest.runOnlyPendingTimers();
jest.useRealTimers();
});
it('initializes with empty sizing when localStorage is empty', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({});
});
it('parses flat ColumnSizingState from localStorage', () => {
mockGet.mockReturnValue(JSON.stringify({ body: 400, timestamp: 180 }));
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({ body: 400, timestamp: 180 });
});
it('parses PersistedColumnSizing wrapper with sizing + columnIdsSignature', () => {
mockGet.mockReturnValue(
JSON.stringify({
version: 1,
columnIdsSignature: 'body|timestamp',
sizing: { body: 300 },
}),
);
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('timestamp')]),
);
expect(result.current.columnSizing).toEqual({ body: 300 });
});
it('drops invalid numeric entries when reading from localStorage', () => {
mockGet.mockReturnValue(
JSON.stringify({
body: 200,
bad: NaN,
zero: 0,
neg: -1,
str: 'wide',
}),
);
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body'), col('bad'), col('zero')]),
);
expect(result.current.columnSizing).toEqual({ body: 200 });
});
it('returns empty sizing when JSON is invalid', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
mockGet.mockReturnValue('not-json');
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
expect(result.current.columnSizing).toEqual({});
spy.mockRestore();
});
it('prunes sizing for columns not in orderedColumns and strips fixed columns', () => {
mockGet.mockReturnValue(JSON.stringify({ body: 400, expand: 32, gone: 100 }));
const { result, rerender } = renderHook(
({ columns }: { columns: OrderedColumn[] }) =>
useColumnSizingPersistence(columns),
{
initialProps: {
columns: [
col('body'),
col('expand'),
col('state-indicator'),
] as OrderedColumn[],
},
},
);
expect(result.current.columnSizing).toEqual({ body: 400 });
act(() => {
rerender({
columns: [col('body'), col('expand'), col('state-indicator')],
});
});
expect(result.current.columnSizing).toEqual({ body: 400 });
});
it('updates setColumnSizing manually', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
act(() => {
result.current.setColumnSizing({ body: 500 });
});
expect(result.current.columnSizing).toEqual({ body: 500 });
});
it('debounces writes to localStorage', () => {
const { result } = renderHook(() =>
useColumnSizingPersistence([col('body')]),
);
act(() => {
result.current.setColumnSizing({ body: 600 });
});
expect(mockSet).not.toHaveBeenCalled();
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalledWith(
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
expect.stringContaining('"body":600'),
);
});
it('does not persist when ordered columns signature effect runs with empty ids early — still debounces empty sizing', () => {
const { result } = renderHook(() => useColumnSizingPersistence([]));
expect(result.current.columnSizing).toEqual({});
act(() => {
jest.advanceTimersByTime(250);
});
expect(mockSet).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,222 @@
import { act, renderHook } from '@testing-library/react';
import type { OrderedColumn } from '../types';
import { useOrderedColumns } from '../useOrderedColumns';
const mockGetDraggedColumns = jest.fn();
jest.mock('hooks/useDragColumns/utils', () => ({
getDraggedColumns: <T,>(current: unknown[], dragged: unknown[]): T[] =>
mockGetDraggedColumns(current, dragged) as T[],
}));
const col = (key: string, title?: string): OrderedColumn =>
({ key, title: title ?? key } as OrderedColumn);
describe('useOrderedColumns', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns columns from getDraggedColumns filtered to keys with string or number', () => {
mockGetDraggedColumns.mockReturnValue([
col('body'),
col('timestamp'),
{ title: 'no-key' },
]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.orderedColumns).toEqual([
col('body'),
col('timestamp'),
]);
expect(result.current.orderedColumnIds).toEqual(['body', 'timestamp']);
});
it('hasSingleColumn is true when exactly one column is not state-indicator', () => {
mockGetDraggedColumns.mockReturnValue([col('state-indicator'), col('body')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.hasSingleColumn).toBe(true);
});
it('hasSingleColumn is false when more than one non-state-indicator column exists', () => {
mockGetDraggedColumns.mockReturnValue([
col('state-indicator'),
col('body'),
col('timestamp'),
]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.hasSingleColumn).toBe(false);
});
it('handleDragEnd reorders columns and calls onColumnOrderChange', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: { id: 'c' },
} as never);
});
expect(onColumnOrderChange).toHaveBeenCalledWith([
expect.objectContaining({ key: 'b' }),
expect.objectContaining({ key: 'c' }),
expect.objectContaining({ key: 'a' }),
]);
// Derived-only: orderedColumns should remain until draggedColumns (URL/localStorage) updates.
expect(result.current.orderedColumns.map((c) => c.key)).toEqual([
'a',
'b',
'c',
]);
});
it('handleDragEnd no-ops when over is null', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
const before = result.current.orderedColumns;
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: null,
} as never);
});
expect(result.current.orderedColumns).toBe(before);
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('handleDragEnd no-ops when active.id equals over.id', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'a' },
over: { id: 'a' },
} as never);
});
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('handleDragEnd no-ops when indices cannot be resolved', () => {
const onColumnOrderChange = jest.fn();
mockGetDraggedColumns.mockReturnValue([col('a'), col('b')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange,
}),
);
act(() => {
result.current.handleDragEnd({
active: { id: 'missing' },
over: { id: 'a' },
} as never);
});
expect(onColumnOrderChange).not.toHaveBeenCalled();
});
it('exposes sensors from useSensors', () => {
mockGetDraggedColumns.mockReturnValue([col('a')]);
const { result } = renderHook(() =>
useOrderedColumns({
columns: [],
draggedColumns: [],
onColumnOrderChange: jest.fn(),
}),
);
expect(result.current.sensors).toBeDefined();
});
it('syncs ordered columns when base order changes externally (e.g. URL / localStorage)', () => {
mockGetDraggedColumns.mockReturnValue([col('a'), col('b'), col('c')]);
const { result, rerender } = renderHook(
({ draggedColumns }: { draggedColumns: unknown[] }) =>
useOrderedColumns({
columns: [],
draggedColumns,
onColumnOrderChange: jest.fn(),
}),
{ initialProps: { draggedColumns: [] as unknown[] } },
);
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
'a',
'b',
'c',
]);
mockGetDraggedColumns.mockReturnValue([col('c'), col('b'), col('a')]);
act(() => {
rerender({ draggedColumns: [{ title: 'from-url' }] as unknown[] });
});
expect(result.current.orderedColumns.map((column) => column.key)).toEqual([
'c',
'b',
'a',
]);
});
});

View File

@@ -0,0 +1,434 @@
import {
forwardRef,
memo,
MouseEvent as ReactMouseEvent,
ReactElement,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { TableVirtuoso, TableVirtuosoHandle } from 'react-virtuoso';
import { DndContext, pointerWithin } from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
} from '@dnd-kit/sortable';
import { toast } from '@signozhq/ui';
import {
ColumnDef,
getCoreRowModel,
useReactTable,
} from '@tanstack/react-table';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { useTableView } from 'components/Logs/TableView/useTableView';
import Spinner from 'components/Spinner';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDragColumns from 'hooks/useDragColumns';
import { getAbsoluteUrl } from 'utils/basePath';
import { infinityDefaultStyles } from '../InfinityTableView/config';
import { TanStackTableStyled } from '../InfinityTableView/styles';
import { InfinityTableProps } from '../InfinityTableView/types';
import TanStackCustomTableRow from './TanStackCustomTableRow';
import TanStackHeaderRow from './TanStackHeaderRow';
import TanStackRowCells from './TanStackRow';
import { TableRecord, TanStackTableRowData } from './types';
import { useColumnSizingPersistence } from './useColumnSizingPersistence';
import { useOrderedColumns } from './useOrderedColumns';
import {
getColumnId,
getColumnMinWidthPx,
resolveColumnTypeRender,
} from './utils';
import '../logsTableVirtuosoScrollbar.scss';
import './styles/TanStackTableView.styles.scss';
const COLUMN_DND_AUTO_SCROLL = {
layoutShiftCompensation: false as const,
threshold: { x: 0.2, y: 0 },
};
const TanStackTableView = forwardRef<TableVirtuosoHandle, InfinityTableProps>(
function TanStackTableView(
{
isLoading,
isFetching,
onRemoveColumn,
tableViewProps,
infitiyTableProps,
onSetActiveLog,
onClearActiveLog,
activeLog,
}: InfinityTableProps,
forwardedRef,
): JSX.Element {
const { pathname } = useLocation();
const virtuosoRef = useRef<TableVirtuosoHandle | null>(null);
// could avoid this if directly use forwardedRef in TableVirtuoso, but need to verify if it causes any issue with react-virtuoso internal ref handling
useImperativeHandle(
forwardedRef,
() => virtuosoRef.current as TableVirtuosoHandle,
[],
);
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const isLogsExplorerPage = pathname === ROUTES.LOGS_EXPLORER;
const { activeLog: activeContextLog } = useActiveLog();
// Column definitions (shared with existing logs table)
const { dataSource, columns } = useTableView({
...tableViewProps,
onClickExpand: onSetActiveLog,
onOpenLogsContext: (log): void => onSetActiveLog?.(log, VIEW_TYPES.CONTEXT),
});
// Column order (drag + persisted order)
const { draggedColumns, onColumnOrderChange } = useDragColumns<TableRecord>(
LOCALSTORAGE.LOGS_LIST_COLUMNS,
);
const {
orderedColumns,
orderedColumnIds,
hasSingleColumn,
handleDragEnd,
sensors,
} = useOrderedColumns({
columns,
draggedColumns,
onColumnOrderChange: onColumnOrderChange as (columns: unknown[]) => void,
});
// Column sizing (persisted). stored to localStorage.
const { columnSizing, setColumnSizing } = useColumnSizingPersistence(
orderedColumns,
);
// don't allow "remove column" when only state-indicator + one data col remain
const isAtMinimumRemovableColumns = useMemo(
() =>
orderedColumns.filter(
(column) => column.key !== 'state-indicator' && column.key !== 'expand',
).length <= 1,
[orderedColumns],
);
// Table data (TanStack row data shape)
// useTableView sends flattened log data. this would not be needed once we move to new log details view
const tableData = useMemo<TanStackTableRowData[]>(
() =>
dataSource
.map((log, rowIndex) => {
const currentLog = tableViewProps.logs[rowIndex];
if (!currentLog) {
return null;
}
return { log, currentLog, rowIndex };
})
.filter(Boolean) as TanStackTableRowData[],
[dataSource, tableViewProps.logs],
);
// TanStack columns + table instance
const tanstackColumns = useMemo<ColumnDef<TanStackTableRowData>[]>(
() =>
orderedColumns.map((column, index) => {
const isStateIndicator = column.key === 'state-indicator';
const isExpand = column.key === 'expand';
const isFixedColumn = isStateIndicator || isExpand;
const fixedWidth = isFixedColumn ? 32 : undefined;
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
const headerTitle = String(column.title || '');
return {
id: getColumnId(column),
header: headerTitle.replace(/^\w/, (character) =>
character.toUpperCase(),
),
accessorFn: (row): unknown => row.log[column.key as keyof TableRecord],
enableResizing: !isFixedColumn && index !== orderedColumns.length - 1,
minSize: fixedWidth ?? minWidthPx,
size: fixedWidth, // last column gets remaining space, so don't set initial size to avoid conflict with resizing
maxSize: fixedWidth,
cell: ({ row, getValue }): ReactElement | string | number | null => {
if (!column.render) {
return null;
}
return resolveColumnTypeRender(
column.render(
getValue(),
row.original.log,
row.original.rowIndex,
) as ColumnTypeRender<Record<string, unknown>>,
);
},
};
}),
[orderedColumns],
);
const table = useReactTable({
data: tableData,
columns: tanstackColumns,
enableColumnResizing: true,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: 'onChange',
onColumnSizingChange: setColumnSizing,
state: {
columnSizing,
},
});
const tableRows = table.getRowModel().rows;
// Infinite-scroll footer UI state
const [loadMoreState, setLoadMoreState] = useState<{
active: boolean;
startCount: number;
}>({
active: false,
startCount: 0,
});
// Map to resolve full log object by id (row highlighting + indicator)
const logsById = useMemo(
() => new Map(tableViewProps.logs.map((log) => [String(log.id), log])),
[tableViewProps.logs],
);
// this is already written in parent. Check if this is needed.
useEffect(() => {
const activeLogIndex = tableViewProps.activeLogIndex ?? -1;
if (activeLogIndex < 0 || activeLogIndex >= tableRows.length) {
return;
}
virtuosoRef.current?.scrollToIndex({
index: activeLogIndex,
align: 'center',
behavior: 'auto',
});
}, [tableRows.length, tableViewProps.activeLogIndex]);
useEffect(() => {
if (!loadMoreState.active) {
return;
}
if (!isFetching || tableRows.length > loadMoreState.startCount) {
setLoadMoreState((prev) =>
prev.active ? { active: false, startCount: prev.startCount } : prev,
);
}
}, [isFetching, loadMoreState, tableRows.length]);
const handleLogCopy = useCallback(
(logId: string, event: ReactMouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
const urlQuery = new URLSearchParams(window.location.search);
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
const link = getAbsoluteUrl(`${pathname}?${urlQuery.toString()}`);
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });
},
[pathname, setCopy],
);
const itemContent = useCallback(
(index: number): JSX.Element | null => {
const row = tableRows[index];
if (!row) {
return null;
}
return (
<TanStackRowCells
row={row}
fontSize={tableViewProps.fontSize}
onSetActiveLog={onSetActiveLog}
onClearActiveLog={onClearActiveLog}
isActiveLog={
String(activeLog?.id ?? '') === String(row.original.currentLog.id ?? '')
}
isDarkMode={isDarkMode}
onLogCopy={handleLogCopy}
isLogsExplorerPage={isLogsExplorerPage}
/>
);
},
[
activeLog?.id,
handleLogCopy,
isDarkMode,
isLogsExplorerPage,
onClearActiveLog,
onSetActiveLog,
tableRows,
tableViewProps.fontSize,
],
);
const flatHeaders = useMemo(
() => table.getFlatHeaders().filter((header) => !header.isPlaceholder),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tanstackColumns],
);
const tableHeader = useCallback(() => {
const orderedColumnsById = new Map(
orderedColumns.map((column) => [getColumnId(column), column] as const),
);
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragEnd={handleDragEnd}
autoScroll={COLUMN_DND_AUTO_SCROLL}
>
<SortableContext
items={orderedColumnIds}
strategy={horizontalListSortingStrategy}
>
<tr>
{flatHeaders.map((header) => {
const column = orderedColumnsById.get(header.id);
if (!column) {
return null;
}
return (
<TanStackHeaderRow
key={header.id}
column={column}
header={header}
isDarkMode={isDarkMode}
fontSize={tableViewProps.fontSize}
hasSingleColumn={hasSingleColumn}
onRemoveColumn={onRemoveColumn}
canRemoveColumn={!isAtMinimumRemovableColumns}
/>
);
})}
</tr>
</SortableContext>
</DndContext>
);
}, [
flatHeaders,
handleDragEnd,
hasSingleColumn,
isDarkMode,
orderedColumnIds,
orderedColumns,
onRemoveColumn,
isAtMinimumRemovableColumns,
sensors,
tableViewProps.fontSize,
]);
const handleEndReached = useCallback(
(index: number): void => {
if (!infitiyTableProps?.onEndReached) {
return;
}
setLoadMoreState({
active: true,
startCount: tableRows.length,
});
infitiyTableProps.onEndReached(index);
},
[infitiyTableProps, tableRows.length],
);
if (isLoading) {
return <Spinner height="35px" tip="Getting Logs" />;
}
return (
<div className="tanstack-table-view-wrapper">
<TableVirtuoso
className="logs-table-virtuoso-scroll"
ref={virtuosoRef}
style={infinityDefaultStyles}
data={tableData}
totalCount={tableRows.length}
increaseViewportBy={{ top: 500, bottom: 500 }}
initialTopMostItemIndex={
tableViewProps.activeLogIndex !== -1 ? tableViewProps.activeLogIndex : 0
}
context={{ activeLog, activeContextLog, logsById }}
fixedHeaderContent={tableHeader}
itemContent={itemContent}
components={{
Table: ({ style, children }): JSX.Element => (
<TanStackTableStyled style={style}>
<colgroup>
{orderedColumns.map((column, colIndex) => {
const columnId = getColumnId(column);
const isFixedColumn =
column.key === 'expand' || column.key === 'state-indicator';
const minWidthPx = getColumnMinWidthPx(column, orderedColumns);
const persistedWidth = columnSizing[columnId];
const computedWidth = table.getColumn(columnId)?.getSize();
const effectiveWidth = persistedWidth ?? computedWidth;
if (isFixedColumn) {
return <col key={columnId} className="tanstack-fixed-col" />;
}
// Last data column should stretch to fill remaining space
const isLastColumn = colIndex === orderedColumns.length - 1;
if (isLastColumn) {
return (
<col
key={columnId}
style={{ width: '100%', minWidth: `${minWidthPx}px` }}
/>
);
}
const widthPx =
effectiveWidth != null
? Math.max(effectiveWidth, minWidthPx)
: minWidthPx;
return (
<col
key={columnId}
style={{ width: `${widthPx}px`, minWidth: `${minWidthPx}px` }}
/>
);
})}
</colgroup>
{children}
</TanStackTableStyled>
),
TableRow: TanStackCustomTableRow,
}}
{...(infitiyTableProps?.onEndReached
? { endReached: handleEndReached }
: {})}
/>
{loadMoreState.active && (
<div className="tanstack-load-more-container">
<Spinner height="20px" tip="Getting Logs" />
</div>
)}
</div>
);
},
);
export default memo(TanStackTableView);

View File

@@ -1,8 +1,8 @@
.tanstackHeaderCell {
.tanstack-header-cell {
position: sticky;
top: 0;
z-index: 2;
padding: 0.3rem;
padding: 0;
transform: translate3d(
var(--tanstack-header-translate-x, 0px),
var(--tanstack-header-translate-y, 0px),
@@ -10,11 +10,11 @@
);
transition: var(--tanstack-header-transition, none);
&.isDragging {
&.is-dragging {
opacity: 0.85;
}
&.isResizing {
&.is-resizing {
background: var(--l2-background-hover);
}
@@ -22,7 +22,7 @@
background-color: var(--l2-background) !important;
}
.tanstackHeaderContent {
.tanstack-header-content {
display: flex;
align-items: center;
height: 100%;
@@ -31,20 +31,20 @@
cursor: default;
max-width: 100%;
&.hasResizeControl {
&.has-resize-control {
max-width: calc(100% - 5px);
}
&.hasActionControl {
&.has-action-control {
max-width: calc(100% - 5px);
}
&.hasResizeControl.hasActionControl {
&.has-resize-control.has-action-control {
max-width: calc(100% - 10px);
}
}
.tanstackGripSlot {
.tanstack-grip-slot {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -54,7 +54,7 @@
flex-shrink: 0;
}
.tanstackGripActivator {
.tanstack-grip-activator {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -66,7 +66,7 @@
touch-action: none;
}
.tanstackHeaderActionTrigger {
.tanstack-header-action-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -75,11 +75,9 @@
cursor: pointer;
flex-shrink: 0;
color: var(--l2-foreground);
margin-left: auto;
}
.tanstackColumnActionsContent {
.tanstack-column-actions-content {
width: 140px;
padding: 0;
background: var(--l2-background);
@@ -88,7 +86,7 @@
box-shadow: none;
}
.tanstackRemoveColumnAction {
.tanstack-remove-column-action {
display: flex;
align-items: center;
gap: 8px;
@@ -110,19 +108,19 @@
background: var(--l2-background-hover);
color: var(--l2-foreground);
.tanstackRemoveColumnActionIcon {
.tanstack-remove-column-action-icon {
color: var(--l2-foreground);
}
}
}
.tanstackRemoveColumnActionIcon {
.tanstack-remove-column-action-icon {
font-size: 11px;
color: var(--l2-foreground);
opacity: 0.95;
}
.tanstackHeaderCell .cursorColResize {
.tanstack-header-cell .cursor-col-resize {
position: absolute;
top: 0;
right: 0;
@@ -134,84 +132,25 @@
background: transparent;
}
.tanstackHeaderCell.isResizing .cursorColResize {
.tanstack-header-cell.is-resizing .cursor-col-resize {
background: var(--bg-robin-300);
}
.tanstackResizeHandleLine {
.tanstack-resize-handle-line {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 4px;
transform: translateX(-50%);
background: var(--l2-border);
opacity: 1;
pointer-events: none;
transition: background 120ms ease, width 120ms ease;
background: transparent;
}
.cursorColResize:hover .tanstackResizeHandleLine {
background: var(--l2-border);
}
.tanstackHeaderCell.isResizing .tanstackResizeHandleLine {
.tanstack-header-cell.is-resizing .tanstack-resize-handle-line {
width: 2px;
background: var(--bg-robin-500);
transition: none;
}
.tanstackSortButton {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
min-width: 0;
max-width: 100%;
flex: 1;
overflow: hidden;
&:hover {
color: var(--l2-foreground);
}
&.isSorted {
color: var(--l2-foreground);
}
}
.tanstackHeaderTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
flex: 1;
}
.tanstackSortLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.tanstackSortIndicator {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 14px;
height: 14px;
color: var(--l2-foreground);
}
.isSortable {
cursor: pointer;
}

View File

@@ -0,0 +1,55 @@
.tanstack-table-view-wrapper {
display: flex;
flex-direction: column;
width: 100%;
min-height: 0;
}
.tanstack-fixed-col {
width: 32px;
min-width: 32px;
max-width: 32px;
}
.tanstack-filler-col {
width: 100%;
min-width: 0;
}
.tanstack-actions-col {
width: 0;
min-width: 0;
max-width: 0;
}
.tanstack-load-more-container {
width: 100%;
min-height: 56px;
display: flex;
align-items: center;
justify-content: center;
padding: 8px 0 12px;
flex-shrink: 0;
}
.tanstack-table-virtuoso {
width: 100%;
overflow-x: scroll;
}
.tanstack-fontSize-small {
font-size: 11px;
}
.tanstack-fontSize-medium {
font-size: 13px;
}
.tanstack-fontSize-large {
font-size: 14px;
}
.tanstack-table-foot-loader-cell {
text-align: center;
padding: 8px 0;
}

View File

@@ -0,0 +1,29 @@
import { ColumnSizingState } from '@tanstack/react-table';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { ILog } from 'types/api/logs/log';
export type TableRecord = Record<string, unknown>;
export type LogsTableColumnDef = {
key?: string | number;
title?: string;
render?: (
value: unknown,
record: TableRecord,
index: number,
) => ColumnTypeRender<Record<string, unknown>>;
};
export type OrderedColumn = LogsTableColumnDef & {
key: string | number;
};
export type TanStackTableRowData = {
log: TableRecord;
currentLog: ILog;
rowIndex: number;
};
export type PersistedColumnSizing = {
sizing: ColumnSizingState;
};

View File

@@ -0,0 +1,111 @@
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react';
import { ColumnSizingState } from '@tanstack/react-table';
import getFromLocalstorage from 'api/browser/localstorage/get';
import setToLocalstorage from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { OrderedColumn, PersistedColumnSizing } from './types';
import { getColumnId } from './utils';
const COLUMN_SIZING_PERSIST_DEBOUNCE_MS = 250;
const sanitizeSizing = (input: unknown): ColumnSizingState => {
if (!input || typeof input !== 'object') {
return {};
}
return Object.entries(
input as Record<string, unknown>,
).reduce<ColumnSizingState>((acc, [key, value]) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
return acc;
}
acc[key] = value;
return acc;
}, {});
};
const readPersistedColumnSizing = (): ColumnSizingState => {
const rawSizing = getFromLocalstorage(LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING);
if (!rawSizing) {
return {};
}
try {
const parsed = JSON.parse(rawSizing) as
| PersistedColumnSizing
| ColumnSizingState;
const sizing = ('sizing' in parsed
? parsed.sizing
: parsed) as ColumnSizingState;
return sanitizeSizing(sizing);
} catch (error) {
console.error('Failed to parse persisted log column sizing', error);
return {};
}
};
type UseColumnSizingPersistenceResult = {
columnSizing: ColumnSizingState;
setColumnSizing: Dispatch<SetStateAction<ColumnSizingState>>;
};
export const useColumnSizingPersistence = (
orderedColumns: OrderedColumn[],
): UseColumnSizingPersistenceResult => {
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() =>
readPersistedColumnSizing(),
);
const orderedColumnIds = useMemo(
() => orderedColumns.map((column) => getColumnId(column)),
[orderedColumns],
);
useEffect(() => {
if (orderedColumnIds.length === 0) {
return;
}
const validColumnIds = new Set(orderedColumnIds);
const nonResizableColumnIds = new Set(
orderedColumns
.filter(
(column) => column.key === 'expand' || column.key === 'state-indicator',
)
.map((column) => getColumnId(column)),
);
setColumnSizing((previousSizing) => {
const nextSizing = Object.entries(previousSizing).reduce<ColumnSizingState>(
(acc, [columnId, size]) => {
if (!validColumnIds.has(columnId) || nonResizableColumnIds.has(columnId)) {
return acc;
}
acc[columnId] = size;
return acc;
},
{},
);
const hasChanged =
Object.keys(nextSizing).length !== Object.keys(previousSizing).length ||
Object.entries(nextSizing).some(
([columnId, size]) => previousSizing[columnId] !== size,
);
return hasChanged ? nextSizing : previousSizing;
});
}, [orderedColumnIds, orderedColumns]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
const persistedSizing = { sizing: columnSizing };
setToLocalstorage(
LOCALSTORAGE.LOGS_LIST_COLUMN_SIZING,
JSON.stringify(persistedSizing),
);
}, COLUMN_SIZING_PERSIST_DEBOUNCE_MS);
return (): void => window.clearTimeout(timeoutId);
}, [columnSizing]);
return { columnSizing, setColumnSizing };
};

View File

@@ -0,0 +1,108 @@
import { useCallback, useMemo } from 'react';
import {
DragEndEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { arrayMove } from '@dnd-kit/sortable';
import { getDraggedColumns } from 'hooks/useDragColumns/utils';
import { OrderedColumn, TableRecord } from './types';
import { getColumnId } from './utils';
type UseOrderedColumnsProps = {
columns: unknown[];
draggedColumns: unknown[];
onColumnOrderChange: (columns: unknown[]) => void;
};
type UseOrderedColumnsResult = {
orderedColumns: OrderedColumn[];
orderedColumnIds: string[];
hasSingleColumn: boolean;
handleDragEnd: (event: DragEndEvent) => void;
sensors: ReturnType<typeof useSensors>;
};
export const useOrderedColumns = ({
columns,
draggedColumns,
onColumnOrderChange,
}: UseOrderedColumnsProps): UseOrderedColumnsResult => {
const baseColumns = useMemo<OrderedColumn[]>(
() =>
getDraggedColumns<TableRecord>(
columns as never[],
draggedColumns as never[],
).filter(
(column): column is OrderedColumn =>
typeof column.key === 'string' || typeof column.key === 'number',
),
[columns, draggedColumns],
);
const orderedColumns = useMemo(() => {
const stateIndicatorIndex = baseColumns.findIndex(
(column) => column.key === 'state-indicator',
);
if (stateIndicatorIndex <= 0) {
return baseColumns;
}
const pinned = baseColumns[stateIndicatorIndex];
const rest = baseColumns.filter((_, i) => i !== stateIndicatorIndex);
return [pinned, ...rest];
}, [baseColumns]);
const handleDragEnd = useCallback(
(event: DragEndEvent): void => {
const { active, over } = event;
if (!over || active.id === over.id) {
return;
}
// Don't allow moving the state-indicator column
if (String(active.id) === 'state-indicator') {
return;
}
const oldIndex = orderedColumns.findIndex(
(column) => getColumnId(column) === String(active.id),
);
const newIndex = orderedColumns.findIndex(
(column) => getColumnId(column) === String(over.id),
);
if (oldIndex === -1 || newIndex === -1) {
return;
}
const nextColumns = arrayMove(orderedColumns, oldIndex, newIndex);
onColumnOrderChange(nextColumns as unknown[]);
},
[onColumnOrderChange, orderedColumns],
);
const orderedColumnIds = useMemo(
() => orderedColumns.map((column) => getColumnId(column)),
[orderedColumns],
);
const hasSingleColumn = useMemo(
() =>
orderedColumns.filter((column) => column.key !== 'state-indicator')
.length === 1,
[orderedColumns],
);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 4 },
}),
);
return {
orderedColumns,
orderedColumnIds,
hasSingleColumn,
handleDragEnd,
sensors,
};
};

View File

@@ -0,0 +1,61 @@
import { cloneElement, isValidElement, ReactElement } from 'react';
import { ColumnTypeRender } from 'components/Logs/TableView/types';
import { OrderedColumn } from './types';
export const getColumnId = (column: OrderedColumn): string =>
String(column.key);
/** Browser default root font size; TanStack column sizing uses px. */
const REM_PX = 16;
const MIN_WIDTH_OTHER_REM = 12;
const MIN_WIDTH_BODY_REM = 40;
/** When total column count is below this, body column min width is doubled (more horizontal space for few columns). */
export const FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD = 4;
/**
* Minimum width (px) for TanStack column defs + colgroup.
* Design: state/expand 32px; body min 40rem (doubled when fewer than
* {@link FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD} total columns); other columns use rem→px (16px root).
*/
export const getColumnMinWidthPx = (
column: OrderedColumn,
orderedColumns?: OrderedColumn[],
): number => {
const key = String(column.key);
if (key === 'state-indicator' || key === 'expand') {
return 32;
}
if (key === 'body') {
const base = MIN_WIDTH_BODY_REM * REM_PX;
const fewColumns =
orderedColumns != null &&
orderedColumns.length < FEW_COLUMNS_BODY_MIN_WIDTH_THRESHOLD;
return fewColumns ? base * 1.5 : base;
}
return MIN_WIDTH_OTHER_REM * REM_PX;
};
export const resolveColumnTypeRender = (
rendered: ColumnTypeRender<Record<string, unknown>>,
): ReactElement | string | number | null => {
if (
rendered &&
typeof rendered === 'object' &&
'children' in rendered &&
isValidElement(rendered.children)
) {
const { children, props } = rendered as {
children: ReactElement;
props?: Record<string, unknown>;
};
return cloneElement(children, props || {});
}
if (rendered && typeof rendered === 'object' && isValidElement(rendered)) {
return rendered;
}
return typeof rendered === 'string' || typeof rendered === 'number'
? rendered
: null;
};

View File

@@ -1,48 +1,33 @@
import type { CSSProperties, MouseEvent, ReactNode } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { toast } from '@signozhq/ui';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
// components
import ListLogView from 'components/Logs/ListLogView';
import LogLinesActionButtons from 'components/Logs/LogLinesActionButtons/LogLinesActionButtons';
import { getRowBackgroundColor } from 'components/Logs/LogStateIndicator/getRowBackgroundColor';
import { getLogIndicatorType } from 'components/Logs/LogStateIndicator/utils';
import RawLogView from 'components/Logs/RawLogView';
import { useLogsTableColumns } from 'components/Logs/TableView/useLogsTableColumns';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import Spinner from 'components/Spinner';
import type { TanStackTableHandle } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import { useHiddenColumnIds } from 'components/TanStackTableView/useColumnStore';
import { CARD_BODY_STYLE } from 'constants/card';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { useOptionsMenu } from 'container/OptionsMenu';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { FontSize } from 'container/OptionsMenu/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import APIError from 'types/api/error';
// interfaces
import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { getAbsoluteUrl } from '@/utils/basePath';
import NoLogs from '../NoLogs/NoLogs';
import { LogsExplorerListProps } from './LogsExplorerList.interfaces';
import { InfinityWrapperStyled } from './styles';
import TanStackTableView from './TanStackTableView';
import {
convertKeysToColumnFields,
getEmptyLogsListConfig,
@@ -65,13 +50,8 @@ function LogsExplorerList({
isFilterApplied,
handleChangeSelectedView,
}: LogsExplorerListProps): JSX.Element {
const ref = useRef<TanStackTableHandle | VirtuosoHandle | null>(null);
const [, setCopy] = useCopyToClipboard();
const isDarkMode = useIsDarkMode();
const ref = useRef<VirtuosoHandle>(null);
const { activeLogId } = useCopyLogLink();
const { logs: logsPreferences } = usePreferenceContext();
const hiddenColumnIds = useHiddenColumnIds(LOCALSTORAGE.LOGS_LIST_COLUMNS);
const hasReconciledHiddenColumnsRef = useRef(false);
const {
activeLog,
@@ -81,7 +61,7 @@ function LogsExplorerList({
handleCloseLogDetail,
} = useLogDetailHandlers();
const { options } = useOptionsMenu({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
aggregateOperator:
@@ -100,59 +80,13 @@ function LogsExplorerList({
);
const selectedFields = useMemo(
() =>
convertKeysToColumnFields([
...defaultLogsSelectedColumns,
...options.selectColumns,
]),
() => convertKeysToColumnFields(options.selectColumns),
[options],
);
const syncedSelectedColumns = useMemo(
() =>
options.selectColumns.filter(({ name }) => !hiddenColumnIds.includes(name)),
[options.selectColumns, hiddenColumnIds],
);
const handleColumnRemove = useCallback(
(columnId: string) => {
const updatedColumns = options.selectColumns.filter(
({ name }) => name !== columnId,
);
logsPreferences.updateColumns(updatedColumns);
},
[options.selectColumns, logsPreferences],
);
const logsColumns = useLogsTableColumns({
fields: selectedFields,
fontSize: options.fontSize,
appendTo: 'end',
});
const makeOnLogCopy = useCallback(
(log: ILog) => (event: MouseEvent<HTMLElement>): void => {
event.preventDefault();
event.stopPropagation();
const urlQuery = new URLSearchParams(window.location.search);
urlQuery.delete(QueryParams.activeLogId);
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.activeLogId, `"${log.id}"`);
const link = getAbsoluteUrl(
`${window.location.pathname}?${urlQuery.toString()}`,
);
setCopy(link);
toast.success('Copied to clipboard', { position: 'top-right' });
},
[setCopy],
);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef: ref as React.RefObject<Pick<
VirtuosoHandle,
'scrollToIndex'
> | null>,
virtuosoRef: ref,
});
useEffect(() => {
@@ -163,20 +97,6 @@ function LogsExplorerList({
}
}, [isLoading, isFetching, isError, logs.length]);
useEffect(() => {
if (hasReconciledHiddenColumnsRef.current) {
return;
}
hasReconciledHiddenColumnsRef.current = true;
if (syncedSelectedColumns.length === options.selectColumns.length) {
return;
}
logsPreferences.updateColumns(syncedSelectedColumns);
}, [logsPreferences, options.selectColumns.length, syncedSelectedColumns]);
const getItemContent = useCallback(
(_: number, log: ILog): JSX.Element => {
if (options.format === 'raw') {
@@ -235,46 +155,25 @@ function LogsExplorerList({
if (options.format === 'table') {
return (
<TanStackTable<ILog>
ref={ref as React.Ref<TanStackTableHandle>}
columns={logsColumns}
columnStorageKey={LOCALSTORAGE.LOGS_LIST_COLUMNS}
onColumnRemove={handleColumnRemove}
plainTextCellLineClamp={options.maxLines}
cellTypographySize={options.fontSize}
data={logs}
isLoading={isLoading || isFetching}
onEndReached={onEndReached}
isRowActive={(log): boolean =>
log.id === activeLog?.id || log.id === activeLogId
}
getRowStyle={(log): CSSProperties =>
({
'--row-active-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
'--row-hover-bg': getRowBackgroundColor(
isDarkMode,
getLogIndicatorType(log),
),
} as CSSProperties)
}
onRowClick={(log): void => {
handleSetActiveLog(log);
<TanStackTableView
ref={ref}
isLoading={isLoading}
isFetching={isFetching}
tableViewProps={{
logs,
fields: selectedFields,
linesPerRow: options.maxLines,
fontSize: options.fontSize,
appendTo: 'end',
activeLogIndex,
}}
onRowDeactivate={handleCloseLogDetail}
activeRowIndex={activeLogIndex}
renderRowActions={(log): ReactNode => (
<LogLinesActionButtons
handleShowContext={(e): void => {
e.preventDefault();
e.stopPropagation();
handleSetActiveLog(log, VIEW_TYPES.CONTEXT);
}}
onLogCopy={makeOnLogCopy(log)}
/>
)}
infitiyTableProps={{ onEndReached }}
handleChangeSelectedView={handleChangeSelectedView}
logs={logs}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
activeLog={activeLog}
onRemoveColumn={config.addColumn?.onRemove}
/>
);
}
@@ -299,7 +198,7 @@ function LogsExplorerList({
<OverlayScrollbar isVirtuoso>
<Virtuoso
key={activeLogIndex || 'logs-virtuoso'}
ref={ref as React.Ref<VirtuosoHandle>}
ref={ref}
initialTopMostItemIndex={activeLogIndex !== -1 ? activeLogIndex : 0}
data={logs}
endReached={onEndReached}
@@ -320,11 +219,12 @@ function LogsExplorerList({
onEndReached,
getItemContent,
isFetching,
selectedFields,
handleChangeSelectedView,
handleSetActiveLog,
handleCloseLogDetail,
activeLog,
isDarkMode,
makeOnLogCopy,
config.addColumn?.onRemove,
]);
const isTraceToLogsNavigation = useMemo(() => {

View File

@@ -0,0 +1,38 @@
.logs-table-virtuoso-scroll {
scrollbar-width: thin;
scrollbar-color: var(--bg-slate-300) transparent;
&::-webkit-scrollbar {
width: 4px;
height: 4px;
}
&::-webkit-scrollbar-corner {
background: transparent;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
border-radius: 9999px;
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
}
.lightMode .logs-table-virtuoso-scroll {
scrollbar-color: var(--bg-vanilla-300) transparent;
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-100);
}
}

View File

@@ -17,7 +17,7 @@ import dayjs, { Dayjs } from 'dayjs';
import {
useGlobalTimeQueryInvalidate,
useIsGlobalTimeQueryRefreshing,
} from 'hooks/globalTime';
} from 'store/globalTime';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -128,35 +128,33 @@ function DateTimeSelection({
}
}, [modalInitialStartTime, modalInitialEndTime]);
const {
localstorageStartTime,
localstorageEndTime,
} = ((): LocalStorageTimeRange => {
const routes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
const { localstorageStartTime, localstorageEndTime } =
((): LocalStorageTimeRange => {
const routes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
if (routes !== null) {
const routesObject = JSON.parse(routes || '{}');
const selectedTime = routesObject[location.pathname];
if (routes !== null) {
const routesObject = JSON.parse(routes || '{}');
const selectedTime = routesObject[location.pathname];
if (selectedTime) {
let parsedSelectedTime: TimeRange;
try {
parsedSelectedTime = JSON.parse(selectedTime);
} catch {
parsedSelectedTime = selectedTime;
if (selectedTime) {
let parsedSelectedTime: TimeRange;
try {
parsedSelectedTime = JSON.parse(selectedTime);
} catch {
parsedSelectedTime = selectedTime;
}
if (isObject(parsedSelectedTime)) {
return {
localstorageStartTime: parsedSelectedTime.startTime,
localstorageEndTime: parsedSelectedTime.endTime,
};
}
return { localstorageStartTime: null, localstorageEndTime: null };
}
if (isObject(parsedSelectedTime)) {
return {
localstorageStartTime: parsedSelectedTime.startTime,
localstorageEndTime: parsedSelectedTime.endTime,
};
}
return { localstorageStartTime: null, localstorageEndTime: null };
}
}
return { localstorageStartTime: null, localstorageEndTime: null };
})();
return { localstorageStartTime: null, localstorageEndTime: null };
})();
const getTime = useCallback((): [number, number] | undefined => {
if (searchEndTime && searchStartTime) {
@@ -183,9 +181,8 @@ function DateTimeSelection({
const [options, setOptions] = useState(getOptions(location.pathname));
const [refreshButtonHidden, setRefreshButtonHidden] = useState<boolean>(false);
const [customDateTimeVisible, setCustomDTPickerVisible] = useState<boolean>(
false,
);
const [customDateTimeVisible, setCustomDTPickerVisible] =
useState<boolean>(false);
const { stagedQuery, currentQuery, initQueryBuilderData } = useQueryBuilder();

View File

@@ -1,2 +0,0 @@
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';

View File

@@ -1,16 +0,0 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
/**
* Use when you want to invalida any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
return useCallback(async () => {
return await queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
});
}, [queryClient]);
}

View File

@@ -1,11 +1,9 @@
import { useCallback } from 'react';
import type { VirtuosoHandle } from 'react-virtuoso';
type ScrollToIndexHandle = Pick<VirtuosoHandle, 'scrollToIndex'>;
type UseScrollToLogParams = {
logs: Array<{ id: string }>;
virtuosoRef: React.RefObject<ScrollToIndexHandle | null>;
virtuosoRef: React.RefObject<VirtuosoHandle | null>;
};
function useScrollToLog({

View File

@@ -0,0 +1,69 @@
import {
// oxlint-disable-next-line no-restricted-imports
createContext,
ReactNode,
// oxlint-disable-next-line no-restricted-imports
useContext,
useState,
} from 'react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import get from 'api/browser/localstorage/get';
import {
createGlobalTimeStore,
defaultGlobalTimeStore,
GlobalTimeStoreApi,
} from './globalTimeStore';
import { GlobalTimeProviderOptions, GlobalTimeSelectedTime } from './types';
import { usePersistence } from './usePersistence';
import { useQueryCacheSync } from './useQueryCacheSync';
import { useUrlSync } from './useUrlSync';
import { useComputedMinMaxSync } from 'store/globalTime/useComputedMinMaxSync';
export const GlobalTimeContext = createContext<GlobalTimeStoreApi | null>(null);
export function GlobalTimeProvider({
children,
inheritGlobalTime = false,
initialTime,
enableUrlParams = false,
removeQueryParamsOnUnmount = false,
localStoragePersistKey,
refreshInterval: initialRefreshInterval,
}: GlobalTimeProviderOptions & { children: ReactNode }): JSX.Element {
const parentStore = useContext(GlobalTimeContext);
const globalStore = parentStore ?? defaultGlobalTimeStore;
const resolveInitialTime = (): GlobalTimeSelectedTime => {
if (inheritGlobalTime) {
return globalStore.getState().selectedTime;
}
if (localStoragePersistKey) {
const stored = get(localStoragePersistKey);
if (stored) {
return stored as GlobalTimeSelectedTime;
}
}
return initialTime ?? DEFAULT_TIME_RANGE;
};
// Create isolated store (stable reference)
const [store] = useState(() =>
createGlobalTimeStore({
selectedTime: resolveInitialTime(),
refreshInterval: initialRefreshInterval ?? 0,
}),
);
useComputedMinMaxSync(store);
useQueryCacheSync(store);
useUrlSync(store, enableUrlParams, removeQueryParamsOnUnmount);
usePersistence(store, localStoragePersistKey);
return (
<GlobalTimeContext.Provider value={store}>
{children}
</GlobalTimeContext.Provider>
);
}

View File

@@ -0,0 +1,698 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import set from 'api/browser/localstorage/set';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeProviderOptions } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
jest.mock('api/browser/localstorage/set');
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const createWrapper = (
providerProps: GlobalTimeProviderOptions,
nuqsProps?: { searchParams?: string },
) => {
const queryClient = createTestQueryClient();
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams={nuqsProps?.searchParams}>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('GlobalTimeProvider', () => {
describe('store isolation', () => {
it('should create isolated store for each provider', () => {
const wrapper1 = createWrapper({ initialTime: '1h' });
const wrapper2 = createWrapper({ initialTime: '15m' });
const { result: result1 } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: wrapper1 },
);
const { result: result2 } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: wrapper2 },
);
expect(result1.current).toBe('1h');
expect(result2.current).toBe('15m');
});
});
describe('inheritGlobalTime', () => {
it('should inherit time from parent store when inheritGlobalTime is true', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// Should inherit '6h' from parent provider
expect(result.current).toBe('6h');
});
it('should use initialTime when inheritGlobalTime is false', () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime={false} initialTime="15m">
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// Should use its own initialTime, not parent's
expect(result.current).toBe('15m');
});
it('should prefer URL params over inheritGlobalTime when both are present', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="?relativeTime=1h">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// inheritGlobalTime sets initial value to '6h', but URL sync updates it to '1h'
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should use inherited time when URL params are empty', async () => {
const queryClient = createTestQueryClient();
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter searchParams="">
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{ wrapper: NestedWrapper },
);
// No URL params, should keep inherited value
expect(result.current).toBe('6h');
});
it('should prefer custom time URL params over inheritGlobalTime', async () => {
const queryClient = createTestQueryClient();
const startTime = 1700000000000;
const endTime = 1700003600000;
const NestedWrapper = ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={`?startTime=${startTime}&endTime=${endTime}`}
>
<GlobalTimeProvider initialTime="6h">
<GlobalTimeProvider inheritGlobalTime enableUrlParams>
{children}
</GlobalTimeProvider>
</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
const { result } = renderHook(() => useGlobalTime(), {
wrapper: NestedWrapper,
});
// URL custom time params should override inherited time
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
});
describe('URL sync', () => {
it('should read relativeTime from URL on mount', async () => {
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: '?relativeTime=1h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
await waitFor(() => {
expect(result.current).toBe('1h');
});
});
it('should read custom time from URL on mount', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should use custom URL keys when provided', async () => {
const wrapper = createWrapper(
{
enableUrlParams: {
relativeTimeKey: 'modalTime',
},
},
{ searchParams: '?modalTime=3h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
await waitFor(() => {
expect(result.current).toBe('3h');
});
});
it('should use custom startTimeKey and endTimeKey when provided', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{
enableUrlParams: {
startTimeKey: 'customStart',
endTimeKey: 'customEnd',
},
},
{ searchParams: `?customStart=${startTime}&customEnd=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should NOT read from URL when enableUrlParams is false', async () => {
const wrapper = createWrapper(
{ enableUrlParams: false, initialTime: '15m' },
{ searchParams: '?relativeTime=1h' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// Should use initialTime, not URL value
expect(result.current).toBe('15m');
});
it('should prefer startTime/endTime over relativeTime when both present in URL', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{
searchParams: `?relativeTime=15m&startTime=${startTime}&endTime=${endTime}`,
},
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
const { minTime, maxTime } = result.current.getMinMaxTime();
// Should use startTime/endTime, not relativeTime
expect(minTime).toBe(startTime * NANO_SECOND_MULTIPLIER);
expect(maxTime).toBe(endTime * NANO_SECOND_MULTIPLIER);
});
});
it('should use initialTime when URL has invalid time values', async () => {
const wrapper = createWrapper(
{ enableUrlParams: true, initialTime: '15m' },
{ searchParams: '?startTime=invalid&endTime=also-invalid' },
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// parseAsInteger returns null for invalid values, so should fallback to initialTime
expect(result.current).toBe('15m');
});
it('should update store when custom time is set from URL with only startTime and endTime', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
const wrapper = createWrapper(
{ enableUrlParams: true },
{ searchParams: `?startTime=${startTime}&endTime=${endTime}` },
);
const { result } = renderHook(() => useGlobalTime(), { wrapper });
await waitFor(() => {
// Verify selectedTime is a custom time range string
expect(result.current.selectedTime).toContain('||_||');
});
});
describe('removeQueryParamsOnUnmount', () => {
const createUnmountTestWrapper = (
getQueryString: () => string,
setQueryString: (qs: string) => void,
) => {
return function TestWrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter
searchParams={getQueryString()}
onUrlUpdate={(event): void => {
setQueryString(event.queryString);
}}
>
{children}
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
it('should remove URL params when provider unmounts with removeQueryParamsOnUnmount=true', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams
removeQueryParamsOnUnmount
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('relativeTime');
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
});
it('should NOT remove URL params when provider unmounts with removeQueryParamsOnUnmount=false', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams
removeQueryParamsOnUnmount={false}
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// Wait a tick to ensure cleanup effects would have run
await waitFor(() => {
// URL params should still be present
expect(currentQueryString).toContain('relativeTime=1h');
});
});
it('should remove custom time URL params on unmount', async () => {
const startTime = 1700000000000;
const endTime = 1700003600000;
let currentQueryString = `startTime=${startTime}&endTime=${endTime}`;
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(() => useGlobalTime(), {
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider enableUrlParams removeQueryParamsOnUnmount>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
});
// Verify initial URL params are present
expect(currentQueryString).toContain('startTime');
expect(currentQueryString).toContain('endTime');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('startTime');
expect(currentQueryString).not.toContain('endTime');
});
});
it('should remove custom URL key params on unmount', async () => {
let currentQueryString = 'modalTime=3h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams={{
relativeTimeKey: 'modalTime',
}}
removeQueryParamsOnUnmount
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('modalTime=3h');
// Unmount the provider
unmount();
// URL params should be removed
await waitFor(() => {
expect(currentQueryString).not.toContain('modalTime');
});
});
it('should NOT remove URL params when enableUrlParams is false', async () => {
let currentQueryString = 'relativeTime=1h';
const TestWrapper = createUnmountTestWrapper(
() => currentQueryString,
(qs) => {
currentQueryString = qs;
},
);
const { unmount } = renderHook(
() => useGlobalTime((s) => s.selectedTime),
{
wrapper: ({ children }) => (
<TestWrapper>
<GlobalTimeProvider
enableUrlParams={false}
removeQueryParamsOnUnmount
>
{children}
</GlobalTimeProvider>
</TestWrapper>
),
},
);
// Verify initial URL params are present
expect(currentQueryString).toContain('relativeTime=1h');
// Unmount the provider
unmount();
// Wait a tick
await waitFor(() => {
// URL params should still be present (enableUrlParams is false)
expect(currentQueryString).toContain('relativeTime=1h');
});
});
});
});
describe('localStorage persistence', () => {
const mockSet = set as jest.MockedFunction<typeof set>;
beforeEach(() => {
localStorage.clear();
mockSet.mockClear();
mockSet.mockReturnValue(true);
});
it('should read from localStorage on mount', () => {
localStorage.setItem('test-time-key', '6h');
const wrapper = createWrapper({ localStoragePersistKey: 'test-time-key' });
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
expect(result.current).toBe('6h');
});
it('should write to localStorage on selectedTime change', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-persist-key',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
act(() => {
result.current.setSelectedTime('12h');
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-persist-key', '12h');
});
});
it('should NOT write to localStorage when persistKey is undefined', async () => {
const wrapper = createWrapper({ initialTime: '15m' });
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
act(() => {
result.current.setSelectedTime('1h');
});
// Wait a tick to ensure any async operations complete
await waitFor(() => {
expect(result.current.selectedTime).toBe('1h');
});
expect(mockSet).not.toHaveBeenCalled();
});
it('should only write to localStorage when selectedTime changes, not other state', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
// Change refreshInterval (not selectedTime)
act(() => {
result.current.setRefreshInterval(5000);
});
// Wait to ensure subscription handler had a chance to run
await waitFor(() => {
expect(result.current.refreshInterval).toBe(5000);
});
// Should NOT have written to localStorage for refreshInterval change
expect(mockSet).not.toHaveBeenCalled();
// Now change selectedTime
act(() => {
result.current.setSelectedTime('1h');
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-key', '1h');
});
});
it('should fallback to initialTime when localStorage contains empty string', () => {
localStorage.setItem('test-key', '');
const wrapper = createWrapper({
localStoragePersistKey: 'test-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
// Empty string is falsy, should use initialTime
expect(result.current).toBe('15m');
});
it('should write custom time range to localStorage', async () => {
const wrapper = createWrapper({
localStoragePersistKey: 'test-custom-key',
initialTime: '15m',
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
mockSet.mockClear();
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime);
});
await waitFor(() => {
expect(mockSet).toHaveBeenCalledWith('test-custom-key', customTime);
});
});
});
describe('refreshInterval', () => {
it('should initialize with provided refreshInterval', () => {
const wrapper = createWrapper({ refreshInterval: 5000 });
const { result } = renderHook(() => useGlobalTime(), { wrapper });
expect(result.current.refreshInterval).toBe(5000);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,83 @@
import { act } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { createGlobalTimeStore, defaultGlobalTimeStore } from '../globalTimeStore';
import { createCustomTimeRange } from '../utils';
describe('createGlobalTimeStore', () => {
describe('factory function', () => {
it('should create independent store instances', () => {
const store1 = createGlobalTimeStore();
const store2 = createGlobalTimeStore();
store1.getState().setSelectedTime('1h');
expect(store1.getState().selectedTime).toBe('1h');
expect(store2.getState().selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should accept initial state', () => {
const store = createGlobalTimeStore({
selectedTime: '15m',
refreshInterval: 5000,
});
expect(store.getState().selectedTime).toBe('15m');
expect(store.getState().refreshInterval).toBe(5000);
expect(store.getState().isRefreshEnabled).toBe(true);
});
it('should compute isRefreshEnabled correctly for custom time', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const store = createGlobalTimeStore({
selectedTime: customTime,
refreshInterval: 5000,
});
expect(store.getState().isRefreshEnabled).toBe(false);
});
});
describe('defaultGlobalTimeStore', () => {
it('should be a singleton', () => {
expect(defaultGlobalTimeStore).toBeDefined();
expect(defaultGlobalTimeStore.getState().selectedTime).toBeDefined();
});
});
describe('setRefreshInterval', () => {
it('should update refresh interval and enable refresh', () => {
const store = createGlobalTimeStore();
act(() => {
store.getState().setRefreshInterval(10000);
});
expect(store.getState().refreshInterval).toBe(10000);
expect(store.getState().isRefreshEnabled).toBe(true);
});
it('should disable refresh when interval is 0', () => {
const store = createGlobalTimeStore({ refreshInterval: 5000 });
act(() => {
store.getState().setRefreshInterval(0);
});
expect(store.getState().refreshInterval).toBe(0);
expect(store.getState().isRefreshEnabled).toBe(false);
});
it('should not enable refresh for custom time range', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const store = createGlobalTimeStore({ selectedTime: customTime });
act(() => {
store.getState().setRefreshInterval(10000);
});
expect(store.getState().refreshInterval).toBe(10000);
expect(store.getState().isRefreshEnabled).toBe(false);
});
});
});

View File

@@ -1,204 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeSelectedTime } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const {
minTime: resultMin,
maxTime: resultMax,
} = result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return different values on subsequent calls for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
// Advance time by 1 second
act(() => {
jest.advanceTimersByTime(1000);
});
const second = result.current.getMinMaxTime();
// maxTime should be different (1 second later)
expect(second.maxTime).toBe(first.maxTime + 1000 * NANO_SECOND_MULTIPLIER);
expect(second.minTime).toBe(first.minTime + 1000 * NANO_SECOND_MULTIPLIER);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,789 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { createGlobalTimeStore, useGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeContext } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeSelectedTime, GlobalTimeState } from '../types';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
/**
* Creates an isolated store wrapper for testing.
* Each test gets its own store instance, avoiding test pollution.
*/
function createIsolatedWrapper(
initialState?: Partial<GlobalTimeState>,
): ({ children }: { children: ReactNode }) => JSX.Element {
const store = createGlobalTimeStore(initialState);
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<GlobalTimeContext.Provider value={store}>
{children}
</GlobalTimeContext.Provider>
);
};
}
describe('globalTimeStore', () => {
beforeEach(() => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime(DEFAULT_TIME_RANGE, 0);
});
});
describe('initial state', () => {
it(`should have default selectedTime of ${DEFAULT_TIME_RANGE}`, () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.selectedTime).toBe(DEFAULT_TIME_RANGE);
});
it('should have isRefreshEnabled as false by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should have refreshInterval as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.refreshInterval).toBe(0);
});
it('should have lastRefreshTimestamp as 0 by default', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.lastRefreshTimestamp).toBe(0);
});
it('should have lastComputedMinMax with default values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
expect(result.current.lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
});
});
describe('setSelectedTime', () => {
it('should update selectedTime', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
expect(result.current.selectedTime).toBe('15m');
});
it('should update refreshInterval when provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should keep existing refreshInterval when not provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
act(() => {
result.current.setSelectedTime('1h');
});
expect(result.current.refreshInterval).toBe(5000);
});
it('should enable refresh for relative time with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 5000);
});
expect(result.current.isRefreshEnabled).toBe(true);
});
it('should disable refresh for relative time with refreshInterval = 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m', 0);
});
expect(result.current.isRefreshEnabled).toBe(false);
});
it('should disable refresh for custom time range even with refreshInterval > 0', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime, 5000);
});
expect(result.current.isRefreshEnabled).toBe(false);
expect(result.current.refreshInterval).toBe(5000);
});
it('should handle various relative time formats', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const timeFormats: GlobalTimeSelectedTime[] = [
'1m',
'5m',
'15m',
'30m',
'1h',
'3h',
'6h',
'1d',
'1w',
];
timeFormats.forEach((time) => {
act(() => {
result.current.setSelectedTime(time, 10000);
});
expect(result.current.selectedTime).toBe(time);
expect(result.current.isRefreshEnabled).toBe(true);
});
});
it('should reset lastComputedMinMax when selectedTime changes', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Compute and store initial values
act(() => {
result.current.computeAndStoreMinMax();
});
// Verify we have cached values
expect(result.current.lastComputedMinMax.maxTime).toBeGreaterThan(0);
// Now switch to a custom time range
const customTime = createCustomTimeRange(1000000000, 2000000000);
act(() => {
result.current.setSelectedTime(customTime);
});
// lastComputedMinMax should be reset
expect(result.current.lastComputedMinMax).toStrictEqual({
minTime: 0,
maxTime: 0,
});
});
it('should return fresh custom time values after switching from relative time', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// Compute and cache values for relative time
act(() => {
result.current.computeAndStoreMinMax();
});
const relativeMinMax = { ...result.current.lastComputedMinMax };
// Switch to custom time range
const customMinTime = 5000000000;
const customMaxTime = 6000000000;
const customTime = createCustomTimeRange(customMinTime, customMaxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
// getMinMaxTime should return the custom time values, not cached relative values
const returned = result.current.getMinMaxTime();
expect(returned.minTime).toBe(customMinTime);
expect(returned.maxTime).toBe(customMaxTime);
expect(returned).not.toStrictEqual(relativeMinMax);
jest.useRealTimers();
});
});
describe('getMinMaxTime', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return min/max time for custom time range', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime: resultMin, maxTime: resultMax } =
result.current.getMinMaxTime();
expect(resultMin).toBe(minTime);
expect(resultMax).toBe(maxTime);
});
it('should NOT round custom time range values to minute boundaries', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
// If rounding occurred, these would change to 12:30:00.000
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
act(() => {
result.current.setSelectedTime(customTime);
});
const { minTime, maxTime } = result.current.getMinMaxTime();
// Should return exact values, NOT rounded values
expect(minTime).toBe(minTimeWithSeconds);
expect(maxTime).toBe(maxTimeWithSeconds);
expect(minTime).not.toBe(minTimeRounded);
expect(maxTime).not.toBe(maxTimeRounded);
});
it('should NOT round custom time range passed as parameter', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Store is set to relative time
act(() => {
result.current.setSelectedTime('15m');
});
// Use timestamps that are NOT on minute boundaries
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
// Pass custom time as parameter (different from store's selectedTime)
const { minTime, maxTime } = result.current.getMinMaxTime(customTime);
// Should return exact values, NOT rounded values
expect(minTime).toBe(minTimeWithSeconds);
expect(maxTime).toBe(maxTimeWithSeconds);
expect(minTime).not.toBe(minTimeRounded);
expect(maxTime).not.toBe(maxTimeRounded);
});
it('should compute fresh min/max time for relative time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const { minTime, maxTime } = result.current.getMinMaxTime();
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(maxTime).toBe(now);
expect(minTime).toBe(now - fifteenMinutesNs);
});
it('should return same values on subsequent calls for relative time under a minute', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
act(() => {
jest.advanceTimersByTime(59000);
});
const second = result.current.getMinMaxTime();
expect(second.maxTime).toBe(first.maxTime);
expect(second.minTime).toBe(first.minTime);
});
it('should return different values on subsequent calls for relative time only after a minute', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
const first = result.current.getMinMaxTime();
act(() => {
jest.advanceTimersByTime(60000);
});
const second = result.current.getMinMaxTime();
expect(second.maxTime).toBe(first.maxTime + 60000 * NANO_SECOND_MULTIPLIER);
expect(second.minTime).toBe(first.minTime + 60000 * NANO_SECOND_MULTIPLIER);
});
it('should return stored lastComputedMinMax when available', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
result.current.computeAndStoreMinMax();
});
const stored = { ...result.current.lastComputedMinMax };
// Advance time by 5 seconds
act(() => {
jest.advanceTimersByTime(5000);
});
// getMinMaxTime should return stored values, not fresh computation
const returned = result.current.getMinMaxTime();
expect(returned).toStrictEqual(stored);
});
it('should compute fresh values when different selectedTime is provided', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
result.current.computeAndStoreMinMax();
});
const stored = { ...result.current.lastComputedMinMax };
// Request time for a different selectedTime
const freshValues = result.current.getMinMaxTime('1h');
// Should NOT equal stored values (different duration)
expect(freshValues).not.toStrictEqual(stored);
});
it('should behave same as no-param call when selectedTime matches state', () => {
// This tests the pattern used in K8sBaseDetails:
// getMinMaxTime(selectedTime) where selectedTime === state.selectedTime
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000, // isRefreshEnabled = true
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call with selectedTime parameter that matches state.selectedTime
// Should behave the same as calling without parameter
const withParam = result.current.getMinMaxTime('15m');
const withoutParam = result.current.getMinMaxTime();
expect(withParam).toStrictEqual(withoutParam);
expect(withParam.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
describe('with isRefreshEnabled (isolated store)', () => {
it('should compute fresh values when isRefreshEnabled is true', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// getMinMaxTime should return fresh values, not cached
const freshValues = result.current.getMinMaxTime();
expect(freshValues.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
expect(freshValues.minTime).toBe(
initialMinMax.minTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
it('should update lastComputedMinMax when values change', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call getMinMaxTime - should update lastComputedMinMax
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
expect(result.current.lastComputedMinMax.minTime).toBe(
initialMinMax.minTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
it('should update lastRefreshTimestamp when values change', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call getMinMaxTime - should update timestamp
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
it('should NOT update lastComputedMinMax when values have not changed (same minute)', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
const initialTimestamp = result.current.lastRefreshTimestamp;
// Advance time but stay within same minute
act(() => {
jest.advanceTimersByTime(30000);
});
// Call getMinMaxTime - should NOT update store (same minute boundary)
act(() => {
result.current.getMinMaxTime();
});
// Values should be unchanged (no unnecessary re-renders)
expect(result.current.lastComputedMinMax).toStrictEqual(initialMinMax);
expect(result.current.lastRefreshTimestamp).toBe(initialTimestamp);
});
it('should return cached values when isRefreshEnabled is false', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 0, // Refresh disabled
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const storedMinMax = { ...result.current.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// getMinMaxTime should return cached values since refresh is disabled
const returned = result.current.getMinMaxTime();
expect(returned).toStrictEqual(storedMinMax);
expect(result.current.lastComputedMinMax).toStrictEqual(storedMinMax);
});
it('should return same values for custom time range regardless of time passing', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
const wrapper = createIsolatedWrapper({
selectedTime: customTime,
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
// isRefreshEnabled should be false for custom time ranges
expect(result.current.isRefreshEnabled).toBe(false);
// Custom time ranges always return the fixed values, not relative to "now"
const first = result.current.getMinMaxTime();
expect(first.minTime).toBe(minTime);
expect(first.maxTime).toBe(maxTime);
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Should still return the same fixed values (custom range doesn't drift)
const second = result.current.getMinMaxTime();
expect(second.minTime).toBe(minTime);
expect(second.maxTime).toBe(maxTime);
});
it('should handle multiple consecutive refetch intervals correctly', () => {
const wrapper = createIsolatedWrapper({
selectedTime: '15m',
refreshInterval: 5000,
});
const { result } = renderHook(() => useGlobalTime(), { wrapper });
act(() => {
result.current.computeAndStoreMinMax();
});
const initialMinMax = { ...result.current.lastComputedMinMax };
// Simulate 3 refetch intervals crossing minute boundaries
for (let i = 1; i <= 3; i++) {
act(() => {
jest.advanceTimersByTime(60000);
});
act(() => {
result.current.getMinMaxTime();
});
expect(result.current.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + i * 60000 * NANO_SECOND_MULTIPLIER,
);
}
});
});
});
describe('computeAndStoreMinMax', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should compute and store rounded min/max values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.setSelectedTime('15m');
});
act(() => {
result.current.computeAndStoreMinMax();
});
// maxTime should be rounded to 12:30:00.000
const expectedMaxTime =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.current.lastComputedMinMax.maxTime).toBe(expectedMaxTime);
expect(result.current.lastComputedMinMax.minTime).toBe(
expectedMaxTime - fifteenMinutesNs,
);
});
it('should update lastRefreshTimestamp', () => {
const { result } = renderHook(() => useGlobalTimeStore());
const beforeTimestamp = Date.now();
act(() => {
result.current.computeAndStoreMinMax();
});
expect(result.current.lastRefreshTimestamp).toBeGreaterThanOrEqual(
beforeTimestamp,
);
});
it('should return the computed values', () => {
const { result } = renderHook(() => useGlobalTimeStore());
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
expect(returnedValue).toStrictEqual(result.current.lastComputedMinMax);
});
it('should NOT round custom time range values to minute boundaries', () => {
const { result } = renderHook(() => useGlobalTimeStore());
// Use timestamps that are NOT on minute boundaries (12:30:45.123)
// If rounding occurred, these would change to 12:30:00.000
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
// What the values would be if rounded down to minute boundary
const minTimeRounded =
new Date('2024-01-15T12:15:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeRounded =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
act(() => {
result.current.setSelectedTime(customTime);
});
let returnedValue: { minTime: number; maxTime: number } | undefined;
act(() => {
returnedValue = result.current.computeAndStoreMinMax();
});
// Should return exact values, NOT rounded values
expect(returnedValue?.minTime).toBe(minTimeWithSeconds);
expect(returnedValue?.maxTime).toBe(maxTimeWithSeconds);
expect(returnedValue?.minTime).not.toBe(minTimeRounded);
expect(returnedValue?.maxTime).not.toBe(maxTimeRounded);
// lastComputedMinMax should also have exact values
expect(result.current.lastComputedMinMax.minTime).toBe(minTimeWithSeconds);
expect(result.current.lastComputedMinMax.maxTime).toBe(maxTimeWithSeconds);
});
});
describe('updateRefreshTimestamp', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should update lastRefreshTimestamp to current time', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.updateRefreshTimestamp();
});
expect(result.current.lastRefreshTimestamp).toBe(Date.now());
});
it('should not modify lastComputedMinMax', () => {
const { result } = renderHook(() => useGlobalTimeStore());
act(() => {
result.current.computeAndStoreMinMax();
});
const beforeMinMax = { ...result.current.lastComputedMinMax };
act(() => {
jest.advanceTimersByTime(5000);
result.current.updateRefreshTimestamp();
});
expect(result.current.lastComputedMinMax).toStrictEqual(beforeMinMax);
});
});
describe('store isolation', () => {
it('should share state between multiple hook instances', () => {
const { result: result1 } = renderHook(() => useGlobalTimeStore());
const { result: result2 } = renderHook(() => useGlobalTimeStore());
act(() => {
result1.current.setSelectedTime('1h', 10000);
});
expect(result2.current.selectedTime).toBe('1h');
expect(result2.current.refreshInterval).toBe(10000);
expect(result2.current.isRefreshEnabled).toBe(true);
});
});
});

View File

@@ -0,0 +1,122 @@
import { act, renderHook } from '@testing-library/react';
import { ReactNode } from 'react';
import { createGlobalTimeStore } from '../globalTimeStore';
import { GlobalTimeContext } from '../GlobalTimeContext';
import {
useGlobalTime,
useGlobalTimeStoreApi,
useIsCustomTimeRange,
useLastComputedMinMax,
} from '../hooks';
import { createCustomTimeRange } from '../utils';
describe('useGlobalTime', () => {
it('should return full store state without selector', () => {
const { result } = renderHook(() => useGlobalTime());
expect(result.current.selectedTime).toBeDefined();
expect(result.current.setSelectedTime).toBeInstanceOf(Function);
});
it('should return selected value with selector', () => {
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime));
expect(typeof result.current).toBe('string');
});
it('should use context store when provided', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '1h' });
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
);
const { result } = renderHook(() => useGlobalTime((s) => s.selectedTime), {
wrapper,
});
expect(result.current).toBe('1h');
});
});
describe('useIsCustomTimeRange', () => {
it('should return false for relative time', () => {
const { result } = renderHook(() => useIsCustomTimeRange());
expect(result.current).toBe(false);
});
it('should return true for custom time range', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const contextStore = createGlobalTimeStore({ selectedTime: customTime });
const { result } = renderHook(() => useIsCustomTimeRange(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
expect(result.current).toBe(true);
});
});
describe('useGlobalTimeStoreApi', () => {
it('should return store API', () => {
const { result } = renderHook(() => useGlobalTimeStoreApi());
expect(result.current.getState).toBeInstanceOf(Function);
expect(result.current.subscribe).toBeInstanceOf(Function);
});
});
describe('useLastComputedMinMax', () => {
it('should return lastComputedMinMax from store', () => {
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
// Compute the min/max first
contextStore.getState().computeAndStoreMinMax();
const { result } = renderHook(() => useLastComputedMinMax(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
expect(result.current).toStrictEqual(contextStore.getState().lastComputedMinMax);
});
it('should update when store changes', () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
const contextStore = createGlobalTimeStore({ selectedTime: '15m' });
contextStore.getState().computeAndStoreMinMax();
const { result } = renderHook(() => useLastComputedMinMax(), {
wrapper: ({ children }: { children: ReactNode }): JSX.Element => (
<GlobalTimeContext.Provider value={contextStore}>
{children}
</GlobalTimeContext.Provider>
),
});
const firstValue = { ...result.current };
// Change time and recompute
act(() => {
jest.advanceTimersByTime(60000); // Advance 1 minute
contextStore.getState().computeAndStoreMinMax();
});
expect(result.current).not.toStrictEqual(firstValue);
jest.useRealTimers();
});
});

View File

@@ -0,0 +1,279 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GlobalTimeProvider } from '../GlobalTimeContext';
import { useGlobalTime } from '../hooks';
import { GlobalTimeProviderOptions } from '../types';
import { useGlobalTimeQueryInvalidate } from '../useGlobalTimeQueryInvalidate';
import { createCustomTimeRange, NANO_SECOND_MULTIPLIER } from '../utils';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: Infinity,
},
},
});
const createWrapper = (
providerProps: GlobalTimeProviderOptions,
queryClient: QueryClient,
) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>
<NuqsTestingAdapter>
<GlobalTimeProvider {...providerProps}>{children}</GlobalTimeProvider>
</NuqsTestingAdapter>
</QueryClientProvider>
);
};
};
describe('useGlobalTimeQueryInvalidate', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
queryClient.clear();
});
it('should return a function', () => {
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
expect(typeof result.current).toBe('function');
});
it('should call computeAndStoreMinMax before invalidating queries', async () => {
const wrapper = createWrapper(
{ initialTime: '15m', refreshInterval: 5000 },
queryClient,
);
const { result } = renderHook(
() => ({
invalidate: useGlobalTimeQueryInvalidate(),
globalTime: useGlobalTime(),
}),
{ wrapper },
);
// Initial computation
const initialMinMax = { ...result.current.globalTime.lastComputedMinMax };
// Advance time past minute boundary
act(() => {
jest.advanceTimersByTime(60000);
});
// Call invalidate
await act(async () => {
await result.current.invalidate();
});
// lastComputedMinMax should have been updated
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
initialMinMax.maxTime + 60000 * NANO_SECOND_MULTIPLIER,
);
});
it('should invalidate queries with AUTO_REFRESH_QUERY key', async () => {
const mockQueryFn = jest.fn().mockResolvedValue({ data: 'test' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up a query with AUTO_REFRESH_QUERY key
const { result: queryResult } = renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test-query'],
queryFn: mockQueryFn,
}),
{ wrapper },
);
// Wait for initial query to complete
await waitFor(() => {
expect(queryResult.current.isSuccess).toBe(true);
});
expect(mockQueryFn).toHaveBeenCalledTimes(1);
// Now render the invalidate hook and call it
const { result: invalidateResult } = renderHook(
() => useGlobalTimeQueryInvalidate(),
{ wrapper },
);
await act(async () => {
await invalidateResult.current();
});
// Query should have been refetched
await waitFor(() => {
expect(mockQueryFn).toHaveBeenCalledTimes(2);
});
});
it('should NOT invalidate queries without AUTO_REFRESH_QUERY key', async () => {
const autoRefreshQueryFn = jest.fn().mockResolvedValue({ data: 'auto' });
const regularQueryFn = jest.fn().mockResolvedValue({ data: 'regular' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up both types of queries
const { result: autoRefreshQuery } = renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto-query'],
queryFn: autoRefreshQueryFn,
}),
{ wrapper },
);
const { result: regularQuery } = renderHook(
() =>
useQuery({
queryKey: ['regular-query'],
queryFn: regularQueryFn,
}),
{ wrapper },
);
// Wait for initial queries to complete
await waitFor(() => {
expect(autoRefreshQuery.current.isSuccess).toBe(true);
expect(regularQuery.current.isSuccess).toBe(true);
});
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(1);
expect(regularQueryFn).toHaveBeenCalledTimes(1);
// Call invalidate
const { result: invalidateResult } = renderHook(
() => useGlobalTimeQueryInvalidate(),
{ wrapper },
);
await act(async () => {
await invalidateResult.current();
});
// Only auto-refresh query should be refetched
await waitFor(() => {
expect(autoRefreshQueryFn).toHaveBeenCalledTimes(2);
});
// Regular query should NOT be refetched
expect(regularQueryFn).toHaveBeenCalledTimes(1);
});
it('should use exact custom time values (not rounded) when invalidating', async () => {
// Use timestamps that are NOT on minute boundaries
const minTimeWithSeconds =
new Date('2024-01-15T12:15:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const maxTimeWithSeconds =
new Date('2024-01-15T12:30:45.123Z').getTime() * NANO_SECOND_MULTIPLIER;
const customTime = createCustomTimeRange(
minTimeWithSeconds,
maxTimeWithSeconds,
);
const wrapper = createWrapper({ initialTime: customTime }, queryClient);
const { result } = renderHook(
() => ({
invalidate: useGlobalTimeQueryInvalidate(),
globalTime: useGlobalTime(),
}),
{ wrapper },
);
// Call invalidate
await act(async () => {
await result.current.invalidate();
});
// Verify custom time values are NOT rounded
expect(result.current.globalTime.lastComputedMinMax.minTime).toBe(
minTimeWithSeconds,
);
expect(result.current.globalTime.lastComputedMinMax.maxTime).toBe(
maxTimeWithSeconds,
);
});
it('should invalidate multiple AUTO_REFRESH_QUERY queries at once', async () => {
const queryFn1 = jest.fn().mockResolvedValue({ data: 'query1' });
const queryFn2 = jest.fn().mockResolvedValue({ data: 'query2' });
const queryFn3 = jest.fn().mockResolvedValue({ data: 'query3' });
const wrapper = createWrapper({ initialTime: '15m' }, queryClient);
// Set up multiple auto-refresh queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: queryFn1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: queryFn2,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query3'],
queryFn: queryFn3,
}),
{ wrapper },
);
// Wait for initial queries
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(1);
expect(queryFn2).toHaveBeenCalledTimes(1);
expect(queryFn3).toHaveBeenCalledTimes(1);
});
// Call invalidate
const { result } = renderHook(() => useGlobalTimeQueryInvalidate(), {
wrapper,
});
await act(async () => {
await result.current();
});
// All queries should be refetched
await waitFor(() => {
expect(queryFn1).toHaveBeenCalledTimes(2);
expect(queryFn2).toHaveBeenCalledTimes(2);
expect(queryFn3).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,229 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useIsGlobalTimeQueryRefreshing } from '../useIsGlobalTimeQueryRefreshing';
const createTestQueryClient = (): QueryClient =>
new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const createWrapper = (
queryClient: QueryClient,
): (({ children }: { children: ReactNode }) => JSX.Element) => {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
};
describe('useIsGlobalTimeQueryRefreshing', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = createTestQueryClient();
});
afterEach(() => {
queryClient.clear();
});
it('should return false when no queries are fetching', () => {
const wrapper = createWrapper(queryClient);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
expect(result.current).toBe(false);
});
it('should return true when AUTO_REFRESH_QUERY is fetching', async () => {
let resolveQuery: (value: unknown) => void;
const queryPromise = new Promise((resolve) => {
resolveQuery = resolve;
});
const wrapper = createWrapper(queryClient);
// Start the auto-refresh query
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
queryFn: () => queryPromise,
}),
{ wrapper },
);
// Check if refreshing hook detects it
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true while fetching
expect(result.current).toBe(true);
// Resolve the query
act(() => {
resolveQuery({ data: 'done' });
});
// Should be false after fetching completes
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should return false when non-AUTO_REFRESH_QUERY is fetching', async () => {
let resolveQuery: (value: unknown) => void;
const queryPromise = new Promise((resolve) => {
resolveQuery = resolve;
});
const wrapper = createWrapper(queryClient);
// Start a regular query (not auto-refresh)
renderHook(
() =>
useQuery({
queryKey: ['regular-query'],
queryFn: () => queryPromise,
}),
{ wrapper },
);
// Check if refreshing hook detects it
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be false - not an auto-refresh query
expect(result.current).toBe(false);
// Cleanup
act(() => {
resolveQuery({ data: 'done' });
});
});
it('should return true when multiple AUTO_REFRESH_QUERY queries are fetching', async () => {
let resolveQuery1: (value: unknown) => void;
let resolveQuery2: (value: unknown) => void;
const queryPromise1 = new Promise((resolve) => {
resolveQuery1 = resolve;
});
const queryPromise2 = new Promise((resolve) => {
resolveQuery2 = resolve;
});
const wrapper = createWrapper(queryClient);
// Start multiple auto-refresh queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query1'],
queryFn: () => queryPromise1,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'query2'],
queryFn: () => queryPromise2,
}),
{ wrapper },
);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true while fetching
expect(result.current).toBe(true);
// Resolve first query
act(() => {
resolveQuery1({ data: 'done1' });
});
// Should still be true (second query still fetching)
await waitFor(() => {
expect(result.current).toBe(true);
});
// Resolve second query
act(() => {
resolveQuery2({ data: 'done2' });
});
// Should be false after all complete
await waitFor(() => {
expect(result.current).toBe(false);
});
});
it('should only track AUTO_REFRESH_QUERY, not other queries', async () => {
let resolveAutoRefresh: (value: unknown) => void;
let resolveRegular: (value: unknown) => void;
const autoRefreshPromise = new Promise((resolve) => {
resolveAutoRefresh = resolve;
});
const regularPromise = new Promise((resolve) => {
resolveRegular = resolve;
});
const wrapper = createWrapper(queryClient);
// Start both types of queries
renderHook(
() =>
useQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'auto'],
queryFn: () => autoRefreshPromise,
}),
{ wrapper },
);
renderHook(
() =>
useQuery({
queryKey: ['regular'],
queryFn: () => regularPromise,
}),
{ wrapper },
);
const { result } = renderHook(() => useIsGlobalTimeQueryRefreshing(), {
wrapper,
});
// Should be true (auto-refresh is fetching)
expect(result.current).toBe(true);
// Resolve auto-refresh query
act(() => {
resolveAutoRefresh({ data: 'done' });
});
// Should be false even though regular query is still fetching
await waitFor(() => {
expect(result.current).toBe(false);
});
// Cleanup
act(() => {
resolveRegular({ data: 'done' });
});
});
});

View File

@@ -0,0 +1,100 @@
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { ReactNode } from 'react';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { createGlobalTimeStore, GlobalTimeStoreApi } from '../globalTimeStore';
import { useQueryCacheSync } from '../useQueryCacheSync';
function createTestQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
}
function createWrapper(
queryClient: QueryClient,
): ({ children }: { children: ReactNode }) => JSX.Element {
return function Wrapper({ children }: { children: ReactNode }): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe('useQueryCacheSync', () => {
let store: GlobalTimeStoreApi;
let queryClient: QueryClient;
beforeEach(() => {
store = createGlobalTimeStore();
queryClient = createTestQueryClient();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
queryClient.clear();
});
it('should update lastRefreshTimestamp when auto-refresh query succeeds', async () => {
// Initialize store
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
// Advance time
act(() => {
jest.advanceTimersByTime(5000);
});
// Render the hook
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Simulate a successful auto-refresh query
await act(async () => {
await queryClient.fetchQuery({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, 'test'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
await waitFor(() => {
expect(store.getState().lastRefreshTimestamp).toBeGreaterThan(
initialTimestamp,
);
});
});
it('should not update timestamp for non-auto-refresh queries', async () => {
act(() => {
store.getState().computeAndStoreMinMax();
});
const initialTimestamp = store.getState().lastRefreshTimestamp;
renderHook(() => useQueryCacheSync(store), {
wrapper: createWrapper(queryClient),
});
// Simulate a regular query (not auto-refresh)
await act(async () => {
await queryClient.fetchQuery({
queryKey: ['some-other-query'],
queryFn: () => Promise.resolve({ data: 'test' }),
});
});
expect(store.getState().lastRefreshTimestamp).toBe(initialTimestamp);
});
});

View File

@@ -1,10 +1,15 @@
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
computeRoundedMinMax,
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
getAutoRefreshQueryKey,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
roundDownToMinute,
} from '../utils';
describe('globalTime/utils', () => {
@@ -59,7 +64,7 @@ describe('globalTime/utils', () => {
const maxTime = 2000000000;
const timeString = `${minTime}${CUSTOM_TIME_SEPARATOR}${maxTime}`;
const result = parseCustomTimeRange(timeString);
expect(result).toEqual({ minTime, maxTime });
expect(result).toStrictEqual({ minTime, maxTime });
});
it('should return null for non-custom time range strings', () => {
@@ -75,7 +80,7 @@ describe('globalTime/utils', () => {
it('should handle zero values', () => {
const result = parseCustomTimeRange(`0${CUSTOM_TIME_SEPARATOR}0`);
expect(result).toEqual({ minTime: 0, maxTime: 0 });
expect(result).toStrictEqual({ minTime: 0, maxTime: 0 });
});
});
@@ -94,7 +99,7 @@ describe('globalTime/utils', () => {
const maxTime = 2000000000;
const timeString = createCustomTimeRange(minTime, maxTime);
const result = parseSelectedTime(timeString);
expect(result).toEqual({ minTime, maxTime });
expect(result).toStrictEqual({ minTime, maxTime });
});
it('should return fallback for invalid custom time range', () => {
@@ -136,4 +141,130 @@ describe('globalTime/utils', () => {
expect(result.minTime).toBe(now - oneDayNs);
});
});
describe('roundDownToMinute', () => {
it('should round down timestamp to minute boundary', () => {
// 2024-01-15T12:30:45.123Z -> 2024-01-15T12:30:00.000Z
const inputNano = 1705321845123 * NANO_SECOND_MULTIPLIER; // 12:30:45.123
const expectedNano = 1705321800000 * NANO_SECOND_MULTIPLIER; // 12:30:00.000
expect(roundDownToMinute(inputNano)).toBe(expectedNano);
});
it('should not change timestamp already at minute boundary', () => {
const inputNano = 1705321800000 * NANO_SECOND_MULTIPLIER; // 12:30:00.000
expect(roundDownToMinute(inputNano)).toBe(inputNano);
});
it('should handle timestamp at 59 seconds', () => {
// 2024-01-15T12:30:59.999Z -> 2024-01-15T12:30:00.000Z
const inputNano = 1705321859999 * NANO_SECOND_MULTIPLIER;
const expectedNano = 1705321800000 * NANO_SECOND_MULTIPLIER;
expect(roundDownToMinute(inputNano)).toBe(expectedNano);
});
});
describe('computeRoundedMinMax', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('should return rounded maxTime for relative time', () => {
const result = computeRoundedMinMax('15m');
// maxTime should be rounded down to 12:30:00.000
const expectedMaxTime =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(expectedMaxTime);
});
it('should compute minTime based on rounded maxTime', () => {
const result = computeRoundedMinMax('15m');
const expectedMaxTime =
new Date('2024-01-15T12:30:00.000Z').getTime() * NANO_SECOND_MULTIPLIER;
const fifteenMinutesNs = 15 * 60 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.minTime).toBe(expectedMaxTime - fifteenMinutesNs);
});
it('should return unchanged values for custom time range', () => {
const minTime = 1000000000;
const maxTime = 2000000000;
const customTime = createCustomTimeRange(minTime, maxTime);
const result = computeRoundedMinMax(customTime);
expect(result.minTime).toBe(minTime);
expect(result.maxTime).toBe(maxTime);
});
it('should return fallback for invalid custom time range', () => {
jest.setSystemTime(new Date('2024-01-15T12:30:45.123Z'));
const invalidCustom = `invalid${CUSTOM_TIME_SEPARATOR}values`;
const result = computeRoundedMinMax(invalidCustom);
const now = Date.now() * NANO_SECOND_MULTIPLIER;
const fallbackDuration = 30 * 1000 * NANO_SECOND_MULTIPLIER;
expect(result.maxTime).toBe(now);
expect(result.minTime).toBe(now - fallbackDuration);
});
});
describe('getAutoRefreshQueryKey', () => {
it('should prefix with AUTO_REFRESH_QUERY constant', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY');
expect(result[0]).toBe(REACT_QUERY_KEY.AUTO_REFRESH_QUERY);
});
it('should append selectedTime at end', () => {
const result = getAutoRefreshQueryKey('15m', 'MY_QUERY', 'param1');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'MY_QUERY',
'param1',
'15m',
]);
});
it('should handle no additional query parts', () => {
const result = getAutoRefreshQueryKey('1h');
expect(result).toStrictEqual([REACT_QUERY_KEY.AUTO_REFRESH_QUERY, '1h']);
});
it('should handle custom time range as selectedTime', () => {
const customTime = createCustomTimeRange(1000000000, 2000000000);
const result = getAutoRefreshQueryKey(customTime, 'METRICS');
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'METRICS',
customTime,
]);
});
it('should handle object query parts', () => {
const params = { entityId: '123', filter: 'active' };
const result = getAutoRefreshQueryKey('15m', 'ENTITY', params);
expect(result).toStrictEqual([
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
'ENTITY',
params,
'15m',
]);
});
});
});

View File

@@ -1,32 +1,138 @@
import { createStore, StoreApi, useStore } from 'zustand';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { create } from 'zustand';
import {
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStore,
ParsedTimeRange,
} from './types';
import { isCustomTimeRange, parseSelectedTime } from './utils';
import {
computeRoundedMinMax,
isCustomTimeRange,
parseSelectedTime,
} from './utils';
export type IGlobalTimeStore = IGlobalTimeStoreState & IGlobalTimeStoreActions;
export type GlobalTimeStoreApi = StoreApi<GlobalTimeStore>;
export type IGlobalTimeStore = GlobalTimeStore;
export const useGlobalTimeStore = create<IGlobalTimeStore>((set, get) => ({
selectedTime: DEFAULT_TIME_RANGE,
isRefreshEnabled: false,
refreshInterval: 0,
setSelectedTime: (selectedTime, refreshInterval): void => {
set((state) => {
const newRefreshInterval = refreshInterval ?? state.refreshInterval;
const isCustom = isCustomTimeRange(selectedTime);
function computeIsRefreshEnabled(
selectedTime: GlobalTimeSelectedTime,
refreshInterval: number,
): boolean {
if (isCustomTimeRange(selectedTime)) {
return false;
}
return refreshInterval > 0;
}
return {
selectedTime,
refreshInterval: newRefreshInterval,
isRefreshEnabled: !isCustom && newRefreshInterval > 0,
};
});
},
getMinMaxTime: (selectedTime): ParsedTimeRange => {
return parseSelectedTime(selectedTime || get().selectedTime);
},
}));
export function createGlobalTimeStore(
initialState?: Partial<GlobalTimeState>,
): GlobalTimeStoreApi {
const selectedTime = initialState?.selectedTime ?? DEFAULT_TIME_RANGE;
const refreshInterval = initialState?.refreshInterval ?? 0;
return createStore<GlobalTimeStore>((set, get) => ({
selectedTime,
refreshInterval,
isRefreshEnabled: computeIsRefreshEnabled(selectedTime, refreshInterval),
lastRefreshTimestamp: 0,
lastComputedMinMax: { minTime: 0, maxTime: 0 },
setSelectedTime: (
time: GlobalTimeSelectedTime,
newRefreshInterval?: number,
): void => {
set((state) => {
const interval = newRefreshInterval ?? state.refreshInterval;
return {
selectedTime: time,
refreshInterval: interval,
isRefreshEnabled: computeIsRefreshEnabled(time, interval),
// Reset cached values so getMinMaxTime computes fresh values for the new selection
lastComputedMinMax: { minTime: 0, maxTime: 0 },
};
});
},
setRefreshInterval: (interval: number): void => {
set((state) => ({
refreshInterval: interval,
isRefreshEnabled: computeIsRefreshEnabled(state.selectedTime, interval),
}));
},
getMinMaxTime: (selectedTime?: GlobalTimeSelectedTime): ParsedTimeRange => {
const state = get();
const timeToUse = selectedTime ?? state.selectedTime;
// For custom time ranges, return exact values without rounding
if (isCustomTimeRange(timeToUse)) {
return parseSelectedTime(timeToUse);
}
if (selectedTime && selectedTime !== state.selectedTime) {
return computeRoundedMinMax(selectedTime);
}
// When auto-refresh is enabled, compute fresh values and update store
// This ensures time moves forward on each refetchInterval cycle
// Note: computeRoundedMinMax rounds to minute boundaries, so all queries
// calling getMinMaxTime within the same minute get consistent values
if (state.isRefreshEnabled) {
const freshMinMax = computeRoundedMinMax(state.selectedTime);
// Only update store if values changed (avoids unnecessary re-renders)
if (
freshMinMax.minTime !== state.lastComputedMinMax.minTime ||
freshMinMax.maxTime !== state.lastComputedMinMax.maxTime
) {
set({
lastComputedMinMax: freshMinMax,
lastRefreshTimestamp: Date.now(),
});
}
return freshMinMax;
}
// Return stored values if they exist (set by computeAndStoreMinMax)
// This ensures all callers get the same values within a refresh cycle
if (state.lastComputedMinMax.maxTime > 0) {
return state.lastComputedMinMax;
}
return computeRoundedMinMax(state.selectedTime);
},
computeAndStoreMinMax: (): ParsedTimeRange => {
const { selectedTime } = get();
// For custom time ranges, use exact values without rounding
const computedMinMax = isCustomTimeRange(selectedTime)
? parseSelectedTime(selectedTime)
: computeRoundedMinMax(selectedTime);
set({
lastComputedMinMax: computedMinMax,
lastRefreshTimestamp: Date.now(),
});
return computedMinMax;
},
updateRefreshTimestamp: (): void => {
set({ lastRefreshTimestamp: Date.now() });
},
}));
}
export const defaultGlobalTimeStore = createGlobalTimeStore();
export const useGlobalTimeStore = <T = GlobalTimeStore>(
selector?: (state: GlobalTimeStore) => T,
): T => {
return useStore(
defaultGlobalTimeStore,
selector ?? ((state) => state as unknown as T),
);
};

View File

@@ -0,0 +1,58 @@
// oxlint-disable-next-line no-restricted-imports
import { useContext } from 'react';
import { useStoreWithEqualityFn } from 'zustand/traditional';
import { GlobalTimeContext } from './GlobalTimeContext';
import { defaultGlobalTimeStore, GlobalTimeStoreApi } from './globalTimeStore';
import { GlobalTimeStore, ParsedTimeRange } from './types';
import { isCustomTimeRange } from './utils';
/**
* Access global time state with optional selector for performance.
*
* @example
* // Full state (re-renders on any change)
* const { selectedTime, setSelectedTime } = useGlobalTime();
*
* @example
* // With selector (re-renders only when selectedTime changes)
* const selectedTime = useGlobalTime(state => state.selectedTime);
*/
export function useGlobalTime<T = GlobalTimeStore>(
selector?: (state: GlobalTimeStore) => T,
equalityFn?: (a: T, b: T) => boolean,
): T {
const contextStore = useContext(GlobalTimeContext);
const store = contextStore ?? defaultGlobalTimeStore;
return useStoreWithEqualityFn(
store,
selector ?? ((state) => state as unknown as T),
equalityFn,
);
}
/**
* Check if currently using a custom time range.
*/
export function useIsCustomTimeRange(): boolean {
const selectedTime = useGlobalTime((state) => state.selectedTime);
return isCustomTimeRange(selectedTime);
}
/**
* Get the store API directly (for subscriptions or non-React contexts).
*/
export function useGlobalTimeStoreApi(): GlobalTimeStoreApi {
const contextStore = useContext(GlobalTimeContext);
return contextStore ?? defaultGlobalTimeStore;
}
/**
* Get the last computed min/max time values.
* Use this for display purposes to ensure consistency with query data.
*/
export function useLastComputedMinMax(): ParsedTimeRange {
return useGlobalTime((state) => state.lastComputedMinMax);
}

View File

@@ -1,9 +1,526 @@
export { useGlobalTimeStore } from './globalTimeStore';
export type { IGlobalTimeStoreState, ParsedTimeRange } from './types';
/**
* # Global Time Store
*
* Centralized time management for the application with auto-refresh support.
*
* ## Quick Start
*
* ```tsx
* import {
* useGlobalTime,
* getAutoRefreshQueryKey,
* NANO_SECOND_MULTIPLIER,
* } from 'store/globalTime';
*
* function MyComponent() {
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* const { data } = useQuery({
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY', params),
* queryFn: () => {
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
* return fetchData({ start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* }
* ```
*
* ## Core Concepts
*
* ### Time Formats
*
* | Format | Example | Description |
* |--------|---------|-------------|
* | Relative | `'15m'`, `'1h'`, `'1d'` | Duration from now, supports auto-refresh |
* | Custom | `'1234567890||_||1234567899'` | Fixed range in nanoseconds, no auto-refresh |
*
* ### Time Units
*
* - Store values are in **nanoseconds**
* - Most APIs expect **seconds**
* - Convert to have seconds: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER / 1000)`
* - Convert to have ms: `Math.floor(nanoTime / NANO_SECOND_MULTIPLIER)`
*
* ## Integration Guide
*
* ### Step 1: Get Store State
*
* Use selectors for optimal re-render performance:
*
* ```tsx
* // Good - only re-renders when selectedTime changes
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
*
* // Avoid - re-renders on ANY store change
* const store = useGlobalTime();
* ```
*
* ### Step 2: Build Query Key
*
* Always use `getAutoRefreshQueryKey` to enable auto-refresh:
*
* ```tsx
* const queryKey = useMemo(
* () => getAutoRefreshQueryKey(
* selectedTime, // Required - triggers invalidation
* 'UNIQUE_KEY', // Your query identifier
* ...otherParams // Additional cache-busting params
* ),
* [selectedTime, ...deps]
* );
* ```
*
* ### Step 3: Fetch Data
*
* **IMPORTANT**: Call `getMinMaxTime()` INSIDE `queryFn`:
*
* ```tsx
* const { data } = useQuery({
* queryKey,
* queryFn: () => {
* // Fresh time values computed here during auto-refresh
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
* return api.fetch({ start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* ```
*
* ### Step 4: Add Refresh Button (Optional)
*
* ```tsx
* import {
* useGlobalTimeQueryInvalidate,
* useIsGlobalTimeQueryRefreshing,
* } from 'store/globalTime';
*
* function RefreshButton() {
* const invalidate = useGlobalTimeQueryInvalidate();
* const isRefreshing = useIsGlobalTimeQueryRefreshing();
*
* return (
* <button onClick={invalidate} disabled={isRefreshing}>
* {isRefreshing ? 'Refreshing...' : 'Refresh'}
* </button>
* );
* }
* ```
*
* ## Avoiding Stale Data
*
* ### Problem: Time Drift During Refresh
*
* If multiple queries compute time independently, they may use different values:
*
* ```tsx
* // BAD - each query gets different time
* queryFn: () => {
* const now = Date.now();
* return fetchData({ end: now, start: now - duration });
* }
* ```
*
* ### Solution: Use getMinMaxTime()
*
* `getMinMaxTime()` ensures all queries use consistent timestamps:
* - When auto-refresh is **disabled**: returns cached values from `computeAndStoreMinMax()`
* - When auto-refresh is **enabled**: computes fresh values (rounded to minute boundaries)
*
* Since values are rounded to minute boundaries, all queries calling `getMinMaxTime()`
* within the same minute get identical timestamps.
*
* ```tsx
* // GOOD - all queries get same time
* queryFn: () => {
* const { minTime, maxTime } = getMinMaxTime();
* return fetchData({ start: minTime, end: maxTime });
* }
* ```
*
* ### How It Works
*
* **Manual refresh:**
* 1. User clicks refresh
* 2. `useGlobalTimeQueryInvalidate` calls `computeAndStoreMinMax()`
* 3. Fresh min/max stored in `lastComputedMinMax`
* 4. All queries re-run and call `getMinMaxTime()`
* 5. All get the SAME cached values
*
* **Auto-refresh (when `isRefreshEnabled = true`):**
* 1. React-query's `refetchInterval` triggers query re-execution
* 2. `getMinMaxTime()` computes fresh values (rounded to minute)
* 3. If values changed, updates `lastComputedMinMax` cache
* 4. All queries within same minute get consistent values
*
* ## Auto-Refresh Setup
*
* Auto-refresh is enabled when:
* - `selectedTime` is a relative duration (e.g., `'15m'`)
* - `refreshInterval > 0`
*
* ```tsx
* // Auto-refresh configuration
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* useQuery({
* queryKey: getAutoRefreshQueryKey(selectedTime, 'MY_QUERY'),
* queryFn: () => { ... },
* // Enable periodic refetch
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
* ```
*
* ## API Reference
*
* ### Hooks
*
* | Hook | Returns | Description |
* |------|---------|-------------|
* | `useGlobalTime(selector?)` | `T` | Access store state with optional selector |
* | `useGlobalTimeQueryInvalidate()` | `() => Promise<void>` | Invalidate all auto-refresh queries |
* | `useIsGlobalTimeQueryRefreshing()` | `boolean` | Check if any query is refreshing |
* | `useIsCustomTimeRange()` | `boolean` | Check if using fixed time range |
* | `useLastComputedMinMax()` | `ParsedTimeRange` | Get cached min/max values |
* | `useGlobalTimeStoreApi()` | `GlobalTimeStoreApi` | Get raw store API |
*
* ### Store Actions
*
* | Action | Description |
* |--------|-------------|
* | `setSelectedTime(time, interval?)` | Set time range and optional refresh interval (resets cache) |
* | `setRefreshInterval(ms)` | Set auto-refresh interval |
* | `getMinMaxTime(time?)` | Get min/max (fresh if auto-refresh enabled, cached otherwise) |
* | `computeAndStoreMinMax()` | Compute fresh values and cache them |
*
* ### Utilities
*
* | Function | Description |
* |----------|-------------|
* | `getAutoRefreshQueryKey(time, ...parts)` | Build query key with auto-refresh support |
* | `parseSelectedTime(time)` | Parse time string to min/max (fresh computation) |
* | `isCustomTimeRange(time)` | Check if time is custom range format |
* | `createCustomTimeRange(min, max)` | Create custom range string |
*
* ### Constants
*
* | Constant | Value | Description |
* |----------|-------|-------------|
* | `NANO_SECOND_MULTIPLIER` | `1000000` | Convert ms to ns |
* | `CUSTOM_TIME_SEPARATOR` | `'||_||'` | Separator in custom range strings |
*
* ## Context & Composition
*
* ### Why Use Context?
*
* By default, `useGlobalTime()` uses a shared global store. Use `GlobalTimeProvider`
* to create isolated time state for specific UI sections (modals, drawers, etc.).
*
* ### Provider Options
*
* | Option | Type | Description |
* |--------|------|-------------|
* | `inheritGlobalTime` | `boolean` | Initialize with parent/global time value |
* | `initialTime` | `string` | Initial time if not inheriting |
* | `enableUrlParams` | `boolean \| object` | Sync time to URL query params |
* | `removeQueryParamsOnUnmount` | `boolean` | Clean URL params on unmount |
* | `localStoragePersistKey` | `string` | Persist time to localStorage |
* | `refreshInterval` | `number` | Initial auto-refresh interval (ms) |
*
* ### Example 1: Isolated Time in Modal
*
* A modal with its own time picker that doesn't affect the main page:
*
* ```tsx
* import { GlobalTimeProvider, useGlobalTime } from 'store/globalTime';
*
* function EntityDetailsModal({ entity, onClose }) {
* return (
* <Modal open onClose={onClose}>
* // Isolated time context - changes here don't affect parent
* <GlobalTimeProvider
* inheritGlobalTime // Start with parent's current time
* refreshInterval={0} // No auto-refresh in modal
* >
* <ModalContent entity={entity} />
* </GlobalTimeProvider>
* </Modal>
* );
* }
*
* function ModalContent({ entity }) {
* // This useGlobalTime reads from the modal's isolated store
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const setSelectedTime = useGlobalTime((s) => s.setSelectedTime);
*
* return (
* <>
* <DateTimePicker
* value={selectedTime}
* onChange={(time) => setSelectedTime(time)}
* />
* <EntityMetrics entity={entity} />
* <EntityLogs entity={entity} />
* </>
* );
* }
* ```
*
* ### Example 2: List Page with Detail Drawer
*
* Main list uses global time, drawer has independent time:
*
* ```tsx
* // Main list page - uses global time (no provider needed)
* function K8sPodsList() {
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const [selectedPod, setSelectedPod] = useState(null);
*
* return (
* <>
* <PageHeader>
* <DateTimeSelectionV3 /> // Controls global time
* </PageHeader>
*
* <PodsTable
* timeRange={selectedTime}
* onRowClick={setSelectedPod}
* />
*
* {selectedPod && (
* <PodDetailsDrawer
* pod={selectedPod}
* onClose={() => setSelectedPod(null)}
* />
* )}
* </>
* );
* }
*
* // Drawer with its own time context
* function PodDetailsDrawer({ pod, onClose }) {
* return (
* <Drawer open onClose={onClose}>
* <GlobalTimeProvider
* inheritGlobalTime // Start with list's time
* removeQueryParamsOnUnmount // Clean up URL when drawer closes
* enableUrlParams={{
* relativeTimeKey: 'drawerTime',
* startTimeKey: 'drawerStart',
* endTimeKey: 'drawerEnd',
* }}
* >
* <DrawerHeader>
* <DateTimeSelectionV3 /> // Controls drawer's time only
* </DrawerHeader>
*
* <Tabs>
* <Tab label="Metrics"><PodMetrics pod={pod} /></Tab>
* <Tab label="Logs"><PodLogs pod={pod} /></Tab>
* <Tab label="Events"><PodEvents pod={pod} /></Tab>
* </Tabs>
* </GlobalTimeProvider>
* </Drawer>
* );
* }
* ```
*
* ### Example 3: Nested Contexts
*
* Contexts can be nested - each level creates isolation:
*
* ```tsx
* // App level - global time
* function App() {
* return (
* <QueryClientProvider>
* // No provider here = uses defaultGlobalTimeStore
* <Dashboard />
* </QueryClientProvider>
* );
* }
*
* // Dashboard with comparison panel
* function Dashboard() {
* return (
* <div className="dashboard">
* // Main dashboard uses global time
* <MainCharts />
*
* // Comparison panel has its own time
* <GlobalTimeProvider initialTime="1h">
* <ComparisonPanel />
* </GlobalTimeProvider>
* </div>
* );
* }
*
* function ComparisonPanel() {
* // This reads from ComparisonPanel's isolated store (1h)
* // Not affected by global time changes
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* return <ComparisonCharts timeRange={selectedTime} />;
* }
* ```
*
* ### Example 4: URL Sync for Shareable Links
*
* Persist time selection to URL for shareable links:
*
* ```tsx
* function TracesExplorer() {
* return (
* <GlobalTimeProvider
* enableUrlParams={{
* relativeTimeKey: 'time', // ?time=15m
* startTimeKey: 'startTime', // ?startTime=1234567890
* endTimeKey: 'endTime', // ?endTime=1234567899
* }}
* initialTime="15m" // Fallback if URL has no time params
* >
* <TracesContent />
* </GlobalTimeProvider>
* );
* }
* ```
*
* ### Example 5: localStorage Persistence
*
* Remember user's last selected time across sessions:
*
* ```tsx
* function MetricsExplorer() {
* return (
* <GlobalTimeProvider
* localStoragePersistKey="metrics-explorer-time"
* initialTime="1h" // Fallback for first visit
* >
* <MetricsContent />
* </GlobalTimeProvider>
* );
* }
* ```
*
* ### Context Resolution Order
*
* When `useGlobalTime()` is called, it resolves the store in this order:
*
* 1. Nearest `GlobalTimeProvider` ancestor (if any)
* 2. `defaultGlobalTimeStore` (global singleton)
*
* ```
* App (no provider -> uses defaultGlobalTimeStore)
* |-- Dashboard
* |-- MainCharts (uses defaultGlobalTimeStore)
* |-- GlobalTimeProvider (isolated store A)
* |-- ComparisonPanel (uses store A)
* |-- GlobalTimeProvider (isolated store B)
* |-- NestedChart (uses store B)
* ```
*
* ## Complete Example
*
* ```tsx
* import { useMemo } from 'react';
* import { useQuery } from 'react-query';
* import {
* useGlobalTime,
* getAutoRefreshQueryKey,
* NANO_SECOND_MULTIPLIER,
* } from 'store/globalTime';
*
* function MetricsPanel({ entityId }: { entityId: string }) {
* // 1. Get store state with selectors
* const selectedTime = useGlobalTime((s) => s.selectedTime);
* const getMinMaxTime = useGlobalTime((s) => s.getMinMaxTime);
* const isRefreshEnabled = useGlobalTime((s) => s.isRefreshEnabled);
* const refreshInterval = useGlobalTime((s) => s.refreshInterval);
*
* // 2. Build query key (memoized)
* const queryKey = useMemo(
* () => getAutoRefreshQueryKey(selectedTime, 'METRICS', entityId),
* [selectedTime, entityId]
* );
*
* // 3. Query with auto-refresh
* const { data, isLoading } = useQuery({
* queryKey,
* queryFn: () => {
* // Get fresh time inside queryFn
* const { minTime, maxTime } = getMinMaxTime();
* const start = Math.floor(minTime / NANO_SECOND_MULTIPLIER / 1000);
* const end = Math.floor(maxTime / NANO_SECOND_MULTIPLIER / 1000);
*
* return fetchMetrics({ entityId, start, end });
* },
* refetchInterval: isRefreshEnabled ? refreshInterval : false,
* });
*
* return <Chart data={data} loading={isLoading} />;
* }
* ```
*
* @module store/globalTime
*/
// Store
export {
createGlobalTimeStore,
defaultGlobalTimeStore,
useGlobalTimeStore,
} from './globalTimeStore';
export type { GlobalTimeStoreApi } from './globalTimeStore';
// Context & Provider
export { GlobalTimeContext, GlobalTimeProvider } from './GlobalTimeContext';
// Hooks
export {
useGlobalTime,
useGlobalTimeStoreApi,
useIsCustomTimeRange,
useLastComputedMinMax,
} from './hooks';
// Query hooks for auto-refresh
export { useGlobalTimeQueryInvalidate } from './useGlobalTimeQueryInvalidate';
export { useIsGlobalTimeQueryRefreshing } from './useIsGlobalTimeQueryRefreshing';
// Types
export type {
CustomTimeRange,
CustomTimeRangeSeparator,
GlobalTimeActions,
GlobalTimeProviderOptions,
GlobalTimeSelectedTime,
GlobalTimeState,
GlobalTimeStore,
IGlobalTimeStoreActions,
IGlobalTimeStoreState,
ParsedTimeRange,
} from './types';
// Utilities
export {
computeRoundedMinMax,
createCustomTimeRange,
CUSTOM_TIME_SEPARATOR,
getAutoRefreshQueryKey,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
parseSelectedTime,
roundDownToMinute,
} from './utils';
// Internal hooks (for advanced use cases)
export { useQueryCacheSync } from './useQueryCacheSync';

View File

@@ -50,3 +50,52 @@ export interface IGlobalTimeStoreActions {
*/
getMinMaxTime: (selectedItem?: GlobalTimeSelectedTime) => ParsedTimeRange;
}
export interface GlobalTimeProviderOptions {
/** Initialize from parent/global time */
inheritGlobalTime?: boolean;
/** Initial time if not inheriting */
initialTime?: GlobalTimeSelectedTime;
/** URL sync configuration. When false/omitted, no URL sync. */
enableUrlParams?:
| boolean
| {
relativeTimeKey?: string;
startTimeKey?: string;
endTimeKey?: string;
};
removeQueryParamsOnUnmount?: boolean;
localStoragePersistKey?: string;
refreshInterval?: number;
}
export interface GlobalTimeState {
selectedTime: GlobalTimeSelectedTime;
refreshInterval: number;
isRefreshEnabled: boolean;
lastRefreshTimestamp: number;
lastComputedMinMax: ParsedTimeRange;
}
export interface GlobalTimeActions {
setSelectedTime: (
time: GlobalTimeSelectedTime,
refreshInterval?: number,
) => void;
setRefreshInterval: (interval: number) => void;
getMinMaxTime: (selectedTime?: GlobalTimeSelectedTime) => ParsedTimeRange;
/**
* Compute fresh rounded min/max values, store them, and update refresh timestamp.
* Call this before invalidating queries to ensure all queries use the same time values.
*
* @returns The newly computed ParsedTimeRange
*/
computeAndStoreMinMax: () => ParsedTimeRange;
/**
* Update the refresh timestamp to current time.
* Called by QueryCache listener when auto-refresh queries complete.
*/
updateRefreshTimestamp: () => void;
}
export type GlobalTimeStore = GlobalTimeState & GlobalTimeActions;

View File

@@ -0,0 +1,20 @@
import { useEffect } from 'react';
import { GlobalTimeStoreApi } from './globalTimeStore';
export function useComputedMinMaxSync(store: GlobalTimeStoreApi): void {
useEffect(() => {
store.getState().computeAndStoreMinMax();
}, [store]);
useEffect(() => {
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
if (state.selectedTime !== previousSelectedTime) {
previousSelectedTime = state.selectedTime;
store.getState().computeAndStoreMinMax();
}
});
}, [store]);
}

View File

@@ -0,0 +1,26 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGlobalTime } from './hooks';
/**
* Use when you want to invalidate any query tracked by {@link REACT_QUERY_KEY.AUTO_REFRESH_QUERY}
*
* This hook computes fresh time values before invalidating queries,
* ensuring all queries use the same min/max time during a refresh cycle.
*/
export function useGlobalTimeQueryInvalidate(): () => Promise<void> {
const queryClient = useQueryClient();
const computeAndStoreMinMax = useGlobalTime((s) => s.computeAndStoreMinMax);
return useCallback(async () => {
// Compute fresh time values BEFORE invalidating
// This ensures all queries that re-run will use the same time values
computeAndStoreMinMax();
return await queryClient.invalidateQueries({
queryKey: [REACT_QUERY_KEY.AUTO_REFRESH_QUERY],
});
}, [queryClient, computeAndStoreMinMax]);
}

View File

@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import set from 'api/browser/localstorage/set';
import { GlobalTimeStoreApi } from './globalTimeStore';
export function usePersistence(
store: GlobalTimeStoreApi,
persistKey: string | undefined,
): void {
useEffect(() => {
if (!persistKey) {
return;
}
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
if (state.selectedTime === previousSelectedTime) {
return;
}
previousSelectedTime = state.selectedTime;
set(persistKey, state.selectedTime);
});
}, [store, persistKey]);
}

View File

@@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { useQueryClient } from 'react-query';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GlobalTimeStoreApi } from './globalTimeStore';
/**
* Subscribes to QueryCache events and updates the store's lastRefreshTimestamp
* when auto-refresh queries complete successfully.
*/
export function useQueryCacheSync(store: GlobalTimeStoreApi): void {
const queryClient = useQueryClient();
useEffect(() => {
const queryCache = queryClient.getQueryCache();
return queryCache.subscribe((event) => {
// Only react to successful query updates
if (event?.type !== 'queryUpdated') {
return;
}
const action = event.action as { type?: string };
if (action?.type !== 'success') {
return;
}
// Check if it's an auto-refresh query by key prefix
const queryKey = event.query.queryKey;
if (
!Array.isArray(queryKey) ||
queryKey[0] !== REACT_QUERY_KEY.AUTO_REFRESH_QUERY
) {
return;
}
// Update the refresh timestamp in store
store.getState().updateRefreshTimestamp();
});
}, [queryClient, store]);
}

View File

@@ -0,0 +1,137 @@
import { useEffect, useRef } from 'react';
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { GlobalTimeStoreApi } from './globalTimeStore';
import { GlobalTimeProviderOptions } from './types';
import {
createCustomTimeRange,
isCustomTimeRange,
NANO_SECOND_MULTIPLIER,
parseCustomTimeRange,
} from './utils';
interface UrlSyncConfig {
relativeTimeKey: string;
startTimeKey: string;
endTimeKey: string;
}
export function useUrlSync(
store: GlobalTimeStoreApi,
enableUrlParams: GlobalTimeProviderOptions['enableUrlParams'],
removeOnUnmount: boolean,
): void {
const isInitialMount = useRef(true);
const keys: UrlSyncConfig =
enableUrlParams && typeof enableUrlParams === 'object'
? {
relativeTimeKey: enableUrlParams.relativeTimeKey ?? 'relativeTime',
startTimeKey: enableUrlParams.startTimeKey ?? 'startTime',
endTimeKey: enableUrlParams.endTimeKey ?? 'endTime',
}
: {
relativeTimeKey: 'relativeTime',
startTimeKey: 'startTime',
endTimeKey: 'endTime',
};
const [urlState, setUrlState] = useQueryStates(
{
[keys.relativeTimeKey]: parseAsString,
[keys.startTimeKey]: parseAsInteger,
[keys.endTimeKey]: parseAsInteger,
},
{ history: 'replace' },
);
useEffect(() => {
if (!enableUrlParams || !isInitialMount.current) {
return;
}
isInitialMount.current = false;
const relativeTime = urlState[keys.relativeTimeKey];
const startTime = urlState[keys.startTimeKey];
const endTime = urlState[keys.endTimeKey];
if (typeof startTime === 'number' && typeof endTime === 'number') {
const customTime = createCustomTimeRange(
startTime * NANO_SECOND_MULTIPLIER,
endTime * NANO_SECOND_MULTIPLIER,
);
store.getState().setSelectedTime(customTime);
} else if (relativeTime) {
store.getState().setSelectedTime(relativeTime as Time);
}
}, [
urlState,
keys?.startTimeKey,
keys?.endTimeKey,
keys?.relativeTimeKey,
store,
enableUrlParams,
]);
useEffect(() => {
if (!enableUrlParams) {
return;
}
let previousSelectedTime = store.getState().selectedTime;
return store.subscribe((state) => {
// Only update URL when selectedTime actually changes
if (state.selectedTime === previousSelectedTime) {
return;
}
previousSelectedTime = state.selectedTime;
if (isCustomTimeRange(state.selectedTime)) {
const parsed = parseCustomTimeRange(state.selectedTime);
if (parsed) {
void setUrlState({
[keys.relativeTimeKey]: null,
[keys.startTimeKey]: Math.floor(parsed.minTime / NANO_SECOND_MULTIPLIER),
[keys.endTimeKey]: Math.floor(parsed.maxTime / NANO_SECOND_MULTIPLIER),
});
}
} else {
void setUrlState({
[keys.relativeTimeKey]: state.selectedTime,
[keys.startTimeKey]: null,
[keys.endTimeKey]: null,
});
}
});
}, [
store,
keys?.startTimeKey,
keys?.endTimeKey,
keys?.relativeTimeKey,
setUrlState,
enableUrlParams,
]);
useEffect(() => {
if (!enableUrlParams || !removeOnUnmount) {
return;
}
return (): void => {
void setUrlState({
[keys.relativeTimeKey]: null,
[keys.startTimeKey]: null,
[keys.endTimeKey]: null,
});
};
}, [
removeOnUnmount,
keys?.relativeTimeKey,
keys?.startTimeKey,
keys?.endTimeKey,
setUrlState,
enableUrlParams,
]);
}

View File

@@ -44,8 +44,8 @@ export function parseCustomTimeRange(
}
const [minStr, maxStr] = selectedTime.split(CUSTOM_TIME_SEPARATOR);
const minTime = parseInt(minStr, 10);
const maxTime = parseInt(maxStr, 10);
const minTime = Number.parseInt(minStr, 10);
const maxTime = Number.parseInt(maxStr, 10);
if (Number.isNaN(minTime) || Number.isNaN(maxTime)) {
return null;
@@ -87,3 +87,54 @@ export function getAutoRefreshQueryKey(
): unknown[] {
return [REACT_QUERY_KEY.AUTO_REFRESH_QUERY, ...queryParts, selectedTime];
}
/**
* Round timestamp down to the nearest minute boundary.
* Used to ensure consistent time values across multiple consumers within the same minute.
*
* @param timestampNano - Timestamp in nanoseconds
* @returns Timestamp rounded down to minute boundary in nanoseconds
*/
export function roundDownToMinute(timestampNano: number): number {
const msPerMinute = 60 * 1000;
const timestampMs = Math.floor(timestampNano / NANO_SECOND_MULTIPLIER);
const roundedMs = Math.floor(timestampMs / msPerMinute) * msPerMinute;
return roundedMs * NANO_SECOND_MULTIPLIER;
}
/**
* Compute min/max time with maxTime rounded down to minute boundary.
* For relative times, this ensures all calls within the same minute return identical values.
* For custom time ranges, returns the stored values unchanged.
*
* @param selectedTime - The selected time (relative like '15m' or custom range)
* @returns ParsedTimeRange with rounded maxTime for relative times
*/
export function computeRoundedMinMax(selectedTime: string): ParsedTimeRange {
if (isCustomTimeRange(selectedTime)) {
const parsed = parseCustomTimeRange(selectedTime);
if (parsed) {
return parsed;
}
// Fallback if parsing fails
const now = Date.now() * NANO_SECOND_MULTIPLIER;
return { minTime: now - fallbackDurationInNanoSeconds, maxTime: now };
}
// For relative time, compute with rounded maxTime
const nowNano = Date.now() * NANO_SECOND_MULTIPLIER;
const roundedMaxTime = roundDownToMinute(nowNano);
// Get the duration from the relative time
const { minTime: originalMin, maxTime: originalMax } = getMinMaxForSelectedTime(
selectedTime as Time,
0,
0,
);
const durationNano = originalMax - originalMin;
return {
minTime: roundedMaxTime - durationNano,
maxTime: roundedMaxTime,
};
}