Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[TASK-882] [TASK-1186] Project activity details modal #5210

Merged
merged 11 commits into from
Nov 4, 2024
197 changes: 197 additions & 0 deletions jsapp/js/components/activity/activity.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
/**
* All possible log item actions.
* @see `AuditAction` class from {@link kobo/apps/audit_log/models.py} (BE code)
*/
export enum AuditActions {
'update-name' = 'update-name',
'update-settings' = 'update-settings',
'deploy' = 'deploy',
'redeploy' = 'redeploy',
'archive' = 'archive',
'unarchive' = 'unarchive',
'replace-form' = 'replace-form',
'update-form' = 'update-form',
'export' = 'export',
'update-qa' = 'update-qa',
'add-media' = 'add-media',
'delete-media' = 'delete-media',
'connect-project' = 'connect-project',
'disconnect-project' = 'disconnect-project',
'modify-imported-fields' = 'modify-imported-fields',
'modify-sharing' = 'modify-sharing',
'enable-sharing' = 'enable-sharing',
'disable-sharing' = 'disable-sharing',
'register-service' = 'register-service',
'modify-service' = 'modify-service',
'delete-service' = 'delete-service',
'add-user' = 'add-user',
'remove-user' = 'remove-user',
'update-permission' = 'update-permission',
'make-public' = 'make-public',
'share-public' = 'share-public',
'transfer' = 'transfer',
}

type AuditActionTypes = {
[P in AuditActions]: {
name: AuditActions;
message: string;
};
};

export const AUDIT_ACTION_TYPES: AuditActionTypes = {
'update-name': {
name: AuditActions['update-name'],
message: t('##username## changed project name'),
},
'update-settings': {
name: AuditActions['update-settings'],
message: t('##username## updated project settings'),
},
'deploy': {
name: AuditActions['deploy'],
message: t('##username## deployed project'),
},
'redeploy': {
name: AuditActions['redeploy'],
message: t('##username## redeployed project'),
},
'archive': {
name: AuditActions['archive'],
message: t('##username## archived project'),
},
'unarchive': {
name: AuditActions['unarchive'],
message: t('##username## unarchived project'),
},
'replace-form': {
name: AuditActions['replace-form'],
message: t('##username## uploaded a new form'),
},
'update-form': {
name: AuditActions['update-form'],
message: t('##username## edited the form in the formbuilder'),
},
'export': {
name: AuditActions['export'],
message: t('##username## exported data'),
},
'update-qa': {
name: AuditActions['update-qa'],
message: t('##username## modified qualitative analysis questions'),
},
'add-media': {
name: AuditActions['add-media'],
message: t('##username## added a media attachment'),
},
'delete-media': {
name: AuditActions['delete-media'],
message: t('##username## removed a media attachment'),
},
'connect-project': {
name: AuditActions['connect-project'],
message: t('##username## connected project data with another project'),
},
'disconnect-project': {
name: AuditActions['disconnect-project'],
message: t('##username## disconnected project from another project'),
},
'modify-imported-fields': {
name: AuditActions['modify-imported-fields'],
message: t('##username## changed imported fields from another project'),
},
'modify-sharing': {
name: AuditActions['modify-sharing'],
message: t('##username## modified data sharing'),
},
'enable-sharing': {
name: AuditActions['enable-sharing'],
message: t('##username## enabled data sharing'),
},
'disable-sharing': {
name: AuditActions['disable-sharing'],
message: t('##username## disabled data sharing'),
},
'register-service': {
name: AuditActions['register-service'],
message: t('##username## registered a new REST service'),
},
'modify-service': {
name: AuditActions['modify-service'],
message: t('##username## modified a REST service'),
},
'delete-service': {
name: AuditActions['delete-service'],
message: t('##username## deleted a REST service'),
},
'add-user': {
name: AuditActions['add-user'],
message: t('##username## added ##username2## to project'),
},
'remove-user': {
name: AuditActions['remove-user'],
message: t('##username## removed ##username2## from project'),
},
'update-permission': {
name: AuditActions['update-permission'],
message: t('##username## updated permissions of ##username2##'),
},
'make-public': {
name: AuditActions['make-public'],
message: t('##username## made the project publicly accessible'),
},
'share-public': {
name: AuditActions['share-public'],
message: t('##username## shared data publicly'),
},
'transfer': {
name: AuditActions['transfer'],
message: t('##username## transferred project ownership to ##username2##'),
},
};

export const FALLBACK_MESSAGE = '##username## did action ##action##';

/**
* All possible log item types.
* @see `AuditType` class from {@link kobo/apps/audit_log/models.py} (BE code)
*/
export enum AuditTypes {
access = 'access',
'project-history' = 'project-history',
'data-editing' = 'data-editing',
'user-management' = 'user-management',
'asset-management' = 'asset-management',
'submission-management' = 'submission-management',
}

export enum AuditSubTypes {
project = 'project',
permission = 'permission',
}

export interface ActivityLogsItem {
/** User url. E.g. "https://kf.beta.kbtdev.org/api/v2/users/<username>/" */
user: string;
user_uid: string;
username: string;
/** Date string in ISO 8601. E.g. "2024-10-04T14:04:18Z" */
date_created: string;
action: AuditActions;
log_type: AuditTypes;
metadata: {
/** E.g. "Firefox (Ubuntu)" */
source: string;
asset_uid: string;
/** E.g. "71.235.120.86" */
ip_address: string;
log_subtype: AuditSubTypes;
old_name?: string;
new_name?: string;
latest_deployed_version_id?: string;
latest_version_id?: string;
version_uid?: string;
second_user?: string;
// a lot of more optional metadata props…
};
}
90 changes: 65 additions & 25 deletions jsapp/js/components/activity/activityLogs.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@ import {keepPreviousData, useQuery} from '@tanstack/react-query';
import type {KoboSelectOption} from 'js/components/common/koboSelect';
import type {FailResponse, PaginatedResponse} from 'js/dataInterface';
import moment from 'moment';
import {
AuditActions,
AuditTypes,
AuditSubTypes,
type ActivityLogsItem,
} from './activity.constants';
import {QueryKeys} from 'js/query/queryKeys';

export interface ActivityLogsItem {
id: number;
who: string;
action: string;
what: string;
date: string;
}

// =============================================================================
// MOCK DATA GENERATION
const mockOptions: KoboSelectOption[] = [
{value: '1', label: 'Option 1'},
Expand All @@ -20,13 +19,58 @@ const mockOptions: KoboSelectOption[] = [
];

const getRandomMockDescriptionData = () => {
const who = ['Trent', 'Jane', 'Alice', 'Bob', 'Charlie'];
const action = ['created', 'updated', 'deleted', 'added', 'removed'];
const what = ['project property', 'the form', 'the permissions'];
// user info
const testUsernames = ['Trent', 'Jane', 'Alice', 'Bob', 'Charlie'];
const username = testUsernames[Math.floor(Math.random() * testUsernames.length)];
const user = `https://kf.beta.kbtdev.org/api/v2/users/${username.toLowerCase()}>/`;
const user_uid = String(Math.random());

// action
const action = Object.keys(AuditActions)[Math.floor(Math.random() * Object.keys(AuditActions).length)];

// log type
const log_type = Object.keys(AuditTypes)[Math.floor(Math.random() * Object.keys(AuditTypes).length)];

// metadata
const log_subtype = Object.keys(AuditSubTypes)[Math.floor(Math.random() * Object.keys(AuditSubTypes).length)];
const testSources = ['MacOS', 'iOS', 'Windows 98', 'CrunchBang Linux'];
const source = testSources[Math.floor(Math.random() * testSources.length)];
const asset_uid = String(Math.random());
const ip_address = (Math.floor(Math.random() * 255) + 1) + '.' + (Math.floor(Math.random() * 255)) + '.' + (Math.floor(Math.random() * 255)) + '.' + (Math.floor(Math.random() * 255));

const metadata: ActivityLogsItem['metadata'] = {
source,
asset_uid,
ip_address,
log_subtype: log_subtype as AuditSubTypes,
};

if (action === 'update-name') {
metadata.old_name = 'I kwno somethign';
metadata.new_name = 'I know something';
}
if (action === 'deploy' || action === 'redeploy') {
metadata.latest_deployed_version_id = 'asd123f3fz';
}
if (action === 'replace-form' || action === 'update-form') {
metadata.latest_version_id = 'aet4b1213c';
}
if (
action === 'add-user' ||
action === 'remove-user' ||
action === 'update-permission' ||
action === 'transfer'
) {
metadata.second_user = 'Josh';
}

Comment on lines +22 to +66
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice extends to the mock! 👍🏻

return {
who: who[Math.floor(Math.random() * who.length)],
action: action[Math.floor(Math.random() * action.length)],
what: what[Math.floor(Math.random() * what.length)],
user,
user_uid,
username,
action: action as AuditActions,
log_type: log_type as AuditTypes,
metadata: metadata,
};
};

Expand All @@ -36,16 +80,16 @@ const mockData: ActivityLogsItem[] = Array.from({length: 150}, (_, index) => {
return {
id: index,
...getRandomMockDescriptionData(),
date: moment(curDate).format('YYYY-MM-DD HH:mm:ss'),
date_created: moment(curDate).format('YYYY-MM-DD HH:mm:ss'),
};
});
// END OF MOCK GENERATION
// =============================================================================

/**
* Fetches the activity logs from the server.
* @param {number} limit Pagination parameter: number of items per page
* @param {number} offset Pagination parameter: offset of the page
* @returns {Promise<PaginatedResponse<ActivityLogsItem>>} The paginated response
* @param limit Pagination parameter: number of items per page
* @param offset Pagination parameter: offset of the page
*/
const getActivityLogs = async (limit: number, offset: number) =>
new Promise<PaginatedResponse<ActivityLogsItem>>((resolve) => {
Expand All @@ -63,7 +107,6 @@ const getActivityLogs = async (limit: number, offset: number) =>

/**
* Fetches the filter options for the activity logs.
* @returns {Promise<KoboSelectOption[]>} The filter options
*/
const getFilterOptions = async () =>
new Promise<KoboSelectOption[]>((resolve) => {
Expand Down Expand Up @@ -91,12 +134,10 @@ const startActivityLogsExport = async () =>
});

/**
* This is a hook that fetches activity logs from the server.
*
* This is a hook that fetches activity logs from the server.
*
* @param {number} itemLimit Pagination parameter: number of items per page
* @param {number} pageOffset Pagination parameter: offset of the page
* @returns {UseQueryResult<PaginatedResponse<ActivityLogsItem>>} The react query result
* @param itemLimit Pagination parameter: number of items per page
* @param pageOffset Pagination parameter: offset of the page
*/
export const useActivityLogsQuery = (itemLimit: number, pageOffset: number) =>
useQuery({
Expand All @@ -107,7 +148,6 @@ export const useActivityLogsQuery = (itemLimit: number, pageOffset: number) =>

/**
* This is a hook to fetch the filter options for the activity logs.
* @returns {UseQueryResult<KoboSelectOption[]>} The react query result
*/
export const useActivityLogsFilterOptionsQuery = () =>
useQuery({
Expand Down
30 changes: 30 additions & 0 deletions jsapp/js/components/activity/activityMessage.component.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import Avatar from '../common/avatar';
import {type ActivityLogsItem, AUDIT_ACTION_TYPES, FALLBACK_MESSAGE} from './activity.constants';
import styles from './activityMessage.module.scss';

/**
* An inline message that starts with avatar and username, and then is followed
* by short text describing what username did.
*/
export function ActivityMessage(props: {data: ActivityLogsItem}) {
let message = AUDIT_ACTION_TYPES[props.data.action]?.message || FALLBACK_MESSAGE;

// Here we reaplace all possible placeholders with appropriate data. This way
// we don't really need to know which message (out of around 30) are we
// dealing with - if it has given placeholder, it would be replaced, if it
// doesn't nothing will happen.
message = message
.replace('##username##', `<strong>${props.data.username}</strong>`)
.replace('##action##', props.data.action);
// We only replace it if metadata is provided.
if (props.data.metadata.second_user) {
message = message.replace('##username2##', `<strong>${props.data.metadata.second_user}</strong>`);
}

return (
<div className={styles.activityMessage}>
<Avatar size='s' username={props.data.username} />
<span dangerouslySetInnerHTML={{__html: message}} />
</div>
);
}
6 changes: 6 additions & 0 deletions jsapp/js/components/activity/activityMessage.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.activityMessage {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 14px;
}
Loading
Loading