Skip to content
2 changes: 2 additions & 0 deletions backend/src/api/member/memberActiveList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import PermissionChecker from '../../services/user/permissionChecker'
* @pathParam {string} tenantId - Your workspace/tenant ID
* @queryParam {string} [filter[platforms]] - Filter by activity platforms (comma separated list without spaces)
* @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.
* @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.
* @queryParam {string} [filter[activityTimestampFrom]] - Filter by activity timestamp from (required)
* @queryParam {string} [filter[activityTimestampTo]] - Filter by activity timestamp to (required)
* @queryParam {string} [orderBy] - How to sort results. Available values: activityCount_DESC, activityCount_ASC, activeDaysCount_DESC, activeDaysCount_ASC (default activityCount_DESC)
Expand Down Expand Up @@ -51,6 +52,7 @@ export default async (req, res) => {
req.query.filter?.isTeamMember === undefined
? undefined
: req.query.filter?.isTeamMember === 'true',
isBot: req.query.filter?.isBot === undefined ? undefined : req.query.filter?.isBot === 'true',
activityTimestampFrom: req.query.filter?.activityTimestampFrom,
activityTimestampTo: req.query.filter?.activityTimestampTo,
}
Expand Down
38 changes: 30 additions & 8 deletions backend/src/database/repositories/memberRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -464,37 +464,58 @@ class MemberRepository {
conditions.push("COALESCE((m.attributes->'isTeamMember'->'default')::boolean, false) = false")
}

if (filter.isBot === true) {
conditions.push("COALESCE((m.attributes->'isBot'->'default')::boolean, false) = true")
} else if (filter.isBot === false) {
conditions.push("COALESCE((m.attributes->'isBot'->'default')::boolean, false) = false")
}

const activityConditions = ['1=1']

if (filter.platforms && filter.platforms.length > 0) {
conditions.push('a.platform in (:platforms)')
activityConditions.push('platform in (:platforms)')
parameters.platforms = filter.platforms
}

const conditionsString = conditions.join(' and ')
const activityConditionsString = activityConditions.join(' and ')

const direction = orderBy.split('_')[1].toLowerCase() === 'desc' ? 'desc' : 'asc'
let orderString: string
if (orderBy.startsWith('activityCount')) {
orderString = `count(a.id) ${direction}`
orderString = `ad."activityCount" ${direction}`
} else if (orderBy.startsWith('activeDaysCount')) {
orderString = `count(distinct a.timestamp::date) ${direction}`
orderString = `ad."activeDaysCount" ${direction}`
} else {
throw new Error(`Invalid order by: ${orderBy}`)
}

const limitCondition = `limit ${limit} offset ${offset}`
const query = `
with orgs as (select mo."memberId", json_agg(row_to_json(o.*)) as organizations
from "memberOrganizations" mo
inner join organizations o on mo."organizationId" = o.id
group by mo."memberId"),
activity_data as (select "memberId",
count(id) as "activityCount",
count(distinct timestamp::date) as "activeDaysCount"
from activities
where ${activityConditionsString} and
timestamp >= :periodStart and
timestamp < :periodEnd
group by "memberId")
select m.id,
m."displayName",
m.username,
m.attributes,
count(a.id) as "activityCount",
count(distinct a.timestamp::date) as "activeDaysCount",
ad."activityCount",
ad."activeDaysCount",
coalesce(o.organizations, json_build_array()) as organizations,
count(*) over () as "totalCount"
from members m
inner join activities a on (m.id = a."memberId" and a.timestamp >= :periodStart and
a.timestamp < :periodEnd)
inner join activity_data ad on ad."memberId" = m.id
left join orgs o on o."memberId" = m.id
where ${conditionsString}
group by m.id, m."displayName", m.username, m.attributes
order by ${orderString}
${limitCondition};
`
Expand Down Expand Up @@ -527,6 +548,7 @@ class MemberRepository {
displayName: row.displayName,
username: row.username,
attributes: row.attributes,
organizations: row.organizations,
activityCount: parseInt(row.activityCount, 10),
activeDaysCount: parseInt(row.activeDaysCount, 10),
}
Expand Down
2 changes: 2 additions & 0 deletions backend/src/database/repositories/types/memberTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ export interface IActiveMemberData {
displayName: string
username: any
attributes: any
organizations: any[]
activityCount: number
activeDaysCount: number
}

export interface IActiveMemberFilter {
platforms?: string[]
isBot?: boolean
isTeamMember?: boolean
activityTimestampFrom: string
activityTimestampTo: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export default {

async handleDoExport() {
try {
await this.doExport(true)
await this.doExport({ selected: true })
} catch (error) {
console.log(error)
}
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/modules/member/member-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,45 @@ export class MemberService {
return response.data
}

static async listActive({
platform,
isTeamMember,
activityTimestampFrom,
activityTimestampTo,
orderBy,
offset,
limit
}) {
const params = {
...(platform.length && {
'filter[platforms]': platform
.map((p) => p.value)
.join(',')
}),
...(isTeamMember === false && {
'filter[isTeamMember]': isTeamMember
Copy link
Copy Markdown
Contributor

@mariobalca mariobalca Jan 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might need to add the filter[isBot]: isBot here as well after/while merging the #465

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the heads up! I'll wait for your PR to update this branch

}),
'filter[isBot]': false,
'filter[activityTimestampFrom]':
activityTimestampFrom,
'filter[activityTimestampTo]': activityTimestampTo,
orderBy,
offset,
limit
}

const tenantId = AuthCurrentTenant.get()

const response = await authAxios.get(
`/tenant/${tenantId}/member/active`,
{
params
}
)

return response.data
}

static async listAutocomplete(query, limit) {
const params = {
query,
Expand Down
33 changes: 25 additions & 8 deletions frontend/src/modules/member/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,27 @@ export default {

async doExport(
{ commit, getters, rootGetters, dispatch },
selected = false
{
selected = false,
customIds = [],
customFilter = null,
count = null
}
) {
let filter

if (selected) {
const ids = customIds.length
? customIds
: [getters.selectedRows.map((i) => i.id)]

filter = {
id: {
in: [getters.selectedRows.map((i) => i.id)]
in: ids
}
}
} else if (customFilter) {
filter = customFilter
} else {
filter = getters.activeView.filter
}
Expand Down Expand Up @@ -58,11 +70,16 @@ export default {
icon: 'ri-file-download-line',
confirmButtonText: 'Send download link to e-mail',
cancelButtonText: 'Cancel',
badgeContent: selected
? `${getters.selectedRows.length} member${
getters.selectedRows.length === 1 ? '' : 's'
}`
: `View: ${getters.activeView.label}`,
badgeContent:
selected || count
? `${
count || getters.selectedRows.length
} member${
(count || getters.selectedRows.length) === 1
? ''
: 's'
}`
: `View: ${getters.activeView.label}`,
highlightedInfo: `${tenantCsvExportCount}/${planCsvExportMax} exports available in this plan used`
})

Expand All @@ -71,7 +88,7 @@ export default {
getters.orderBy,
0,
null,
!selected // build API payload if selected === false
!selected && !customFilter // build API payload if selected === false || !customFilter
)

await dispatch(`auth/doRefreshCurrentUser`, null, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,18 @@
class="app-page-spinner"
></div>
<div v-else class="flex flex-col gap-8">
<app-widget-total-members :filters="filters" />
<app-widget-active-members :filters="filters" />
<app-widget-active-members-area :filters="filters" />
<app-widget-total-members
:filters="filters"
:is-public-view="isPublicView"
/>
<app-widget-active-members
:filters="filters"
:is-public-view="isPublicView"
/>
<app-widget-active-members-area
:filters="filters"
:is-public-view="isPublicView"
/>
<app-widget-active-leaderboard-members
v-if="!isPublicView"
:platforms="filters.platform.value"
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/modules/report/templates/template-report-charts.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ const defaultChartOptions = {
colors: ['#E94F2E'],
loading: 'Loading...',
library: {
layout: {
padding: {
top: 20
}
},
lineTension: 0.25,
scales: {
x: {
Expand Down Expand Up @@ -62,7 +67,10 @@ const defaultChartOptions = {
callbacks: {
title: parseTooltipTitle,
label: formatTooltipTitle,
afterLabel: parseTooltipBody
afterLabel: parseTooltipBody,
footer: (context) => {
return context[0].dataset.tooltipBtn
}
}
},
legend: {
Expand Down
74 changes: 67 additions & 7 deletions frontend/src/modules/report/tooltip.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export const externalTooltipHandler = (context) => {
export const externalTooltipHandler = (
context,
clickFn
) => {
// Tooltip Element
const { tooltip } = context
const { tooltip, chart } = context
let tooltipEl = document.getElementById('chartjs-tooltip')
// Create element on first render
if (!tooltipEl) {
Expand All @@ -10,9 +13,46 @@ export const externalTooltipHandler = (context) => {
document.body.appendChild(tooltipEl)
}

// Handle mouseenter event on tooltip
tooltipEl.onmouseenter = () => {
if (chart.canvas) {
const meta = chart.getDatasetMeta(0)
const canvas = chart.canvas.getBoundingClientRect()
const point =
meta.data[
tooltip.dataPoints[0].dataIndex
].getCenterPoint()
const evt = new MouseEvent('mousemove', {
clientX: canvas.x + point.x,
clientY: canvas.y + point.y
})
const canvasNode = chart.canvas

// Dispatch mousemove event to canvas
// This will allow for the tooltip render
// logic to still be on the library side
canvasNode?.dispatchEvent(evt)
}
}

// Handle mouseleave event on tooltip
tooltipEl.onmouseleave = ({ clientX, clientY }) => {
if (chart.canvas) {
const evt = new MouseEvent('mouseout', {
clientX,
clientY
})
const canvasNode = chart.canvas

// Dispatch mouseposition in the mouseout event
// This will hide tooltip
canvasNode?.dispatchEvent(evt)
}
}

// Hide if no tooltip
if (tooltip.opacity === 0) {
tooltipEl.style.opacity = 0
tooltipEl.style.display = 'none'
return
}

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

innerHtml += '</tbody>'

let footerBtn = document.getElementById(
'custom-tooltip-footer-btn'
)
if (!footerBtn && tooltip.footer) {
footerBtn = document.createElement('el-button')
footerBtn.id = 'custom-tooltip-footer-btn'
tooltip.footer.forEach((lines) => {
footerBtn.className =
'btn btn--sm btn--full btn--secondary mt-4'
footerBtn.innerText = lines
tooltipEl.appendChild(footerBtn)
})
}

// Add clickFn to footerBtn
// This will allow each graph to handle the button click differently
footerBtn.onclick = clickFn

let tableRoot = tooltipEl.querySelector('table')
tableRoot.innerHTML = innerHtml
}
Expand All @@ -89,6 +147,7 @@ export const externalTooltipHandler = (context) => {

// Display, position, and set styles for font
tooltipEl.style.opacity = 1
tooltipEl.style.display = 'block'
tooltipEl.style.backgroundColor = 'white'
tooltipEl.style.borderRadius = '8px'
tooltipEl.style.position = 'absolute'
Expand All @@ -103,12 +162,13 @@ export const externalTooltipHandler = (context) => {
tooltipEl.style.top =
position.top +
window.pageYOffset +
tooltip.caretY -
(tooltip.dataPoints?.[0]?.element?.y ||
tooltip.caretY) -
tooltipEl.getBoundingClientRect().height -
40 +
20 +
'px'
tooltipEl.style.padding = '12px'
tooltipEl.style.textAlign = 'left'
tooltipEl.style.pointerEvents = 'none'
tooltipEl.style.zIndex = '20'
tooltipEl.style.maxWidth = '200px'
}
Loading