Skip to content

Commit

Permalink
feat(organizations): members table TASK-980 (#5261)
Browse files Browse the repository at this point in the history
Add MembersRoute component that renders things at
`#/account/organization/members` (a header and a table of organization
members).

Add `membersQuery` that defines `OrganizationMember` TS interface and
adds way for getting organization members.
Change minimum column size in UniversalTable props to 60 (smaller then
previously).
  • Loading branch information
magicznyleszek authored Nov 25, 2024
1 parent 79e33e8 commit 1386a15
Show file tree
Hide file tree
Showing 8 changed files with 221 additions and 2 deletions.
102 changes: 102 additions & 0 deletions jsapp/js/account/organization/MembersRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// Libraries
import React from 'react';

// Partial components
import PaginatedQueryUniversalTable from 'js/universalTable/paginatedQueryUniversalTable.component';
import LoadingSpinner from 'js/components/common/loadingSpinner';
import Avatar from 'js/components/common/avatar';
import Badge from 'jsapp/js/components/common/badge';

// Stores, hooks and utilities
import {formatTime} from 'js/utils';
import {useOrganizationQuery} from './organizationQuery';
import useOrganizationMembersQuery from './membersQuery';

// Constants and types
import type {OrganizationMember} from './membersQuery';

// Styles
import styles from './membersRoute.module.scss';

export default function MembersRoute() {
const orgQuery = useOrganizationQuery();

if (!orgQuery.data?.id) {
return (
<LoadingSpinner />
);
}

return (
<div className={styles.membersRouteRoot}>
<header className={styles.header}>
<h2 className={styles.headerText}>{t('Members')}</h2>
</header>

<PaginatedQueryUniversalTable<OrganizationMember>
queryHook={useOrganizationMembersQuery}
columns={[
{
key: 'user__extra_details__name',
label: t('Name'),
cellFormatter: (member: OrganizationMember) => (
<Avatar
size='m'
username={member.user__username}
isUsernameVisible
email={member.user__email}
// We pass `undefined` for the case it's an empty string
fullName={member.user__extra_details__name || undefined}
/>
),
size: 360,
},
{
key: 'invite',
label: t('Status'),
size: 120,
cellFormatter: (member: OrganizationMember) => {
if (member.invite?.status) {
return member.invite.status;
} else {
return <Badge color='light-green' size='s' label={t('Active')} />;
}
return null;
},
},
{
key: 'date_joined',
label: t('Date added'),
size: 140,
cellFormatter: (member: OrganizationMember) => formatTime(member.date_joined),
},
{
key: 'role',
label: t('Role'),
size: 120,
},
{
key: 'user__has_mfa_enabled',
label: t('2FA'),
size: 90,
cellFormatter: (member: OrganizationMember) => {
if (member.user__has_mfa_enabled) {
return <Badge size='s' color='light-blue' icon='check' />;
}
return <Badge size='s' color='light-storm' icon='minus' />;
},
},
{
// We use `url` here, but the cell would contain interactive UI
// element
key: 'url',
label: '',
size: 64,
// TODO: this will be added soon
cellFormatter: () => (' '),
},
]}
/>
</div>
);
}
85 changes: 85 additions & 0 deletions jsapp/js/account/organization/membersQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {keepPreviousData, useQuery} from '@tanstack/react-query';
import {endpoints} from 'js/api.endpoints';
import type {PaginatedResponse} from 'js/dataInterface';
import {fetchGet} from 'js/api';
import {QueryKeys} from 'js/query/queryKeys';
import {useOrganizationQuery, type OrganizationUserRole} from './organizationQuery';

export interface OrganizationMember {
/**
* The url to the member within the organization
* `/api/v2/organizations/<organization_uid>/members/<username>/`
*/
url: string;
/** `/api/v2/users/<username>/` */
user: string;
user__username: string;
/** can be an empty string in some edge cases */
user__email: string | '';
/** can be an empty string in some edge cases */
user__extra_details__name: string | '';
role: OrganizationUserRole;
user__has_mfa_enabled: boolean;
user__is_active: boolean;
/** yyyy-mm-dd HH:MM:SS */
date_joined: string;
invite?: {
/** '/api/v2/organizations/<organization_uid>/invites/<invite_uid>/' */
url: string;
/** yyyy-mm-dd HH:MM:SS */
date_created: string;
/** yyyy-mm-dd HH:MM:SS */
date_modified: string;
status: 'sent' | 'accepted' | 'expired' | 'declined';
};
}

/**
* Fetches paginated list of members for given organization.
* This is mainly needed for `useOrganizationMembersQuery`, so you most probably
* would use it through that hook rather than directly.
*/
async function getOrganizationMembers(
limit: number,
offset: number,
orgId: string
) {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString(),
});

const apiUrl = endpoints.ORGANIZATION_MEMBERS_URL.replace(':organization_id', orgId);

return fetchGet<PaginatedResponse<OrganizationMember>>(
apiUrl + '?' + params,
{
errorMessageDisplay: t('There was an error getting the list.'),
}
);
}

/**
* A hook that gives you paginated list of organization members. Uses
* `useOrganizationQuery` to get the id.
*/
export default function useOrganizationMembersQuery(
itemLimit: number,
pageOffset: number
) {
const orgQuery = useOrganizationQuery();
const orgId = orgQuery.data?.id;

return useQuery({
queryKey: [QueryKeys.organizationMembers, itemLimit, pageOffset, orgId],
// `orgId!` because it's ensured to be there in `enabled` property :ok:
queryFn: () => getOrganizationMembers(itemLimit, pageOffset, orgId!),
placeholderData: keepPreviousData,
enabled: !!orgId,
// We might want to improve this in future, for now let's not retry
retry: false,
// The `refetchOnWindowFocus` option is `true` by default, I'm setting it
// here so we don't forget about it.
refetchOnWindowFocus: true,
});
}
26 changes: 26 additions & 0 deletions jsapp/js/account/organization/membersRoute.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
@use 'scss/colors';
@use 'scss/breakpoints';

.membersRouteRoot {
padding: 20px;
overflow-y: auto;
height: 100%;
}

.header {
margin-bottom: 20px;
}

h2.headerText {
color: colors.$kobo-storm;
text-transform: uppercase;
font-size: 18px;
font-weight: 700;
margin: 0;
}

@include breakpoints.breakpoint(mediumAndUp) {
.membersRouteRoot {
padding: 50px;
}
}
3 changes: 3 additions & 0 deletions jsapp/js/account/routes.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const AccountSettings = React.lazy(
export const DataStorage = React.lazy(
() => import(/* webpackPrefetch: true */ './usage/usageTopTabs')
);
export const MembersRoute = React.lazy(
() => import(/* webpackPrefetch: true */ './organization/MembersRoute')
);
export const OrganizationSettingsRoute = React.lazy(
() => import(/* webpackPrefetch: true */ './organization/OrganizationSettingsRoute')
);
Expand Down
3 changes: 2 additions & 1 deletion jsapp/js/account/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
DataStorage,
PlansRoute,
SecurityRoute,
MembersRoute,
OrganizationSettingsRoute,
} from 'js/account/routes.constants';
import {useFeatureFlag, FeatureFlag} from 'js/featureFlags';
Expand Down Expand Up @@ -121,7 +122,7 @@ export default function routes() {
mmoOnly
redirectRoute={ACCOUNT_ROUTES.ACCOUNT_SETTINGS}
>
<div>Organization members view to be implemented</div>
<MembersRoute />
</RequireOrgPermissions>
</RequireAuth>
}
Expand Down
1 change: 1 addition & 0 deletions jsapp/js/api.endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const endpoints = {
PRODUCTS_URL: '/api/v2/stripe/products/',
SUBSCRIPTION_URL: '/api/v2/stripe/subscriptions/',
ADD_ONS_URL: '/api/v2/stripe/addons/',
ORGANIZATION_MEMBERS_URL: '/api/v2/organizations/:organization_id/members/',
/** Expected parameters: price_id and organization_id **/
CHECKOUT_URL: '/api/v2/stripe/checkout-link',
/** Expected parameter: organization_id **/
Expand Down
1 change: 1 addition & 0 deletions jsapp/js/query/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export enum QueryKeys {
activityLogs = 'activityLogs',
activityLogsFilter = 'activityLogsFilter',
organization = 'organization',
organizationMembers = 'organizationMembers',
}
2 changes: 1 addition & 1 deletion jsapp/js/universalTable/universalTable.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ interface UniversalTableProps<DataItem> {

const DEFAULT_COLUMN_SIZE = {
size: 200, // starting column size
minSize: 100, // enforced during column resizing
minSize: 60, // enforced during column resizing
maxSize: 600, // enforced during column resizing
};

Expand Down

0 comments on commit 1386a15

Please sign in to comment.