Skip to content

Commit 9d4ed13

Browse files
joanagmaiaUroš Marolt
andauthored
Report detailed drawers (#461)
Co-authored-by: Uroš Marolt <uros@crowd.dev>
1 parent 34aab21 commit 9d4ed13

23 files changed

Lines changed: 1093 additions & 138 deletions

backend/src/api/member/memberActiveList.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import PermissionChecker from '../../services/user/permissionChecker'
1313
* @pathParam {string} tenantId - Your workspace/tenant ID
1414
* @queryParam {string} [filter[platforms]] - Filter by activity platforms (comma separated list without spaces)
1515
* @queryParam {string} [filter[isTeamMember]] - If true we will return just team members, if false we will return just non-team members, if undefined we will return both.
16+
* @queryParam {string} [filter[isBot]] - If true we will return just members who are bots, if false we will return just non-bot members, if undefined we will return both.
1617
* @queryParam {string} [filter[activityTimestampFrom]] - Filter by activity timestamp from (required)
1718
* @queryParam {string} [filter[activityTimestampTo]] - Filter by activity timestamp to (required)
1819
* @queryParam {string} [orderBy] - How to sort results. Available values: activityCount_DESC, activityCount_ASC, activeDaysCount_DESC, activeDaysCount_ASC (default activityCount_DESC)
@@ -51,6 +52,7 @@ export default async (req, res) => {
5152
req.query.filter?.isTeamMember === undefined
5253
? undefined
5354
: req.query.filter?.isTeamMember === 'true',
55+
isBot: req.query.filter?.isBot === undefined ? undefined : req.query.filter?.isBot === 'true',
5456
activityTimestampFrom: req.query.filter?.activityTimestampFrom,
5557
activityTimestampTo: req.query.filter?.activityTimestampTo,
5658
}

backend/src/database/repositories/memberRepository.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -464,37 +464,58 @@ class MemberRepository {
464464
conditions.push("COALESCE((m.attributes->'isTeamMember'->'default')::boolean, false) = false")
465465
}
466466

467+
if (filter.isBot === true) {
468+
conditions.push("COALESCE((m.attributes->'isBot'->'default')::boolean, false) = true")
469+
} else if (filter.isBot === false) {
470+
conditions.push("COALESCE((m.attributes->'isBot'->'default')::boolean, false) = false")
471+
}
472+
473+
const activityConditions = ['1=1']
474+
467475
if (filter.platforms && filter.platforms.length > 0) {
468-
conditions.push('a.platform in (:platforms)')
476+
activityConditions.push('platform in (:platforms)')
469477
parameters.platforms = filter.platforms
470478
}
471479

472480
const conditionsString = conditions.join(' and ')
481+
const activityConditionsString = activityConditions.join(' and ')
473482

474483
const direction = orderBy.split('_')[1].toLowerCase() === 'desc' ? 'desc' : 'asc'
475484
let orderString: string
476485
if (orderBy.startsWith('activityCount')) {
477-
orderString = `count(a.id) ${direction}`
486+
orderString = `ad."activityCount" ${direction}`
478487
} else if (orderBy.startsWith('activeDaysCount')) {
479-
orderString = `count(distinct a.timestamp::date) ${direction}`
488+
orderString = `ad."activeDaysCount" ${direction}`
480489
} else {
481490
throw new Error(`Invalid order by: ${orderBy}`)
482491
}
483492

484493
const limitCondition = `limit ${limit} offset ${offset}`
485494
const query = `
495+
with orgs as (select mo."memberId", json_agg(row_to_json(o.*)) as organizations
496+
from "memberOrganizations" mo
497+
inner join organizations o on mo."organizationId" = o.id
498+
group by mo."memberId"),
499+
activity_data as (select "memberId",
500+
count(id) as "activityCount",
501+
count(distinct timestamp::date) as "activeDaysCount"
502+
from activities
503+
where ${activityConditionsString} and
504+
timestamp >= :periodStart and
505+
timestamp < :periodEnd
506+
group by "memberId")
486507
select m.id,
487508
m."displayName",
488509
m.username,
489510
m.attributes,
490-
count(a.id) as "activityCount",
491-
count(distinct a.timestamp::date) as "activeDaysCount",
511+
ad."activityCount",
512+
ad."activeDaysCount",
513+
coalesce(o.organizations, json_build_array()) as organizations,
492514
count(*) over () as "totalCount"
493515
from members m
494-
inner join activities a on (m.id = a."memberId" and a.timestamp >= :periodStart and
495-
a.timestamp < :periodEnd)
516+
inner join activity_data ad on ad."memberId" = m.id
517+
left join orgs o on o."memberId" = m.id
496518
where ${conditionsString}
497-
group by m.id, m."displayName", m.username, m.attributes
498519
order by ${orderString}
499520
${limitCondition};
500521
`
@@ -527,6 +548,7 @@ class MemberRepository {
527548
displayName: row.displayName,
528549
username: row.username,
529550
attributes: row.attributes,
551+
organizations: row.organizations,
530552
activityCount: parseInt(row.activityCount, 10),
531553
activeDaysCount: parseInt(row.activeDaysCount, 10),
532554
}

backend/src/database/repositories/types/memberTypes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@ export interface IActiveMemberData {
33
displayName: string
44
username: any
55
attributes: any
6+
organizations: any[]
67
activityCount: number
78
activeDaysCount: number
89
}
910

1011
export interface IActiveMemberFilter {
1112
platforms?: string[]
13+
isBot?: boolean
1214
isTeamMember?: boolean
1315
activityTimestampFrom: string
1416
activityTimestampTo: string

frontend/src/modules/member/components/list/member-list-toolbar.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ export default {
212212
213213
async handleDoExport() {
214214
try {
215-
await this.doExport(true)
215+
await this.doExport({ selected: true })
216216
} catch (error) {
217217
console.log(error)
218218
}

frontend/src/modules/member/member-service.js

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,45 @@ export class MemberService {
127127
return response.data
128128
}
129129

130+
static async listActive({
131+
platform,
132+
isTeamMember,
133+
activityTimestampFrom,
134+
activityTimestampTo,
135+
orderBy,
136+
offset,
137+
limit
138+
}) {
139+
const params = {
140+
...(platform.length && {
141+
'filter[platforms]': platform
142+
.map((p) => p.value)
143+
.join(',')
144+
}),
145+
...(isTeamMember === false && {
146+
'filter[isTeamMember]': isTeamMember
147+
}),
148+
'filter[isBot]': false,
149+
'filter[activityTimestampFrom]':
150+
activityTimestampFrom,
151+
'filter[activityTimestampTo]': activityTimestampTo,
152+
orderBy,
153+
offset,
154+
limit
155+
}
156+
157+
const tenantId = AuthCurrentTenant.get()
158+
159+
const response = await authAxios.get(
160+
`/tenant/${tenantId}/member/active`,
161+
{
162+
params
163+
}
164+
)
165+
166+
return response.data
167+
}
168+
130169
static async listAutocomplete(query, limit) {
131170
const params = {
132171
query,

frontend/src/modules/member/store/actions.js

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,27 @@ export default {
2020

2121
async doExport(
2222
{ commit, getters, rootGetters, dispatch },
23-
selected = false
23+
{
24+
selected = false,
25+
customIds = [],
26+
customFilter = null,
27+
count = null
28+
}
2429
) {
2530
let filter
31+
2632
if (selected) {
33+
const ids = customIds.length
34+
? customIds
35+
: [getters.selectedRows.map((i) => i.id)]
36+
2737
filter = {
2838
id: {
29-
in: [getters.selectedRows.map((i) => i.id)]
39+
in: ids
3040
}
3141
}
42+
} else if (customFilter) {
43+
filter = customFilter
3244
} else {
3345
filter = getters.activeView.filter
3446
}
@@ -58,11 +70,16 @@ export default {
5870
icon: 'ri-file-download-line',
5971
confirmButtonText: 'Send download link to e-mail',
6072
cancelButtonText: 'Cancel',
61-
badgeContent: selected
62-
? `${getters.selectedRows.length} member${
63-
getters.selectedRows.length === 1 ? '' : 's'
64-
}`
65-
: `View: ${getters.activeView.label}`,
73+
badgeContent:
74+
selected || count
75+
? `${
76+
count || getters.selectedRows.length
77+
} member${
78+
(count || getters.selectedRows.length) === 1
79+
? ''
80+
: 's'
81+
}`
82+
: `View: ${getters.activeView.label}`,
6683
highlightedInfo: `${tenantCsvExportCount}/${planCsvExportMax} exports available in this plan used`
6784
})
6885

@@ -71,7 +88,7 @@ export default {
7188
getters.orderBy,
7289
0,
7390
null,
74-
!selected // build API payload if selected === false
91+
!selected && !customFilter // build API payload if selected === false || !customFilter
7592
)
7693

7794
await dispatch(`auth/doRefreshCurrentUser`, null, {

frontend/src/modules/report/pages/templates/report-member-template.vue

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,18 @@
66
class="app-page-spinner"
77
></div>
88
<div v-else class="flex flex-col gap-8">
9-
<app-widget-total-members :filters="filters" />
10-
<app-widget-active-members :filters="filters" />
11-
<app-widget-active-members-area :filters="filters" />
9+
<app-widget-total-members
10+
:filters="filters"
11+
:is-public-view="isPublicView"
12+
/>
13+
<app-widget-active-members
14+
:filters="filters"
15+
:is-public-view="isPublicView"
16+
/>
17+
<app-widget-active-members-area
18+
:filters="filters"
19+
:is-public-view="isPublicView"
20+
/>
1221
<app-widget-active-leaderboard-members
1322
v-if="!isPublicView"
1423
:platforms="filters.platform.value"

frontend/src/modules/report/templates/template-report-charts.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@ const defaultChartOptions = {
1313
colors: ['#E94F2E'],
1414
loading: 'Loading...',
1515
library: {
16+
layout: {
17+
padding: {
18+
top: 20
19+
}
20+
},
1621
lineTension: 0.25,
1722
scales: {
1823
x: {
@@ -62,7 +67,10 @@ const defaultChartOptions = {
6267
callbacks: {
6368
title: parseTooltipTitle,
6469
label: formatTooltipTitle,
65-
afterLabel: parseTooltipBody
70+
afterLabel: parseTooltipBody,
71+
footer: (context) => {
72+
return context[0].dataset.tooltipBtn
73+
}
6674
}
6775
},
6876
legend: {

frontend/src/modules/report/tooltip.js

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
export const externalTooltipHandler = (context) => {
1+
export const externalTooltipHandler = (
2+
context,
3+
clickFn
4+
) => {
25
// Tooltip Element
3-
const { tooltip } = context
6+
const { tooltip, chart } = context
47
let tooltipEl = document.getElementById('chartjs-tooltip')
58
// Create element on first render
69
if (!tooltipEl) {
@@ -10,9 +13,46 @@ export const externalTooltipHandler = (context) => {
1013
document.body.appendChild(tooltipEl)
1114
}
1215

16+
// Handle mouseenter event on tooltip
17+
tooltipEl.onmouseenter = () => {
18+
if (chart.canvas) {
19+
const meta = chart.getDatasetMeta(0)
20+
const canvas = chart.canvas.getBoundingClientRect()
21+
const point =
22+
meta.data[
23+
tooltip.dataPoints[0].dataIndex
24+
].getCenterPoint()
25+
const evt = new MouseEvent('mousemove', {
26+
clientX: canvas.x + point.x,
27+
clientY: canvas.y + point.y
28+
})
29+
const canvasNode = chart.canvas
30+
31+
// Dispatch mousemove event to canvas
32+
// This will allow for the tooltip render
33+
// logic to still be on the library side
34+
canvasNode?.dispatchEvent(evt)
35+
}
36+
}
37+
38+
// Handle mouseleave event on tooltip
39+
tooltipEl.onmouseleave = ({ clientX, clientY }) => {
40+
if (chart.canvas) {
41+
const evt = new MouseEvent('mouseout', {
42+
clientX,
43+
clientY
44+
})
45+
const canvasNode = chart.canvas
46+
47+
// Dispatch mouseposition in the mouseout event
48+
// This will hide tooltip
49+
canvasNode?.dispatchEvent(evt)
50+
}
51+
}
52+
1353
// Hide if no tooltip
1454
if (tooltip.opacity === 0) {
15-
tooltipEl.style.opacity = 0
55+
tooltipEl.style.display = 'none'
1656
return
1757
}
1858

@@ -57,7 +97,7 @@ export const externalTooltipHandler = (context) => {
5797
innerHtml += `
5898
<tr class="border-b border-gray-100 last:border-none text-gray-900 text-xs font-medium">
5999
<td class="pb-2">
60-
<div class="flex items-center gap-2">
100+
<div class="flex items-center flex-wrap gap-2">
61101
<div class="${classes.bgColor} rounded-md ${
62102
classes.color
63103
} h-5 px-1 flex items-center">
@@ -80,6 +120,24 @@ export const externalTooltipHandler = (context) => {
80120

81121
innerHtml += '</tbody>'
82122

123+
let footerBtn = document.getElementById(
124+
'custom-tooltip-footer-btn'
125+
)
126+
if (!footerBtn && tooltip.footer) {
127+
footerBtn = document.createElement('el-button')
128+
footerBtn.id = 'custom-tooltip-footer-btn'
129+
tooltip.footer.forEach((lines) => {
130+
footerBtn.className =
131+
'btn btn--sm btn--full btn--secondary mt-4'
132+
footerBtn.innerText = lines
133+
tooltipEl.appendChild(footerBtn)
134+
})
135+
}
136+
137+
// Add clickFn to footerBtn
138+
// This will allow each graph to handle the button click differently
139+
footerBtn.onclick = clickFn
140+
83141
let tableRoot = tooltipEl.querySelector('table')
84142
tableRoot.innerHTML = innerHtml
85143
}
@@ -89,6 +147,7 @@ export const externalTooltipHandler = (context) => {
89147

90148
// Display, position, and set styles for font
91149
tooltipEl.style.opacity = 1
150+
tooltipEl.style.display = 'block'
92151
tooltipEl.style.backgroundColor = 'white'
93152
tooltipEl.style.borderRadius = '8px'
94153
tooltipEl.style.position = 'absolute'
@@ -103,12 +162,13 @@ export const externalTooltipHandler = (context) => {
103162
tooltipEl.style.top =
104163
position.top +
105164
window.pageYOffset +
106-
tooltip.caretY -
165+
(tooltip.dataPoints?.[0]?.element?.y ||
166+
tooltip.caretY) -
107167
tooltipEl.getBoundingClientRect().height -
108-
40 +
168+
20 +
109169
'px'
110170
tooltipEl.style.padding = '12px'
111171
tooltipEl.style.textAlign = 'left'
112-
tooltipEl.style.pointerEvents = 'none'
113172
tooltipEl.style.zIndex = '20'
173+
tooltipEl.style.maxWidth = '200px'
114174
}

0 commit comments

Comments
 (0)