Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 24 additions & 39 deletions src/components/common/QuotaDialog.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,66 +4,51 @@ import DialogContent from '@mui/material/DialogContent'
import DialogActions from '@mui/material/DialogActions'
import Typography from '@mui/material/Typography'
import Button from '@mui/material/Button'
import LinearProgress from '@mui/material/LinearProgress'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'

import Dialog from './Dialog'
import DialogTitle from './DialogTitle'
import CloseIconButton from './CloseIconButton'
import { isCapMeter, QUOTA_PRICING_URL, REQUEST_MORE_ACCESS_URL } from './quotaErrors'
import { isCapMeter } from './quotaErrors'
import useLimitAction from './useLimitAction'

const QuotaDialog = ({open, onClose, meter, surface, plan, usage}) => {
const QuotaDialog = ({open, onClose, errorCode, meter, surface}) => {
const { t } = useTranslation()
const isCap = isCapMeter(meter)
const { used, limit, period } = usage || {}
const hasCount = Number.isFinite(used) && Number.isFinite(limit) && limit > 0
const progress = hasCount ? Math.min(100, (used / limit) * 100) : 100
const linkProps = {target: '_blank', rel: 'noopener noreferrer', endIcon: <OpenInNewIcon />, sx: {textTransform: 'none', borderRadius: '100px', whiteSpace: 'nowrap', flexShrink: 0, '&:hover, &:focus, &:active': {textDecoration: 'none'}, '&:focus:not(:focus-visible)': {outline: 'none'}}}
const { action, onActionClick } = useLimitAction(open, errorCode)

return (
<Dialog open={open} onClose={onClose} maxWidth='sm' fullWidth>
<DialogTitle sx={{display: 'flex', alignItems: 'center', justifyContent: 'space-between'}}>
{t(isCap ? 'quota.title_cap' : 'quota.title_quota')}
{t(isCapMeter(meter) ? 'quota.title_cap' : 'quota.title_quota')}
<CloseIconButton onClick={onClose} aria-label={t('common.close')} />
</DialogTitle>
<DialogContent sx={{px: 0, pb: 0}}>
<Typography variant='body1' sx={{my: 2}}>
{t([`quota.consequence.${meter}.${surface}`, `quota.consequence.${meter}.default`, 'quota.consequence.generic'])}
</Typography>
<div style={{backgroundColor: '#fff', borderRadius: '20px', padding: '16px 20px'}}>
{
plan &&
<div style={{paddingBottom: '12px', marginBottom: '12px', borderBottom: '1px solid rgba(0,0,0,0.12)'}}>
<Typography variant='caption' sx={{display: 'block', letterSpacing: '0.1em', textTransform: 'uppercase', color: 'text.secondary'}}>
{t('quota.your_plan')}
</Typography>
<Typography variant='subtitle1' sx={{fontWeight: 600}}>{plan}</Typography>
</div>
}
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '8px'}}>
<Typography variant='body1'>{t(`quota.meter.${meter}`, {defaultValue: t('quota.meter.generic')})}</Typography>
{
hasCount &&
<Typography variant='body1' sx={{fontWeight: 600}}>{t('quota.used_of', {used, limit})}</Typography>
}
</div>
<LinearProgress variant='determinate' value={progress} sx={{my: 1, height: 8, borderRadius: '4px'}} />
{
!isCap && period === 'one_time' &&
<Typography variant='body2' color='text.secondary'>{t('quota.period_one_time')}</Typography>
}
</div>
{
action?.message &&
<Typography variant='body1' sx={{mt: 2}}>{action.message}</Typography>
}
</DialogContent>
<DialogActions disableSpacing sx={{p: 0, pt: 3, gap: 1, justifyContent: 'flex-end', flexDirection: {xs: 'column-reverse', sm: 'row'}, alignItems: {xs: 'stretch', sm: 'center'}}}>
<Button onClick={onClose} sx={{textTransform: 'none', mr: {sm: 'auto'}}}>
{t('common.cancel')}
</Button>
<Button href={REQUEST_MORE_ACCESS_URL} variant='outlined' {...linkProps} sx={{...linkProps.sx, '&, &:hover, &:focus, &:active': {color: 'primary.main', textDecoration: 'none'}}}>
{t('quota.request_more_access')}
</Button>
<Button href={QUOTA_PRICING_URL} variant='contained' {...linkProps} sx={{...linkProps.sx, '&, &:hover, &:focus, &:active': {color: 'primary.contrastText', textDecoration: 'none'}}}>
{t('quota.view_pricing')}
{t('common.close')}
</Button>
{
action &&
<Button
href={action.url}
onClick={onActionClick}
target='_blank'
rel='noopener noreferrer'
variant='contained'
endIcon={<OpenInNewIcon />}
sx={{textTransform: 'none', borderRadius: '100px', flexShrink: 0, '&, &:hover, &:focus, &:active': {color: 'primary.contrastText', textDecoration: 'none'}, '&:focus:not(:focus-visible)': {outline: 'none'}}}
>
{action.label}
</Button>
}
</DialogActions>
</Dialog>
)
Expand Down
59 changes: 59 additions & 0 deletions src/components/common/__tests__/limitAction.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import test from 'node:test'
import assert from 'node:assert/strict'

const { getLimitActionRequest, getLimitSubject, toLimitAction } = await import('../limitAction.js')
const { PREVIEW_LIMIT_ERROR_CODES } = await import('../../map-projects/previewLimits.js')

const ACTION = {kind: 'example', url: 'https://online.example.org/next', label: 'Next step', message: 'A message from the service.'}

test('no online-api URL or no token: no request', () => {
assert.equal(getLimitActionRequest({baseURL: '', token: 'personal-token', errorCode: 'mapper_access_denied', lang: 'en'}), null)
assert.equal(getLimitActionRequest({baseURL: undefined, token: 'personal-token'}), null)
assert.equal(getLimitActionRequest({baseURL: 'https://online.example.org', token: undefined}), null)
})

test('asks about the refused capability in the UI language, with the person\'s token', () => {
assert.deepEqual(
getLimitActionRequest({baseURL: 'https://online.example.org/', token: 'personal-token', errorCode: 'mapper_rows_per_project_not_entitled', lang: 'pt-BR'}),
{
url: 'https://online.example.org/billing/plan/',
params: {context: 'limit', capability: 'mapper.rows_per_project', lang: 'pt-BR'},
headers: {Authorization: 'Token personal-token'},
}
)
})

test('an access check asks about its permission', () => {
assert.deepEqual(
getLimitActionRequest({baseURL: 'https://online.example.org', token: 'personal-token', errorCode: 'mapper_scispacy_denied', lang: 'en'}).params,
{context: 'limit', permission: 'users.mapper_scispacy', lang: 'en'}
)
})

test('every refusal the dialogs show names one capability or permission', () => {
[
'mapper_match_operations_limit_reached',
'ai_assistant_calls_limit_reached',
'ai_assistant_change_comments_limit_reached',
'mapper_projects_limit_reached',
'mapper_rows_per_project_limit_reached',
...PREVIEW_LIMIT_ERROR_CODES,
].forEach(errorCode => assert.equal(Object.keys(getLimitSubject(errorCode)).length, 1, errorCode))
assert.deepEqual(getLimitSubject('validation_error'), {})
})

test('an action with a link and a label is shown as sent', () => {
assert.deepEqual(toLimitAction({action: ACTION}), ACTION)
assert.deepEqual(toLimitAction({action: {...ACTION, message: null}}), {...ACTION, message: null})
assert.equal(toLimitAction({action: {...ACTION, message: ' '}}).message, null)
})

test('no action, or one that is not a usable link, shows nothing', () => {
assert.equal(toLimitAction({action: null}), null)
assert.equal(toLimitAction(undefined), null)
assert.equal(toLimitAction({action: {...ACTION, label: ''}}), null)
assert.equal(toLimitAction({action: {...ACTION, url: 'not a url'}}), null)
assert.equal(toLimitAction({action: {...ACTION, url: 'javascript:void(0)'}}), null)
assert.equal(toLimitAction({action: {...ACTION, url: 'data:text/html,hi'}}), null)
assert.equal(toLimitAction({action: {...ACTION, url: 42}}), null)
})
4 changes: 2 additions & 2 deletions src/components/common/__tests__/quotaErrors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { getQuotaError, isCapMeter, isQuotaError } from '../quotaErrors.js'

test('getQuotaError: maps quota and cap error codes to a meter and kind', () => {
assert.deepEqual(getQuotaError({error_code: 'mapper_match_operations_limit_reached', limit: 6, used: 6}), {
meter: 'match_operations', kind: 'quota', errorCode: 'mapper_match_operations_limit_reached', usage: {used: 6, limit: 6},
meter: 'match_operations', kind: 'quota', errorCode: 'mapper_match_operations_limit_reached',
})
assert.equal(getQuotaError({error_code: 'ai_assistant_calls_limit_reached'}).meter, 'ai_assistant_calls')
assert.equal(getQuotaError({error_code: 'ai_assistant_change_comments_limit_reached'}).meter, 'ai_assistant_change_comments')
Expand All @@ -15,7 +15,7 @@ test('getQuotaError: maps quota and cap error codes to a meter and kind', () =>

test('getQuotaError: unwraps axios error responses', () => {
const error = {response: {status: 403, data: {error_code: 'mapper_match_operations_limit_reached', limit: 3, used: 3}}}
assert.deepEqual(getQuotaError(error).usage, {used: 3, limit: 3})
assert.equal(getQuotaError(error).errorCode, 'mapper_match_operations_limit_reached')
})

test('getQuotaError: ignores permission errors and unrelated payloads', () => {
Expand Down
50 changes: 50 additions & 0 deletions src/components/common/limitAction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// A limit refusal can come with a next step from the connected OCL Online
// service (OpenConceptLab/ocl_online#367). Open code shows only what the
// service sends; with no service configured it shows nothing.

// What each refusal is about, as the service names it: the capability for a
// limit, the permission for an access check.
const LIMIT_SUBJECTS = {
mapper_match_operations_limit_reached: {capability: 'mapper.match_operations'},
mapper_match_operations_not_entitled: {capability: 'mapper.match_operations'},
mapper_rows_per_project_limit_reached: {capability: 'mapper.rows_per_project'},
mapper_rows_per_project_not_entitled: {capability: 'mapper.rows_per_project'},
mapper_projects_limit_reached: {capability: 'mapper.projects'},
mapper_projects_not_entitled: {capability: 'mapper.projects'},
ai_assistant_calls_limit_reached: {capability: 'ai_assistant.calls'},
ai_assistant_calls_not_entitled: {capability: 'ai_assistant.calls'},
ai_assistant_change_comments_limit_reached: {capability: 'ai_assistant.change_comments'},
mapper_access_denied: {permission: 'users.mapper_use'},
mapper_org_projects_denied: {permission: 'users.mapper_org_projects'},
mapper_custom_algorithms_denied: {permission: 'users.mapper_custom_algorithms'},
mapper_ai_assistant_denied: {permission: 'users.mapper_ai_assistant'},
mapper_scispacy_denied: {permission: 'users.mapper_scispacy'},
}

export const getLimitSubject = errorCode => LIMIT_SUBJECTS[errorCode] || {}

export const getLimitActionRequest = ({baseURL, token, errorCode, lang}) => {
if(!baseURL || !token)
return null
return {
url: `${baseURL.replace(/\/+$/, '')}/billing/plan/`,
params: {context: 'limit', ...getLimitSubject(errorCode), lang},
headers: {Authorization: `Token ${token}`},
}
}

// Only an http(s) link with a label becomes a button.
export const toLimitAction = data => {
const { kind, url, label, message } = data?.action || {}
if(typeof label !== 'string' || !label.trim() || typeof url !== 'string')
return null
let href
try {
href = new URL(url)
} catch(_) {
return null
}
if(!['https:', 'http:'].includes(href.protocol))
return null
return {kind, url: href.href, label, message: typeof message === 'string' && message.trim() ? message : null}
}
6 changes: 1 addition & 5 deletions src/components/common/quotaErrors.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
// Community-site pages (ocl_online#170).
export const QUOTA_PRICING_URL = 'https://openconceptlab.org/pricing'
export const REQUEST_MORE_ACCESS_URL = 'https://openconceptlab.org/early-access?source=mapper-limit'

const QUOTA_ERRORS = {
mapper_match_operations_limit_reached: {meter: 'match_operations', kind: 'quota'},
ai_assistant_calls_limit_reached: {meter: 'ai_assistant_calls', kind: 'quota'},
Expand All @@ -19,7 +15,7 @@ export const getQuotaError = value => {
const config = QUOTA_ERRORS[data?.error_code]
if(!config)
return null
return {...config, errorCode: data.error_code, usage: {used: data.used, limit: data.limit}}
return {...config, errorCode: data.error_code}
}

export const isQuotaError = value => Boolean(getQuotaError(value))
43 changes: 43 additions & 0 deletions src/components/common/useLimitAction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/*eslint no-process-env: 0*/
import React from 'react'
import axios from 'axios'
import { useTranslation } from 'react-i18next'

import GAService from '../../services/GAService'
import { currentUserToken } from '../../common/utils'
import { getLimitActionRequest, getLimitSubject, toLimitAction } from './limitAction'

/*eslint no-undef: 0*/
const getOnlineAPIURL = () => window.OCL_ONLINE_API_URL || process.env.OCL_ONLINE_API_URL

// Records the refusal and, when online-api is configured, asks it for the
// person's next step. Not configured, failing or nothing to offer: no action.
// axios directly, not APIService, so online-api's errors skip its sign-out and throttling handling.
const useLimitAction = (open, errorCode) => {
const { i18n } = useTranslation()
const [action, setAction] = React.useState(null)
const subject = getLimitSubject(errorCode)

React.useEffect(() => {
setAction(null)
if(!open)
return undefined
GAService.recordActionEvent('Limit', 'limit_hit', errorCode, subject)
const request = getLimitActionRequest({baseURL: getOnlineAPIURL(), token: currentUserToken(), errorCode, lang: i18n.language})
if(!request)
return undefined
let current = true
axios.get(request.url, {params: request.params, headers: request.headers, timeout: 10000})
.then(response => current && setAction(toLimitAction(response.data)))
.catch(() => {})
return () => {
current = false
}
}, [open, errorCode])

const onActionClick = () => GAService.recordActionEvent('Limit', 'limit_action_click', errorCode, {...subject, action_kind: action?.kind})

return {action, onActionClick}
}

export default useLimitAction
26 changes: 9 additions & 17 deletions src/components/map-projects/MapProject.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,8 @@ const MapProject = () => {
const ownNavigationRef = React.useRef(false)
const refreshRowStageSnapshotRef = React.useRef({});

const [previewLimit, setPreviewLimit] = React.useState(null) // {errorCode, limit, used} or null
const previewLimitQuota = previewLimit ? getQuotaError({error_code: previewLimit.errorCode, limit: previewLimit.limit, used: previewLimit.used}) : null
const [previewLimit, setPreviewLimit] = React.useState(null) // {errorCode} or null
const previewLimitQuota = previewLimit ? getQuotaError({error_code: previewLimit.errorCode}) : null

const [row, setRow] = React.useState(false)
const [loadingMatches, setLoadingMatches] = React.useState(false)
Expand Down Expand Up @@ -809,11 +809,6 @@ const MapProject = () => {
? getNewProjectBlockReason(getMapperPreview())
: null

React.useEffect(() => {
if(newProjectBlockReason)
baseSetAlert({severity: 'error', message: t(`map_project.preview_limit_title_${newProjectBlockReason}`), duration: 8000})
}, [newProjectBlockReason])

React.useEffect(() => {
const isDefaultApplied = isRepoDefaultFilterApplied(repoVersion)
if(project?.id)
Expand Down Expand Up @@ -1827,7 +1822,7 @@ const MapProject = () => {
// The user has left the project, so no dialog can show: say so once.
baseSetAlert({severity: 'error', message: t('map_project.leave_save_failed', {name: name || project?.name, detail: errorData?.detail || t('unknown_error')}), duration: 12000})
} else if(status === 403 && errorData?.error_code) {
setPreviewLimit({errorCode: errorData.error_code, limit: errorData.limit, used: errorData.used})
setPreviewLimit({errorCode: errorData.error_code})
} else if(!isAutoSave && status !== 429 && status !== 401) {
baseSetAlert({severity: 'error', message: errorData?.detail || t('unknown_error'), duration: 8000})
}
Expand Down Expand Up @@ -2073,7 +2068,7 @@ const MapProject = () => {
abortRef.current = true
setLoadingMatches(false)
setIsLoadingInDecisionView(false)
setPreviewLimit({errorCode: data.error_code, limit: data.limit, used: data.used})
setPreviewLimit({errorCode: data.error_code})
return
}
projectLog({action: 'automatch_run_create_failed', extras: {status: status || 'no-id', ...(result.ok ? {} : {reason: result.reason, error: result.error?.message || null})}})
Expand Down Expand Up @@ -4003,7 +3998,7 @@ const MapProject = () => {
if(isNumber(rowId))
refreshMapperQuotaCache()
}
setPreviewLimit({errorCode: response.error_code, limit: response.limit, used: response.used})
setPreviewLimit({errorCode: response.error_code})
return true
}

Expand Down Expand Up @@ -6214,10 +6209,9 @@ const MapProject = () => {
const onCopyClick = event => {
event.preventDefault()
event.stopPropagation()
const preview = getMapperPreview()
const blockReason = getNewProjectBlockReason(preview)
const blockReason = getNewProjectBlockReason()
if(blockReason) {
setPreviewLimit({errorCode: NEW_PROJECT_BLOCK_ERROR_CODES[blockReason], limit: preview.projects.limit, used: preview.projects.used})
setPreviewLimit({errorCode: NEW_PROJECT_BLOCK_ERROR_CODES[blockReason]})
return
}
if(project?.url) {
Expand All @@ -6234,7 +6228,7 @@ const MapProject = () => {
return minFactor
}

return permissionDenied ? <Error403/> : newProjectBlockReason ? <Redirect to='/' /> : (
return permissionDenied ? <Error403/> : newProjectBlockReason ? <Redirect to={{pathname: '/', state: {newProjectBlock: newProjectBlockReason}}} /> : (
<div className='col-xs-12 padding-0' style={{borderRadius: '10px', width: 'calc(100vw - 32px)'}}>
{
(() => {
Expand Down Expand Up @@ -6688,16 +6682,14 @@ const MapProject = () => {
<QuotaDialog
open
onClose={() => setPreviewLimit(null)}
errorCode={previewLimitQuota.errorCode}
meter={previewLimitQuota.meter}
surface={MAPPER_QUOTA_SURFACE[previewLimitQuota.meter]}
usage={{...previewLimitQuota.usage, period: 'one_time'}}
/> :
<PreviewLimitDialog
open={Boolean(previewLimit)}
onClose={() => setPreviewLimit(null)}
errorCode={previewLimit?.errorCode}
limit={previewLimit?.limit}
used={previewLimit?.used}
/>
}
</Paper>
Expand Down
Loading
Loading