diff --git a/package-lock.json b/package-lock.json
index 7b4a164b..731db1fb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11659,7 +11659,7 @@
"fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
- "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
"dev": true
},
"fast-uri": {
@@ -14005,7 +14005,7 @@
"json-stable-stringify-without-jsonify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
- "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
"dev": true
},
"json-stringify-safe": {
@@ -14941,7 +14941,7 @@
"natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
- "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
"dev": true
},
"negotiator": {
@@ -21117,7 +21117,7 @@
"text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
- "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==",
+ "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
"dev": true
},
"through2": {
diff --git a/src/components/collections/AddReferencesDialog.jsx b/src/components/collections/AddReferencesDialog.jsx
new file mode 100644
index 00000000..bd67b45f
--- /dev/null
+++ b/src/components/collections/AddReferencesDialog.jsx
@@ -0,0 +1,550 @@
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+import TextField from '@mui/material/TextField'
+import Autocomplete from '@mui/material/Autocomplete'
+import CircularProgress from '@mui/material/CircularProgress'
+import Button from '@mui/material/Button'
+import Typography from '@mui/material/Typography'
+import Box from '@mui/material/Box'
+import Alert from '@mui/material/Alert'
+import FormControl from '@mui/material/FormControl'
+import InputLabel from '@mui/material/InputLabel'
+import Select from '@mui/material/Select'
+import MenuItem from '@mui/material/MenuItem'
+import Checkbox from '@mui/material/Checkbox'
+import FormControlLabel from '@mui/material/FormControlLabel'
+import Table from '@mui/material/Table'
+import TableHead from '@mui/material/TableHead'
+import TableBody from '@mui/material/TableBody'
+import TableRow from '@mui/material/TableRow'
+import TableCell from '@mui/material/TableCell'
+import Divider from '@mui/material/Divider'
+import Chip from '@mui/material/Chip'
+import OpenInNewIcon from '@mui/icons-material/OpenInNew'
+import { DialogContent, DialogActions } from '@mui/material'
+import { toLower, includes } from 'lodash'
+import APIService from '../../services/APIService'
+import Dialog from '../common/Dialog'
+import DialogTitle from '../common/DialogTitle'
+import CloseIconButton from '../common/CloseIconButton'
+import CascadeSelector from '../common/CascadeSelector'
+import GroupHeader from '../common/GroupHeader'
+import GroupItems from '../common/GroupItems'
+import AutocompleteLoading from '../common/AutocompleteLoading'
+
+// ---- error formatting (same pattern as AddToCollectionDialog) ----
+
+const extractLabel = expression => {
+ if (!expression) return expression
+ const parts = expression.replace(/\/$/, '').split('/')
+ for (const resource of ['concepts', 'mappings']) {
+ const idx = parts.lastIndexOf(resource)
+ if (idx !== -1 && parts[idx + 1]) return parts[idx + 1]
+ }
+ return expression
+}
+
+const formatErrorEntry = entry => {
+ if (!entry || typeof entry !== 'object') return String(entry)
+ const lines = []
+ if (entry.description) lines.push(entry.description)
+ if (entry.conflicting_references?.length) {
+ const refs = entry.conflicting_references.map(r => {
+ const m = r.match(/\/references\/([^/]+)\/?$/)
+ return m ? `Reference #${m[1]}` : r
+ })
+ lines.push(`Conflicting: ${refs.join(', ')}`)
+ }
+ if (entry.conflicting_concept_id) {
+ const label = entry.conflicting_concept_name
+ ? `${entry.conflicting_concept_id} ${entry.conflicting_concept_name}`
+ : entry.conflicting_concept_id
+ lines.push(`Conflicting concept: ${label}`)
+ }
+ if (entry.conflicting_name) lines.push(`Conflicting name: "${entry.conflicting_name}"`)
+ return lines.join(' — ') || JSON.stringify(entry)
+}
+
+const formatErrorMessage = message => {
+ if (!message) return '—'
+ if (typeof message === 'string') return message
+ const allErrors = []
+ Object.values(message).forEach(val => {
+ if (val?.errors) val.errors.forEach(e => allErrors.push(formatErrorEntry(e)))
+ else if (val && typeof val === 'object') allErrors.push(formatErrorEntry(val))
+ })
+ return allErrors.length ? allErrors.join('\n') : JSON.stringify(message)
+}
+
+// ---- component ----
+
+const AddReferencesDialog = ({ open, onClose, collectionUrl, onSuccess }) => {
+ const { t } = useTranslation()
+
+ // Seed (source or collection to pull from)
+ const [seeds, setSeeds] = React.useState([])
+ const [seedLoading, setSeedLoading] = React.useState(false)
+ const [seedInput, setSeedInput] = React.useState('')
+ const [seed, setSeed] = React.useState(null)
+
+ // Version pin
+ const [pinVersion, setPinVersion] = React.useState(false)
+ const [versions, setVersions] = React.useState([])
+ const [versionsLoading, setVersionsLoading] = React.useState(false)
+ const [selectedVersion, setSelectedVersion] = React.useState('')
+
+ // Reference configuration
+ const [resourceType, setResourceType] = React.useState('concepts')
+ const [mode, setMode] = React.useState('ids')
+ const [ids, setIds] = React.useState('')
+ const [expression, setExpression] = React.useState('')
+ const [includeExclude, setIncludeExclude] = React.useState('include')
+ const [cascadeParams, setCascadeParams] = React.useState({})
+
+ // Submission state
+ const [submitting, setSubmitting] = React.useState(false)
+ const [results, setResults] = React.useState(null)
+ const [error, setError] = React.useState(null)
+
+ // Reset on open
+ React.useEffect(() => {
+ if (open) {
+ setSeeds([])
+ setSeedInput('')
+ setSeed(null)
+ setPinVersion(false)
+ setVersions([])
+ setSelectedVersion('')
+ setResourceType('concepts')
+ setMode('ids')
+ setIds('')
+ setExpression('')
+ setIncludeExclude('include')
+ setCascadeParams({})
+ setResults(null)
+ setError(null)
+ }
+ }, [open])
+
+ // Debounced global repo search
+ const seedSearchTimer = React.useRef(null)
+ const handleSeedInputChange = (_, value) => {
+ setSeedInput(value || '')
+ clearTimeout(seedSearchTimer.current)
+ if (!value || value.length < 2) { setSeeds([]); return }
+ setSeedLoading(true)
+ seedSearchTimer.current = setTimeout(() => {
+ APIService.new().overrideURL('/repos/').get(null, null, { q: value, limit: 20, verbose: true })
+ .then(response => {
+ setSeeds(Array.isArray(response?.data) ? response.data : [])
+ setSeedLoading(false)
+ })
+ }, 300)
+ }
+
+ // Fetch released versions when seed changes
+ React.useEffect(() => {
+ if (!seed?.url) { setVersions([]); setSelectedVersion(''); return }
+ setVersionsLoading(true)
+ APIService.new().overrideURL(seed.url + 'versions/').get(null, null, { released: true, limit: 20 })
+ .then(response => {
+ const vList = (Array.isArray(response?.data) ? response.data : [])
+ .filter(v => v.version !== 'HEAD')
+ .map(v => v.version || v.id)
+ setVersions(vList)
+ setVersionsLoading(false)
+ if (vList.length) setSelectedVersion(vList[0])
+ })
+ }, [seed])
+
+ // Seed the expression field when relevant inputs change in Expression mode
+ React.useEffect(() => {
+ if (mode !== 'expression') return
+ setExpression(buildBasePath())
+ }, [seed, pinVersion, selectedVersion, resourceType, mode]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const buildBasePath = () => {
+ if (!seed?.url) return ''
+ const base = seed.url.replace(/\/$/, '') + '/'
+ const version = pinVersion && selectedVersion ? selectedVersion + '/' : ''
+ return base + version + resourceType + '/'
+ }
+
+ const clearResultsOnEdit = () => { if (results !== null) { setResults(null); setError(null) } }
+
+ const buildExpressions = () => {
+ if (mode === 'expression') return expression.trim() ? [expression.trim()] : []
+ const base = buildBasePath()
+ if (!base) return []
+ return ids.split(',').map(id => id.trim()).filter(Boolean).map(id => base + id + '/')
+ }
+
+ const expressionList = buildExpressions()
+
+ const filterSeedOptions = (options, { inputValue }) => {
+ if (!inputValue) return options
+ const q = toLower(inputValue)
+ return options.filter(o =>
+ includes(toLower(o.name), q) ||
+ includes(toLower(o.id), q) ||
+ includes(toLower(o.short_code), q) ||
+ includes(toLower(o.owner), q)
+ )
+ }
+
+ const handleSubmit = () => {
+ if (!expressionList.length) return
+ setSubmitting(true)
+ setError(null)
+ setResults(null)
+ const queryParams = Object.keys(cascadeParams).length ? cascadeParams : undefined
+ // CollectionVersionReferencesView (matched by /HEAD/references/) is read-only;
+ // strip any version segment so the request hits CollectionReferencesView which handles PUT.
+ const refsUrl = collectionUrl.replace(/\/HEAD\//i, '/') + 'references/'
+ APIService.new().overrideURL(refsUrl)
+ .put(
+ { data: { expressions: expressionList, ...(includeExclude === 'exclude' && { exclude: true }) }, cascade: cascadeParams.method || '' },
+ null,
+ {},
+ queryParams
+ )
+ .then(response => {
+ setSubmitting(false)
+ if (response?.status === 200 || response?.status === 201) {
+ setResults(Array.isArray(response.data) ? response.data : [])
+ if (onSuccess) onSuccess()
+ } else if (response?.status === 202) {
+ setResults('pending')
+ if (onSuccess) onSuccess()
+ } else {
+ setError((response?.detail || response?.error) || t('common.something_went_wrong'))
+ }
+ })
+ .catch(() => {
+ setSubmitting(false)
+ setError(t('reference.request_failed'))
+ })
+ }
+
+ const isPending = results === 'pending'
+ const resultList = Array.isArray(results) ? results : []
+ const addedCount = resultList.filter(r => r.added).length
+ const failedCount = resultList.filter(r => !r.added).length
+ const hasResults = results !== null
+ // Lock the form only on full success or async-accepted. Failures keep the form editable so
+ // the user can fix the expression and resubmit without closing and reopening the dialog.
+ const formLocked = isPending || (hasResults && addedCount > 0 && failedCount === 0)
+ const canSubmit = !submitting && !formLocked && expressionList.length > 0
+ const resolvedVersionLabel = seed
+ ? (pinVersion && selectedVersion
+ ? selectedVersion
+ : versionsLoading ? '…' : versions.length > 0 ? versions[0] : t('reference.latest_released'))
+ : ''
+
+ return (
+
+ )
+}
+
+export default AddReferencesDialog
diff --git a/src/components/repos/RepoHome.jsx b/src/components/repos/RepoHome.jsx
index 30a09465..91b777d0 100644
--- a/src/components/repos/RepoHome.jsx
+++ b/src/components/repos/RepoHome.jsx
@@ -5,6 +5,8 @@ import Paper from '@mui/material/Paper'
import orderBy from 'lodash/orderBy'
import filter from 'lodash/filter'
+import Button from '@mui/material/Button'
+import AddIcon from '@mui/icons-material/Add'
import APIService from '../../services/APIService';
import { dropVersion, toParentURI, toOwnerURI, currentUserHasAccess } from '../../common/utils';
import { WHITE } from '../../common/colors';
@@ -25,6 +27,7 @@ import ReleaseVersion from './ReleaseVersion'
import RepoHeader from './RepoHeader';
import CollectionVersionsTab from './CollectionVersionsTab';
import ReferenceHome from '../references/ReferenceHome'
+import AddReferencesDialog from '../collections/AddReferencesDialog'
const RepoHome = () => {
const { t } = useTranslation()
@@ -53,6 +56,8 @@ const RepoHome = () => {
const [deleteTarget, setDeleteTarget] = React.useState(false)
const [releaseTarget, setReleaseTarget] = React.useState(false)
const [showSummary, setShowSummary] = React.useState(true)
+ const [addReferencesOpen, setAddReferencesOpen] = React.useState(false)
+ const [referencesKey, setReferencesKey] = React.useState(0)
const TAB_KEYS = tabs.map(tab => tab.key)
const findTab = () => TAB_KEYS.includes(params?.tab || params?.repoVersion) ? params.tab || params.repoVersion : 'concepts'
@@ -275,6 +280,7 @@ const RepoHome = () => {
{
repo?.id && ['concepts', 'mappings', 'references'].includes(tab) &&
{
containerStyle={{padding: 0}}
properties={(!tab || tab === 'concepts') ? repo?.meta?.display?.concept_summary_properties : []}
propertyFilters={(!tab || tab === 'concepts') ? repo?.filters : []}
+ toolbarControl={
+ isCollection && !isVersion && tab === 'references'
+ ? (
+ }
+ onClick={() => setAddReferencesOpen(true)}
+ sx={{ textTransform: 'none' }}
+ >
+ {t('reference.add_references')}
+
+ )
+ : undefined
+ }
/>
}
{
@@ -357,6 +378,15 @@ const RepoHome = () => {
onClose={(postUpsert) => onVersionFormClose(postUpsert)}
/>
}
+ {
+ isCollection &&
+ setAddReferencesOpen(false)}
+ collectionUrl={getURL()}
+ onSuccess={() => setReferencesKey(k => k + 1)}
+ />
+ }
{
repo?.id &&
{
properties={props.properties}
propertyFilters={props.propertyFilters}
isMatch={isMatchOp}
+ toolbarControl={props.toolbarControl}
/>
diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json
index 0af2e32e..0ce803e5 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -77,6 +77,8 @@
"public": "Public",
"private": "Private",
"close": "Close",
+ "something_went_wrong": "Something went wrong",
+ "no_results": "No results",
"content": "Content",
"short_code": "Short Code",
"attribute": "Attribute",
@@ -282,7 +284,28 @@
"translation": "Translation",
"advanced": "Advanced",
"hide_advanced": "Hide advanced",
- "transform_tooltip": "Converts the reference to extensional reference(s). References for resolved concepts or mappings will be stored as individual extensional references instead of intensional reference(s)."
+ "transform_tooltip": "Converts the reference to extensional reference(s). References for resolved concepts or mappings will be stored as individual extensional references instead of intensional reference(s).",
+ "add_references": "Add References",
+ "add_reference": "Add Reference",
+ "source_or_collection": "Source / Collection",
+ "search_sources_collections": "Search sources and collections...",
+ "group_collections": "Collections",
+ "group_sources": "Sources",
+ "type_to_search": "Type at least 2 characters to search",
+ "open_repo": "Open {{name}}",
+ "resolves_to": "Resolves to {{version}}.",
+ "latest_released": "latest released",
+ "pin_to_version": "Pin to a specific version",
+ "include_exclude": "Include / Exclude",
+ "include": "Include",
+ "add_by_ids": "Add by ID(s)",
+ "concept_ids": "Concept IDs",
+ "mapping_ids": "Mapping IDs",
+ "select_source_first": "Select a source or collection above to use Add by ID(s)",
+ "id_helper": "Each ID becomes {{path}}{id}/",
+ "expression_hint": "Full reference expression. Seeded from the source / collection selector above; edit as needed.",
+ "adding_references": "Adding references…",
+ "request_failed": "Request failed — check your network connection and try again."
},
"checksums": {
"standard": "Standard Checksum",
diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json
index 075df0f5..0d7b961d 100644
--- a/src/i18n/locales/es/translations.json
+++ b/src/i18n/locales/es/translations.json
@@ -53,7 +53,9 @@
"register": "Registrar",
"signing_in": "Iniciando sesión...",
"sign_in_success": "Ingreso exitoso.",
- "sign_in_error": "No se pudo iniciar sesión en este momento."
+ "sign_in_error": "No se pudo iniciar sesión en este momento.",
+ "something_went_wrong": "Algo salió mal",
+ "no_results": "Sin resultados"
},
"concept": {
"concepts": "Conceptos",
@@ -75,7 +77,28 @@
"all_source_concepts_and_mappings": "Todos los conceptos y mapeos de la fuente",
"advanced": "Avanzado",
"hide_advanced": "Ocultar avanzado",
- "transform_tooltip": "Convierte la referencia en referencia(s) extensional(es). Las referencias para conceptos o mapeos resueltos se almacenaran como referencias extensionales individuales en lugar de referencia(s) intensional(es)."
+ "transform_tooltip": "Convierte la referencia en referencia(s) extensional(es). Las referencias para conceptos o mapeos resueltos se almacenaran como referencias extensionales individuales en lugar de referencia(s) intensional(es).",
+ "add_references": "Agregar referencias",
+ "add_reference": "Agregar referencia",
+ "source_or_collection": "Fuente / Colección",
+ "search_sources_collections": "Buscar fuentes y colecciones...",
+ "group_collections": "Colecciones",
+ "group_sources": "Fuentes",
+ "type_to_search": "Escribe al menos 2 caracteres para buscar",
+ "open_repo": "Abrir {{name}}",
+ "resolves_to": "Resuelve en {{version}}.",
+ "latest_released": "última versión publicada",
+ "pin_to_version": "Fijar a una versión específica",
+ "include_exclude": "Incluir / Excluir",
+ "include": "Incluir",
+ "add_by_ids": "Agregar por ID(s)",
+ "concept_ids": "IDs de conceptos",
+ "mapping_ids": "IDs de mapeos",
+ "select_source_first": "Selecciona una fuente o colección para usar Agregar por ID(s)",
+ "id_helper": "Cada ID se convierte en {{path}}{id}/",
+ "expression_hint": "Expresión de referencia completa. Precargada desde el selector de fuente / colección; edita según sea necesario.",
+ "adding_references": "Agregando referencias…",
+ "request_failed": "La solicitud falló — verifica tu conexión de red e inténtalo de nuevo."
},
"repo": {
"repos": "Repositorios",
diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json
index e4b5a350..a52ab6a9 100644
--- a/src/i18n/locales/zh/translations.json
+++ b/src/i18n/locales/zh/translations.json
@@ -89,7 +89,9 @@
"properties": "特性",
"custom": "自定义",
"none": "无",
- "load_more": "加载更多"
+ "load_more": "加载更多",
+ "something_went_wrong": "出了些问题",
+ "no_results": "无结果"
},
"errors": {
"404": "很抱歉,未能找到您的页面。",
@@ -294,7 +296,28 @@
"all_source_concepts_and_mappings": "所有源概念和映射",
"advanced": "高级",
"hide_advanced": "隐藏高级",
- "transform_tooltip": "将引用转换为外延引用。已解析概念或映射的引用将作为单独的外延引用存储,而不是作为内涵引用存储。"
+ "transform_tooltip": "将引用转换为外延引用。已解析概念或映射的引用将作为单独的外延引用存储,而不是作为内涵引用存储。",
+ "add_references": "添加参考",
+ "add_reference": "添加参考",
+ "source_or_collection": "来源 / 集合",
+ "search_sources_collections": "搜索来源和集合...",
+ "group_collections": "集合",
+ "group_sources": "来源",
+ "type_to_search": "请输入至少 2 个字符进行搜索",
+ "open_repo": "打开 {{name}}",
+ "resolves_to": "解析为 {{version}}。",
+ "latest_released": "最新发布版本",
+ "pin_to_version": "固定到特定版本",
+ "include_exclude": "包含 / 排除",
+ "include": "包含",
+ "add_by_ids": "按 ID 添加",
+ "concept_ids": "概念 ID",
+ "mapping_ids": "映射 ID",
+ "select_source_first": "请在上方选择来源或集合以使用按 ID 添加",
+ "id_helper": "每个 ID 变为 {{path}}{id}/",
+ "expression_hint": "完整的参考表达式。由上方来源/集合选择器预填充;根据需要编辑。",
+ "adding_references": "正在添加参考…",
+ "request_failed": "请求失败 — 请检查您的网络连接并重试。"
},
"search": {
"filters": "筛选项",