diff --git a/src/components/repos/CollectionVersionsTab.jsx b/src/components/repos/CollectionVersionsTab.jsx
new file mode 100644
index 00000000..eac79e13
--- /dev/null
+++ b/src/components/repos/CollectionVersionsTab.jsx
@@ -0,0 +1,1160 @@
+import React from "react";
+import { useLocation } from "react-router-dom";
+import {
+ Alert,
+ Box,
+ Button as MuiButton,
+ Chip,
+ CircularProgress,
+ Collapse,
+ IconButton,
+ Menu,
+ MenuItem,
+ Paper,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography,
+ Skeleton
+} from "@mui/material";
+import {
+ AspectRatio as ExpansionIcon,
+ CheckCircleOutline as DefaultIcon,
+ ExpandLess as CollapseIcon,
+ ExpandMore as ExpandIcon,
+ 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 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 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 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,
+ 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 [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 || "");
+ 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) || "-"}
+
+ );
+ };
+
+ const openVersionMenu = (event, version) => {
+ 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)
+ }
+ >
+ {expanded ? : }
+
+
+
+
+ {version.version || version.id}
+
+ {isHeadVersion(version) && }
+ {!isHeadVersion(version) && released && (
+ }
+ />
+ )}
+ {!isHeadVersion(version) && !released && (
+ }
+ />
+ )}
+ {staleCount > 0 && (
+ }
+ />
+ )}
+ }
+ />
+ {version.autoexpand && (
+ }
+ />
+ )}
+
+
+ {version.description && (
+
+ {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 && (
+ openVersionMenu(event, version)}
+ >
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {t("repo.expansions")}
+
+
+ {t("search.concepts")}
+
+
+ {t("search.mappings")}
+
+
+ {t("repo.resolved_repo_versions")}
+
+
+
+
+
+ {versionLoading && (
+
+
+
+
+
+ )}
+ {!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={{
+ backgroundColor: highlighted
+ ? "rgba(237, 108, 2, 0.06)"
+ : "transparent"
+ }}
+ >
+
+
+
+
+ setDetailsExpansion(expansion)
+ }
+ >
+ {expansion.mnemonic}
+
+ {expansion.default && (
+ }
+ />
+ )}
+ {expansion.auto && !expansion.default && (
+ }
+ />
+ )}
+ {isStaleExpansion(expansion) && (
+ }
+ />
+ )}
+
+ {expansion.canonical_url && (
+
+ {expansion.canonical_url}
+
+ )}
+ {renderAudit(
+ "repo.last_built_by",
+ expansion.updated_on || expansion.created_on,
+ expansion.updated_by || expansion.created_by
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {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")}
+
+ )}
+
+
+
+ {hasAccess && (
+
+ openExpansionMenu(event, version, expansion)
+ }
+ >
+
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+
+
+
+ );
+ };
+
+ return (
+
+
+ {count !== false ? `${count} versions` : }
+
+
+
+
+
+
+
+ {t("common.id")}
+ {t("search.concepts")}
+ {t("search.mappings")}
+
+ {t("reference.references")}
+
+
+
+
+
+ {headLoading && !displayVersions.length && (
+
+
+
+
+
+ )}
+ {displayVersions.map(renderVersionRow)}
+
+
+
+
+
+
+ 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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+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 (
+
+ );
+};
+
+export default RebuildExpansionDialog;
diff --git a/src/components/repos/RepoHome.jsx b/src/components/repos/RepoHome.jsx
index 915d79f9..cf048cb1 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,19 +36,21 @@ 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)
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)
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 +76,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])
@@ -98,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]
@@ -111,8 +115,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 +174,7 @@ const RepoHome = () => {
setShowItem(false)
setConceptForm(false)
setMappingForm(false)
- setVersionForm(true)
+ setVersionForm({edit: false, version: repo, expansions: []})
}
const onVersionFormClose = postUpsert => {
@@ -178,21 +184,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 +210,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 +239,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 +248,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 +266,7 @@ const RepoHome = () => {
onCreateConceptClick={onCreateConceptClick}
onCreateMappingClick={onCreateMappingClick}
onCreateVersionClick={onCreateVersionClick}
- onDeleteRepoClick={() => setDeleteRepo(true)}
+ onDeleteRepoClick={() => setDeleteTarget(repo)}
onVersionEditClick={() => onVersionEditClick()}
onReleaseVersionClick={() => onReleaseVersionClick()}
/>
@@ -280,6 +293,21 @@ const RepoHome = () => {
propertyFilters={(!tab || tab === 'concepts') ? repo?.filters : []}
/>
}
+ {
+ tab === 'versions' && isCollection &&
+ setReleaseTarget(version)}
+ onDeleteVersion={version => setDeleteTarget(version)}
+ onDataChange={() => {
+ fetchRepo()
+ fetchVersions()
+ }}
+ />
+ }
{
tab === 'about' &&
@@ -316,24 +344,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..844017f6 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,60 @@
"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",
+ "resolved_repo_versions": "Resolved Repo Versions",
"version": {
"vectorized_for_mapper": "Concepts vectorization is enabled for this repository version",
"form": {
@@ -346,6 +401,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",