From ea012d709fe34c29c4990905dba51d5bafec1324 Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Tue, 14 Apr 2026 15:34:09 +0530 Subject: [PATCH 1/2] OpenConceptLab/ocl_issues#1983 | Expansion Authoring --- .../repos/CollectionVersionsTab.jsx | 1105 +++++++++++++++++ .../repos/ExpansionDetailsDialog.jsx | 114 ++ src/components/repos/ExpansionForm.jsx | 463 +++++++ .../repos/RebuildExpansionDialog.jsx | 56 + src/components/repos/RepoHome.jsx | 88 +- src/i18n/locales/en/translations.json | 125 ++ 6 files changed, 1923 insertions(+), 28 deletions(-) create mode 100644 src/components/repos/CollectionVersionsTab.jsx create mode 100644 src/components/repos/ExpansionDetailsDialog.jsx create mode 100644 src/components/repos/ExpansionForm.jsx create mode 100644 src/components/repos/RebuildExpansionDialog.jsx diff --git a/src/components/repos/CollectionVersionsTab.jsx b/src/components/repos/CollectionVersionsTab.jsx new file mode 100644 index 00000000..5470228a --- /dev/null +++ b/src/components/repos/CollectionVersionsTab.jsx @@ -0,0 +1,1105 @@ +import React from "react"; +import { useLocation } from "react-router-dom"; +import { + Alert, + Box, + Button as MuiButton, + Chip, + CircularProgress, + Collapse, + Divider, + Paper, + Stack, + Typography +} from "@mui/material"; +import { + AccountTreeOutlined as VersionIcon, + AspectRatio as ExpansionIcon, + CheckCircleOutline as DefaultIcon, + EditOutlined as DraftIcon, + ExpandLess as CollapseIcon, + ExpandMore as ExpandIcon, + InfoOutlined as InfoIcon, + NewReleases as ReleaseIcon, + WarningAmberOutlined as WarningIcon +} from "@mui/icons-material"; +import find from "lodash/find"; +import orderBy from "lodash/orderBy"; +import uniqBy from "lodash/uniqBy"; +import filter from "lodash/filter"; +import get from "lodash/get"; +import isNumber from "lodash/isNumber"; +import { useTranslation } from "react-i18next"; + +import APIService from "../../services/APIService"; +import { + dropVersion, + formatDateTime, + headFirst, + currentUserHasAccess +} from "../../common/utils"; +import { OperationsContext } from "../app/LayoutContext"; +import DeleteEntityDialog from "../common/DeleteEntityDialog"; +import ExpansionForm from "./ExpansionForm"; +import ExpansionDetailsDialog from "./ExpansionDetailsDialog"; +import RebuildExpansionDialog from "./RebuildExpansionDialog"; + +const isHeadVersion = version => (version?.version || version?.id) === "HEAD"; + +const isStaleExpansion = expansion => + Boolean( + expansion?.is_stale || + expansion?.stale || + expansion?.extras?.__stale_expansion || + expansion?.extras?.stale + ); + +const getVersionKey = version => + version?.version_url || version?.url || version?.id; + +const getVersionEndpoint = version => { + const versionURL = version?.version_url || version?.url || ""; + return version?.version === "HEAD" + ? `${dropVersion(versionURL)}HEAD/` + : versionURL; +}; + +const matchesByValue = (value, candidates = []) => { + if (!value) return false; + + const expected = value.toLowerCase(); + return candidates + .filter(Boolean) + .some(candidate => String(candidate).toLowerCase() === expected); +}; + +const findVersionFromQuery = (versions, value) => + find(versions, version => + matchesByValue(value, [ + version?.version, + version?.id, + version?.url, + version?.version_url + ]) + ); + +const findExpansionFromQuery = (expansions, value) => + find(expansions, expansion => + matchesByValue(value, [expansion?.mnemonic, expansion?.id, expansion?.url]) + ); + +const formatExpansions = (version, versionExpansions = []) => { + let expansions = orderBy( + versionExpansions, + ["created_on", "id"], + ["desc", "desc"] + ).map(expansion => ({ ...expansion, default: false, auto: false })); + + if (version?.autoexpand && expansions.length > 0) { + const [latestExpansion, ...rest] = expansions; + expansions = [{ ...latestExpansion, auto: true }, ...rest]; + } + + if (version?.expansion_url) { + const defaultExpansion = find(expansions, { url: version.expansion_url }); + if (defaultExpansion) { + expansions = [ + { ...defaultExpansion, default: true }, + ...expansions.filter( + expansion => expansion.url !== version.expansion_url + ) + ]; + } + } + + return expansions; +}; + +const getUserLabel = user => + user?.username || + user?.id || + user?.name || + (typeof user === "string" ? user : ""); + +const getReferencesCount = entity => { + const explicitTotal = + get(entity, "summary.references.total") ?? get(entity, "references.total"); + if (isNumber(explicitTotal)) return explicitTotal; + + const conceptRefs = + get(entity, "summary.references.concepts") ?? + get(entity, "references.concepts"); + const mappingRefs = + get(entity, "summary.references.mappings") ?? + get(entity, "references.mappings"); + if (isNumber(conceptRefs) || isNumber(mappingRefs)) + return (conceptRefs || 0) + (mappingRefs || 0); + + return undefined; +}; + +const SummaryMetric = ({ label, value }) => ( + + + {label} + + + {isNumber(value) ? value.toLocaleString() : "-"} + + +); + +const StatusChip = ({ + label, + color = "default", + icon = undefined, + variant = "filled" +}) => ( + +); + +const CollectionVersionsTab = ({ + repo, + versions, + onCreateVersion, + onReleaseVersion, + onDeleteVersion, + onDataChange +}) => { + const { t } = useTranslation(); + const location = useLocation(); + const { setAlert } = React.useContext(OperationsContext); + const [headVersion, setHeadVersion] = React.useState( + isHeadVersion(repo) ? repo : null + ); + const [expandedVersionKey, setExpandedVersionKey] = React.useState(null); + const [expansionsByVersion, setExpansionsByVersion] = React.useState({}); + const [loadingByVersion, setLoadingByVersion] = React.useState({}); + const [headLoading, setHeadLoading] = React.useState(false); + const [versionOverrides, setVersionOverrides] = React.useState({}); + const [expansionFormState, setExpansionFormState] = React.useState({ + open: false, + version: null, + copyFrom: null + }); + const [deleteExpansion, setDeleteExpansion] = React.useState(null); + const [detailsExpansion, setDetailsExpansion] = React.useState(null); + const [rebuildExpansion, setRebuildExpansion] = React.useState(null); + const expansionRefs = React.useRef({}); + const hasAccess = currentUserHasAccess(); + const baseRepoURL = dropVersion(repo?.version_url || repo?.url || ""); + const searchParams = React.useMemo( + () => new URLSearchParams(location.search), + [location.search] + ); + const notificationVersion = + searchParams.get("version_url") || + searchParams.get("version") || + searchParams.get("version_id"); + const notificationExpansion = + searchParams.get("expansion_url") || + searchParams.get("expansion") || + searchParams.get("expansion_id"); + const dependencyNotification = Boolean( + notificationVersion || + notificationExpansion || + ["dependency", "stale"].includes( + (searchParams.get("notification") || "").toLowerCase() + ) + ); + + React.useEffect(() => { + if (!baseRepoURL || isHeadVersion(repo)) { + setHeadVersion(repo); + return; + } + + setHeadLoading(true); + APIService.new() + .overrideURL(baseRepoURL) + .get(null, null, { includeSummary: true }, true) + .then(response => { + setHeadVersion(response?.data || response?.response?.data || null); + setHeadLoading(false); + }); + }, [baseRepoURL, repo]); + + const displayVersions = React.useMemo(() => { + const head = headVersion + ? { + ...headVersion, + id: "HEAD", + version: "HEAD", + version_url: headVersion.url || headVersion.version_url || baseRepoURL + } + : null; + const versionList = uniqBy( + [head, ...(versions || [])].filter(Boolean).map(version => ({ + ...version, + ...(versionOverrides[getVersionKey(version)] || {}) + })), + version => getVersionKey(version) + ); + return headFirst(orderBy(versionList, ["created_on"], ["desc"])); + }, [baseRepoURL, headVersion, versionOverrides, versions]); + + const fetchExpansions = React.useCallback( + (version, force = false) => { + const versionKey = getVersionKey(version); + if ( + !versionKey || + loadingByVersion[versionKey] || + (!force && expansionsByVersion[versionKey]) + ) + return; + + const versionURL = getVersionEndpoint(version); + if (!versionURL) return; + + setLoadingByVersion(prev => ({ ...prev, [versionKey]: true })); + APIService.new() + .overrideURL(versionURL) + .appendToUrl("expansions/") + .get(null, null, { includeSummary: true, verbose: true }) + .then(response => { + const data = response?.data || []; + setExpansionsByVersion(prev => ({ + ...prev, + [versionKey]: formatExpansions(version, data) + })); + setLoadingByVersion(prev => ({ ...prev, [versionKey]: false })); + }); + }, + [expansionsByVersion, loadingByVersion] + ); + + React.useEffect(() => { + displayVersions.forEach(version => fetchExpansions(version)); + }, [displayVersions, fetchExpansions]); + + React.useEffect(() => { + if (!displayVersions.length) return; + + const querySelectedVersion = findVersionFromQuery( + displayVersions, + notificationVersion + ); + if ( + querySelectedVersion && + getVersionKey(querySelectedVersion) !== expandedVersionKey + ) + setExpandedVersionKey(getVersionKey(querySelectedVersion)); + }, [displayVersions, expandedVersionKey, notificationVersion]); + + const expandedVersion = + find( + displayVersions, + version => getVersionKey(version) === expandedVersionKey + ) || null; + const expandedExpansions = expandedVersion + ? expansionsByVersion[getVersionKey(expandedVersion)] || [] + : []; + const highlightedExpansion = notificationExpansion + ? findExpansionFromQuery(expandedExpansions, notificationExpansion) + : null; + + React.useEffect(() => { + if ( + highlightedExpansion?.url && + expansionRefs.current[highlightedExpansion.url] + ) + expansionRefs.current[highlightedExpansion.url].scrollIntoView({ + behavior: "smooth", + block: "center" + }); + }, [highlightedExpansion]); + + const getDefaultExpansionLabel = version => { + const versionExpansions = expansionsByVersion[getVersionKey(version)] || []; + const defaultExpansion = find( + versionExpansions, + expansion => expansion.default || expansion.auto + ); + if (defaultExpansion) return defaultExpansion.mnemonic; + if (version?.expansion_url) + return version.expansion_url + .split("/") + .filter(Boolean) + .slice(-1)[0]; + if (version?.autoexpand) return t("repo.autoexpand"); + return t("common.none"); + }; + + const getStaleCount = version => { + const versionExpansions = expansionsByVersion[getVersionKey(version)] || []; + return versionExpansions.filter(isStaleExpansion).length; + }; + + const refreshSelectedExpansions = version => { + if (version) fetchExpansions(version, true); + }; + + const onMarkExpansionDefault = (version, expansion) => { + APIService.new() + .overrideURL(getVersionEndpoint(version)) + .put({ expansion_url: expansion.url }) + .then(response => { + if (response?.status === 200) { + const versionKey = getVersionKey(version); + setVersionOverrides(prev => ({ + ...prev, + [versionKey]: { + ...(prev[versionKey] || {}), + expansion_url: expansion.url + } + })); + setExpansionsByVersion(prev => ({ + ...prev, + [versionKey]: formatExpansions( + { ...version, expansion_url: expansion.url }, + prev[versionKey] || [] + ) + })); + setAlert({ + severity: "success", + message: t("repo.default_expansion_updated") + }); + onDataChange?.(); + } else { + setAlert({ + severity: "error", + message: + response?.data?.detail || + response?.detail || + t("repo.unable_set_default_expansion") + }); + } + }); + }; + + const onDeleteExpansionSubmit = () => { + if (!deleteExpansion?.url) return; + + APIService.new() + .overrideURL(deleteExpansion.url) + .delete() + .then(response => { + if (!response || response?.status === 204) { + setDeleteExpansion(false); + setAlert({ + severity: "success", + message: t("repo.expansion_deleted") + }); + refreshSelectedExpansions(deleteExpansion.__version); + onDataChange?.(); + } else { + setAlert({ + severity: "error", + message: + response?.data?.detail || + response?.detail || + t("repo.unable_delete_expansion") + }); + } + }); + }; + + const onRebuildExpansion = expansion => { + APIService.new() + .overrideURL(`${expansion.url}re-evaluate/`) + .post() + .then(response => { + setRebuildExpansion(false); + if ( + response?.status === 200 || + response?.status === 201 || + response?.status === 202 + ) { + setAlert({ + severity: "success", + message: t("repo.expansion_rebuild_accepted") + }); + refreshSelectedExpansions(expansion.__version); + } else { + setAlert({ + severity: "error", + message: + response?.data?.detail || + response?.detail || + t("repo.unable_rebuild_expansion") + }); + } + }); + }; + + let notificationMessage = ""; + if (highlightedExpansion) { + notificationMessage = t("repo.opened_dependency_expansion", { + expansion: highlightedExpansion.mnemonic, + version: expandedVersion?.version || expandedVersion?.id + }); + } else if (dependencyNotification && expandedVersion) { + notificationMessage = t("repo.opened_dependency_version", { + version: expandedVersion.version || expandedVersion.id + }); + } + + const renderAudit = (translationKey, date, user) => { + if (!date) return null; + + return ( + + {t(translationKey)} {formatDateTime(date)} {t("common.by")}{" "} + {getUserLabel(user) || "-"} + + ); + }; + + return ( + + + + + {t("repo.versions")} + + + {t("repo.versions_expansions_subtitle")} + + + {hasAccess && ( + + {t("repo.new_version")} + + )} + + + {dependencyNotification && notificationMessage && ( + + {notificationMessage} + + )} + + + {headLoading && !displayVersions.length && ( + + + + )} + {displayVersions.map(version => { + const versionKey = getVersionKey(version); + const expanded = versionKey === expandedVersionKey; + const released = Boolean(version.released); + const staleCount = getStaleCount(version); + const canRelease = hasAccess && !isHeadVersion(version) && !released; + const canDelete = + hasAccess && + !isHeadVersion(version) && + !released && + (expansionsByVersion[versionKey] || []).length === 0; + const versionExpansions = expansionsByVersion[versionKey] || []; + const versionLoading = loadingByVersion[versionKey]; + let versionStatusChip; + if (!isHeadVersion(version)) + versionStatusChip = released ? ( + } + /> + ) : ( + } + /> + ); + + return ( + + + + + + + {version.version || version.id} + + {versionStatusChip} + {staleCount > 0 && ( + } + /> + )} + + + + {version.description && ( + + {version.description} + + )} + {version.external_id && ( + + {t("common.external_id")}: {version.external_id} + + )} + {renderAudit( + "repo.created_by_at", + version.created_on, + version.created_by + )} + {renderAudit( + "repo.updated_by_at", + version.updated_on, + version.updated_by + )} + + } + /> + {version.autoexpand && ( + } + /> + )} + + + + + + + + + {versionLoading && ( + + )} + + + + {hasAccess && ( + + {canRelease && ( + onReleaseVersion(version)} + > + {t("common.release")} + + )} + {canDelete && ( + onDeleteVersion(version)} + > + {t("common.delete_label")} + + )} + + )} + + : } + onClick={() => + setExpandedVersionKey(expanded ? null : versionKey) + } + > + {expanded + ? t("repo.hide_expansions") + : t("repo.show_expansions", { + count: versionExpansions.length + })} + + + + + + + + + + {t("repo.expansions")} + + + {t("repo.expansions_for_version", { + version: version.version || version.id + })} + + + {hasAccess && ( + + setExpansionFormState({ + open: true, + version, + copyFrom: null + }) + } + > + {t("repo.new_expansion")} + + )} + + + {versionLoading && ( + + + + )} + + {!versionLoading && versionExpansions.length === 0 && ( + + + {t("repo.no_expansions_yet")} + + + {t("repo.no_expansions_message")} + + {hasAccess && ( + + setExpansionFormState({ + open: true, + version, + copyFrom: null + }) + } + > + {t("repo.create_first_expansion")} + + )} + + )} + + + {!versionLoading && + versionExpansions.map(expansion => { + const highlighted = + highlightedExpansion?.url === expansion.url; + + return ( + { + expansionRefs.current[expansion.url] = element; + }} + sx={{ + p: 1.5, + borderRadius: "8px", + border: "1px solid", + borderColor: highlighted + ? "warning.main" + : "surface.nv80", + backgroundColor: highlighted + ? "rgba(237, 108, 2, 0.06)" + : "white", + boxShadow: "none" + }} + > + + + + + + {expansion.mnemonic} + + {expansion.default && ( + } + /> + )} + {expansion.auto && !expansion.default && ( + } + /> + )} + {isStaleExpansion(expansion) && ( + } + /> + )} + {expansion.is_processing && ( + + )} + + {expansion.canonical_url && ( + + {expansion.canonical_url} + + )} + {renderAudit( + "repo.last_built_by", + expansion.updated_on || expansion.created_on, + expansion.updated_by || expansion.created_by + )} + + + + + + + + + + + + + {!expansion.default && ( + + onMarkExpansionDefault(version, expansion) + } + > + {t("repo.set_as_default")} + + )} + + setExpansionFormState({ + open: true, + version, + copyFrom: expansion + }) + } + > + {t("repo.create_similar")} + + + setRebuildExpansion({ + ...expansion, + __version: version + }) + } + disabled={Boolean(expansion.is_processing)} + > + {t("repo.rebuild")} + + } + onClick={() => setDetailsExpansion(expansion)} + > + {t("common.details")} + + + setDeleteExpansion({ + ...expansion, + __version: version + }) + } + disabled={Boolean(expansion.default)} + > + {t("common.delete_label")} + + + + ); + })} + + + + + ); + })} + + + + setExpansionFormState({ + open: false, + version: null, + copyFrom: null + }) + } + version={expansionFormState.version} + versions={displayVersions} + copyFrom={expansionFormState.copyFrom} + onSubmitSuccess={() => { + refreshSelectedExpansions(expansionFormState.version); + onDataChange?.(); + }} + /> + + setDetailsExpansion(false)} + /> + + setRebuildExpansion(false)} + onRebuild={onRebuildExpansion} + onCreateSimilar={expansion => { + setRebuildExpansion(false); + setExpansionFormState({ + open: true, + version: expansion.__version, + copyFrom: expansion + }); + }} + /> + + setDeleteExpansion(false)} + onSubmit={onDeleteExpansionSubmit} + entityType={t("repo.collection_expansion")} + entityId={deleteExpansion?.mnemonic || ""} + relationship="" + associationsLabel={t("repo.concepts_and_mappings")} + warning={false} + /> + + ); +}; + +export default CollectionVersionsTab; diff --git a/src/components/repos/ExpansionDetailsDialog.jsx b/src/components/repos/ExpansionDetailsDialog.jsx new file mode 100644 index 00000000..e0fa6169 --- /dev/null +++ b/src/components/repos/ExpansionDetailsDialog.jsx @@ -0,0 +1,114 @@ +import React from "react"; +import { + Box, + DialogContent, + DialogActions, + Divider, + Typography, + Button as MuiButton +} from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import Dialog from "../common/Dialog"; +import DialogTitle from "../common/DialogTitle"; + +const RepoVersionList = ({ title, versions = [], emptyLabel }) => ( + + + {title} ({versions.length.toLocaleString()}) + + {versions.length ? ( + + {versions.map(version => ( +
  • + + {`${version.owner} / ${version.short_code}:${version.version}`} + +
  • + ))} +
    + ) : ( + + {emptyLabel} + + )} +
    +); + +const ExpansionDetailsDialog = ({ expansion, onClose }) => { + const { t } = useTranslation(); + if (!expansion) return null; + + const explicitRepoVersions = [ + ...(expansion?.explicit_source_versions || []), + ...(expansion?.explicit_collection_versions || []) + ]; + const evaluatedRepoVersions = [ + ...(expansion?.evaluated_source_versions || []), + ...(expansion?.evaluated_collection_versions || []) + ]; + + return ( + + + {t("repo.expansion_details_title", { mnemonic: expansion.mnemonic })} + + + + {t("repo.version.expansion_form.parameters")} + + + {JSON.stringify(expansion.parameters || {}, null, 2)} + + + + + + + + + + {t("common.close")} + + + + ); +}; + +export default ExpansionDetailsDialog; diff --git a/src/components/repos/ExpansionForm.jsx b/src/components/repos/ExpansionForm.jsx new file mode 100644 index 00000000..c30fb5eb --- /dev/null +++ b/src/components/repos/ExpansionForm.jsx @@ -0,0 +1,463 @@ +import React from "react"; +import { + Autocomplete, + Box, + Checkbox, + FormControlLabel, + Stack, + TextField, + Tooltip, + Typography, + Button as MuiButton +} from "@mui/material"; +import { Info as InfoIcon } from "@mui/icons-material"; +import { + get, + set, + isBoolean, + isString, + map, + uniq, + has, + forEach, + pickBy, + values +} from "lodash"; +import { useTranslation } from "react-i18next"; + +import APIService from "../../services/APIService"; +import { dropVersion } from "../../common/utils"; +import { OperationsContext } from "../app/LayoutContext"; +import Dialog from "../common/Dialog"; +import DialogTitle from "../common/DialogTitle"; +import CloseIconButton from "../common/CloseIconButton"; + +const PARAMETER_CONFIG = { + filter: { supported: true }, + activeOnly: { supported: true }, + date: { + supported: true, + regex: /([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1])(T([01][0-9]|2[0-3]):[0-5][0-9]:([0-5][0-9]|60)(\.[0-9]+)?(Z|(\+|-)((0[0-9]|1[0-3]):[0-5][0-9]|14:00)))?)?)?/gm, + format: "YYYY, YYYY-MM, YYYY-MM-DD or YYYY-MM-DD hh:mm:ss" + }, + count: { supported: false }, + offset: { supported: false }, + includeDesignations: { supported: false }, + includeDefinition: { supported: false }, + excludeNested: { supported: false }, + excludeNotForUI: { supported: false }, + excludePostCoordinated: { supported: false }, + "exclude-system": { supported: true }, + "system-version": { supported: true }, + "check-system-version": { supported: false }, + "force-system-version": { supported: false } +}; + +const defaultFields = () => ({ + mnemonic: "", + canonical_url: "", + parameters: { + filter: "", + "exclude-system": "", + "system-version": "", + date: "", + count: 0, + offset: 0, + activeOnly: false, + includeDesignations: true, + includeDefinition: false, + excludeNested: true, + excludeNotForUI: true, + excludePostCoordinated: true, + "check-system-version": "", + "force-system-version": "" + } +}); + +const getVersionEndpoint = version => { + const versionURL = version?.version_url || version?.url || ""; + return version?.version === "HEAD" + ? `${dropVersion(versionURL)}HEAD/` + : versionURL; +}; + +const fieldSx = { + background: "none", + "& .MuiFormHelperText-root": { + mx: 0, + px: 1.75, + pb: 0.25 + } +}; + +const ExpansionForm = ({ + open, + onClose, + versions = [], + version, + copyFrom, + onSubmitSuccess +}) => { + const { t } = useTranslation(); + const { setAlert } = React.useContext(OperationsContext); + + const getInitialState = React.useCallback(() => { + const fields = defaultFields(); + const source = copyFrom; + if (source) { + fields.mnemonic = ""; + fields.canonical_url = source.canonical_url || ""; + forEach(fields.parameters, (value, key) => { + if (has(source.parameters, key)) + fields.parameters[key] = source.parameters[key]; + }); + } + + return { + selectedVersion: version || null, + fields, + fieldErrors: {}, + helperTexts: {}, + saving: false + }; + }, [copyFrom, version]); + + const [state, setState] = React.useState(getInitialState); + const { selectedVersion, fields, fieldErrors, helperTexts, saving } = state; + + React.useEffect(() => { + setState(getInitialState()); + }, [getInitialState, open]); + + const setFieldValue = (path, value) => { + setState(prev => { + const next = { ...prev }; + set(next, path, value); + return next; + }); + }; + + const onRegexTextFieldChange = (event, parameter) => { + const { value, id } = event.target; + const fieldId = id.replace("fields.parameters.", ""); + const re = new RegExp(parameter.regex); + const matches = value.match(re); + const nextState = { + ...state, + fields: { ...state.fields, parameters: { ...state.fields.parameters } } + }; + nextState.fields.parameters[fieldId] = value; + + if (matches) { + nextState.fieldErrors[fieldId] = undefined; + const newValue = uniq(matches).join(","); + nextState.helperTexts[fieldId] = + newValue !== value + ? t("repo.version.expansion_form.cleaned_values", { + values: newValue + }) + : undefined; + } else if (value) { + nextState.helperTexts[fieldId] = undefined; + nextState.fieldErrors[fieldId] = t( + "repo.version.expansion_form.format_hint", + { + format: parameter.format + } + ); + } else { + nextState.helperTexts[fieldId] = undefined; + nextState.fieldErrors[fieldId] = undefined; + } + + setState(nextState); + }; + + const onRegexTextFieldBlur = (event, parameter) => { + const { value, id } = event.target; + const fieldId = id.replace("fields.parameters.", ""); + const helperText = get(helperTexts, fieldId); + const error = get(fieldErrors, fieldId); + + if (helperText && !error) { + const re = new RegExp(parameter.regex); + const matches = value.match(re); + if (matches) + setFieldValue(`fields.parameters.${fieldId}`, uniq(matches).join(",")); + } + }; + + const getPayload = () => ({ + mnemonic: fields.mnemonic, + canonical_url: fields.canonical_url || undefined, + parameters: pickBy( + fields.parameters, + value => value !== "" && value !== null && value !== undefined + ) + }); + + const onSubmit = event => { + event.preventDefault(); + event.stopPropagation(); + + const payload = getPayload(); + + if (!payload.mnemonic) { + setState(prev => ({ + ...prev, + fieldErrors: { + ...prev.fieldErrors, + mnemonic: t("repo.version.expansion_form.id_required") + } + })); + return; + } + + if (!selectedVersion) { + setAlert({ + severity: "error", + message: t("repo.version.expansion_form.version_required") + }); + return; + } + + setState(prev => ({ ...prev, saving: true })); + + const request = APIService.new() + .overrideURL(getVersionEndpoint(selectedVersion)) + .appendToUrl("expansions/") + .post(payload); + + request.then(response => { + setState(prev => ({ ...prev, saving: false })); + if (response?.status === 200 || response?.status === 201) { + setAlert({ + severity: "success", + message: t("repo.version.expansion_form.created") + }); + onSubmitSuccess(response?.data || payload); + onClose(); + } else { + const genericError = get(response, "__all__"); + setAlert({ + severity: "error", + message: + genericError?.join("\n") || + get(response, "detail") || + values(response || {}).join("\n") || + t("common.generic_error") + }); + } + }); + }; + + const title = t("repo.version.expansion_form.new_title", { + version: + selectedVersion?.version || + selectedVersion?.id || + t("repo.version.expansion_form.select_version") + }); + + return ( + + + {title} + + + + + + setFieldValue("fields.mnemonic", event.target.value) + } + fullWidth + required + helperText={t("repo.version.form.id.helper_text")} + inputProps={{ pattern: "[a-zA-Z0-9-._@]+" }} + sx={fieldSx} + /> + + option?.version || option?.id || ""} + isOptionEqualToValue={(option, value) => + (option?.version_url || option?.url) === + (value?.version_url || value?.url) + } + onChange={(event, item) => + setState(prev => ({ ...prev, selectedVersion: item })) + } + renderInput={params => ( + + )} + /> + + + setFieldValue("fields.canonical_url", event.target.value) + } + fullWidth + sx={fieldSx} + /> + + + + {t("repo.version.expansion_form.parameters")} + + + + {map(pickBy(fields.parameters, isBoolean), (value, attr) => { + const parameter = PARAMETER_CONFIG[attr]; + return ( + + setFieldValue( + `fields.parameters.${attr}`, + event.target.checked + ) + } + /> + } + label={ + + + {t( + `repo.version.expansion_form.parameters_fields.${attr}.label` + )} + + + + + + } + /> + ); + })} + + + + {map(pickBy(fields.parameters, isString), (value, attr) => { + const parameter = PARAMETER_CONFIG[attr]; + return ( + + parameter.regex + ? onRegexTextFieldChange(event, parameter) + : setFieldValue( + `fields.parameters.${attr}`, + event.target.value + ) + } + onBlur={event => + parameter.regex + ? onRegexTextFieldBlur(event, parameter) + : null + } + fullWidth + helperText={ + fieldErrors[attr] || + helperTexts[attr] || + t( + `repo.version.expansion_form.parameters_fields.${attr}.tooltip` + ) + } + sx={fieldSx} + /> + ); + })} + + + + + + {t("common.create")} + + + + + + ); +}; + +export default ExpansionForm; diff --git a/src/components/repos/RebuildExpansionDialog.jsx b/src/components/repos/RebuildExpansionDialog.jsx new file mode 100644 index 00000000..f11032f3 --- /dev/null +++ b/src/components/repos/RebuildExpansionDialog.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import DialogContent from "@mui/material/DialogContent"; +import DialogActions from "@mui/material/DialogActions"; +import { Button as MuiButton } from "@mui/material"; +import { useTranslation } from "react-i18next"; + +import Dialog from "../common/Dialog"; +import DialogTitle from "../common/DialogTitle"; + +const RebuildExpansionDialog = ({ + expansion, + onClose, + onCreateSimilar, + onRebuild +}) => { + const { t } = useTranslation(); + if (!expansion) return null; + + return ( + + {t("repo.rebuild_expansion")} + + {t("repo.rebuild_expansion_message")} +
    +
    + {t("repo.rebuild_expansion_compare_message")} +
    + + onCreateSimilar(expansion)} + > + {t("repo.create_similar")} + + onRebuild(expansion)} + > + {t("repo.rebuild")} + + + {t("common.cancel")} + + +
    + ); +}; + +export default RebuildExpansionDialog; diff --git a/src/components/repos/RepoHome.jsx b/src/components/repos/RepoHome.jsx index 915d79f9..c232b889 100644 --- a/src/components/repos/RepoHome.jsx +++ b/src/components/repos/RepoHome.jsx @@ -23,6 +23,7 @@ import RepoOverview from './RepoOverview' import VersionForm from './VersionForm' import ReleaseVersion from './ReleaseVersion' import RepoHeader from './RepoHeader'; +import CollectionVersionsTab from './CollectionVersionsTab'; const RepoHome = () => { const { t } = useTranslation() @@ -35,7 +36,8 @@ const RepoHome = () => { ] const isCollection = params.repoType === 'collections' - const [tabs, setTabs] = React.useState(isCollection ? [...TABS, {key: 'references', label: t('reference.references')}] : [...TABS]) + const [tabs, setTabs] = React.useState(isCollection ? [...TABS, {key: 'references', label: t('reference.references')}, {key: 'versions', label: t('repo.versions_expansions')}] : [...TABS]) + const [status, setStatus] = React.useState(false) const [repo, setRepo] = React.useState(false) const [owner, setOwner] = React.useState(false) @@ -46,8 +48,8 @@ const RepoHome = () => { const [conceptForm, setConceptForm] = React.useState(false) const [mappingForm, setMappingForm] = React.useState(false) const [versionForm, setVersionForm] = React.useState(false) - const [deleteRepo, setDeleteRepo] = React.useState(false) - const [releaseVersion, setReleaseVersion] = React.useState(false) + const [deleteTarget, setDeleteTarget] = React.useState(false) + const [releaseTarget, setReleaseTarget] = React.useState(false) const [showSummary, setShowSummary] = React.useState(true) const TAB_KEYS = tabs.map(tab => tab.key) @@ -73,7 +75,7 @@ const RepoHome = () => { fetchOwner() fetchRepoSummary() if(isCollection) - setTabs([...TABS, {key: 'references', label: t('reference.references')}]) + setTabs([...TABS, {key: 'references', label: t('reference.references')}, {key: 'versions', label: t('repo.versions_expansions')}]) else setTabs([...TABS]) @@ -111,8 +113,10 @@ const RepoHome = () => { if(toParentURI(location.pathname) === (repo?.version_url || repo.url)) { if(location.pathname.includes('/concepts')) setTab('concepts') - if(location.pathname.includes('/mappings')) - setTab('mappings') + if(location.pathname.includes('/mappings')) + setTab('mappings') + if(location.pathname.includes('/versions')) + setTab('versions') if(location.pathname.includes('/references')) setTab('references') } @@ -168,7 +172,7 @@ const RepoHome = () => { setShowItem(false) setConceptForm(false) setMappingForm(false) - setVersionForm(true) + setVersionForm({edit: false, version: repo, expansions: []}) } const onVersionFormClose = postUpsert => { @@ -178,21 +182,25 @@ const RepoHome = () => { setVersionForm(false) } - const isVersion = repo?.version && repo.version !== 'HEAD' + const getTargetVersion = target => target || repo + const isVersionObject = target => target?.version && target.version !== 'HEAD' + const isVersion = isVersionObject(repo) const onDeleteRepo = () => { - const url = isVersion ? repo.version_url : repo.url + const target = getTargetVersion(deleteTarget) + const deletingVersion = isVersionObject(target) + const url = deletingVersion ? target.version_url : target.url if(!url) return APIService.new().overrideURL(url).delete().then(response => { if(!response || response?.status === 204) { - setDeleteRepo(false) - setAlert({severity: 'success', message: isVersion ? t('repo.success_delete_version') : t('repo.success_delete')}) - history.push(isVersion ? repo.url : (owner?.url || repo.owner_url)) + setDeleteTarget(false) + setAlert({severity: 'success', message: deletingVersion ? t('repo.success_delete_version') : t('repo.success_delete')}) + history.push(deletingVersion ? target.url : (owner?.url || repo.owner_url)) } else if(response?.status === 202 || response?.detail === 'Already Queued') { - setDeleteRepo(false) - setAlert({severity: 'warning', message: isVersion ? t('repo.delete_accepted_version') : t('repo.delete_accepted')}) + setDeleteTarget(false) + setAlert({severity: 'warning', message: deletingVersion ? t('repo.delete_accepted_version') : t('repo.delete_accepted')}) } else setAlert({severity: 'error', message: response?.data?.detail || t('common.generic_error')}) @@ -200,10 +208,12 @@ const RepoHome = () => { } const onReleaseVersion = () => { - APIService.new().overrideURL(repo.version_url).put({released: !repo.released}).then(response => { - setReleaseVersion(false) + const target = getTargetVersion(releaseTarget) + APIService.new().overrideURL(target.version_url).put({released: !target.released}).then(response => { + setReleaseTarget(false) if(response?.status === 200) { fetchVersions() + fetchRepo() setAlert({severity: 'success', message: t('common.success_update')}) } else if(response?.status === 202 || response?.detail === 'Already Queued' || response?.__all__ === 'Already Queued') { @@ -227,6 +237,7 @@ const RepoHome = () => { const isConceptURL = tab === 'concepts' const isMappingURL = tab === 'mappings' const isReferenceURL = tab === 'references' + const isVersionsURL = tab === 'versions' const getConceptURLFromMainURL = () => (isConceptURL && params.resource) ? getURL() + 'concepts/' + params.resource + '/' : false const getMappingURLFromMainURL = () => (isMappingURL && params.resource) ? getURL() + 'mappings/' + params.resource + '/' : false const getReferenceURLFromMainURL = () => (isReferenceURL && params.resource) ? getURL() + 'references/' + params.resource + '/' : false @@ -235,8 +246,8 @@ const RepoHome = () => { const showReferenceURL = ((showItem?.expression || params.resource) && isReferenceURL) ? showItem?.version_url || showItem?.url || getReferenceURLFromMainURL() : false const isSplitView = conceptForm || mappingForm || showConceptURL || showMappingURL || showReferenceURL || versionForm - const onVersionEditClick = () => isVersion && setVersionForm(true) - const onReleaseVersionClick = () => isVersion && setReleaseVersion(true) + const onVersionEditClick = () => isVersion && setVersionForm({edit: true, version: repo, expansions: []}) + const onReleaseVersionClick = () => isVersion && setReleaseTarget(repo) return (
    @@ -253,7 +264,7 @@ const RepoHome = () => { onCreateConceptClick={onCreateConceptClick} onCreateMappingClick={onCreateMappingClick} onCreateVersionClick={onCreateVersionClick} - onDeleteRepoClick={() => setDeleteRepo(true)} + onDeleteRepoClick={() => setDeleteTarget(repo)} onVersionEditClick={() => onVersionEditClick()} onReleaseVersionClick={() => onReleaseVersionClick()} /> @@ -280,6 +291,20 @@ const RepoHome = () => { propertyFilters={(!tab || tab === 'concepts') ? repo?.filters : []} /> } + { + tab === 'versions' && isCollection && + setReleaseTarget(version)} + onDeleteVersion={version => setDeleteTarget(version)} + onDataChange={() => { + fetchRepo() + fetchVersions() + }} + /> + } { tab === 'about' && @@ -316,24 +341,31 @@ const RepoHome = () => { } { versionForm && - onVersionFormClose(postUpsert)} /> + onVersionFormClose(postUpsert)} + /> } { repo?.id && setDeleteRepo(false)} + open={deleteTarget} + onClose={() => setDeleteTarget(false)} onSubmit={onDeleteRepo} - entityType={isVersion ? repo.type : repo.type.replace(' Version', '')} - entityId={isVersion ? `${repo.short_code} [${repo.version}]` : (repo.short_code || repo.id)} - relationship={isVersion ? '' : 'versions, '} + entityType={isVersionObject(getTargetVersion(deleteTarget)) ? getTargetVersion(deleteTarget).type : repo.type.replace(' Version', '')} + entityId={isVersionObject(getTargetVersion(deleteTarget)) ? `${getTargetVersion(deleteTarget).short_code} [${getTargetVersion(deleteTarget).version}]` : (repo.short_code || repo.id)} + relationship={isVersionObject(getTargetVersion(deleteTarget)) ? '' : 'versions, '} associationsLabel='concepts and mappings' - warning={!isVersion} + warning={!isVersionObject(getTargetVersion(deleteTarget))} /> } { - isVersion && - setReleaseVersion(false)} repo={repo} onSubmit={onReleaseVersion} /> + Boolean(releaseTarget) && + setReleaseTarget(false)} repo={getTargetVersion(releaseTarget)} onSubmit={onReleaseVersion} /> }
    diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 86fdebe4..079d35cb 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -94,6 +94,7 @@ "to_concepts_in": "to concepts in", "json": "JSON", "draft": "Draft", + "released": "Released", "highlights": "Highlights", "click_to_copy": "Click to Copy", "submit": "Submit", @@ -111,10 +112,14 @@ "your_url_will_be": "Your URL will be", "proceed": "Proceed", "loading": "Loading...", + "none": "None", + "default": "Default", + "processing": "Processing", "release": "Release", "unrelease": "Un-Release", "reason": "Reason", "success_update": "Successfully Updated", + "generic_error": "Something went wrong.", "company": "Company", "location": "Location", "hierarchy": "Hierarchy", @@ -331,10 +336,59 @@ "success_create": "Successfuly created repo", "success_delete": "Successfuly deleted repo", "success_delete_version": "Successfuly deleted repo version", + "success_version_create": "Successfully created version", + "success_version_update": "Successfully updated version", + "error_version_create": "Could not create version", + "error_version_update": "Could not update version", + "versions_expansions": "Versions + Expansions", + "versions": "Versions", + "expansions": "Expansions", + "versions_expansions_subtitle": "HEAD, released versions, and their expansions", + "autoexpand": "Auto-expand", + "expansion_url": "Expansion URL", + "canonical_url": "Canonical URL", "autoexpand_head": "AutoExpand HEAD", "immutable": "Immutable", "ocl_repo_url": "OCL Repo URL", "create_version": "Create Version", + "new_version": "New Version", + "new_expansion": "New Expansion", + "show_expansions": "Show expansions ({{count}})", + "hide_expansions": "Hide expansions", + "default_expansion_label": "Default: {{expansion}}", + "edit_description": "Edit description", + "stale": "Stale", + "stale_count": "{{count}} stale", + "expansions_for_version": "For version {{version}}", + "no_description": "No description", + "no_expansions_yet": "No expansions yet", + "no_expansions_message": "This collection version does not have any expansions yet.", + "create_first_expansion": "Create First Expansion", + "set_as_default": "Set as default", + "update_metadata": "Update metadata", + "create_similar": "Create similar", + "rebuild": "Rebuild", + "rebuild_expansion": "Rebuild Expansion", + "rebuild_expansion_message": "This will re-evaluate all references in this collection version. This cannot be undone.", + "rebuild_expansion_compare_message": "You can also create a similar expansion first if you want to compare results side by side.", + "collection_version_updated": "Collection version updated.", + "unable_update_version_metadata": "Unable to update version metadata.", + "default_expansion_updated": "Default expansion updated.", + "unable_set_default_expansion": "Unable to set default expansion.", + "expansion_deleted": "Expansion deleted.", + "unable_delete_expansion": "Unable to delete expansion.", + "expansion_rebuild_accepted": "Expansion rebuild accepted.", + "unable_rebuild_expansion": "Unable to rebuild expansion.", + "opened_dependency_expansion": "Opened from a dependency notification. Showing {{expansion}} in version {{version}}.", + "opened_dependency_version": "Opened from a dependency notification. Showing version {{version}}.", + "created_by_at": "Created", + "updated_by_at": "Updated", + "last_built_by": "Last built", + "collection_expansion": "Collection Expansion", + "concepts_and_mappings": "concepts and mappings", + "expansion_details_title": "Expansion: {{mnemonic}}", + "explicit_repo_versions": "Explicit Repo Versions", + "evaluated_repo_versions": "Evaluated Repo Versions", "version": { "vectorized_for_mapper": "Concepts vectorization is enabled for this repository version", "form": { @@ -346,6 +400,77 @@ "update_dialog": { "title": "Update {{resourceType}}: {{resourceId}}", "message": "Are you sure you want to {{action}} this {{resourceType}} {{repoId}}?" + }, + "expansion_form": { + "new_title": "New Expansion: {{version}}", + "edit_title": "Edit Expansion: {{mnemonic}}", + "select_version": "Select version", + "collection_version": "Collection Version", + "parameters": "Parameters", + "id_required": "ID is required", + "version_required": "Please select a collection version.", + "created": "Expansion created.", + "updated": "Expansion updated.", + "cleaned_values": "Cleaned values: {{values}}", + "format_hint": "Format: {{format}}", + "parameters_fields": { + "filter": { + "label": "filter", + "tooltip": "Add search criteria" + }, + "activeOnly": { + "label": "activeOnly", + "tooltip": "Select this to include unretired concepts and mappings only" + }, + "date": { + "label": "date", + "tooltip": "The revision date filter. Format: YYYY, YYYY-MM, YYYY-MM-DD, or full timestamp" + }, + "count": { + "label": "count", + "tooltip": "This parameter is not yet supported." + }, + "offset": { + "label": "offset", + "tooltip": "This parameter is not yet supported." + }, + "includeDesignations": { + "label": "includeDesignations", + "tooltip": "This parameter is not yet supported." + }, + "includeDefinition": { + "label": "includeDefinition", + "tooltip": "This parameter is not yet supported." + }, + "excludeNested": { + "label": "excludeNested", + "tooltip": "This parameter is not yet supported." + }, + "excludeNotForUI": { + "label": "excludeNotForUI", + "tooltip": "This parameter is not yet supported." + }, + "excludePostCoordinated": { + "label": "excludePostCoordinated", + "tooltip": "This parameter is not yet supported." + }, + "exclude-system": { + "label": "exclude-system", + "tooltip": "Canonical URL with optional version to exclude from the expansion" + }, + "system-version": { + "label": "system-version", + "tooltip": "Canonical URL with optional version to force while resolving a system" + }, + "check-system-version": { + "label": "check-system-version", + "tooltip": "This parameter is not yet supported." + }, + "force-system-version": { + "label": "force-system-version", + "tooltip": "This parameter is not yet supported." + } + } } }, "delete_repo": "Delete Repo", From 564c0ab6bbd62f1e77769fca014004e588ffba2f Mon Sep 17 00:00:00 2001 From: Sunny Aggarwal Date: Tue, 14 Apr 2026 17:12:44 +0530 Subject: [PATCH 2/2] OpenConceptLab/ocl_issues#1983 | Using nested table and OCL table theme --- .../repos/CollectionVersionsTab.jsx | 1131 +++++++++-------- src/components/repos/RepoHome.jsx | 3 + src/i18n/locales/en/translations.json | 1 + 3 files changed, 597 insertions(+), 538 deletions(-) diff --git a/src/components/repos/CollectionVersionsTab.jsx b/src/components/repos/CollectionVersionsTab.jsx index 5470228a..eac79e13 100644 --- a/src/components/repos/CollectionVersionsTab.jsx +++ b/src/components/repos/CollectionVersionsTab.jsx @@ -7,26 +7,32 @@ import { Chip, CircularProgress, Collapse, - Divider, + IconButton, + Menu, + MenuItem, Paper, Stack, - Typography + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, + Skeleton } from "@mui/material"; import { - AccountTreeOutlined as VersionIcon, AspectRatio as ExpansionIcon, CheckCircleOutline as DefaultIcon, - EditOutlined as DraftIcon, ExpandLess as CollapseIcon, ExpandMore as ExpandIcon, - InfoOutlined as InfoIcon, + MoreVert as MoreVertIcon, NewReleases as ReleaseIcon, WarningAmberOutlined as WarningIcon } from "@mui/icons-material"; import find from "lodash/find"; import orderBy from "lodash/orderBy"; import uniqBy from "lodash/uniqBy"; -import filter from "lodash/filter"; import get from "lodash/get"; import isNumber from "lodash/isNumber"; import { useTranslation } from "react-i18next"; @@ -138,44 +144,67 @@ const getReferencesCount = entity => { return undefined; }; -const SummaryMetric = ({ label, value }) => ( - - - {label} - - - {isNumber(value) ? value.toLocaleString() : "-"} - - -); +const headerCellSx = { + color: "surface.contrastText", + fontSize: "12px", + fontWeight: 'bold', + borderBottom: "1px solid", + borderColor: "surface.nv80", + backgroundColor: "white", + padding: '3px 16px', + lineHeight: '1.2rem' +}; + +const bodyCellSx = { + borderBottom: "1px solid", + borderColor: "surface.nv80", + verticalAlign: "top", + py: 1.25 +}; -const StatusChip = ({ - label, - color = "default", - icon = undefined, - variant = "filled" -}) => ( +const compactButtonSx = { + minWidth: 0, + px: 0, + textTransform: "none", + justifyContent: "flex-start" +}; + +const MetaChip = ({ label, color = "default", icon, variant = "outlined" }) => ( ); +const CountCell = ({ value }) => ( + + {isNumber(value) ? value.toLocaleString() : "-"} + +); + +const getExplicitRepoVersions = expansion => [ + ...(expansion?.explicit_source_versions || []), + ...(expansion?.explicit_collection_versions || []) +]; + +const getEvaluatedRepoVersions = expansion => [ + ...(expansion?.evaluated_source_versions || []), + ...(expansion?.evaluated_collection_versions || []) +]; + +const renderRepoVersionLabel = version => + `${version.owner} / ${version.short_code}:${version.version}`; + const CollectionVersionsTab = ({ repo, versions, + count, onCreateVersion, onReleaseVersion, onDeleteVersion, @@ -200,6 +229,15 @@ const CollectionVersionsTab = ({ const [deleteExpansion, setDeleteExpansion] = React.useState(null); const [detailsExpansion, setDetailsExpansion] = React.useState(null); const [rebuildExpansion, setRebuildExpansion] = React.useState(null); + const [versionMenu, setVersionMenu] = React.useState({ + anchorEl: null, + version: null + }); + const [expansionMenu, setExpansionMenu] = React.useState({ + anchorEl: null, + version: null, + expansion: null + }); const expansionRefs = React.useRef({}); const hasAccess = currentUserHasAccess(); const baseRepoURL = dropVersion(repo?.version_url || repo?.url || ""); @@ -248,6 +286,7 @@ const CollectionVersionsTab = ({ version_url: headVersion.url || headVersion.version_url || baseRepoURL } : null; + const versionList = uniqBy( [head, ...(versions || [])].filter(Boolean).map(version => ({ ...version, @@ -255,6 +294,7 @@ const CollectionVersionsTab = ({ })), version => getVersionKey(version) ); + return headFirst(orderBy(versionList, ["created_on"], ["desc"])); }, [baseRepoURL, headVersion, versionOverrides, versions]); @@ -302,8 +342,9 @@ const CollectionVersionsTab = ({ if ( querySelectedVersion && getVersionKey(querySelectedVersion) !== expandedVersionKey - ) + ) { setExpandedVersionKey(getVersionKey(querySelectedVersion)); + } }, [displayVersions, expandedVersionKey, notificationVersion]); const expandedVersion = @@ -322,11 +363,12 @@ const CollectionVersionsTab = ({ if ( highlightedExpansion?.url && expansionRefs.current[highlightedExpansion.url] - ) + ) { expansionRefs.current[highlightedExpansion.url].scrollIntoView({ behavior: "smooth", block: "center" }); + } }, [highlightedExpansion]); const getDefaultExpansionLabel = version => { @@ -336,11 +378,12 @@ const CollectionVersionsTab = ({ expansion => expansion.default || expansion.auto ); if (defaultExpansion) return defaultExpansion.mnemonic; - if (version?.expansion_url) + if (version?.expansion_url) { return version.expansion_url .split("/") .filter(Boolean) .slice(-1)[0]; + } if (version?.autoexpand) return t("repo.autoexpand"); return t("common.none"); }; @@ -463,477 +506,295 @@ const CollectionVersionsTab = ({ if (!date) return null; return ( - + {t(translationKey)} {formatDateTime(date)} {t("common.by")}{" "} {getUserLabel(user) || "-"} ); }; - return ( - - - - - {t("repo.versions")} - - - {t("repo.versions_expansions_subtitle")} - - - {hasAccess && ( - - {t("repo.new_version")} - - )} - - - {dependencyNotification && notificationMessage && ( - - {notificationMessage} - - )} - - - {headLoading && !displayVersions.length && ( - - - - )} - {displayVersions.map(version => { - const versionKey = getVersionKey(version); - const expanded = versionKey === expandedVersionKey; - const released = Boolean(version.released); - const staleCount = getStaleCount(version); - const canRelease = hasAccess && !isHeadVersion(version) && !released; - const canDelete = - hasAccess && - !isHeadVersion(version) && - !released && - (expansionsByVersion[versionKey] || []).length === 0; - const versionExpansions = expansionsByVersion[versionKey] || []; - const versionLoading = loadingByVersion[versionKey]; - let versionStatusChip; - if (!isHeadVersion(version)) - versionStatusChip = released ? ( - } - /> - ) : ( - } - /> - ); - - return ( - - { + setVersionMenu({ anchorEl: event.currentTarget, version }); + }; + + const closeVersionMenu = () => { + setVersionMenu({ anchorEl: null, version: null }); + }; + + const openExpansionMenu = (event, version, expansion) => { + setExpansionMenu({ anchorEl: event.currentTarget, version, expansion }); + }; + + const closeExpansionMenu = () => { + setExpansionMenu({ anchorEl: null, version: null, expansion: null }); + }; + + const renderVersionRow = version => { + const versionKey = getVersionKey(version); + const expanded = versionKey === expandedVersionKey; + const released = Boolean(version.released); + const staleCount = getStaleCount(version); + const canRelease = hasAccess && !isHeadVersion(version) && !released; + const canDelete = + hasAccess && + !isHeadVersion(version) && + !released && + (expansionsByVersion[versionKey] || []).length === 0; + const versionExpansions = expansionsByVersion[versionKey] || []; + const versionLoading = loadingByVersion[versionKey]; + + return ( + + + + + + setExpandedVersionKey(expanded ? null : versionKey) + } > - - : } + + + + - + {isHeadVersion(version) && } + {!isHeadVersion(version) && released && ( + } /> - - {version.version || version.id} - - {versionStatusChip} - {staleCount > 0 && ( - } - /> - )} - - - - {version.description && ( - - {version.description} - - )} - {version.external_id && ( - - {t("common.external_id")}: {version.external_id} - - )} - {renderAudit( - "repo.created_by_at", - version.created_on, - version.created_by - )} - {renderAudit( - "repo.updated_by_at", - version.updated_on, - version.updated_by - )} - - } - /> - {version.autoexpand && ( - } - /> - )} - - - - - - - - - {versionLoading && ( - )} - - - - {hasAccess && ( - - {canRelease && ( - onReleaseVersion(version)} - > - {t("common.release")} - + {!isHeadVersion(version) && !released && ( + } + /> )} - {canDelete && ( - 0 && ( + onDeleteVersion(version)} - > - {t("common.delete_label")} - + icon={} + /> + )} + } + /> + {version.autoexpand && ( + } + /> )} - )} - + {version.description} + + )} + + {renderAudit( + "repo.created_by_at", + version.created_on, + version.created_by + )} + {renderAudit( + "repo.updated_by_at", + version.updated_on, + version.updated_by + )} + + + + + + + + + + + {versionLoading ? ( + + ) : ( + + )} + + + {hasAccess && ( + : } - onClick={() => - setExpandedVersionKey(expanded ? null : versionKey) - } + onClick={event => openVersionMenu(event, version)} > - {expanded - ? t("repo.hide_expansions") - : t("repo.show_expansions", { - count: versionExpansions.length - })} - - - - - - - - - + + + )} + + + + + + + + + + {t("repo.expansions")} - - - {t("repo.expansions_for_version", { - version: version.version || version.id - })} - - - {hasAccess && ( - - setExpansionFormState({ - open: true, - version, - copyFrom: null - }) - } - > - {t("repo.new_expansion")} - - )} - - - {versionLoading && ( - - - - )} - - {!versionLoading && versionExpansions.length === 0 && ( - - - {t("repo.no_expansions_yet")} - - - {t("repo.no_expansions_message")} - - {hasAccess && ( - - setExpansionFormState({ - open: true, - version, - copyFrom: null - }) - } + + + {t("search.concepts")} + + + {t("search.mappings")} + + + {t("repo.resolved_repo_versions")} + + + + + + {versionLoading && ( + + - {t("repo.create_first_expansion")} - - )} - - )} - - + + + + )} + {!versionLoading && versionExpansions.length === 0 && ( + + + + + {t("repo.no_expansions_message")} + + {hasAccess && ( + + setExpansionFormState({ + open: true, + version, + copyFrom: null + }) + } + > + {t("repo.create_first_expansion")} + + )} + + + + )} {!versionLoading && versionExpansions.map(expansion => { const highlighted = highlightedExpansion?.url === expansion.url; - + const explicitRepoVersions = getExplicitRepoVersions( + expansion + ); + const evaluatedRepoVersions = getEvaluatedRepoVersions( + expansion + ); return ( - { expansionRefs.current[expansion.url] = element; }} sx={{ - p: 1.5, - borderRadius: "8px", - border: "1px solid", - borderColor: highlighted - ? "warning.main" - : "surface.nv80", backgroundColor: highlighted ? "rgba(237, 108, 2, 0.06)" - : "white", - boxShadow: "none" + : "transparent" }} > - - - + + - + setDetailsExpansion(expansion) + } > {expansion.mnemonic} {expansion.default && ( - } /> )} {expansion.auto && !expansion.default && ( - } /> )} {isStaleExpansion(expansion) && ( - } /> )} - {expansion.is_processing && ( - - )} - + {expansion.canonical_url && ( - - - + + + + + + + + + + + {t("repo.explicit_repo_versions")} + + {explicitRepoVersions.length ? ( + + {explicitRepoVersions.map(repoVersion => ( + + {renderRepoVersionLabel(repoVersion)} + + ))} + + ) : ( + + {t("common.none")} + + )} - - - - - - - + {t("repo.evaluated_repo_versions")} + + {evaluatedRepoVersions.length ? ( + + {evaluatedRepoVersions.map(repoVersion => ( + + {renderRepoVersionLabel(repoVersion)} + + ))} + + ) : ( + + {t("common.none")} + + )} + + + - {!expansion.default && ( - - onMarkExpansionDefault(version, expansion) + onClick={event => + openExpansionMenu(event, version, expansion) } > - {t("repo.set_as_default")} - + + )} - - setExpansionFormState({ - open: true, - version, - copyFrom: expansion - }) - } - > - {t("repo.create_similar")} - - - setRebuildExpansion({ - ...expansion, - __version: version - }) - } - disabled={Boolean(expansion.is_processing)} - > - {t("repo.rebuild")} - - } - onClick={() => setDetailsExpansion(expansion)} - > - {t("common.details")} - - - setDeleteExpansion({ - ...expansion, - __version: version - }) - } - disabled={Boolean(expansion.default)} - > - {t("common.delete_label")} - - - + + ); })} - - - - - ); - })} - + +
    +
    +
    +
    +
    + + ); + }; + + return ( + + + {count !== false ? `${count} versions` : } + + + + + + + + {t("common.id")} + {t("search.concepts")} + {t("search.mappings")} + + {t("reference.references")} + + + + + + {headLoading && !displayVersions.length && ( + + + + + + )} + {displayVersions.map(renderVersionRow)} + +
    +
    +
    + + + { + const version = versionMenu.version; + closeVersionMenu(); + if (version) { + setExpansionFormState({ open: true, version, copyFrom: null }); + } + }} + > + {t("repo.new_expansion")} + + {Boolean( + versionMenu.version && + hasAccess && + !isHeadVersion(versionMenu.version) && + !versionMenu.version.released + ) && ( + { + const version = versionMenu.version; + closeVersionMenu(); + if (version) onReleaseVersion(version); + }} + > + {t("common.release")} + + )} + {Boolean( + versionMenu.version && + hasAccess && + !isHeadVersion(versionMenu.version) && + !versionMenu.version.released && + (expansionsByVersion[getVersionKey(versionMenu.version)] || []) + .length === 0 + ) && ( + { + const version = versionMenu.version; + closeVersionMenu(); + if (version) onDeleteVersion(version); + }} + sx={{ color: "error.main" }} + > + {t("common.delete_label")} + + )} + + + + {Boolean( + expansionMenu.expansion && !expansionMenu.expansion.default + ) && ( + { + const { version, expansion } = expansionMenu; + closeExpansionMenu(); + if (version && expansion) + onMarkExpansionDefault(version, expansion); + }} + > + {t("repo.set_as_default")} + + )} + { + const { version, expansion } = expansionMenu; + closeExpansionMenu(); + if (version && expansion) { + setExpansionFormState({ + open: true, + version, + copyFrom: expansion + }); + } + }} + > + {t("repo.create_similar")} + + { + const { version, expansion } = expansionMenu; + closeExpansionMenu(); + if (version && expansion) { + setRebuildExpansion({ ...expansion, __version: version }); + } + }} + > + {t("repo.rebuild")} + + { + const expansion = expansionMenu.expansion; + closeExpansionMenu(); + if (expansion) setDetailsExpansion(expansion); + }} + > + {t("common.details")} + + { + const { version, expansion } = expansionMenu; + closeExpansionMenu(); + if (version && expansion) { + setDeleteExpansion({ ...expansion, __version: version }); + } + }} + sx={{ color: "error.main" }} + > + {t("common.delete_label")} + +
    ); }; diff --git a/src/components/repos/RepoHome.jsx b/src/components/repos/RepoHome.jsx index c232b889..cf048cb1 100644 --- a/src/components/repos/RepoHome.jsx +++ b/src/components/repos/RepoHome.jsx @@ -43,6 +43,7 @@ const RepoHome = () => { const [owner, setOwner] = React.useState(false) const [repoSummary, setRepoSummary] = React.useState(false) const [versions, setVersions] = React.useState(false) + const [versionsCount, setVersionsCount] = React.useState(false) const [loading, setLoading] = React.useState(true) const [showItem, setShowItem] = React.useState(false) const [conceptForm, setConceptForm] = React.useState(false) @@ -100,6 +101,7 @@ const RepoHome = () => { APIService.new().overrideURL(dropVersion(getURL())).appendToUrl('versions/').get(null, null, {verbose:true, includeSummary: true, limit: 100}).then(response => { const _versions = response?.data || [] setVersions(_versions) + setVersionsCount(response.headers['num_found'] || 1) if(!repo.version_url && params.repoVersion !== 'HEAD' && !showConceptURL && !showMappingURL) { const releasedVersions = filter(_versions, {released: true}) let version = orderBy(releasedVersions, 'created_on', ['desc'])[0] || orderBy(_versions, 'created_on', ['desc'])[0] @@ -296,6 +298,7 @@ const RepoHome = () => { setReleaseTarget(version)} onDeleteVersion={version => setDeleteTarget(version)} diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 079d35cb..844017f6 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -389,6 +389,7 @@ "expansion_details_title": "Expansion: {{mnemonic}}", "explicit_repo_versions": "Explicit Repo Versions", "evaluated_repo_versions": "Evaluated Repo Versions", + "resolved_repo_versions": "Resolved Repo Versions", "version": { "vectorized_for_mapper": "Concepts vectorization is enabled for this repository version", "form": {