diff --git a/src/components/repos/RepoCreate.jsx b/src/components/repos/RepoCreate.jsx index 29d8eb440..030555097 100644 --- a/src/components/repos/RepoCreate.jsx +++ b/src/components/repos/RepoCreate.jsx @@ -41,6 +41,7 @@ import RepoCreateLanguages from './RepoCreateLanguages' import RepoCreateAdditionalMetadata from './RepoCreateAdditionalMetadata' import RepoCreatePublisher from './RepoCreatePublisher' import RepoCreateHierarchy from './RepoCreateHierarchy' +import RepoCreateDisplay from './RepoCreateDisplay' import RepoCreateIDAutoAssignment from './RepoCreateIDAutoAssignment' import { parseRepoURL } from './utils' @@ -544,6 +545,9 @@ const RepoCreate = () => { } + + + { selectedTab.autoAssignIDs && diff --git a/src/components/repos/RepoCreateDisplay.jsx b/src/components/repos/RepoCreateDisplay.jsx new file mode 100644 index 000000000..9f2559b2a --- /dev/null +++ b/src/components/repos/RepoCreateDisplay.jsx @@ -0,0 +1,76 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import Typography from '@mui/material/Typography' +import Select from '@mui/material/Select' +import MenuItem from '@mui/material/MenuItem' +import InputLabel from '@mui/material/InputLabel' +import FormControl from '@mui/material/FormControl' +import FormHelperText from '@mui/material/FormHelperText' +import ListItemText from '@mui/material/ListItemText' +import { isBlankJSONString, jsonToString, stringToJSON } from '../common/JSONTextField' + +const RepoCreateDisplay = ({ isSource, meta, hierarchyRootURL, onChange }) => { + const { t } = useTranslation() + const parsed = isBlankJSONString(meta) ? {} : stringToJSON(meta) + const usable = Boolean(parsed && typeof parsed === 'object' && !Array.isArray(parsed)) + const options = isSource ? ['table', 'card', 'hierarchy'] : ['table', 'card'] + const savedView = parsed?.display?.default_view + const value = options.includes(savedView) ? savedView : 'table' + const defaultFilters = Object.entries(parsed?.display?.default_filter || {}) + const helperText = !usable ? 'repo.default_view_invalid_meta' : + value === 'hierarchy' && !hierarchyRootURL ? 'repo.default_view_needs_root' : 'repo.default_view_help' + const onSelect = event => onChange('meta', jsonToString({...parsed, display: {...(parsed.display || {}), default_view: event.target.value}})) + + return ( +
+ + {t('repo.display')} + + + {t('repo.display_description')} + +
+ + {t('repo.default_view')} + + {t(helperText)} + +
+ { + isSource && +
+ + {t('repo.default_filters')} + + { + defaultFilters.length ? defaultFilters.map(([key, value]) => ( + + {key.replace(/^properties__/, '')}{String(value).startsWith('!') ? ` ≠ ${String(value).slice(1)}` : `: ${value}`} + + )) : ( + + {t('repo.default_filters_none')} + + ) + } + + {t('repo.default_filters_help')} + +
+ } +
+ ) +} + +export default RepoCreateDisplay diff --git a/src/components/repos/RepoHome.jsx b/src/components/repos/RepoHome.jsx index 0c3502142..17f305bd2 100644 --- a/src/components/repos/RepoHome.jsx +++ b/src/components/repos/RepoHome.jsx @@ -60,7 +60,7 @@ const RepoHome = () => { const [tabs, setTabs] = React.useState(getRepoTabs) const [status, setStatus] = React.useState(false) - const [repo, setRepo] = React.useState(false) + const [{repo, repoRoute}, setRepoState] = React.useState({repo: false, repoRoute: null}) const [owner, setOwner] = React.useState(false) const [repoSummary, setRepoSummary] = React.useState(false) const [versions, setVersions] = React.useState(false) @@ -161,7 +161,7 @@ const RepoHome = () => { setStatus(newStatus) setLoading(false) - setRepo(_repo) + setRepoState({repo: _repo, repoRoute: route}) if(!isCollection) setContextRepo(_repo) if(!sameRepoAsBefore) @@ -510,7 +510,7 @@ const RepoHome = () => { return let meta = {...repo?.meta, display: {...repo?.meta?.display, default_filter: appliedFilters}} APIService.new().overrideURL(repo.version_url || repo.url).patch({meta: meta}).then(() => { - setRepo({...repo, meta: meta}) + setRepoState(prev => prev.repo?.version_url === repo.version_url ? {...prev, repo: {...prev.repo, meta: meta}} : prev) setAlert({severity: 'success', message: t('common.success_update')}) }) } @@ -538,7 +538,8 @@ const RepoHome = () => { const onVersionEditClick = () => isVersion && setVersionForm({edit: true, version: repo, expansions: []}) const onReleaseVersionClick = () => isVersion && setReleaseTarget(repo) // References are collection-only, so never query them on a source. - const _canRenderSearch = repo?.id && ['concepts', 'mappings', 'references'].includes(tab) && (tab !== 'references' || isCollection) && canRenderSearch + // Search reads the repo's saved defaults when it mounts, so wait for the version the route asks for + const _canRenderSearch = repo?.id && ['concepts', 'mappings', 'references'].includes(tab) && (tab !== 'references' || isCollection) && canRenderSearch && isRepoForRoute && isSameRepoScope(repoRoute, route) const showProcessingBanner = _canRenderSearch && isVersionProcessing(currentRepo) const heightTakenInProcessingBanner = showProcessingBanner ? 43 : 0 return ( diff --git a/src/components/search/Search.jsx b/src/components/search/Search.jsx index 0648360ef..0f83bcad3 100644 --- a/src/components/search/Search.jsx +++ b/src/components/search/Search.jsx @@ -15,7 +15,7 @@ import UserIcon from '@mui/icons-material/Person'; import DeleteForeverIcon from '@mui/icons-material/DeleteForever'; import TransformIcon from '@mui/icons-material/Transform'; import DownIcon from '@mui/icons-material/KeyboardArrowDown'; -import { forEach, keys, pickBy, isEmpty, find, uniq, has, orderBy as sortBy, uniqBy, omit, max, isEqual, isBoolean } from 'lodash'; +import { forEach, keys, pick, pickBy, isEmpty, find, uniq, has, orderBy as sortBy, uniqBy, omit, max, isEqual, isBoolean } from 'lodash'; import { COLORS } from '../../common/colors'; import { dropVersion, highlightTexts, isLoggedIn, getCurrentUserUsername } from '../../common/utils'; import usePins, { PIN_RESOURCES, MAX_PINS_ALLOWED } from '../../hooks/usePins'; @@ -41,6 +41,10 @@ import { getQuotaError } from '../common/quotaErrors' const DEFAULT_LIMIT = 25; const FILTERS_WIDTH = 250 const FILTERABLE_RESOURCES = ['concepts', 'mappings', 'repos', 'sources', 'collections', 'references'] +const HIERARCHY_QUERY_PARAMS = ['q', 'page', 'limit', 'includeSearchMeta', 'onlyHierarchyRoot'] + +// Facet filters force the API onto the search-index path, which ignores onlyHierarchyRoot. +const toRequestParams = params => params.onlyHierarchyRoot ? pick(params, HIERARCHY_QUERY_PARAMS) : params const getBaseCollectionUrl = url => { const match = (url || '').match(/^(.*\/collections\/[^/]+\/)(?:[^/]+\/)?(?:concepts|mappings|references)\/?$/) @@ -71,6 +75,19 @@ const Search = props => { const [page, setPage] = React.useState(0); const [pageSize, setPageSize] = React.useState(DEFAULT_LIMIT); const [resource, setResource] = React.useState(false) + const isHierarchySupported = Boolean(props.nested && props.repo?.hierarchy_root_url && (resource || props.resource) === 'concepts') + const getDefaultDisplay = () => { + if(!props.nested || !props.repo || (resource || props.resource) !== 'concepts') + return undefined + const savedDisplay = props.repo.meta?.display?.default_view + const queryParams = new URLSearchParams(window.location.search) + if(savedDisplay === 'hierarchy' && isHierarchySupported && !queryParams.has('filters') && !queryParams.get('q')) + return 'hierarchy' + if(savedDisplay === 'card' && !props.noCardDisplay) + return 'card' + return 'table' + } + const [defaultDisplay] = React.useState(getDefaultDisplay) const [result, setResult] = React.useState({}) const [filters, setFilters] = React.useState({}) const [selected, setSelected] = React.useState([]) @@ -85,11 +102,12 @@ const Search = props => { const [transformingReferences, setTransformingReferences] = React.useState(false) const [bulkRemoveOpen, setBulkRemoveOpen] = React.useState(false) const [bulkRemoving, setBulkRemoving] = React.useState(false) - const [hierarchy, setHierarchy] = React.useState(false) + const [hierarchy, setHierarchy] = React.useState(defaultDisplay === 'hierarchy') const didMount = React.useRef(false); + const pendingRepoDefaultFilters = React.useRef(false) const isFilterable = _resource => FILTERABLE_RESOURCES.includes(_resource) - const isHierarchySupported = Boolean(props.nested && props.repo?.hierarchy_root_url && (resource || props.resource) === 'concepts') + const isHierarchyView = isHierarchySupported && hierarchy React.useEffect(() => { if(!props.url) @@ -118,7 +136,13 @@ const Search = props => { if(newHierarchy !== hierarchy) { setHierarchy(newHierarchy) setPage(1) - fetchResults(getQueryParams(input, 1, pageSize, filters, orderBy, order, newHierarchy), true) + const applyRepoDefaultFilters = !newHierarchy && pendingRepoDefaultFilters.current && !getFiltersFromQueryParams() + if(!newHierarchy) + pendingRepoDefaultFilters.current = false + if(applyRepoDefaultFilters) + history.replace(getCurrentLayoutURL(getQueryParams(input, 1, pageSize, getAppliedFacetFromQueryParam(props.repoDefaultFilters), orderBy, order, false))) + else + fetchResults(getQueryParams(input, 1, pageSize, filters, orderBy, order, newHierarchy), true) } } input && setTimeout(highlight, 100) @@ -134,7 +158,7 @@ const Search = props => { _resource = 'orgs' let url = '/search/?' if(q) - url += `&q=${q || ''}` + url += `&q=${encodeURIComponent(q || '')}` if(_resource !== 'concepts') url += `&type=${_resource}` if(limit !== DEFAULT_LIMIT) @@ -216,7 +240,10 @@ const Search = props => { _fetch = true _fetchFacets = true } - if(includeRepoDefaultFilters && !_filters && !isEmpty(props.repoDefaultFilters)) { + // The saved defaults are list filters: opening in Hierarchy leaves them for the first switch to Table or Card. + if(includeRepoDefaultFilters && !_filters && !isEmpty(props.repoDefaultFilters) && isHierarchyView) + pendingRepoDefaultFilters.current = true + else if(includeRepoDefaultFilters && !_filters && !isEmpty(props.repoDefaultFilters)) { _filters = getAppliedFacetFromQueryParam(props.repoDefaultFilters) // Write the defaults into the URL so it stays the single source of truth -- // otherwise the next location.search-driven sync sees no `filters` param and @@ -326,7 +353,7 @@ const Search = props => { const isSearchIndexQuery = () => { const params = omit( - getQueryParams(input, page, pageSize, filters, orderBy, order), + toRequestParams(getQueryParams(input, page, pageSize, filters, orderBy, order)), ['page', 'limit', 'includeSearchMeta', 'onlyHierarchyRoot'] ) return Object.values(params).some(Boolean) @@ -357,6 +384,7 @@ const Search = props => { } const fetchResults = (params, facets=true, _resource=undefined) => { + params = toRequestParams(params) let __resource = _resource || resource if(!__resource) return @@ -393,7 +421,7 @@ const Search = props => { return {...result, [__resource]: resourceResult} }) setLoading(false) - if(facets && isFilterable(__resource) && __resource !== 'references') + if(facets && !params.onlyHierarchyRoot && isFilterable(__resource) && __resource !== 'references') fetchFacets(params, resourceResult, __resource) }) } @@ -512,7 +540,7 @@ const Search = props => { } const noResults = !loading && input && !(result[resource]?.results || []).length - const showFilters = openFilters && !noResults && isFilterable(resource) + const showFilters = openFilters && !noResults && isFilterable(resource) && !isHierarchyView const getSearchResultsWidth = () => { let toSubtract = 0; @@ -756,6 +784,7 @@ const Search = props => {
{ nested={props.nested} openFilters={showFilters} appliedFilters={filters} - isFilterable={isFilterable(resource)} + isFilterable={isFilterable(resource) && !isHierarchyView} noResults={noResults} searchedText={input} bgColor={searchBgColor} diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index e19eed625..ca52c13b0 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -515,6 +515,18 @@ "smart": "Smart Checksum" }, "repo": { + "display": "Display", + "display_description": "How the Concepts tab opens for visitors", + "default_view": "Default view", + "default_view_table_description": "Rows with sortable columns", + "default_view_card_description": "One card per concept", + "default_view_hierarchy_description": "Tree from the hierarchy root", + "default_view_help": "Visitors can still switch views", + "default_view_needs_root": "Set a hierarchy root to open in Hierarchy. Until then, visitors see Table.", + "default_view_invalid_meta": "Fix the JSON in the Meta field to change this", + "default_filters": "Default filters (Table and Card)", + "default_filters_none": "None", + "default_filters_help": "Set these with \"Save as Default Filters\" in the filter panel on the Concepts tab. The Hierarchy view always shows the full tree.", "repos": "Repositories", "repo": "Repository", "new_repo": "New Repository", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index 2325c081a..1c0566faf 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -356,6 +356,18 @@ "unresolved": "No resuelta" }, "repo": { + "display": "Visualización", + "display_description": "Cómo se abre la pestaña Conceptos para los visitantes", + "default_view": "Vista predeterminada", + "default_view_table_description": "Filas con columnas que se pueden ordenar", + "default_view_card_description": "Una tarjeta por concepto", + "default_view_hierarchy_description": "Árbol desde la raíz de la jerarquía", + "default_view_help": "Los visitantes pueden cambiar de vista", + "default_view_needs_root": "Establezca una raíz de jerarquía para abrir en la vista Jerarquía. Hasta entonces, los visitantes verán la vista Tabla.", + "default_view_invalid_meta": "Corrija el JSON del campo Meta para cambiar esta opción", + "default_filters": "Filtros predeterminados (Tabla y Tarjeta)", + "default_filters_none": "Ninguno", + "default_filters_help": "Configúrelos con \"Guardar como Filtros Predeterminados\" en el panel de filtros de la pestaña Conceptos. La vista Jerarquía siempre muestra el árbol completo.", "repos": "Repositorios", "repo": "Repositorio", "repo_type": "Tipo de Repositorio", diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index b7f3b620a..0b0bf28fe 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -507,6 +507,18 @@ "smart": "Checksum inteligente" }, "repo": { + "display": "Exibição", + "display_description": "Como a aba Conceitos é aberta para os visitantes", + "default_view": "Visualização padrão", + "default_view_table_description": "Linhas com colunas que podem ser ordenadas", + "default_view_card_description": "Um cartão por conceito", + "default_view_hierarchy_description": "Árvore a partir da raiz da hierarquia", + "default_view_help": "Os visitantes ainda podem alternar entre as visualizações", + "default_view_needs_root": "Defina uma raiz de hierarquia para abrir na visualização Hierarquia. Até lá, os visitantes verão a visualização Tabela.", + "default_view_invalid_meta": "Corrija o JSON no campo Meta para alterar esta opção", + "default_filters": "Filtros padrão (Tabela e Cartão)", + "default_filters_none": "Nenhum", + "default_filters_help": "Configure-os com \"Salvar como filtros padrão\" no painel de filtros da aba Conceitos. A visualização Hierarquia sempre mostra a árvore completa.", "repos": "Repositórios", "repo": "Repositório", "new_repo": "Novo repositório", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 52368eeef..50beea191 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -352,6 +352,18 @@ "smart": "智能校验和" }, "repo": { + "display": "显示", + "display_description": "访客打开“概念”选项卡时的显示方式", + "default_view": "默认视图", + "default_view_table_description": "按行显示,可按列排序", + "default_view_card_description": "每个概念显示为一张卡片", + "default_view_hierarchy_description": "从层级根节点展开的树形结构", + "default_view_help": "访客仍可切换视图", + "default_view_needs_root": "设置层级根节点后才能默认打开层级视图。在此之前,访客将看到表格视图。", + "default_view_invalid_meta": "请修正 Meta 字段中的 JSON 后再更改此选项", + "default_filters": "默认筛选条件(表格和卡片)", + "default_filters_none": "无", + "default_filters_help": "在“概念”选项卡的筛选面板中,使用“另存为默认筛选条件”进行设置。层级视图始终显示完整的树形结构。", "repos": "存储库", "repo": "存储库", "repo_type": "存储库类型",