Skip to content

Commit 9cb0f0a

Browse files
committed
Merge branch 'feature/viewer-redesign'
# Conflicts: # cap/app/viewer/index.html
2 parents e6a9f64 + 57492ba commit 9cb0f0a

2 files changed

Lines changed: 609 additions & 128 deletions

File tree

cap/app/viewer/app.js

Lines changed: 264 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -1,119 +1,304 @@
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+
56
const 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

Comments
 (0)