Skip to content

Commit

Permalink
Merge branch 'main' into kalvis/organizations-settings
Browse files Browse the repository at this point in the history
  • Loading branch information
magicznyleszek committed Nov 25, 2024
2 parents cb07f7f + fe8e7ec commit bf09e84
Show file tree
Hide file tree
Showing 32 changed files with 757 additions and 133 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
} from 'js/account/stripe.types';
import {isAddonProduct} from 'js/account/stripe.utils';
import styles from './addOnList.module.scss';
import {OneTimeAddOnRow} from 'js/account/add-ons/oneTimeAddOnRow.component';
import {OneTimeAddOnRow} from 'jsapp/js/account/addOns/oneTimeAddOnRow.component';
import type {BadgeColor} from 'jsapp/js/components/common/badge';
import Badge from 'jsapp/js/components/common/badge';
import {formatDate} from 'js/utils';
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import styles from 'js/account/add-ons/addOnList.module.scss';
import styles from 'js/account/addOns/addOnList.module.scss';
import React, {useMemo, useState} from 'react';
import type {
Product,
Expand Down
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;
}
}
12 changes: 4 additions & 8 deletions jsapp/js/account/organization/organization.utils.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {SubscriptionInfo} from 'jsapp/js/account/stripe.types';
import type {EnvStoreData} from 'jsapp/js/envStore';

/** Only use this directly for complex cases/strings (for example, possessive case).
/** Only use this directly for complex cases/strings (for example, possessive case).
* Otherwise, use getSimpleMMOLabel.
* @param {EnvStoreData} envStoreData
* @param {SubscriptionInfo} subscription
Expand All @@ -11,13 +11,9 @@ export function shouldUseTeamLabel(
envStoreData: EnvStoreData,
subscription: SubscriptionInfo | null
) {
if (subscription) {
return (
subscription.items[0].price.product.metadata?.use_team_label === 'true'
);
}

return envStoreData.use_team_label;
return subscription
? subscription.items[0].price.product.metadata?.use_team_label === 'true'
: envStoreData.use_team_label;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion jsapp/js/account/plans/plan.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {ACTIVE_STRIPE_STATUSES} from 'js/constants';
import type {FreeTierThresholds} from 'js/envStore';
import envStore from 'js/envStore';
import useWhen from 'js/hooks/useWhen.hook';
import AddOnList from 'js/account/add-ons/addOnList.component';
import AddOnList from 'jsapp/js/account/addOns/addOnList.component';
import subscriptionStore from 'js/account/subscriptionStore';
import {when} from 'mobx';
import {
Expand Down
5 changes: 4 additions & 1 deletion jsapp/js/account/routes.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,17 @@ export const PlansRoute = React.lazy(
() => import(/* webpackPrefetch: true */ './plans/plan.component')
);
export const AddOnsRoute = React.lazy(
() => import(/* webpackPrefetch: true */ './add-ons/addOns.component')
() => import(/* webpackPrefetch: true */ './addOns/addOns.component')
);
export const AccountSettings = React.lazy(
() => import(/* webpackPrefetch: true */ './accountSettingsRoute')
);
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
Loading

0 comments on commit bf09e84

Please sign in to comment.