Skip to content

Commit

Permalink
Merge branch 'main' into leszek/task-985-member-mutation-api
Browse files Browse the repository at this point in the history
  • Loading branch information
magicznyleszek authored Nov 29, 2024
2 parents 3e9048e + aaa8aac commit 5c8d255
Show file tree
Hide file tree
Showing 53 changed files with 655 additions and 447 deletions.
41 changes: 2 additions & 39 deletions jsapp/js/account/account.utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type {AccountFieldsValues} from './account.constants';
import envStore from '../envStore';
import {USER_FIELD_NAMES} from './account.constants';

export function getInitialAccountFieldsValues(): AccountFieldsValues {
return {
Expand All @@ -25,41 +23,6 @@ export function getInitialAccountFieldsValues(): AccountFieldsValues {
* For given field values produces an object to use with the `/me` endpoint for
* updating the `extra_details`.
*/
export function getProfilePatchData(fields: AccountFieldsValues) {
// HACK: dumb down the `output` type here, so TS doesn't have a problem with
// types inside the `forEach` loop below, and the output is compatible with
// functions from `api.ts` file.
const output: {extra_details: {[key: string]: any}} = {
extra_details: getInitialAccountFieldsValues(),
};

// To patch correctly with recent changes to the backend,
// ensure that we send empty strings if the field is left blank.

// We should only overwrite user metadata that the user can see.
// Fields that:
// (a) are enabled in constance
// (b) the frontend knows about

// Make a list of user metadata fields to include in the patch
const presentMetadataFields =
// Fields enabled in constance
envStore.data
.getUserMetadataFieldNames()
// Intersected with:
.filter(
(fieldName) =>
// Fields the frontend knows about
fieldName in USER_FIELD_NAMES
);

// Populate the patch with user form input, or empty strings.
presentMetadataFields.forEach((fieldName) => {
output.extra_details[fieldName] = fields[fieldName] || '';
});

// Always include require_auth, defaults to 'false'.
output.extra_details.require_auth = fields.require_auth ? true : false;

return output;
export function getProfilePatchData(fields: Partial<AccountFieldsValues>) {
return {extra_details: fields};
}
5 changes: 2 additions & 3 deletions jsapp/js/account/accountFieldsEditor.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ interface AccountFieldsEditorProps {
* `displayedFields` prop)
*/
values: AccountFieldsValues;
onChange: (fields: AccountFieldsValues) => void;
onFieldChange: (fieldName: UserFieldName, value: UserFieldValue) => void;
}

/**
Expand Down Expand Up @@ -82,8 +82,7 @@ export default function AccountFieldsEditor(props: AccountFieldsEditorProps) {
fieldName: UserFieldName,
newValue: UserFieldValue
) {
const newValues = {...props.values, [fieldName]: newValue};
props.onChange(newValues);
props.onFieldChange(fieldName, newValue);
}

const cleanedUrl = (value: string) => {
Expand Down
181 changes: 80 additions & 101 deletions jsapp/js/account/accountSettingsRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import React, {useEffect, useState} from 'react';
import type React from 'react';
import {useEffect, useState} from 'react';
import Button from 'js/components/common/button';
import InlineMessage from 'js/components/common/inlineMessage';
import {observer} from 'mobx-react';
import type {Form} from 'react-router-dom';
import {unstable_usePrompt as usePrompt} from 'react-router-dom';
import bem, {makeBem} from 'js/bem';
import sessionStore from 'js/stores/session';
import './accountSettings.scss';
import {notify} from 'js/utils';
import {dataInterface} from '../dataInterface';
Expand All @@ -21,119 +19,93 @@ import type {
AccountFieldsErrors,
} from './account.constants';
import {HELP_ARTICLE_ANON_SUBMISSIONS_URL} from 'js/constants';
import {useSession} from '../stores/useSession';

bem.AccountSettings = makeBem(null, 'account-settings', 'form');
bem.AccountSettings__left = makeBem(bem.AccountSettings, 'left');
bem.AccountSettings__right = makeBem(bem.AccountSettings, 'right');
bem.AccountSettings__item = makeBem(bem.FormModal, 'item');
bem.AccountSettings__actions = makeBem(bem.AccountSettings, 'actions');

interface Form {
isPristine: boolean;
/**
* Whether we have loaded the user metadata values. Used to avoid displaying
* blank form with values coming in a moment later (in visible way).
*/
isUserDataLoaded: boolean;
fields: AccountFieldsValues;
fieldsWithErrors: {
extra_details?: AccountFieldsErrors;
};
}

const AccountSettings = observer(() => {
const [form, setForm] = useState<Form>({
isPristine: true,
isUserDataLoaded: false,
fields: getInitialAccountFieldsValues(),
fieldsWithErrors: {},
});
const AccountSettings = () => {
const [isPristine, setIsPristine] = useState(true);
const [fieldErrors, setFieldErrors] = useState<AccountFieldsErrors>({});
const [formFields, setFormFields] = useState<AccountFieldsValues>(
getInitialAccountFieldsValues()
);
const [editedFields, setEditedFields] = useState<
Partial<AccountFieldsValues>
>({});

const {currentLoggedAccount, refreshAccount} = useSession();

useEffect(() => {
if (
!sessionStore.isPending &&
sessionStore.isInitialLoadComplete &&
!sessionStore.isInitialRoute
) {
sessionStore.refreshAccount();
}
}, []);
useEffect(() => {
const currentAccount = sessionStore.currentAccount;
if (
!sessionStore.isPending &&
sessionStore.isInitialLoadComplete &&
'email' in currentAccount
) {
setForm({
...form,
isUserDataLoaded: true,
fields: {
name: currentAccount.extra_details.name,
organization_type: currentAccount.extra_details.organization_type,
organization: currentAccount.extra_details.organization,
organization_website:
currentAccount.extra_details.organization_website,
sector: currentAccount.extra_details.sector,
gender: currentAccount.extra_details.gender,
bio: currentAccount.extra_details.bio,
city: currentAccount.extra_details.city,
country: currentAccount.extra_details.country,
require_auth: currentAccount.extra_details.require_auth,
twitter: currentAccount.extra_details.twitter,
linkedin: currentAccount.extra_details.linkedin,
instagram: currentAccount.extra_details.instagram,
newsletter_subscription:
currentAccount.extra_details.newsletter_subscription,
},
fieldsWithErrors: {},
});
if (!currentLoggedAccount) {
return;
}
}, [sessionStore.isPending]);

setFormFields({
name: currentLoggedAccount.extra_details.name,
organization_type: currentLoggedAccount.extra_details.organization_type,
organization: currentLoggedAccount.extra_details.organization,
organization_website: currentLoggedAccount.extra_details.organization_website,
sector: currentLoggedAccount.extra_details.sector,
gender: currentLoggedAccount.extra_details.gender,
bio: currentLoggedAccount.extra_details.bio,
city: currentLoggedAccount.extra_details.city,
country: currentLoggedAccount.extra_details.country,
require_auth: currentLoggedAccount.extra_details.require_auth,
twitter: currentLoggedAccount.extra_details.twitter,
linkedin: currentLoggedAccount.extra_details.linkedin,
instagram: currentLoggedAccount.extra_details.instagram,
newsletter_subscription:
currentLoggedAccount.extra_details.newsletter_subscription,
});
}, [currentLoggedAccount]);

usePrompt({
when: !form.isPristine,
when: !isPristine,
message: t('You have unsaved changes. Leave settings without saving?'),
});

const onUpdateComplete = () => {
notify(t('Updated profile successfully'));
setIsPristine(true);
setFieldErrors({});
};

const onUpdateFail = (errors: AccountFieldsErrors) => {
setFieldErrors(errors);
};

const updateProfile = (e: React.FormEvent) => {
e?.preventDefault?.(); // Prevent form submission page reload

const profilePatchData = getProfilePatchData(form.fields);
const patchData = getProfilePatchData(editedFields);
dataInterface
.patchProfile(profilePatchData)
.patchProfile(patchData)
.done(() => {
onUpdateComplete();
refreshAccount();
})
.fail((...args: any) => {
onUpdateFail(args);
onUpdateFail(args[0].responseJSON);
});
};

const onAccountFieldsEditorChange = (fields: AccountFieldsValues) => {
setForm({
...form,
fields: fields,
isPristine: false,
});
};

const onUpdateComplete = () => {
notify(t('Updated profile successfully'));
setForm({
...form,
isPristine: true,
fieldsWithErrors: {},
const onFieldChange = (fieldName: string, value: string | boolean) => {
setFormFields({
...formFields,
[fieldName]: value,
});
};

const onUpdateFail = (data: any) => {
setForm({
...form,
isPristine: false,
fieldsWithErrors: data[0].responseJSON,
setEditedFields({
...editedFields,
[fieldName]: value,
});
setIsPristine(false);
};

const accountName = sessionStore.currentAccount.username;
const accountName = currentLoggedAccount?.username || '';

return (
<bem.AccountSettings onSubmit={updateProfile}>
Expand All @@ -143,49 +115,56 @@ const AccountSettings = observer(() => {
className='account-settings-save'
size={'l'}
isSubmit
label={t('Save Changes') + (form.isPristine ? '' : ' *')}
label={t('Save Changes') + (isPristine ? '' : ' *')}
/>
</bem.AccountSettings__actions>

<bem.AccountSettings__item m={'column'}>
<bem.AccountSettings__item m='username'>
<Avatar size='m' username={accountName} isUsernameVisible/>
<Avatar size='m' username={accountName} isUsernameVisible />
</bem.AccountSettings__item>

{sessionStore.isInitialLoadComplete && form.isUserDataLoaded && (
{currentLoggedAccount && (
<bem.AccountSettings__item m='fields'>
<InlineMessage
type='warning'
icon='information'
message={(
message={
<>
<strong>
{t('You can now control whether to allow anonymous submissions in web forms for each project. Previously, this was an account-wide setting.')}
{t(
'You can now control whether to allow anonymous submissions in web forms for each project. Previously, this was an account-wide setting.'
)}
</strong>
&nbsp;
{t('This privacy feature is now a per-project setting. New projects will require authentication by default.')}
{t(
'This privacy feature is now a per-project setting. New projects will require authentication by default.'
)}
&nbsp;
<a
href={envStore.data.support_url + HELP_ARTICLE_ANON_SUBMISSIONS_URL}
href={
envStore.data.support_url +
HELP_ARTICLE_ANON_SUBMISSIONS_URL
}
target='_blank'
>
{t('Learn more about these changes here.')}
</a>
</>
)}
}
className='anonymous-submission-notice'
/>

<AccountFieldsEditor
errors={form.fieldsWithErrors.extra_details}
values={form.fields}
onChange={onAccountFieldsEditorChange}
errors={fieldErrors}
values={formFields}
onFieldChange={onFieldChange}
/>
</bem.AccountSettings__item>
)}
</bem.AccountSettings__item>
</bem.AccountSettings>
);
});
};

export default AccountSettings;
19 changes: 18 additions & 1 deletion jsapp/js/account/subscriptionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import {makeAutoObservable} from 'mobx';
import {handleApiFail, fetchGet} from 'js/api';
import {ACTIVE_STRIPE_STATUSES, ROOT_URL} from 'js/constants';
import type {PaginatedResponse} from 'js/dataInterface';
import type {Product, SubscriptionInfo} from 'js/account/stripe.types';
import {PlanNames, type Product, type SubscriptionInfo} from 'js/account/stripe.types';
import envStore from 'js/envStore';

const PRODUCTS_URL = '/api/v2/stripe/products/';

Expand Down Expand Up @@ -43,6 +44,22 @@ class SubscriptionStore {
});
}

/*
* The plan name displayed to the user. This will display, in order of precedence:
* * The user's active plan subscription
* * The FREE_TIER_DISPLAY["name"] setting (if the user registered before FREE_TIER_CUTOFF_DATE
* * The free plan
*/
public get planName() {
if (
this.planResponse.length &&
this.planResponse[0].items.length
) {
return this.planResponse[0].items[0].price.product.name;
}
return envStore.data?.free_tier_display?.name || PlanNames.FREE;
}

private onFetchSubscriptionInfoDone(
response: PaginatedResponse<SubscriptionInfo>
) {
Expand Down
13 changes: 4 additions & 9 deletions jsapp/js/account/usage/usage.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,15 @@ export interface UsageResponse {
}

const USAGE_URL = '/api/v2/service_usage/';
const ORGANIZATION_USAGE_URL =
'/api/v2/organizations/##ORGANIZATION_ID##/service_usage/';
const ORGANIZATION_USAGE_URL = '/api/v2/organizations/:organization_id/service_usage/';

const ASSET_USAGE_URL = '/api/v2/asset_usage/';
const ORGANIZATION_ASSET_USAGE_URL =
'/api/v2/organizations/##ORGANIZATION_ID##/asset_usage/';
const ORGANIZATION_ASSET_USAGE_URL = '/api/v2/organizations/:organization_id/asset_usage/';

export async function getUsage(organization_id: string | null = null) {
if (organization_id) {
return fetchGet<UsageResponse>(
ORGANIZATION_USAGE_URL.replace('##ORGANIZATION_ID##', organization_id),
ORGANIZATION_USAGE_URL.replace(':organization_id', organization_id),
{
includeHeaders: true,
errorMessageDisplay: t('There was an error fetching usage data.'),
Expand Down Expand Up @@ -95,10 +93,7 @@ export async function getAssetUsageForOrganization(
return await getAssetUsage(ASSET_USAGE_URL);
}

const apiUrl = ORGANIZATION_ASSET_USAGE_URL.replace(
'##ORGANIZATION_ID##',
organizationId
);
const apiUrl = ORGANIZATION_ASSET_USAGE_URL.replace(':organization_id', organizationId);

const params = new URLSearchParams({
page: pageNumber.toString(),
Expand Down
Loading

0 comments on commit 5c8d255

Please sign in to comment.