1- /* global Vue axios */ //> from vue.html
2- const GET = ( url ) => axios . get ( '/odata/v4/-data' + url )
3- const storageGet = ( key , def ) => localStorage . getItem ( 'data-viewer:' + key ) || def
4- const storageSet = ( key , val ) => localStorage . setItem ( 'data-viewer:' + key , val )
1+ /* global Vue axios */
2+ const GET = ( url ) => axios . get ( '/odata/v4/-data' + url )
3+ const storageGet = ( key , def ) => localStorage . getItem ( 'data-viewer:' + key ) ?? def
4+ const storageSet = ( key , val ) => localStorage . setItem ( 'data-viewer:' + key , val )
5+
56const columnKeysFirst = ( c1 , c2 ) => {
6- if ( c1 . isKey && ! c2 . isKey ) return - 1
7+ if ( c1 . isKey && ! c2 . isKey ) return - 1
78 if ( ! c1 . isKey && c2 . isKey ) return 1
89 if ( c1 . isKey && c2 . isKey ) return c1 . name . localeCompare ( c2 . name )
9- return 0 // retain natural order of normal columns
10+ return 0
1011}
1112
12- const vue = Vue . createApp ( {
13-
14- data ( ) { return {
15- error : undefined ,
16- dataSource : storageGet ( 'data-source' , 'db' ) ,
17- skip : storageGet ( 'skip' , 0 ) ,
18- top : storageGet ( 'top' , 20 ) ,
19- entity : storageGet ( 'entity' ) ? JSON . parse ( storageGet ( 'entity' ) ) : undefined ,
20- entities : [ ] ,
21- columns : [ ] ,
22- data : [ ] ,
23- rowDetails : { } ,
24- rowKey : storageGet ( 'rowKey' )
25- } } ,
13+ const app = Vue . createApp ( {
14+
15+ data ( ) {
16+ const savedTheme = localStorage . getItem ( 'data-viewer:theme' ) ||
17+ ( window . matchMedia ( '(prefers-color-scheme: light)' ) . matches ? 'light' : 'dark' )
18+ return {
19+ // Theme
20+ theme : savedTheme ,
21+ // Data source
22+ dataSource : storageGet ( 'data-source' , 'db' ) ,
23+ // Pagination
24+ skip : Number ( storageGet ( 'skip' , 0 ) ) ,
25+ top : Number ( storageGet ( 'top' , 20 ) ) ,
26+ totalCount : 0 ,
27+ // Entities + columns
28+ entity : ( ( ) => { try { const v = storageGet ( 'entity' ) ; return v ? JSON . parse ( v ) : undefined } catch { return undefined } } ) ( ) ,
29+ entities : [ ] ,
30+ columns : [ ] ,
31+ // Raw data (string[][])
32+ data : [ ] ,
33+ loading : false ,
34+ error : undefined ,
35+ // Sort
36+ sortState : { col : null , dir : null } ,
37+ // Search
38+ search : '' ,
39+ // Column visibility
40+ hiddenCols : new Set ( ) ,
41+ showColPanel : false ,
42+ // Row selection
43+ rowDetails : { } ,
44+ rowKey : storageGet ( 'row-key' , '' ) ,
45+ activeRowIndex : - 1 ,
46+ // Toast
47+ toastVisible : false ,
48+ toastMessage : '' ,
49+ }
50+ } ,
51+
52+ computed : {
53+ sortedData ( ) {
54+ const { col, dir } = this . sortState
55+ if ( col === null || dir === null ) return this . data
56+ return [ ...this . data ] . sort ( ( a , b ) => {
57+ const cmp = String ( a [ col ] ) . localeCompare ( String ( b [ col ] ) , undefined , { numeric : true , sensitivity : 'base' } )
58+ return dir === 'asc' ? cmp : - cmp
59+ } )
60+ } ,
61+
62+ filteredData ( ) {
63+ if ( ! this . search ) return this . sortedData
64+ const q = this . search . toLowerCase ( )
65+ return this . sortedData . filter ( row =>
66+ row . some ( ( cell , i ) => ! this . hiddenCols . has ( i ) && String ( cell ) . toLowerCase ( ) . includes ( q ) )
67+ )
68+ } ,
69+
70+ displayKey ( ) {
71+ if ( ! this . columns . length || ! Object . keys ( this . rowDetails ) . length ) return ''
72+ return this . columns
73+ . filter ( c => c . isKey )
74+ . map ( c => this . rowDetails [ c . name ] ?? '' )
75+ . join ( ', ' )
76+ } ,
77+ } ,
2678
2779 watch : {
28- dataSource : ( v ) => { storageSet ( 'data-source' , v ) ; vue . fetchEntities ( ) } ,
29- skip : ( v ) => { storageSet ( 'skip' , v ) ; if ( vue . entity ) vue . fetchData ( ) } ,
30- top : ( v ) => { storageSet ( 'top' , v ) ; if ( vue . entity ) vue . fetchData ( ) } ,
80+ dataSource ( v ) { storageSet ( 'data-source' , v ) ; this . fetchEntities ( ) } ,
81+ skip ( v ) { storageSet ( 'skip' , v ) ; if ( this . entity ) this . fetchData ( ) } ,
82+ top ( v ) {
83+ storageSet ( 'top' , v )
84+ // Avoid double fetch: if skip changes, the skip watcher calls fetchData().
85+ // Only call fetchData directly when skip is already 0 (watcher won't fire).
86+ if ( this . skip === 0 ) {
87+ if ( this . entity ) this . fetchData ( )
88+ } else {
89+ this . skip = 0 // skip watcher will call fetchData()
90+ }
91+ } ,
92+
93+ showColPanel ( open ) {
94+ if ( open ) {
95+ this . _colPanelHandler = ( e ) => {
96+ if ( ! e . composedPath ( ) . includes ( this . $refs . gearWrap ) ) this . showColPanel = false
97+ }
98+ document . addEventListener ( 'click' , this . _colPanelHandler )
99+ } else {
100+ document . removeEventListener ( 'click' , this . _colPanelHandler )
101+ }
102+ } ,
103+ } ,
104+
105+ mounted ( ) {
106+ document . addEventListener ( 'keydown' , this . _onKeydown . bind ( this ) )
107+ this . fetchEntities ( )
31108 } ,
32109
33110 methods : {
34111
35- async fetchEntities ( ) {
36- let url = `/Entities`
37- if ( vue . dataSource === 'db' ) url += `?dataSource=db`
38- const { data} = await GET ( url )
39- vue . entities = data . value
40- vue . entities . forEach ( entity => entity . columns . sort ( columnKeysFirst ) )
41- const entity = vue . entity && vue . entities . find ( e => e . name === vue . entity . name )
42- if ( entity ) { // restore selection from previous fetch
43- vue . columns = entity . columns
44- await vue . fetchData ( entity )
112+ // ── Theme ──────────────────────────────────────────────
113+ toggleTheme ( ) {
114+ this . theme = this . theme === 'dark' ? 'light' : 'dark'
115+ if ( this . theme === 'light' ) {
116+ document . documentElement . dataset . theme = 'light'
45117 } else {
46- vue . entity = undefined
47- vue . columns = [ ]
48- vue . data = [ ]
49- vue . rowDetails = { }
118+ delete document . documentElement . dataset . theme
50119 }
120+ storageSet ( 'theme' , this . theme )
51121 } ,
52122
53- async inspectEntity ( eve ) {
54- const entity = vue . entity = vue . entities [ eve . currentTarget . rowIndex - 1 ]
55- storageSet ( 'entity' , JSON . stringify ( entity ) )
56- vue . columns = vue . entities . find ( e => e . name === entity . name ) . columns
57- return await this . fetchData ( )
123+ // ── Entity fetching ────────────────────────────────────
124+ async fetchEntities ( ) {
125+ this . error = undefined
126+ let url = '/Entities'
127+ if ( this . dataSource === 'db' ) url += '?dataSource=db'
128+ try {
129+ const { data } = await GET ( url )
130+ this . entities = data . value
131+ this . entities . forEach ( e => e . columns . sort ( columnKeysFirst ) )
132+ const entity = this . entity && this . entities . find ( e => e . name === this . entity . name )
133+ if ( entity ) {
134+ this . columns = entity . columns
135+ await this . fetchData ( )
136+ } else {
137+ this . entity = undefined
138+ this . columns = [ ]
139+ this . data = [ ]
140+ this . rowDetails = { }
141+ }
142+ } catch ( err ) {
143+ this . error = err . response ?. data ?. error ?? { code : err . code , message : err . message }
144+ }
58145 } ,
59146
60- async fetchData ( ) {
61- let url = `/Data?entity=${ vue . entity . name } &$skip=${ vue . skip } &$top=${ vue . top } `
62- if ( vue . dataSource === 'db' ) url += `&dataSource=db`
147+ inspectEntity ( entity ) {
148+ this . entity = entity
149+ storageSet ( 'entity' , JSON . stringify ( entity ) )
150+ this . columns = this . entities . find ( e => e . name === entity . name ) . columns
151+ // Reset per-entity state
152+ this . search = ''
153+ this . hiddenCols = new Set ( )
154+ this . sortState = { col : null , dir : null }
155+ this . activeRowIndex = - 1
156+ this . rowDetails = { }
157+ this . rowKey = ''
158+ storageSet ( 'row-key' , '' )
159+ document . title = 'Data Browser \u2014 ' + entity . name
160+ // If skip is already 0, the skip watcher won't fire — call fetchData explicitly.
161+ // If skip > 0, setting it triggers the skip watcher which calls fetchData.
162+ if ( this . skip === 0 ) {
163+ return this . fetchData ( )
164+ } else {
165+ this . skip = 0
166+ }
167+ } ,
63168
169+ // ── Data fetching ──────────────────────────────────────
170+ async fetchData ( ) {
171+ let url = `/Data?entity=${ this . entity . name } &$skip=${ this . skip } &$top=${ this . top } &$count=true`
172+ if ( this . dataSource === 'db' ) url += '&dataSource=db'
173+ this . loading = true
64174 try {
65- const { data} = await GET ( url )
66- // sort data along column order
67- const columnIndexes = { }
68- vue . columns . forEach ( ( col , i ) => columnIndexes [ col . name ] = i )
69- vue . data = data . value . map ( d => d . record
70- . sort ( ( r1 , r2 ) => columnIndexes [ r1 . column ] - columnIndexes [ r2 . column ] )
71- . map ( r => r . data )
175+ const { data } = await GET ( url )
176+ this . totalCount = data [ '@odata.count' ] ?? 0
177+ const colIdx = { }
178+ this . columns . forEach ( ( c , i ) => { colIdx [ c . name ] = i } )
179+ this . data = data . value . map ( d =>
180+ d . record
181+ . sort ( ( r1 , r2 ) => colIdx [ r1 . column ] - colIdx [ r2 . column ] )
182+ . map ( r => r . data )
72183 )
73- const row = vue . data . find ( data => vue . _makeRowKey ( data ) === vue . rowKey )
74- if ( row ) vue . _setRowDetails ( row )
75- else vue . rowDetails = { }
76- vue . error = undefined
77- } catch ( err ) {
78- vue . data = [ ]
79- vue . rowDetails = { }
80- if ( err . response ?. data ?. error ) {
81- vue . error = err . response . data . error
184+ // Restore row selection if key still exists on this page
185+ const row = this . data . find ( r => this . _makeRowKey ( r ) === this . rowKey )
186+ if ( row ) {
187+ this . activeRowIndex = this . filteredData . indexOf ( row )
188+ this . _setRowDetails ( row )
82189 } else {
83- vue . error = { code :err . code , message :err . message }
190+ this . rowDetails = { }
191+ this . activeRowIndex = - 1
84192 }
193+ this . error = undefined
194+ } catch ( err ) {
195+ this . data = [ ]
196+ this . rowDetails = { }
197+ this . activeRowIndex = - 1
198+ this . error = err . response ?. data ?. error ?? { code : err . code , message : err . message }
199+ } finally {
200+ this . loading = false
85201 }
202+ } ,
86203
204+ // ── Row selection ──────────────────────────────────────
205+ selectRow ( idx ) {
206+ this . activeRowIndex = idx
207+ const row = this . filteredData [ idx ]
208+ this . rowKey = this . _makeRowKey ( row )
209+ storageSet ( 'row-key' , this . rowKey )
210+ this . _setRowDetails ( row )
87211 } ,
88212
89- inspectRow ( eve ) {
90- vue . rowDetails = { }
91- const selectedRow = eve . currentTarget . rowIndex - 1
92- vue . rowKey = vue . _makeRowKey ( vue . data [ selectedRow ] )
93- storageSet ( 'rowKey' , vue . rowKey )
94- vue . _setRowDetails ( vue . data [ selectedRow ] )
213+ // Select row and copy the clicked cell value (single click on <td>)
214+ selectAndCopy ( rowIdx , cellValue ) {
215+ this . selectRow ( rowIdx )
216+ this . copyCell ( cellValue )
95217 } ,
96218
97219 _setRowDetails ( row ) {
98- vue . rowDetails = { }
99- row . forEach ( ( line , colIndex ) => {
100- vue . rowDetails [ vue . columns [ colIndex ] . name ] = line
220+ this . rowDetails = { }
221+ row . forEach ( ( val , i ) => {
222+ this . rowDetails [ this . columns [ i ] . name ] = val
101223 } )
102224 } ,
103225
104226 _makeRowKey ( row ) {
105- // to identify a row, build a key string out of all key columns' values
106227 return row
107- . filter ( ( _ , colIndex ) => vue . columns [ colIndex ] && vue . columns [ colIndex ] . isKey )
108- . reduce ( ( ( prev , next ) => prev += next ) , '' )
228+ . filter ( ( _ , i ) => this . columns [ i ] ? .isKey )
229+ . reduce ( ( acc , v ) => acc + v , '' )
109230 } ,
110231
111- isActiveRow ( row ) {
112- return vue . _makeRowKey ( row ) === vue . rowKey
113- }
232+ dismissDetail ( ) {
233+ this . rowDetails = { }
234+ this . rowKey = ''
235+ storageSet ( 'row-key' , '' )
236+ this . activeRowIndex = - 1
237+ } ,
238+
239+ isKeyColumn ( colName ) {
240+ return this . columns . some ( c => c . name === colName && c . isKey )
241+ } ,
242+
243+ // ── Sort ───────────────────────────────────────────────
244+ sortBy ( colIdx ) {
245+ const s = this . sortState
246+ if ( s . col !== colIdx ) {
247+ this . sortState = { col : colIdx , dir : 'asc' }
248+ } else if ( s . dir === 'asc' ) {
249+ this . sortState = { col : colIdx , dir : 'desc' }
250+ } else {
251+ this . sortState = { col : null , dir : null }
252+ }
253+ } ,
254+
255+ // ── Pagination ─────────────────────────────────────────
256+ prevPage ( ) {
257+ if ( this . skip > 0 ) this . skip = Math . max ( 0 , this . skip - this . top )
258+ } ,
259+ nextPage ( ) {
260+ if ( this . skip + this . top < this . totalCount || ( this . totalCount === 0 && this . data . length >= this . top ) ) this . skip += this . top
261+ } ,
262+
263+ // ── Column visibility ──────────────────────────────────
264+ toggleCol ( idx ) {
265+ const h = new Set ( this . hiddenCols )
266+ if ( h . has ( idx ) ) h . delete ( idx )
267+ else h . add ( idx )
268+ this . hiddenCols = h
269+ } ,
270+
271+ // ── Copy + toast ───────────────────────────────────────
272+ copyCell ( value ) {
273+ navigator . clipboard . writeText ( String ( value ?? '' ) )
274+ this . toastMessage = 'Copied \u2713'
275+ this . toastVisible = true
276+ clearTimeout ( this . _toastTimer )
277+ this . _toastTimer = setTimeout ( ( ) => { this . toastVisible = false } , 1500 )
278+ } ,
279+
280+ // ── Keyboard navigation ────────────────────────────────
281+ _onKeydown ( e ) {
282+ const tag = document . activeElement ?. tagName
283+ if ( tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA' ) return
284+
285+ if ( e . key === 'ArrowDown' ) {
286+ e . preventDefault ( )
287+ if ( this . activeRowIndex < this . filteredData . length - 1 ) {
288+ this . selectRow ( this . activeRowIndex + 1 )
289+ }
290+ } else if ( e . key === 'ArrowUp' ) {
291+ e . preventDefault ( )
292+ if ( this . activeRowIndex > 0 ) {
293+ this . selectRow ( this . activeRowIndex - 1 )
294+ }
295+ } else if ( e . key === 'Escape' ) {
296+ this . dismissDetail ( )
297+ this . showColPanel = false
298+ }
299+ } ,
114300
115301 }
116302} )
117- . mount ( '#app' )
118303
119- vue . fetchEntities ( )
304+ app . mount ( '#app' )
0 commit comments