diff --git a/src/components/common/QuotaDialog.jsx b/src/components/common/QuotaDialog.jsx
index b3b0f53c..8fb7004b 100644
--- a/src/components/common/QuotaDialog.jsx
+++ b/src/components/common/QuotaDialog.jsx
@@ -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: , 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 (
)
diff --git a/src/components/common/__tests__/limitAction.test.js b/src/components/common/__tests__/limitAction.test.js
new file mode 100644
index 00000000..814f2161
--- /dev/null
+++ b/src/components/common/__tests__/limitAction.test.js
@@ -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)
+})
diff --git a/src/components/common/__tests__/quotaErrors.test.js b/src/components/common/__tests__/quotaErrors.test.js
index 3e54c8fc..1f041c46 100644
--- a/src/components/common/__tests__/quotaErrors.test.js
+++ b/src/components/common/__tests__/quotaErrors.test.js
@@ -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')
@@ -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', () => {
diff --git a/src/components/common/limitAction.js b/src/components/common/limitAction.js
new file mode 100644
index 00000000..1871ba85
--- /dev/null
+++ b/src/components/common/limitAction.js
@@ -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}
+}
diff --git a/src/components/common/quotaErrors.js b/src/components/common/quotaErrors.js
index 8399554d..d588b41c 100644
--- a/src/components/common/quotaErrors.js
+++ b/src/components/common/quotaErrors.js
@@ -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'},
@@ -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))
diff --git a/src/components/common/useLimitAction.js b/src/components/common/useLimitAction.js
new file mode 100644
index 00000000..bd90292c
--- /dev/null
+++ b/src/components/common/useLimitAction.js
@@ -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
diff --git a/src/components/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx
index c7f53d7b..61c8a1e8 100644
--- a/src/components/map-projects/MapProject.jsx
+++ b/src/components/map-projects/MapProject.jsx
@@ -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)
@@ -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)
@@ -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})
}
@@ -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})}})
@@ -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
}
@@ -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) {
@@ -6234,7 +6228,7 @@ const MapProject = () => {
return minFactor
}
- return permissionDenied ? : newProjectBlockReason ? : (
+ return permissionDenied ? : newProjectBlockReason ? : (
{
(() => {
@@ -6688,16 +6682,14 @@ const MapProject = () => {
setPreviewLimit(null)}
+ errorCode={previewLimitQuota.errorCode}
meter={previewLimitQuota.meter}
surface={MAPPER_QUOTA_SURFACE[previewLimitQuota.meter]}
- usage={{...previewLimitQuota.usage, period: 'one_time'}}
/> :
setPreviewLimit(null)}
errorCode={previewLimit?.errorCode}
- limit={previewLimit?.limit}
- used={previewLimit?.used}
/>
}
diff --git a/src/components/map-projects/MapProjects.jsx b/src/components/map-projects/MapProjects.jsx
index 3eac57df..e9e92cd8 100644
--- a/src/components/map-projects/MapProjects.jsx
+++ b/src/components/map-projects/MapProjects.jsx
@@ -99,6 +99,15 @@ const MapProjects = () => {
fetchProjects()
}, [])
+ // MapProject sends a blocked /map-projects/new here: show the same dialog once.
+ React.useEffect(() => {
+ const reason = history.location.state?.newProjectBlock
+ if(reason) {
+ setNewProjectBlock({reason})
+ history.replace({...history.location, state: undefined})
+ }
+ }, [])
+
const onProjectDelete = (success) => {
if(success === true) {
setProjects(reject(projects, {id: deleteProject.id}))
@@ -109,7 +118,7 @@ const MapProjects = () => {
const startNewProject = templateURL => {
const goToNewProject = () => history.push(templateURL ? `/map-projects/new?templateFrom=${encodeURIComponent(templateURL)}` : '/map-projects/new')
const reasonFromCache = () => getNewProjectBlockReason(getMapperPreview())
- const showBlock = reason => setNewProjectBlock({reason, preview: getMapperPreview()})
+ const showBlock = reason => setNewProjectBlock({reason})
const cachedReason = reasonFromCache()
if(cachedReason) {
@@ -342,9 +351,9 @@ const MapProjects = () => {
setNewProjectBlock(null)}
+ errorCode='mapper_projects_limit_reached'
meter='projects'
surface='new_project'
- usage={{used: newProjectBlock.preview.projects.used, limit: newProjectBlock.preview.projects.limit, period: 'one_time'}}
/> :
{
+// Says what the account can't use. Any next step comes from the connected
+// service (OpenConceptLab/ocl_online#367) and opens in a new tab.
+const PreviewLimitDialog = ({open, onClose, errorCode}) => {
const { t } = useTranslation()
+ const { action, onActionClick } = useLimitAction(open, errorCode)
const kind = PREVIEW_LIMIT_COPY_KEY[errorCode] || 'generic'
return (