1+ class DataTableViewer {
2+ constructor ( config ) {
3+ this . config = {
4+ dataUrl : config . dataUrl ,
5+ editEndpoint : config . editEndpoint || '' ,
6+ extraEditData : config . extraEditData || { } ,
7+ onEditSuccess : config . onEditSuccess || ( ( response ) => console . log ( 'Edit success:' , response ) ) ,
8+ columns : config . columns || [ ] ,
9+ rowsPerPage : config . rowsPerPage || 10 ,
10+ container : config . container || document . getElementById ( 'tableContainer' )
11+ } ;
12+
13+ this . data = [ ] ;
14+ this . filteredData = [ ] ;
15+ this . currentPage = 1 ;
16+ this . sortColumn = null ;
17+ this . sortDirection = 'asc' ;
18+ this . columnSearchValues = { } ;
19+ this . globalSearchValue = '' ;
20+ this . hiddenColumns = new Set ( ) ;
21+
22+ this . init ( ) ;
23+ }
24+
25+ async init ( ) {
26+ await this . loadData ( ) ;
27+ this . setupControls ( ) ;
28+ this . render ( ) ;
29+ }
30+
31+ async loadData ( ) {
32+ this . config . container . innerHTML = '<div class="loading">Loading data...</div>' ;
33+ try {
34+ if ( this . config . dataUrl ) {
35+ const response = await fetch ( this . config . dataUrl ) ;
36+ this . data = await response . json ( ) ;
37+ } else {
38+ // Use sample data for preview
39+ this . data = sampleData ;
40+ }
41+ this . filteredData = [ ...this . data ] ;
42+ } catch ( error ) {
43+ console . error ( 'Error loading data:' , error ) ;
44+ this . config . container . innerHTML = '<div class="error">Error loading data</div>' ;
45+ }
46+ }
47+
48+ setupControls ( ) {
49+ // Global search
50+ document . getElementById ( 'globalSearch' ) . addEventListener ( 'input' , ( e ) => {
51+ this . globalSearchValue = e . target . value . toLowerCase ( ) ;
52+ this . filterData ( ) ;
53+ this . currentPage = 1 ;
54+ this . render ( ) ;
55+ } ) ;
56+
57+ // Rows per page
58+ document . getElementById ( 'rowsPerPage' ) . addEventListener ( 'change' , ( e ) => {
59+ this . config . rowsPerPage = parseInt ( e . target . value ) ;
60+ this . currentPage = 1 ;
61+ this . render ( ) ;
62+ } ) ;
63+
64+ // Column toggle menu
65+ const toggleBtn = document . getElementById ( 'toggleColumnsBtn' ) ;
66+ const menu = document . getElementById ( 'columnToggleMenu' ) ;
67+
68+ toggleBtn . addEventListener ( 'click' , ( ) => {
69+ menu . classList . toggle ( 'hidden' ) ;
70+ menu . style . top = `${ toggleBtn . offsetTop + toggleBtn . offsetHeight } px` ;
71+ menu . style . left = `${ toggleBtn . offsetLeft } px` ;
72+
73+ menu . innerHTML = this . config . columns . map ( col => `
74+ <label>
75+ <input type="checkbox"
76+ ${ ! this . hiddenColumns . has ( col . field ) ? 'checked' : '' }
77+ data-column="${ col . field } ">
78+ ${ col . title }
79+ </label>
80+ ` ) . join ( '' ) ;
81+ } ) ;
82+
83+ menu . addEventListener ( 'change' , ( e ) => {
84+ if ( e . target . matches ( 'input[type="checkbox"]' ) ) {
85+ const column = e . target . dataset . column ;
86+ if ( e . target . checked ) {
87+ this . hiddenColumns . delete ( column ) ;
88+ } else {
89+ this . hiddenColumns . add ( column ) ;
90+ }
91+ this . render ( ) ;
92+ }
93+ } ) ;
94+
95+ // Close menu when clicking outside
96+ document . addEventListener ( 'click' , ( e ) => {
97+ if ( ! menu . contains ( e . target ) && e . target !== toggleBtn ) {
98+ menu . classList . add ( 'hidden' ) ;
99+ }
100+ } ) ;
101+ }
102+ filterData ( ) {
103+ this . filteredData = this . data . filter ( row => {
104+ // Global search
105+ if ( this . globalSearchValue ) {
106+ const rowString = Object . values ( row ) . join ( ' ' ) . toLowerCase ( ) ;
107+ if ( ! rowString . includes ( this . globalSearchValue ) ) return false ;
108+ }
109+
110+ // Column-specific search
111+ return Object . entries ( this . columnSearchValues ) . every ( ( [ field , searchValue ] ) => {
112+ if ( ! searchValue ) return true ;
113+ const value = String ( row [ field ] ) . toLowerCase ( ) ;
114+ // For select type columns, do exact match
115+ const col = this . config . columns . find ( c => c . field === field ) ;
116+ if ( col ?. searchType === 'select' ) {
117+ return value === searchValue . toLowerCase ( ) ;
118+ }
119+ // For text type columns, do contains match
120+ return value . includes ( searchValue . toLowerCase ( ) ) ;
121+ } ) ;
122+ } ) ;
123+ }
124+
125+ sortData ( column ) {
126+ if ( this . sortColumn === column ) {
127+ this . sortDirection = this . sortDirection === 'asc' ? 'desc' : 'asc' ;
128+ } else {
129+ this . sortColumn = column ;
130+ this . sortDirection = 'asc' ;
131+ }
132+
133+ const col = this . config . columns . find ( c => c . field === column ) ;
134+
135+ this . filteredData . sort ( ( a , b ) => {
136+ let valA = a [ column ] ;
137+ let valB = b [ column ] ;
138+
139+ if ( col . type === 'date' ) {
140+ valA = new Date ( valA ) ;
141+ valB = new Date ( valB ) ;
142+ }
143+
144+ if ( valA < valB ) return this . sortDirection === 'desc' ? 1 : - 1 ;
145+ if ( valA > valB ) return this . sortDirection === 'desc' ? - 1 : 1 ;
146+ return 0 ;
147+ } ) ;
148+
149+ this . render ( ) ;
150+ }
151+
152+ async handleEdit ( rowIndex , row ) {
153+ try {
154+ const response = await fetch ( this . config . editEndpoint , {
155+ method : 'POST' ,
156+ headers : {
157+ 'Content-Type' : 'application/json'
158+ } ,
159+ body : JSON . stringify ( {
160+ ...row ,
161+ ...this . config . extraEditData
162+ } )
163+ } ) ;
164+
165+ if ( ! response . ok ) throw new Error ( 'Edit failed' ) ;
166+
167+ const result = await response . json ( ) ;
168+ this . config . onEditSuccess ( result ) ;
169+
170+ // Update local data
171+ Object . assign ( this . data [ rowIndex ] , row ) ;
172+ this . filterData ( ) ;
173+ this . render ( ) ;
174+
175+ } catch ( error ) {
176+ console . error ( 'Error saving edit:' , error ) ;
177+ alert ( 'Failed to save changes' ) ;
178+ }
179+ }
180+
181+ renderHeader ( ) {
182+ const visibleColumns = this . config . columns . filter ( col => ! this . hiddenColumns . has ( col . field ) ) ;
183+ return `
184+ <thead class="container-thead">
185+ <tr>
186+ <th>Edit</th>
187+ ${ visibleColumns . map ( col => `
188+ <th class="header-cell ${ this . sortColumn === col . field ? `sort-${ this . sortDirection } ` : 'sort-indicator' } "
189+ data-column="${ col . field } ">
190+ ${ col . title }
191+ </th>
192+ ` ) . join ( '' ) }
193+ </tr>
194+ </thead>
195+ ` ;
196+ }
197+
198+ renderBody ( ret = 'html' ) {
199+ const visibleColumns = this . config . columns . filter ( col => ! this . hiddenColumns . has ( col . field ) ) ;
200+ const startIndex = ( this . currentPage - 1 ) * this . config . rowsPerPage ;
201+ const endIndex = startIndex + this . config . rowsPerPage ;
202+ const pageData = this . filteredData . slice ( startIndex , endIndex ) ;
203+
204+ const tbody = `
205+ <tbody class="container-tbody">
206+ ${ pageData . map ( ( row , idx ) => `
207+ <tr id="row-${ idx } ">
208+ <td>
209+ <button class="edit-btn" data-row="${ idx } ">Edit</button>
210+ </td>
211+ ${ visibleColumns . map ( col => `
212+ <td class="data-cell" data-column="${ col . field } ">${ row [ col . field ] } </td>
213+ ` ) . join ( '' ) }
214+ </tr>
215+ ` ) . join ( '' ) }
216+ </tbody>
217+ ` ;
218+
219+ if ( ret === 'html' ) {
220+ return tbody ;
221+ } else if ( ret === 'update' ) {
222+ this . config . container . querySelector ( '.container-tbody' ) . innerHTML = tbody ;
223+ this . renderEdit ( ) ;
224+ }
225+ }
226+
227+ renderFooter ( ) {
228+ const visibleColumns = this . config . columns . filter ( col => ! this . hiddenColumns . has ( col . field ) ) ;
229+ return `
230+ <tfoot class="container-tfoot">
231+ <tr>
232+ <td></td>
233+ ${ visibleColumns . map ( col => `
234+ <td>
235+ ${ col . searchType === 'select' ?
236+ `<select class="column-search" data-column="${ col . field } ">
237+ <option value="">All</option>
238+ ${ [ ...new Set ( this . data . map ( item => item [ col . field ] ) ) ] . sort ( ) . map ( value =>
239+ `<option value="${ value } " ${ this . columnSearchValues [ col . field ] === value ? 'selected' : '' } >${ value } </option>`
240+ ) . join ( '' ) }
241+ </select>` :
242+ `<input type="text" class="column-search" data-column="${ col . field } "
243+ value="${ this . columnSearchValues [ col . field ] || '' } "
244+ placeholder="Search ${ col . title } ...">`
245+ }
246+ </td>
247+ ` ) . join ( '' ) }
248+ </tr>
249+ </tfoot>
250+ ` ;
251+ }
252+
253+ renderPagination ( ) {
254+ return `
255+ <nav class="container-pagination">
256+ <ul></ul>
257+ <ul><li>
258+ <div class="pagination">
259+ <button ${ this . currentPage === 1 ? 'disabled' : '' } onclick="dataTable.currentPage--; dataTable.render()">Previous</button>
260+ <span>Page ${ this . currentPage } of ${ Math . ceil ( this . filteredData . length / this . config . rowsPerPage ) } </span>
261+ <button ${ this . currentPage === Math . ceil ( this . filteredData . length / this . config . rowsPerPage ) ? 'disabled' : '' }
262+ onclick="dataTable.currentPage++; dataTable.render()">Next</button>
263+ </div>
264+ </li></ul>
265+ <ul></ul>
266+ </nav>
267+ ` ;
268+ }
269+
270+ render ( ) {
271+ const startIndex = ( this . currentPage - 1 ) * this . config . rowsPerPage ;
272+ const endIndex = startIndex + this . config . rowsPerPage ;
273+ const pageData = this . filteredData . slice ( startIndex , endIndex ) ;
274+
275+ this . config . container . innerHTML = `
276+ <table class="striped">
277+ ${ this . renderHeader ( ) }
278+ ${ this . renderBody ( ) }
279+ ${ this . renderFooter ( ) }
280+ </table>
281+ ${ this . renderPagination ( ) }
282+ ` ;
283+
284+ this . renderColumnSearch ( ) ;
285+ this . renderColumnSort ( ) ;
286+ this . renderEdit ( ) ;
287+ }
288+
289+ renderColumnSearch ( ) {
290+ // Setup column search
291+ this . config . container . querySelectorAll ( '.column-search' ) . forEach ( input => {
292+ input . addEventListener ( 'keyup' , ( e ) => {
293+ const column = e . target . dataset . column ;
294+ const value = e . target . value ;
295+
296+ if ( value === '' ) {
297+ delete this . columnSearchValues [ column ] ;
298+ } else {
299+ this . columnSearchValues [ column ] = value ;
300+ }
301+ this . filterData ( ) ;
302+ this . currentPage = 1 ;
303+ this . render ( ) ;
304+ //this.renderColumnSearch();
305+ // Focus the input and move the cursor to the end
306+ const targetInput = document . querySelector ( `tfoot input[data-column="${ column } "]` ) ;
307+ if ( targetInput ) {
308+ targetInput . focus ( ) ;
309+ const length = targetInput . value . length ;
310+ targetInput . setSelectionRange ( length , length ) ;
311+ }
312+ } ) ;
313+ } ) ;
314+ }
315+
316+ renderColumnSort ( ) {
317+ // Setup sorting
318+ this . config . container . querySelectorAll ( '.header-cell' ) . forEach ( header => {
319+ header . addEventListener ( 'click' , ( ) => {
320+ const column = header . dataset . column ;
321+ this . sortData ( column ) ;
322+ } ) ;
323+ } ) ;
324+ }
325+
326+ renderEdit ( ) {
327+ // Setup edit buttons
328+ const visibleColumns = this . config . columns . filter ( col => ! this . hiddenColumns . has ( col . field ) ) ;
329+ const startIndex = ( this . currentPage - 1 ) * this . config . rowsPerPage ;
330+ const endIndex = startIndex + this . config . rowsPerPage ;
331+ const pageData = this . filteredData . slice ( startIndex , endIndex ) ;
332+
333+ this . config . container . querySelectorAll ( '.edit-btn' ) . forEach ( btn => {
334+ btn . addEventListener ( 'click' , ( ) => {
335+ const rowIndex = parseInt ( btn . dataset . row ) ;
336+ const row = pageData [ rowIndex ] ;
337+
338+ const tr = document . getElementById ( `row-${ rowIndex } ` ) ;
339+ const originalContent = tr . innerHTML ;
340+
341+ tr . innerHTML = `
342+ <td>
343+ <button class="save-btn">Save</button>
344+ <button class="cancel-btn">Cancel</button>
345+ </td>
346+ ${ visibleColumns . map ( col => `
347+ <td>
348+ <input type="text" value="${ row [ col . field ] } " data-field="${ col . field } ">
349+ </td>
350+ ` ) . join ( '' ) }
351+ ` ;
352+
353+ tr . querySelector ( '.save-btn' ) . addEventListener ( 'click' , ( ) => {
354+ const updatedRow = { ...row } ;
355+ tr . querySelectorAll ( 'input[data-field]' ) . forEach ( input => {
356+ updatedRow [ input . dataset . field ] = input . value ;
357+ } ) ;
358+ this . handleEdit ( rowIndex , updatedRow ) ;
359+ } ) ;
360+
361+ tr . querySelector ( '.cancel-btn' ) . addEventListener ( 'click' , ( ) => {
362+ //this.renderBody();
363+ tr . innerHTML = originalContent ;
364+ } ) ;
365+ } ) ;
366+ } ) ;
367+ }
368+
369+ }
0 commit comments