From eac6b2da2295b7269530ce986f33ef74dc36b4ae Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 27 Oct 2026 13:04:59 -0400 Subject: [PATCH 1/3] OpenConceptLab/ocl_online#367 | Limit dialogs show only the next step online-api sends When QuotaDialog or PreviewLimitDialog opens, the Mapper asks the configured online-api (OCL_ONLINE_API_URL, already used for match algorithms) for a next step (GET /billing/plan/?context=limit, with the person's token and UI language) and, if one comes back, shows its message and a link button that opens in a new tab. With the setting unset, an error or no action, the dialogs show only what was refused. - The dialogs no longer carry links of their own. PreviewLimitDialog says in neutral words what the account can't use, in all four languages, and the new-project alert uses the same strings. - The call goes through axios rather than APIService, so online-api's responses can't trigger the sign-out or the throttling screen. - GA: limit_hit when a dialog opens, limit_action_click on the button. Refs OpenConceptLab/ocl_online#325, OpenConceptLab/ocl_online#311 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AaZ4qVXboeBSRvAQgQjdbs --- src/components/common/QuotaDialog.jsx | 48 ++++++++--------- .../common/__tests__/limitAction.test.js | 52 +++++++++++++++++++ src/components/common/limitAction.js | 49 +++++++++++++++++ src/components/common/quotaErrors.js | 4 -- src/components/common/useLimitAction.js | 43 +++++++++++++++ src/components/map-projects/MapProject.jsx | 8 +-- src/components/map-projects/MapProjects.jsx | 3 +- .../map-projects/PreviewLimitDialog.jsx | 45 +++++++--------- .../__tests__/previewLimits.test.js | 13 +++++ src/i18n/__tests__/aiAssistantNaming.test.js | 4 +- src/i18n/locales/en/translations.json | 50 ++++-------------- src/i18n/locales/es/translations.json | 50 ++++-------------- src/i18n/locales/pt-BR/translations.json | 50 ++++-------------- src/i18n/locales/zh/translations.json | 50 ++++-------------- 14 files changed, 250 insertions(+), 219 deletions(-) create mode 100644 src/components/common/__tests__/limitAction.test.js create mode 100644 src/components/common/limitAction.js create mode 100644 src/components/common/useLimitAction.js diff --git a/src/components/common/QuotaDialog.jsx b/src/components/common/QuotaDialog.jsx index b3b0f53c..567d9913 100644 --- a/src/components/common/QuotaDialog.jsx +++ b/src/components/common/QuotaDialog.jsx @@ -10,15 +10,16 @@ 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, usage}) => { const { t } = useTranslation() + const { action, onActionClick } = useLimitAction(open, errorCode) const isCap = isCapMeter(meter) - const { used, limit, period } = usage || {} + const { used, limit } = 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'}}} return ( @@ -31,15 +32,6 @@ const QuotaDialog = ({open, onClose, meter, surface, plan, usage}) => { {t([`quota.consequence.${meter}.${surface}`, `quota.consequence.${meter}.default`, 'quota.consequence.generic'])}
- { - plan && -
- - {t('quota.your_plan')} - - {plan} -
- }
{t(`quota.meter.${meter}`, {defaultValue: t('quota.meter.generic')})} { @@ -48,22 +40,30 @@ const QuotaDialog = ({open, onClose, meter, surface, plan, usage}) => { }
- { - !isCap && period === 'one_time' && - {t('quota.period_one_time')} - }
+ { + action?.message && + {action.message} + } - - + { + action && + + }
) diff --git a/src/components/common/__tests__/limitAction.test.js b/src/components/common/__tests__/limitAction.test.js new file mode 100644 index 00000000..05ccbfd1 --- /dev/null +++ b/src/components/common/__tests__/limitAction.test.js @@ -0,0 +1,52 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +const { getLimitActionRequest, getLimitCapability, 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('every refusal the dialogs show names a capability', () => { + [ + '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.ok(getLimitCapability(errorCode), errorCode)) + assert.equal(getLimitCapability('validation_error'), undefined) +}) + +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/limitAction.js b/src/components/common/limitAction.js new file mode 100644 index 00000000..48509cc5 --- /dev/null +++ b/src/components/common/limitAction.js @@ -0,0 +1,49 @@ +// 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. + +// The capability each refusal is about, as the service names it. +const LIMIT_CAPABILITIES = { + mapper_match_operations_limit_reached: 'mapper.match_operations', + mapper_match_operations_not_entitled: 'mapper.match_operations', + mapper_rows_per_project_limit_reached: 'mapper.rows_per_project', + mapper_rows_per_project_not_entitled: 'mapper.rows_per_project', + mapper_projects_limit_reached: 'mapper.projects', + mapper_projects_not_entitled: 'mapper.projects', + ai_assistant_calls_limit_reached: 'ai_assistant.calls', + ai_assistant_calls_not_entitled: 'ai_assistant.calls', + ai_assistant_change_comments_limit_reached: 'ai_assistant.change_comments', + mapper_access_denied: 'users.mapper_use', + mapper_org_projects_denied: 'users.mapper_org_projects', + mapper_custom_algorithms_denied: 'users.mapper_custom_algorithms', + mapper_ai_assistant_denied: 'users.mapper_ai_assistant', + mapper_scispacy_denied: 'users.mapper_scispacy', +} + +export const getLimitCapability = errorCode => LIMIT_CAPABILITIES[errorCode] + +export const getLimitActionRequest = ({baseURL, token, errorCode, lang}) => { + if(!baseURL || !token) + return null + return { + url: `${baseURL.replace(/\/+$/, '')}/billing/plan/`, + params: {context: 'limit', capability: getLimitCapability(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..1d3d24e3 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'}, diff --git a/src/components/common/useLimitAction.js b/src/components/common/useLimitAction.js new file mode 100644 index 00000000..c26b0b7d --- /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, getLimitCapability, 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 capability = getLimitCapability(errorCode) + + React.useEffect(() => { + setAction(null) + if(!open) + return undefined + GAService.recordActionEvent('Limit', 'limit_hit', errorCode, {capability}) + 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, {capability, 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..310ea657 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -151,6 +151,7 @@ import '../common/ResizablePanel.scss' // } const NEW_PROJECT_BLOCK_ERROR_CODES = {access: 'mapper_access_denied', projects_not_entitled: 'mapper_projects_not_entitled', projects: 'mapper_projects_limit_reached'} +const NEW_PROJECT_BLOCK_MESSAGES = {access: 'map_project.limit_unavailable_body_access', projects_not_entitled: 'map_project.limit_unavailable_body_projects_not_entitled', projects: 'quota.consequence.projects.new_project'} const MAPPER_QUOTA_SURFACE = {match_operations: 'auto_match', ai_assistant_calls: 'auto_match', projects: 'new_project', rows: 'import'} const MapProject = () => { @@ -811,7 +812,7 @@ const MapProject = () => { React.useEffect(() => { if(newProjectBlockReason) - baseSetAlert({severity: 'error', message: t(`map_project.preview_limit_title_${newProjectBlockReason}`), duration: 8000}) + baseSetAlert({severity: 'error', message: t(NEW_PROJECT_BLOCK_MESSAGES[newProjectBlockReason]), duration: 8000}) }, [newProjectBlockReason]) React.useEffect(() => { @@ -6688,16 +6689,15 @@ const MapProject = () => { setPreviewLimit(null)} + errorCode={previewLimitQuota.errorCode} meter={previewLimitQuota.meter} surface={MAPPER_QUOTA_SURFACE[previewLimitQuota.meter]} - usage={{...previewLimitQuota.usage, period: 'one_time'}} + usage={previewLimitQuota.usage} /> : 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..011d6c6d 100644 --- a/src/components/map-projects/MapProjects.jsx +++ b/src/components/map-projects/MapProjects.jsx @@ -342,9 +342,10 @@ 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'}} + usage={{used: newProjectBlock.preview.projects.used, limit: newProjectBlock.preview.projects.limit}} /> : { +// 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 ( - {t(`map_project.preview_limit_title_${kind}`)} + {t('map_project.limit_unavailable_title')} - - { - (limit || limit === 0) ? - t(`map_project.preview_limit_body_${kind}`, {limit, used}) : - t(`map_project.preview_limit_body_${kind}_no_count`) - } - - - {t('map_project.preview_limit_hardship_note')} + + {t([`map_project.limit_unavailable_body_${kind}`, 'map_project.limit_unavailable_body_generic'])} + { + action?.message && + {action.message} + } - - + { + action && + + } diff --git a/src/components/map-projects/__tests__/previewLimits.test.js b/src/components/map-projects/__tests__/previewLimits.test.js index aa362b20..15772b98 100644 --- a/src/components/map-projects/__tests__/previewLimits.test.js +++ b/src/components/map-projects/__tests__/previewLimits.test.js @@ -1,5 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { getPreviewLimitError, @@ -50,3 +51,15 @@ test('AI preview errors use AI Assistant dialog copy', () => { assert.equal(isAIPreviewLimitError('ai_assistant_calls_limit_reached'), true) assert.equal(isAIPreviewLimitError('mapper_match_operations_limit_reached'), false) }) + +// Limits reached go to QuotaDialog; PreviewLimitDialog shows the rest. +test('every refusal PreviewLimitDialog shows has its own message in each language', () => { + for (const locale of ['en', 'es', 'pt-BR', 'zh']) { + const catalog = JSON.parse(readFileSync(new URL(`../../../i18n/locales/${locale}/translations.json`, import.meta.url), 'utf8')).map_project + const missing = Object.entries(PREVIEW_LIMIT_COPY_KEY) + .filter(([errorCode]) => !errorCode.endsWith('_limit_reached')) + .map(([, kind]) => `limit_unavailable_body_${kind}`) + .filter(key => !catalog[key]) + assert.deepEqual(missing, [], locale) + } +}) diff --git a/src/i18n/__tests__/aiAssistantNaming.test.js b/src/i18n/__tests__/aiAssistantNaming.test.js index c714e4e9..a0a59714 100644 --- a/src/i18n/__tests__/aiAssistantNaming.test.js +++ b/src/i18n/__tests__/aiAssistantNaming.test.js @@ -28,9 +28,7 @@ const NAMES_THE_ASSISTANT = [ 'input_language_helper', 'ai_assistant', 'preview_quota_ai_calls', - 'preview_limit_title_ai_assistant', - 'preview_limit_body_ai_assistant', - 'preview_limit_body_ai_assistant_no_count', + 'limit_unavailable_body_ai_assistant', 'column_send_to_ai_assistant', 'ai_prompt_template_description', 'set_ai_assistant_output_language', diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 40e03d63..ea4d7df1 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -317,40 +317,16 @@ "preview_quota_ai_calls": "OCL AI Assistant calls", "preview_quota_used_of_limit": "{{used}} of {{limit}} used", "preview_rows_activated_notice": "Preview projects hold {{limit}} rows - we activated the first {{limit}} of your {{total}}.", - "preview_limit_title_rows": "You've used this project's preview rows", - "preview_limit_title_match_operations": "You've used your preview match operations", - "preview_limit_title_projects": "The preview covers one project", - "preview_limit_title_org_projects": "Organization projects aren't in the preview", - "preview_limit_title_access": "Mapper access is limited right now", - "preview_limit_title_custom_algorithms": "Externally hosted algorithms aren't in the preview", - "preview_limit_title_ai_assistant": "The OCL AI Assistant isn't available yet", - "preview_limit_title_scispacy": "ScispaCy LOINC isn't available yet", - "preview_limit_title_rows_not_entitled": "This project's rows aren't part of your preview yet", - "preview_limit_title_match_operations_not_entitled": "Match operations aren't part of your account yet", - "preview_limit_title_projects_not_entitled": "Map projects aren't part of your account yet", - "preview_limit_title_generic": "You've reached a preview limit", - "preview_limit_body_rows": "Your free preview covers {{limit}} rows matched by AutoMatch for this project - you've used all {{limit}}. Nothing is locked: keep reviewing, editing and exporting this project as much as you like.", - "preview_limit_body_rows_no_count": "Your free preview's row limit for this project has been reached. Nothing is locked: keep reviewing, editing and exporting this project as much as you like.", - "preview_limit_body_match_operations": "Your free preview covers {{limit}} match operations - you've used all {{limit}}. Nothing is locked: keep reviewing, editing and exporting this project as much as you like.", - "preview_limit_body_match_operations_no_count": "Your free preview's match-operation limit has been reached. Nothing is locked: keep reviewing, editing and exporting this project as much as you like.", - "preview_limit_body_projects": "You already have an active Mapper project. The free preview covers one project at a time - delete the one you have, or ask about more access below.", - "preview_limit_body_projects_no_count": "You already have an active Mapper project. The free preview covers one project at a time.", - "preview_limit_body_org_projects": "Organization-owned map projects aren't part of the preview yet. Create a project under your own account instead, or ask about more access below.", - "preview_limit_body_org_projects_no_count": "Organization-owned map projects aren't part of the preview yet.", - "preview_limit_body_access": "Your account doesn't currently have Mapper access. Ask about more access below and we'll sort it out.", - "preview_limit_body_access_no_count": "Your account doesn't currently have Mapper access.", - "preview_limit_body_custom_algorithms_no_count": "Externally hosted algorithms aren't part of the preview yet. Use OCL's built-in algorithms, or ask about more access below.", - "preview_limit_body_ai_assistant": "Your free preview covers {{limit}} OCL AI Assistant calls - you've used {{used}} of {{limit}}. Matching can continue without AI analysis for the remaining rows.", - "preview_limit_body_ai_assistant_no_count": "The OCL AI Assistant isn't available for your account. Ask about more access below and we'll sort it out.", - "preview_limit_body_scispacy_no_count": "ScispaCy LOINC matching isn't available for your account. Ask about more access below and we'll sort it out.", - "preview_limit_body_rows_not_entitled_no_count": "This project doesn't have a row allowance configured yet. Ask about more access below and we'll sort it out.", - "preview_limit_body_match_operations_not_entitled_no_count": "Your account doesn't have match-operations access configured yet. Ask about more access below and we'll sort it out.", - "preview_limit_body_projects_not_entitled_no_count": "Your account doesn't have map-project access configured yet. Ask about more access below and we'll sort it out.", - "preview_limit_body_generic": "You've reached a preview limit ({{used}} of {{limit}}).", - "preview_limit_body_generic_no_count": "You've reached a preview limit for the Mapper.", - "preview_limit_hardship_note": "Working on a grant budget that doesn't stretch, or for a ministry of health? Use \"Request more access\" below - if the limit doesn't work for your situation, we'll find something that does.", - "preview_limit_notify_me": "Notify me when plans launch", - "preview_limit_request_access": "Request more access", + "limit_unavailable_title": "Not available for your account", + "limit_unavailable_body_access": "Your account doesn't have access to the Mapper.", + "limit_unavailable_body_org_projects": "Your account can't create map projects owned by an organization. You can create one under your own account instead.", + "limit_unavailable_body_custom_algorithms": "Your account can't use externally hosted algorithms. You can use OCL's built-in algorithms instead.", + "limit_unavailable_body_ai_assistant": "The OCL AI Assistant isn't available for your account.", + "limit_unavailable_body_scispacy": "ScispaCy LOINC matching isn't available for your account.", + "limit_unavailable_body_rows_not_entitled": "Your account can't run AutoMatch on this project's rows.", + "limit_unavailable_body_match_operations_not_entitled": "Your account can't run AutoMatch.", + "limit_unavailable_body_projects_not_entitled": "Your account can't create map projects.", + "limit_unavailable_body_generic": "This isn't available for your account.", "preview_limit_dismiss": "Back to my project", "preview_config_unavailable_title": "This project uses preview-restricted settings", "preview_config_unavailable_org_projects": "Organization-owned Mapper projects are not part of your preview. Choose your personal namespace as the project owner.", @@ -594,11 +570,7 @@ "quota": { "title_quota": "You've reached your quota", "title_cap": "You've reached your limit", - "your_plan": "Your plan", "used_of": "{{used}} of {{limit}} used", - "period_one_time": "One-time preview allowance", - "request_more_access": "Request more access", - "view_pricing": "View pricing", "meter": { "match_operations": "Match operations", "ai_assistant_calls": "OCL AI Assistant calls", @@ -608,7 +580,7 @@ "generic": "Usage" }, "consequence": { - "generic": "Some features are paused until your allowance is increased.", + "generic": "Some features are paused.", "ai_assistant_calls": { "default": "Features that use the OCL AI Assistant are paused.", "auto_match": "AutoMatch will keep running without recommendations from the OCL AI Assistant." diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 3ef8477f..fc017227 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -301,40 +301,16 @@ "preview_quota_ai_calls": "Llamadas al asistente de IA de OCL", "preview_quota_used_of_limit": "{{used}} de {{limit}} usados", "preview_rows_activated_notice": "Los proyectos de vista previa contienen {{limit}} filas: activamos las primeras {{limit}} de tus {{total}}.", - "preview_limit_title_rows": "Has usado las filas de vista previa de este proyecto", - "preview_limit_title_match_operations": "Has usado tus operaciones de coincidencia de vista previa", - "preview_limit_title_projects": "La vista previa cubre un proyecto", - "preview_limit_title_org_projects": "Los proyectos de organización no están en la vista previa", - "preview_limit_title_access": "El acceso al Mapper está limitado por ahora", - "preview_limit_title_custom_algorithms": "Los algoritmos externos no están en la vista previa", - "preview_limit_title_ai_assistant": "El asistente de IA de OCL aún no está disponible", - "preview_limit_title_scispacy": "ScispaCy LOINC aún no está disponible", - "preview_limit_title_rows_not_entitled": "Las filas de este proyecto aún no forman parte de tu vista previa", - "preview_limit_title_match_operations_not_entitled": "Las operaciones de coincidencia aún no forman parte de tu cuenta", - "preview_limit_title_projects_not_entitled": "Los proyectos de mapeo aún no forman parte de tu cuenta", - "preview_limit_title_generic": "Has alcanzado un límite de la vista previa", - "preview_limit_body_rows": "Tu vista previa gratuita cubre {{limit}} filas con AutoMatch para este proyecto: has usado las {{limit}}. Nada está bloqueado: sigue revisando, editando y exportando este proyecto tanto como quieras.", - "preview_limit_body_rows_no_count": "Se alcanzó el límite de filas de tu vista previa gratuita para este proyecto. Nada está bloqueado: sigue revisando, editando y exportando este proyecto tanto como quieras.", - "preview_limit_body_match_operations": "Tu vista previa gratuita cubre {{limit}} operaciones de coincidencia: has usado las {{limit}}. Nada está bloqueado: sigue revisando, editando y exportando este proyecto tanto como quieras.", - "preview_limit_body_match_operations_no_count": "Se alcanzó el límite de operaciones de coincidencia de tu vista previa gratuita. Nada está bloqueado: sigue revisando, editando y exportando este proyecto tanto como quieras.", - "preview_limit_body_projects": "Ya tienes un proyecto de Mapper activo. La vista previa gratuita cubre un proyecto a la vez: elimina el que tienes, o solicita más acceso abajo.", - "preview_limit_body_projects_no_count": "Ya tienes un proyecto de Mapper activo. La vista previa gratuita cubre un proyecto a la vez.", - "preview_limit_body_org_projects": "Los proyectos de mapeo propiedad de una organización aún no forman parte de la vista previa. Crea un proyecto con tu propia cuenta, o solicita más acceso abajo.", - "preview_limit_body_org_projects_no_count": "Los proyectos de mapeo propiedad de una organización aún no forman parte de la vista previa.", - "preview_limit_body_access": "Tu cuenta no tiene actualmente acceso al Mapper. Solicita más acceso abajo y lo resolveremos.", - "preview_limit_body_access_no_count": "Tu cuenta no tiene actualmente acceso al Mapper.", - "preview_limit_body_custom_algorithms_no_count": "Los algoritmos externos aún no forman parte de la vista previa. Usa los algoritmos integrados de OCL, o solicita más acceso abajo.", - "preview_limit_body_ai_assistant": "Tu vista previa gratuita cubre {{limit}} llamadas al asistente de IA de OCL: has usado {{used}} de {{limit}}. La asignación puede continuar sin análisis de IA para las filas restantes.", - "preview_limit_body_ai_assistant_no_count": "El asistente de IA de OCL no está disponible para tu cuenta. Solicita más acceso abajo y lo resolveremos.", - "preview_limit_body_scispacy_no_count": "La coincidencia con ScispaCy LOINC no está disponible para tu cuenta. Solicita más acceso abajo y lo resolveremos.", - "preview_limit_body_rows_not_entitled_no_count": "Este proyecto aún no tiene una asignación de filas configurada. Solicita más acceso abajo y lo resolveremos.", - "preview_limit_body_match_operations_not_entitled_no_count": "Tu cuenta aún no tiene acceso a operaciones de coincidencia configurado. Solicita más acceso abajo y lo resolveremos.", - "preview_limit_body_projects_not_entitled_no_count": "Tu cuenta aún no tiene acceso a proyectos de mapeo configurado. Solicita más acceso abajo y lo resolveremos.", - "preview_limit_body_generic": "Has alcanzado un límite de la vista previa ({{used}} de {{limit}}).", - "preview_limit_body_generic_no_count": "Has alcanzado un límite de la vista previa del Mapper.", - "preview_limit_hardship_note": "¿Trabajas con un presupuesto de subvención ajustado, o para un ministerio de salud? Usa \"Solicitar más acceso\" abajo: si el límite no funciona para tu situación, encontraremos algo que sí.", - "preview_limit_notify_me": "Avísame cuando se lancen los planes", - "preview_limit_request_access": "Solicitar más acceso", + "limit_unavailable_title": "No disponible para tu cuenta", + "limit_unavailable_body_access": "Tu cuenta no tiene acceso al Mapper.", + "limit_unavailable_body_org_projects": "Tu cuenta no puede crear proyectos de mapeo propiedad de una organización. Puedes crear uno con tu propia cuenta.", + "limit_unavailable_body_custom_algorithms": "Tu cuenta no puede usar algoritmos alojados externamente. Puedes usar los algoritmos integrados de OCL.", + "limit_unavailable_body_ai_assistant": "El asistente de IA de OCL no está disponible para tu cuenta.", + "limit_unavailable_body_scispacy": "La coincidencia con ScispaCy LOINC no está disponible para tu cuenta.", + "limit_unavailable_body_rows_not_entitled": "Tu cuenta no puede ejecutar AutoMatch en las filas de este proyecto.", + "limit_unavailable_body_match_operations_not_entitled": "Tu cuenta no puede ejecutar AutoMatch.", + "limit_unavailable_body_projects_not_entitled": "Tu cuenta no puede crear proyectos de mapeo.", + "limit_unavailable_body_generic": "Esto no está disponible para tu cuenta.", "preview_limit_dismiss": "Volver a mi proyecto", "preview_config_unavailable_title": "Este proyecto usa configuraciones restringidas de la vista previa", "preview_config_unavailable_org_projects": "Los proyectos de Mapper propiedad de una organización no forman parte de tu vista previa. Elige tu espacio de nombres personal como propietario del proyecto.", @@ -594,11 +570,7 @@ "quota": { "title_quota": "Ha alcanzado su cuota", "title_cap": "Ha alcanzado su límite", - "your_plan": "Su plan", "used_of": "{{used}} de {{limit}} usados", - "period_one_time": "Asignación de vista previa única", - "request_more_access": "Solicitar más acceso", - "view_pricing": "Ver precios", "meter": { "match_operations": "Operaciones de coincidencia", "ai_assistant_calls": "Llamadas al asistente de IA de OCL", @@ -608,7 +580,7 @@ "generic": "Uso" }, "consequence": { - "generic": "Algunas funciones están en pausa hasta que se amplíe su asignación.", + "generic": "Algunas funciones están en pausa.", "ai_assistant_calls": { "default": "Las funciones que usan el asistente de IA de OCL están en pausa.", "auto_match": "AutoMatch seguirá funcionando sin recomendaciones del asistente de IA de OCL." diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index ccf40067..f9592b06 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -317,40 +317,16 @@ "preview_quota_ai_calls": "Chamadas do Assistente de IA do OCL", "preview_quota_used_of_limit": "{{used}} de {{limit}} usado(s)", "preview_rows_activated_notice": "Projetos de prévia contêm {{limit}} linhas - ativamos as primeiras {{limit}} de suas {{total}}.", - "preview_limit_title_rows": "Você usou as linhas de prévia deste projeto", - "preview_limit_title_match_operations": "Você usou suas operações de correspondência de prévia", - "preview_limit_title_projects": "A prévia cobre um projeto", - "preview_limit_title_org_projects": "Projetos de organização não estão na prévia", - "preview_limit_title_access": "Acesso ao Mapper está limitado no momento", - "preview_limit_title_custom_algorithms": "Algoritmos hospedados externamente não estão na prévia", - "preview_limit_title_ai_assistant": "O Assistente de IA do OCL ainda não está disponível", - "preview_limit_title_scispacy": "ScispaCy LOINC ainda não está disponível", - "preview_limit_title_rows_not_entitled": "As linhas deste projeto não fazem parte de sua prévia ainda", - "preview_limit_title_match_operations_not_entitled": "Operações de correspondência não fazem parte de sua conta ainda", - "preview_limit_title_projects_not_entitled": "Projetos de mapeamento não fazem parte de sua conta ainda", - "preview_limit_title_generic": "Você atingiu um limite de prévia", - "preview_limit_body_rows": "Sua prévia gratuita cobre {{limit}} linhas com AutoMatch para este projeto - você usou todas as {{limit}}. Nada está bloqueado: continue revisando, editando e exportando este projeto quanto quiser.", - "preview_limit_body_rows_no_count": "O limite de linhas de sua prévia gratuita para este projeto foi atingido. Nada está bloqueado: continue revisando, editando e exportando este projeto quanto quiser.", - "preview_limit_body_match_operations": "Sua prévia gratuita cobre {{limit}} operações de correspondência - você usou todas as {{limit}}. Nada está bloqueado: continue revisando, editando e exportando este projeto quanto quiser.", - "preview_limit_body_match_operations_no_count": "O limite de operações de correspondência de sua prévia gratuita foi atingido. Nada está bloqueado: continue revisando, editando e exportando este projeto quanto quiser.", - "preview_limit_body_projects": "Você já tem um projeto do Mapper ativo. A prévia gratuita cobre um projeto por vez - exclua o que você tem, ou peça mais acesso abaixo.", - "preview_limit_body_projects_no_count": "Você já tem um projeto do Mapper ativo. A prévia gratuita cobre um projeto por vez.", - "preview_limit_body_org_projects": "Projetos de mapeamento pertencentes a organizações não fazem parte da prévia ainda. Crie um projeto sob sua própria conta, ou peça mais acesso abaixo.", - "preview_limit_body_org_projects_no_count": "Projetos de mapeamento pertencentes a organizações não fazem parte da prévia ainda.", - "preview_limit_body_access": "Sua conta não tem acesso ao Mapper no momento. Peça mais acesso abaixo e nós resolveremos.", - "preview_limit_body_access_no_count": "Sua conta não tem acesso ao Mapper no momento.", - "preview_limit_body_custom_algorithms_no_count": "Algoritmos hospedados externamente não fazem parte da prévia ainda. Use os algoritmos integrados do OCL, ou peça mais acesso abaixo.", - "preview_limit_body_ai_assistant": "Sua prévia gratuita cobre {{limit}} chamadas do Assistente de IA do OCL - você usou {{used}} de {{limit}}. A correspondência pode continuar sem análise de IA para as linhas restantes.", - "preview_limit_body_ai_assistant_no_count": "O Assistente de IA do OCL não está disponível para sua conta. Peça mais acesso abaixo e nós resolveremos.", - "preview_limit_body_scispacy_no_count": "A correspondência ScispaCy LOINC não está disponível para sua conta. Peça mais acesso abaixo e nós resolveremos.", - "preview_limit_body_rows_not_entitled_no_count": "Este projeto ainda não tem uma cota de linhas configurada. Peça mais acesso abaixo e nós resolveremos.", - "preview_limit_body_match_operations_not_entitled_no_count": "Sua conta não tem acesso a operações de correspondência configurado ainda. Peça mais acesso abaixo e nós resolveremos.", - "preview_limit_body_projects_not_entitled_no_count": "Sua conta não tem acesso a projetos de mapeamento configurado ainda. Peça mais acesso abaixo e nós resolveremos.", - "preview_limit_body_generic": "Você atingiu um limite de prévia ({{used}} de {{limit}}).", - "preview_limit_body_generic_no_count": "Você atingiu um limite de prévia para o Mapper.", - "preview_limit_hardship_note": "Trabalha com recursos de financiamento (grant) que não cobrem esse custo, ou para um ministério da saúde? Use \"Solicitar mais acesso\" abaixo - se o limite não funcionar para a sua situação, encontraremos algo que funcione.", - "preview_limit_notify_me": "Notifique-me quando os planos forem lançados", - "preview_limit_request_access": "Solicitar mais acesso", + "limit_unavailable_title": "Não disponível para sua conta", + "limit_unavailable_body_access": "Sua conta não tem acesso ao Mapper.", + "limit_unavailable_body_org_projects": "Sua conta não pode criar projetos de mapeamento pertencentes a organizações. Você pode criar um sob sua própria conta.", + "limit_unavailable_body_custom_algorithms": "Sua conta não pode usar algoritmos hospedados externamente. Você pode usar os algoritmos integrados do OCL.", + "limit_unavailable_body_ai_assistant": "O Assistente de IA do OCL não está disponível para sua conta.", + "limit_unavailable_body_scispacy": "A correspondência ScispaCy LOINC não está disponível para sua conta.", + "limit_unavailable_body_rows_not_entitled": "Sua conta não pode executar o AutoMatch nas linhas deste projeto.", + "limit_unavailable_body_match_operations_not_entitled": "Sua conta não pode executar o AutoMatch.", + "limit_unavailable_body_projects_not_entitled": "Sua conta não pode criar projetos de mapeamento.", + "limit_unavailable_body_generic": "Isto não está disponível para sua conta.", "preview_limit_dismiss": "Voltar ao meu projeto", "preview_config_unavailable_title": "Este projeto usa configurações restritas de prévia", "preview_config_unavailable_org_projects": "Projetos de Mapper pertencentes a organizações não fazem parte de sua prévia. Escolha seu namespace pessoal como proprietário do projeto.", @@ -594,11 +570,7 @@ "quota": { "title_quota": "Você atingiu sua cota", "title_cap": "Você atingiu seu limite", - "your_plan": "Seu plano", "used_of": "{{used}} de {{limit}} usado(s)", - "period_one_time": "Cota única de prévia", - "request_more_access": "Solicitar mais acesso", - "view_pricing": "Ver preços", "meter": { "match_operations": "Operações de correspondência", "ai_assistant_calls": "Chamadas do Assistente de IA do OCL", @@ -608,7 +580,7 @@ "generic": "Uso" }, "consequence": { - "generic": "Alguns recursos estão pausados até que sua cota seja aumentada.", + "generic": "Alguns recursos estão pausados.", "ai_assistant_calls": { "default": "Recursos que usam o Assistente de IA do OCL estão pausados.", "auto_match": "O AutoMatch continuará rodando sem recomendações do Assistente de IA do OCL." diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index acf56c82..778d5a9c 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -313,40 +313,16 @@ "preview_quota_ai_calls": "OCL AI 助手调用", "preview_quota_used_of_limit": "已用 {{used}} / {{limit}}", "preview_rows_activated_notice": "预览项目最多包含 {{limit}} 行——我们已激活你 {{total}} 行中的前 {{limit}} 行。", - "preview_limit_title_rows": "本项目的预览行数已用完", - "preview_limit_title_match_operations": "预览匹配操作次数已用完", - "preview_limit_title_projects": "预览版仅支持一个项目", - "preview_limit_title_org_projects": "组织项目不在预览范围内", - "preview_limit_title_access": "当前 Mapper 访问受限", - "preview_limit_title_custom_algorithms": "外部托管算法不在预览范围内", - "preview_limit_title_ai_assistant": "OCL AI 助手暂不可用", - "preview_limit_title_scispacy": "ScispaCy LOINC 暂不可用", - "preview_limit_title_rows_not_entitled": "本项目尚未开通预览行额度", - "preview_limit_title_match_operations_not_entitled": "你的账户尚未开通匹配操作权限", - "preview_limit_title_projects_not_entitled": "你的账户尚未开通映射项目权限", - "preview_limit_title_generic": "已达到预览版限制", - "preview_limit_body_rows": "你的免费预览版覆盖本项目通过 AutoMatch 匹配的 {{limit}} 行 —— 已全部用完。项目未被锁定:你可以继续查看、编辑和导出。", - "preview_limit_body_rows_no_count": "本项目的免费预览行数限制已达到。项目未被锁定:你可以继续查看、编辑和导出。", - "preview_limit_body_match_operations": "你的免费预览版覆盖 {{limit}} 次匹配操作 —— 已全部用完。项目未被锁定:你可以继续查看、编辑和导出。", - "preview_limit_body_match_operations_no_count": "免费预览版的匹配操作限额已达到。项目未被锁定:你可以继续查看、编辑和导出。", - "preview_limit_body_projects": "你已经有一个进行中的 Mapper 项目。免费预览版一次只支持一个项目 —— 删除现有项目,或在下方申请更多访问权限。", - "preview_limit_body_projects_no_count": "你已经有一个进行中的 Mapper 项目。免费预览版一次只支持一个项目。", - "preview_limit_body_org_projects": "组织拥有的映射项目暂未开放给预览版。请改用你的个人账户创建项目,或在下方申请更多访问权限。", - "preview_limit_body_org_projects_no_count": "组织拥有的映射项目暂未开放给预览版。", - "preview_limit_body_access": "你的账户目前没有 Mapper 访问权限。请在下方申请更多访问权限,我们会处理。", - "preview_limit_body_access_no_count": "你的账户目前没有 Mapper 访问权限。", - "preview_limit_body_custom_algorithms_no_count": "外部托管算法暂未开放给预览版。请使用 OCL 内置算法,或在下方申请更多访问权限。", - "preview_limit_body_ai_assistant": "你的免费预览版包含 {{limit}} 次 OCL AI 助手调用,已使用 {{used}} / {{limit}}。剩余行仍可继续匹配,但不会进行 AI 分析。", - "preview_limit_body_ai_assistant_no_count": "你的账户暂时无法使用 OCL AI 助手。请在下方申请更多访问权限,我们会处理。", - "preview_limit_body_scispacy_no_count": "你的账户暂时无法使用 ScispaCy LOINC 匹配。请在下方申请更多访问权限,我们会处理。", - "preview_limit_body_rows_not_entitled_no_count": "本项目尚未配置行额度。请在下方申请更多访问权限,我们会处理。", - "preview_limit_body_match_operations_not_entitled_no_count": "你的账户尚未配置匹配操作权限。请在下方申请更多访问权限,我们会处理。", - "preview_limit_body_projects_not_entitled_no_count": "你的账户尚未配置映射项目权限。请在下方申请更多访问权限,我们会处理。", - "preview_limit_body_generic": "已达到预览版限制({{used}} / {{limit}})。", - "preview_limit_body_generic_no_count": "已达到 Mapper 预览版的某项限制。", - "preview_limit_hardship_note": "预算紧张,或来自卫生部?点击下方“申请更多访问权限” —— 如果现有额度不适合你的情况,我们会想办法解决。", - "preview_limit_notify_me": "计划上线时通知我", - "preview_limit_request_access": "申请更多访问权限", + "limit_unavailable_title": "你的账户无法使用此功能", + "limit_unavailable_body_access": "你的账户没有 Mapper 访问权限。", + "limit_unavailable_body_org_projects": "你的账户无法创建组织拥有的映射项目。你可以改用个人账户创建项目。", + "limit_unavailable_body_custom_algorithms": "你的账户无法使用外部托管算法。你可以改用 OCL 内置算法。", + "limit_unavailable_body_ai_assistant": "你的账户无法使用 OCL AI 助手。", + "limit_unavailable_body_scispacy": "你的账户无法使用 ScispaCy LOINC 匹配。", + "limit_unavailable_body_rows_not_entitled": "你的账户无法对此项目的行运行 AutoMatch。", + "limit_unavailable_body_match_operations_not_entitled": "你的账户无法运行 AutoMatch。", + "limit_unavailable_body_projects_not_entitled": "你的账户无法创建映射项目。", + "limit_unavailable_body_generic": "你的账户无法使用此功能。", "preview_limit_dismiss": "返回我的项目", "preview_config_unavailable_title": "此项目使用了预览版受限设置", "preview_config_unavailable_org_projects": "组织拥有的 Mapper 项目不在你的预览范围内。请选择个人命名空间作为项目所有者。", @@ -593,11 +569,7 @@ "quota": { "title_quota": "您已达到配额上限", "title_cap": "您已达到使用上限", - "your_plan": "您的套餐", "used_of": "已使用 {{used}} / {{limit}}", - "period_one_time": "一次性预览额度", - "request_more_access": "申请更多权限", - "view_pricing": "查看价格", "meter": { "match_operations": "匹配操作", "ai_assistant_calls": "OCL AI 助手调用", @@ -607,7 +579,7 @@ "generic": "用量" }, "consequence": { - "generic": "在提高额度之前,部分功能已暂停。", + "generic": "部分功能已暂停。", "ai_assistant_calls": { "default": "使用 OCL AI 助手的功能已暂停。", "auto_match": "AutoMatch 将继续运行,但不再提供 OCL AI 助手的推荐。" From 7aca4db63bf14d621c219c48371b49791aa2d1ae Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 27 Oct 2026 13:08:37 -0400 Subject: [PATCH 2/3] OpenConceptLab/ocl_online#367 | Access-check refusals ask online-api about their permission The Mapper's access-denied dialogs now send permission= instead of capability, as online-api's contract now asks. Limits and missing allowances still send capability. GA events carry whichever one was sent. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AaZ4qVXboeBSRvAQgQjdbs --- .../common/__tests__/limitAction.test.js | 15 ++++++-- src/components/common/limitAction.js | 37 ++++++++++--------- src/components/common/useLimitAction.js | 8 ++-- 3 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/components/common/__tests__/limitAction.test.js b/src/components/common/__tests__/limitAction.test.js index 05ccbfd1..814f2161 100644 --- a/src/components/common/__tests__/limitAction.test.js +++ b/src/components/common/__tests__/limitAction.test.js @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -const { getLimitActionRequest, getLimitCapability, toLimitAction } = await import('../limitAction.js') +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.'} @@ -23,7 +23,14 @@ test('asks about the refused capability in the UI language, with the person\'s t ) }) -test('every refusal the dialogs show names a capability', () => { +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', @@ -31,8 +38,8 @@ test('every refusal the dialogs show names a capability', () => { 'mapper_projects_limit_reached', 'mapper_rows_per_project_limit_reached', ...PREVIEW_LIMIT_ERROR_CODES, - ].forEach(errorCode => assert.ok(getLimitCapability(errorCode), errorCode)) - assert.equal(getLimitCapability('validation_error'), undefined) + ].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', () => { diff --git a/src/components/common/limitAction.js b/src/components/common/limitAction.js index 48509cc5..1871ba85 100644 --- a/src/components/common/limitAction.js +++ b/src/components/common/limitAction.js @@ -2,32 +2,33 @@ // service (OpenConceptLab/ocl_online#367). Open code shows only what the // service sends; with no service configured it shows nothing. -// The capability each refusal is about, as the service names it. -const LIMIT_CAPABILITIES = { - mapper_match_operations_limit_reached: 'mapper.match_operations', - mapper_match_operations_not_entitled: 'mapper.match_operations', - mapper_rows_per_project_limit_reached: 'mapper.rows_per_project', - mapper_rows_per_project_not_entitled: 'mapper.rows_per_project', - mapper_projects_limit_reached: 'mapper.projects', - mapper_projects_not_entitled: 'mapper.projects', - ai_assistant_calls_limit_reached: 'ai_assistant.calls', - ai_assistant_calls_not_entitled: 'ai_assistant.calls', - ai_assistant_change_comments_limit_reached: 'ai_assistant.change_comments', - mapper_access_denied: 'users.mapper_use', - mapper_org_projects_denied: 'users.mapper_org_projects', - mapper_custom_algorithms_denied: 'users.mapper_custom_algorithms', - mapper_ai_assistant_denied: 'users.mapper_ai_assistant', - mapper_scispacy_denied: 'users.mapper_scispacy', +// 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 getLimitCapability = errorCode => LIMIT_CAPABILITIES[errorCode] +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', capability: getLimitCapability(errorCode), lang}, + params: {context: 'limit', ...getLimitSubject(errorCode), lang}, headers: {Authorization: `Token ${token}`}, } } diff --git a/src/components/common/useLimitAction.js b/src/components/common/useLimitAction.js index c26b0b7d..bd90292c 100644 --- a/src/components/common/useLimitAction.js +++ b/src/components/common/useLimitAction.js @@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next' import GAService from '../../services/GAService' import { currentUserToken } from '../../common/utils' -import { getLimitActionRequest, getLimitCapability, toLimitAction } from './limitAction' +import { getLimitActionRequest, getLimitSubject, toLimitAction } from './limitAction' /*eslint no-undef: 0*/ const getOnlineAPIURL = () => window.OCL_ONLINE_API_URL || process.env.OCL_ONLINE_API_URL @@ -16,13 +16,13 @@ const getOnlineAPIURL = () => window.OCL_ONLINE_API_URL || process.env.OCL_ONLIN const useLimitAction = (open, errorCode) => { const { i18n } = useTranslation() const [action, setAction] = React.useState(null) - const capability = getLimitCapability(errorCode) + const subject = getLimitSubject(errorCode) React.useEffect(() => { setAction(null) if(!open) return undefined - GAService.recordActionEvent('Limit', 'limit_hit', errorCode, {capability}) + GAService.recordActionEvent('Limit', 'limit_hit', errorCode, subject) const request = getLimitActionRequest({baseURL: getOnlineAPIURL(), token: currentUserToken(), errorCode, lang: i18n.language}) if(!request) return undefined @@ -35,7 +35,7 @@ const useLimitAction = (open, errorCode) => { } }, [open, errorCode]) - const onActionClick = () => GAService.recordActionEvent('Limit', 'limit_action_click', errorCode, {capability, action_kind: action?.kind}) + const onActionClick = () => GAService.recordActionEvent('Limit', 'limit_action_click', errorCode, {...subject, action_kind: action?.kind}) return {action, onActionClick} } From 26cfe49f8048e244011c39c30539b494d020e129 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Tue, 27 Oct 2026 13:12:55 -0400 Subject: [PATCH 3/3] OpenConceptLab/ocl_online#367 | Limit dialogs drop the usage meter; a blocked new project opens the dialog Codex pass 1: - F1.1: without an action, QuotaDialog still showed "N of M used" and a progress bar. It now shows only its neutral line, plus whatever online-api sends. The strings only the meter used are removed in all four languages, and getQuotaError no longer passes the counts along. - F1.2: opening /map-projects/new while blocked showed a toast and redirected, so online-api was never asked and no limit_hit was recorded. MapProject now redirects to the project list with the reason in the route state, and the list opens the same dialog it opens for the New button. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AaZ4qVXboeBSRvAQgQjdbs --- src/components/common/QuotaDialog.jsx | 19 ++------------- .../common/__tests__/quotaErrors.test.js | 4 ++-- src/components/common/quotaErrors.js | 2 +- src/components/map-projects/MapProject.jsx | 24 +++++++------------ src/components/map-projects/MapProjects.jsx | 12 ++++++++-- src/i18n/locales/en/translations.json | 9 ------- src/i18n/locales/es/translations.json | 9 ------- src/i18n/locales/pt-BR/translations.json | 9 ------- src/i18n/locales/zh/translations.json | 9 ------- 9 files changed, 23 insertions(+), 74 deletions(-) diff --git a/src/components/common/QuotaDialog.jsx b/src/components/common/QuotaDialog.jsx index 567d9913..8fb7004b 100644 --- a/src/components/common/QuotaDialog.jsx +++ b/src/components/common/QuotaDialog.jsx @@ -4,7 +4,6 @@ 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' @@ -13,34 +12,20 @@ import CloseIconButton from './CloseIconButton' import { isCapMeter } from './quotaErrors' import useLimitAction from './useLimitAction' -const QuotaDialog = ({open, onClose, errorCode, meter, surface, usage}) => { +const QuotaDialog = ({open, onClose, errorCode, meter, surface}) => { const { t } = useTranslation() const { action, onActionClick } = useLimitAction(open, errorCode) - const isCap = isCapMeter(meter) - const { used, limit } = usage || {} - const hasCount = Number.isFinite(used) && Number.isFinite(limit) && limit > 0 - const progress = hasCount ? Math.min(100, (used / limit) * 100) : 100 return ( - {t(isCap ? 'quota.title_cap' : 'quota.title_quota')} + {t(isCapMeter(meter) ? 'quota.title_cap' : 'quota.title_quota')} {t([`quota.consequence.${meter}.${surface}`, `quota.consequence.${meter}.default`, 'quota.consequence.generic'])} -
-
- {t(`quota.meter.${meter}`, {defaultValue: t('quota.meter.generic')})} - { - hasCount && - {t('quota.used_of', {used, limit})} - } -
- -
{ action?.message && {action.message} 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/quotaErrors.js b/src/components/common/quotaErrors.js index 1d3d24e3..d588b41c 100644 --- a/src/components/common/quotaErrors.js +++ b/src/components/common/quotaErrors.js @@ -15,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/map-projects/MapProject.jsx b/src/components/map-projects/MapProject.jsx index 310ea657..61c8a1e8 100644 --- a/src/components/map-projects/MapProject.jsx +++ b/src/components/map-projects/MapProject.jsx @@ -151,7 +151,6 @@ import '../common/ResizablePanel.scss' // } const NEW_PROJECT_BLOCK_ERROR_CODES = {access: 'mapper_access_denied', projects_not_entitled: 'mapper_projects_not_entitled', projects: 'mapper_projects_limit_reached'} -const NEW_PROJECT_BLOCK_MESSAGES = {access: 'map_project.limit_unavailable_body_access', projects_not_entitled: 'map_project.limit_unavailable_body_projects_not_entitled', projects: 'quota.consequence.projects.new_project'} const MAPPER_QUOTA_SURFACE = {match_operations: 'auto_match', ai_assistant_calls: 'auto_match', projects: 'new_project', rows: 'import'} const MapProject = () => { @@ -290,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) @@ -810,11 +809,6 @@ const MapProject = () => { ? getNewProjectBlockReason(getMapperPreview()) : null - React.useEffect(() => { - if(newProjectBlockReason) - baseSetAlert({severity: 'error', message: t(NEW_PROJECT_BLOCK_MESSAGES[newProjectBlockReason]), duration: 8000}) - }, [newProjectBlockReason]) - React.useEffect(() => { const isDefaultApplied = isRepoDefaultFilterApplied(repoVersion) if(project?.id) @@ -1828,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}) } @@ -2074,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})}}) @@ -4004,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 } @@ -6215,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) { @@ -6235,7 +6228,7 @@ const MapProject = () => { return minFactor } - return permissionDenied ? : newProjectBlockReason ? : ( + return permissionDenied ? : newProjectBlockReason ? : (
{ (() => { @@ -6692,7 +6685,6 @@ const MapProject = () => { errorCode={previewLimitQuota.errorCode} meter={previewLimitQuota.meter} surface={MAPPER_QUOTA_SURFACE[previewLimitQuota.meter]} - usage={previewLimitQuota.usage} /> : { 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) { @@ -345,7 +354,6 @@ const MapProjects = () => { errorCode='mapper_projects_limit_reached' meter='projects' surface='new_project' - usage={{used: newProjectBlock.preview.projects.used, limit: newProjectBlock.preview.projects.limit}} /> :