Skip to content

Commit

Permalink
Merge branch 'main' into task-1083-mmo-label-util
Browse files Browse the repository at this point in the history
  • Loading branch information
duvld committed Oct 28, 2024
2 parents 0fdab90 + 5eef785 commit 6246521
Show file tree
Hide file tree
Showing 67 changed files with 1,183 additions and 474 deletions.
1 change: 1 addition & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
6. [ ] Mention any related issues in this repository (as #ISSUE) and in other repositories (as kobotoolbox/other#ISSUE)
7. [ ] Open an issue in the [docs](https://github.com/kobotoolbox/docs/issues/new) if there are UI/UX changes
8. [ ] Create a testing plan for the reviewer and add it to the Testing section
9. [ ] Add frontend or backend tag and any other appropriate tags to this pull request

## Description

Expand Down
21 changes: 21 additions & 0 deletions hub/models/extra_user_detail.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ def save(
if not update_fields or (update_fields and 'data' in update_fields):
self.standardize_json_field('data', 'organization', str)
self.standardize_json_field('data', 'name', str)
if not created:
self._sync_org_name()

super().save(
force_insert=force_insert,
Expand All @@ -59,3 +61,22 @@ def save(
self.user.id,
self.validated_password,
)

def _sync_org_name(self):
"""
Synchronizes the `name` field of the Organization model with the
"organization" attribute found in the `data` field of ExtraUserDetail,
but only if the user is the owner.
This ensures that any updates in the metadata are accurately reflected
in the organization's name.
"""
user_organization = self.user.organization
if user_organization.is_owner(self.user):
try:
organization_name = self.data['organization'].strip()
except (KeyError, AttributeError):
organization_name = None

user_organization.name = organization_name
user_organization.save(update_fields=['name'])
11 changes: 5 additions & 6 deletions jsapp/js/account/accountSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import {NavLink} from 'react-router-dom';
import {observer} from 'mobx-react-lite';
import bem from 'js/bem';
import Icon from 'js/components/common/icon';
import {IconName} from 'jsapp/fonts/k-icons';
import type {IconName} from 'jsapp/fonts/k-icons';
import Badge from '../components/common/badge';
import subscriptionStore from 'js/account/subscriptionStore';
import './accountSidebar.scss';
import useWhenStripeIsEnabled from 'js/hooks/useWhenStripeIsEnabled.hook';
import {OrganizationContext} from 'js/account/organizations/useOrganization.hook';
import {ACCOUNT_ROUTES} from 'js/account/routes.constants';
import {useOrganizationQuery} from './stripe.api';

interface AccountNavLinkProps {
iconName: IconName;
Expand All @@ -34,9 +34,8 @@ function AccountNavLink(props: AccountNavLinkProps) {

function AccountSidebar() {
const [showPlans, setShowPlans] = useState(false);
const [organization, _] = useContext(OrganizationContext);

const isOrgOwner = useMemo(() => organization?.is_owner, [organization]);
const orgQuery = useOrganizationQuery();

useWhenStripeIsEnabled(() => {
if (!subscriptionStore.isInitialised) {
Expand All @@ -61,7 +60,7 @@ function AccountSidebar() {
name={t('Security')}
to={ACCOUNT_ROUTES.SECURITY}
/>
{isOrgOwner && (
{orgQuery.data?.is_owner && (
<>
<AccountNavLink
iconName='reports'
Expand All @@ -80,7 +79,7 @@ function AccountSidebar() {
iconName='plus'
name={t('Add-ons')}
to={ACCOUNT_ROUTES.ADD_ONS}
isNew={true}
isNew
/>
)}
</>
Expand Down
22 changes: 9 additions & 13 deletions jsapp/js/account/billingContextProvider.component.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
import React, {ReactNode} from 'react';
import {UsageContext, useUsage} from 'js/account/usage/useUsage.hook';
import {ProductsContext, useProducts} from 'js/account/useProducts.hook';
import {
OrganizationContext,
useOrganization,
} from 'js/account/organizations/useOrganization.hook';
import sessionStore from 'js/stores/session';
import {useOrganizationQuery} from 'js/account/stripe.api';

export const BillingContextProvider = (props: {children: ReactNode}) => {
const orgQuery = useOrganizationQuery();

if (!sessionStore.isLoggedIn) {
return <>{props.children}</>;
}
const [organization, reloadOrg, orgStatus] = useOrganization();
const usage = useUsage(organization?.id);
const usage = useUsage(orgQuery.data?.id || null);
const products = useProducts();
return (
<OrganizationContext.Provider value={[organization, reloadOrg, orgStatus]}>
<UsageContext.Provider value={usage}>
<ProductsContext.Provider value={products}>
{props.children}
</ProductsContext.Provider>
</UsageContext.Provider>
</OrganizationContext.Provider>
<UsageContext.Provider value={usage}>
<ProductsContext.Provider value={products}>
{props.children}
</ProductsContext.Provider>
</UsageContext.Provider>
);
};
17 changes: 9 additions & 8 deletions jsapp/js/account/organizations/requireOrgOwner.component.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
import React, {Suspense, useContext, useEffect} from 'react';
import React, {Suspense, useEffect} from 'react';
import {useNavigate} from 'react-router-dom';
import {OrganizationContext} from 'js/account/organizations/useOrganization.hook';
import LoadingSpinner from 'js/components/common/loadingSpinner';
import {ACCOUNT_ROUTES} from 'js/account/routes.constants';
import {useOrganizationQuery} from 'js/account/stripe.api';

interface Props {
children: React.ReactNode;
redirect?: boolean;
}

export const RequireOrgOwner = ({children, redirect = true}: Props) => {
const [organization, _, orgStatus] = useContext(OrganizationContext);
const navigate = useNavigate();
const orgQuery = useOrganizationQuery();

// Redirect to Account Settings if you're not the owner
useEffect(() => {
if (
redirect &&
!orgStatus.pending &&
organization &&
!organization.is_owner
!orgQuery.isPending &&
orgQuery.data &&
!orgQuery.data.is_owner
) {
navigate(ACCOUNT_ROUTES.ACCOUNT_SETTINGS);
}
}, [organization, orgStatus.pending, redirect]);
}, [redirect, orgQuery.isSuccess, orgQuery.data, navigate]);

return redirect && organization?.is_owner ? (
return redirect && orgQuery.data?.is_owner ? (
<Suspense fallback={null}>{children}</Suspense>
) : (
<LoadingSpinner />
Expand Down
26 changes: 0 additions & 26 deletions jsapp/js/account/organizations/useOrganization.hook.tsx

This file was deleted.

24 changes: 11 additions & 13 deletions jsapp/js/account/plans/plan.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import React, {
} from 'react';
import {useNavigate, useSearchParams} from 'react-router-dom';
import styles from './plan.module.scss';
import {postCheckout, postCustomerPortal} from '../stripe.api';
import {postCheckout, postCustomerPortal, useOrganizationQuery} from '../stripe.api';
import Button from 'js/components/common/button';
import classnames from 'classnames';
import LoadingSpinner from 'js/components/common/loadingSpinner';
Expand Down Expand Up @@ -37,7 +37,6 @@ import type {ConfirmChangeProps} from 'js/account/plans/confirmChangeModal.compo
import ConfirmChangeModal from 'js/account/plans/confirmChangeModal.component';
import {PlanContainer} from 'js/account/plans/planContainer.component';
import {ProductsContext} from '../useProducts.hook';
import {OrganizationContext} from 'js/account/organizations/useOrganization.hook';
import {ACCOUNT_ROUTES} from 'js/account/routes.constants';
import {useRefreshApiFetcher} from 'js/hooks/useRefreshApiFetcher.hook';

Expand Down Expand Up @@ -115,8 +114,7 @@ export default function Plan(props: PlanProps) {
>([]);
const [products, loadProducts, productsStatus] = useContext(ProductsContext);
useRefreshApiFetcher(loadProducts, productsStatus);
const [organization, loadOrg, orgStatus] = useContext(OrganizationContext);
useRefreshApiFetcher(loadOrg, orgStatus);
const orgQuery = useOrganizationQuery();
const [confirmModal, setConfirmModal] = useState<ConfirmChangeProps>({
newPrice: null,
products: [],
Expand Down Expand Up @@ -149,8 +147,8 @@ export default function Plan(props: PlanProps) {

const isDataLoading = useMemo(
(): boolean =>
!(products.isLoaded && organization && state.subscribedProduct),
[products.isLoaded, organization, state.subscribedProduct]
!(products.isLoaded && orgQuery.data && state.subscribedProduct),
[products.isLoaded, orgQuery.data, state.subscribedProduct]
);

const isDisabled = useMemo(() => isBusy, [isBusy]);
Expand Down Expand Up @@ -227,10 +225,10 @@ export default function Plan(props: PlanProps) {

// if the user is not the owner of their org, send them back to the settings page
useEffect(() => {
if (!organization?.is_owner) {
if (!orgQuery.data?.is_owner) {
navigate(ACCOUNT_ROUTES.ACCOUNT_SETTINGS);
}
}, [organization]);
}, [orgQuery.data]);

// Re-fetch data from API and re-enable buttons if displaying from back/forward cache
useEffect(() => {
Expand Down Expand Up @@ -372,15 +370,15 @@ export default function Plan(props: PlanProps) {
};

const buySubscription = (price: Price, quantity = 1) => {
if (!price.id || isDisabled || !organization?.id) {
if (!price.id || isDisabled || !orgQuery.data?.id) {
return;
}
setIsBusy(true);
if (activeSubscriptions.length) {
if (!isDowngrade(activeSubscriptions, price, quantity)) {
// if the user is upgrading prices, send them to the customer portal
// this will immediately change their subscription
postCustomerPortal(organization.id, price.id, quantity)
postCustomerPortal(orgQuery.data.id, price.id, quantity)
.then(processCheckoutResponse)
.catch(() => setIsBusy(false));
} else {
Expand All @@ -395,7 +393,7 @@ export default function Plan(props: PlanProps) {
}
} else {
// just send the user to the checkout page
postCheckout(price.id, organization.id, quantity)
postCheckout(price.id, orgQuery.data.id, quantity)
.then(processCheckoutResponse)
.catch(() => setIsBusy(false));
}
Expand Down Expand Up @@ -441,7 +439,7 @@ export default function Plan(props: PlanProps) {
</div>
);

if (!products.products.length || !organization) {
if (!products.products.length || !orgQuery.data) {
return null;
}

Expand Down Expand Up @@ -557,7 +555,7 @@ export default function Plan(props: PlanProps) {
isBusy={isBusy}
setIsBusy={setIsBusy}
products={products.products}
organization={organization}
organization={orgQuery.data}
onClickBuy={buySubscription}
/>
)}
Expand Down
15 changes: 5 additions & 10 deletions jsapp/js/account/plans/planButton.component.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import BillingButton from 'js/account/plans/billingButton.component';
import React, {useContext} from 'react';
import type {
Price,
Organization,
SinglePricedProduct,
} from 'js/account/stripe.types';
import {postCustomerPortal} from 'js/account/stripe.api';
import type {Price, SinglePricedProduct} from 'js/account/stripe.types';
import {postCustomerPortal, useOrganizationQuery} from 'js/account/stripe.api';
import {processCheckoutResponse} from 'js/account/stripe.utils';
import {OrganizationContext} from 'js/account/organizations/useOrganization.hook';

interface PlanButtonProps {
buySubscription: (price: Price, quantity?: number) => void;
Expand All @@ -34,15 +29,15 @@ export const PlanButton = ({
quantity,
isSubscribedToPlan,
}: PlanButtonProps) => {
const [organization] = useContext(OrganizationContext);
const orgQuery = useOrganizationQuery();

if (!product || !organization || product.price.unit_amount === 0) {
if (!product || !orgQuery.data || product.price.unit_amount === 0) {
return null;
}

const manageSubscription = (subscriptionPrice?: Price) => {
setIsBusy(true);
postCustomerPortal(organization.id, subscriptionPrice?.id, quantity)
postCustomerPortal(orgQuery.data.id, subscriptionPrice?.id, quantity)
.then(processCheckoutResponse)
.catch(() => setIsBusy(false));
};
Expand Down
15 changes: 9 additions & 6 deletions jsapp/js/account/stripe.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import type {
Organization,
PriceMetadata,
Product,
TransformQuantity,
} from 'js/account/stripe.types';
import {Limits} from 'js/account/stripe.types';
import {getAdjustedQuantityForPrice} from 'js/account/stripe.utils';
import {useQuery} from '@tanstack/react-query';
import {QueryKeys} from 'js/query/queryKeys';

const DEFAULT_LIMITS: AccountLimit = Object.freeze({
submission_limit: Limits.unlimited,
Expand Down Expand Up @@ -47,11 +48,13 @@ export async function changeSubscription(
});
}

export async function getOrganization() {
return fetchGet<PaginatedResponse<Organization>>(endpoints.ORGANIZATION_URL, {
errorMessageDisplay: t("Couldn't get data for your organization."),
});
}
export const useOrganizationQuery = () => useQuery({
queryFn: async () => {
const response = await fetchGet<PaginatedResponse<Organization>>(endpoints.ORGANIZATION_URL);
return response.results?.[0];
},
queryKey: [QueryKeys.organization],
});

/**
* Start a checkout session for the given price and organization. Response contains the checkout URL.
Expand Down
9 changes: 5 additions & 4 deletions jsapp/js/account/usage/usageProjectBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, {useState, useEffect, useContext} from 'react';
import type React from 'react';
import {useState, useEffect, useContext} from 'react';
import styles from './usageProjectBreakdown.module.scss';
import {Link} from 'react-router-dom';
import {ROUTES} from 'jsapp/js/router/routerConstants';
Expand All @@ -15,7 +16,7 @@ import {convertSecondsToMinutes} from 'jsapp/js/utils';
import {UsageContext} from './useUsage.hook';
import Button from 'js/components/common/button';
import Icon from 'js/components/common/icon';
import {OrganizationContext} from 'js/account/organizations/useOrganization.hook';
import {useOrganizationQuery} from 'js/account/stripe.api';

type ButtonType = 'back' | 'forward';

Expand All @@ -31,14 +32,14 @@ const ProjectBreakdown = () => {
const [showIntervalBanner, setShowIntervalBanner] = useState(true);
const [loading, setLoading] = useState(true);
const [usage] = useContext(UsageContext);
const [organization] = useContext(OrganizationContext);
const orgQuery = useOrganizationQuery();

useEffect(() => {
async function fetchData() {
const data = await getAssetUsageForOrganization(
currentPage,
order,
organization?.id
orgQuery.data?.id
);
const updatedResults = data.results.map((projectResult) => {
const assetParts = projectResult.asset.split('/');
Expand Down
2 changes: 1 addition & 1 deletion jsapp/js/assetUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export function getLanguagesDisplayString(asset: AssetResponse | ProjectViewAsse
export function getSectorDisplayString(asset: AssetResponse | ProjectViewAsset): string {
let output = '-';

if (asset.settings.sector?.value) {
if (asset.settings.sector && 'value' in asset.settings.sector) {
/**
* We don't want to use labels from asset's settings, as these are localized
* and thus prone to not be true (e.g. creating form in spanish UI language
Expand Down
Loading

0 comments on commit 6246521

Please sign in to comment.