Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add resolvable URL query params filters, fix issues. #1580

Open
wants to merge 6 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions projects/mercury/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export const DATE_FORMAT = 'dd-MM-yyyy';
// The maximum number of items in a list in the right panel, for performance reasons.
// If you change this, also change it in 'MetadataService.java'
export const MAX_LIST_LENGTH = 100;
// Max length of URL in the browser
export const MAX_URL_LENGTH = 2000;

// Metadata schemas
export const SHACL_NS = 'http://www.w3.org/ns/shacl#';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export const LinkedDataEntityForm = ({
labelFirst,
descriptionFirst,
systemPropertiesLast,
compareBy(p => (typeof p.order === 'number' ? p.order : Number.MAX_SAFE_INTEGER)),
compareBy(p => (Number.isNaN(Number(p.order)) ? Number.MAX_SAFE_INTEGER : Number(p.order))),
compareBy('label')
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export const ReferringValue = ({property, entry}) => {
// External links should be represented by a direct link to the URI itself
// Other iri entities should be opened in the metadata editor
return property.isExternalLink ? (
<a href={entry.id}>{entry.id}</a>
<a href={entry.id} target="_blank" rel="noreferrer">
{entry.id}
</a>
) : (
<LinkedDataLink uri={entry.id}>{displayValue}</LinkedDataLink>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ describe('ReferringValue', () => {
property,
entry
})
).toEqual(<a href="https://thehyve.nl">https://thehyve.nl</a>);
).toEqual(
<a rel="noreferrer" target="_blank" href="https://thehyve.nl">
https://thehyve.nl
</a>
);
});

it('should render a generic iri resource as link to editor', () => {
Expand Down
91 changes: 72 additions & 19 deletions projects/mercury/src/metadata/views/MetadataView.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import _ from 'lodash';
import {useHistory} from 'react-router-dom';
import {Button, Grid, Typography} from '@mui/material';
import withStyles from '@mui/styles/withStyles';
import {Assignment, Close} from '@mui/icons-material';
import {Assignment, Close, ContentCopy} from '@mui/icons-material';
import queryString from 'query-string';
import qs from 'qs';

import {SnackbarProvider, useSnackbar} from 'notistack';
import styles from './MetadataView.styles';
import type {MetadataViewFacet, MetadataViewFilter, MetadataViewOptions, ValueType} from './MetadataViewAPI';
import BreadCrumbs from '../../common/components/BreadCrumbs';
Expand Down Expand Up @@ -34,6 +36,7 @@ import usePageTitleUpdater from '../../common/hooks/UsePageTitleUpdater';
import MetadataViewFacetsContext from './MetadataViewFacetsContext';
import {accessLevelForCollection} from '../../collections/collectionUtils';
import InternalMetadataSourceContext from '../metadata-sources/InternalMetadataSourceContext';
import {MAX_URL_LENGTH} from '../../constants';

type ContextualMetadataViewProperties = {
classes: any
Expand Down Expand Up @@ -70,6 +73,7 @@ export const MetadataView = (props: MetadataViewProperties) => {

const {collections} = useContext(CollectionsContext);
const {toggle, selected} = useSingleSelection();
const {enqueueSnackbar} = useSnackbar();

const [filterCandidates, setFilterCandidates] = useState([]);
const [textFiltersObject, setTextFiltersObject] = useState({});
Expand Down Expand Up @@ -199,7 +203,9 @@ export const MetadataView = (props: MetadataViewProperties) => {
};

const areFacetFiltersNonEmpty = useMemo(
() => filters && filters.some(filter => facetsEx.some(facet => facet.name === filter.field)),
() =>
filters &&
filters.some(filter => facetsEx.some(facet => facet.name.toLowerCase() === filter.field.toLowerCase())),
[filters, facetsEx]
);
const areTextFiltersNonEmpty = useMemo(
Expand All @@ -218,6 +224,48 @@ export const MetadataView = (props: MetadataViewProperties) => {
return `${window.location.host}${pathPrefix}?${prefilteringQueryString}`;
};

const copyFiltersUrl = () => {
const queryParams = filters.reduce((acc, filter) => {
acc[filter.field.toLowerCase()] = filter.values.join(',');
return acc;
}, {});
const queryStringFilters = queryString.stringify(queryParams);
const url = `${window.location.protocol}//${window.location.host}/metadata-views?view=${currentView.name}&${queryStringFilters}`;

if (url.length > MAX_URL_LENGTH) {
enqueueSnackbar('Failed to copy metadata view filters URL to clipboard: URL too long');
return;
}
navigator.clipboard
.writeText(url)
.then(() => enqueueSnackbar('Metadata view filters URL copied to clipboard'))
.catch(() => enqueueSnackbar('Failed to copy metadata view filters URL to clipboard'));
};

useEffect(() => {
const queryStringFilters = qs.parse(window.location.search, {ignoreQueryPrefix: true});
if (queryStringFilters && Object.keys(queryStringFilters).length > 0) {
const idTextFilter = queryStringFilters[currentViewIdColumn.name.toLowerCase()];
if (idTextFilter && (!areTextFiltersNonEmpty || !textFiltersObject.keys.includes(currentViewIdColumn))) {
setTextFiltersObject({...textFiltersObject, [currentViewIdColumn.name]: idTextFilter});
}
if (!areFacetFiltersNonEmpty) {
const facetNames = facets.map(f => f.name.toLowerCase());
const newFilters = Object.keys(queryStringFilters)
.filter(k => facetNames.includes(k.toLowerCase()))
.reduce((arr, key) => {
arr.push({
field: key,
values: queryStringFilters[key].split(',')
});
return arr;
}, []);
updateFilters(newFilters);
}
}
// eslint-disable-next-line
}, []);

return (
<BreadcrumbsContext.Provider
value={{
Expand All @@ -233,12 +281,15 @@ export const MetadataView = (props: MetadataViewProperties) => {
<BreadCrumbs additionalSegments={getPathSegments(locationContext)} />
{(areFacetFiltersNonEmpty || areTextFiltersNonEmpty) && (
<Grid container justifyContent="space-between" direction="row-reverse">
<Grid item xs={2} className={classes.clearAllButtonContainer}>
<Grid item xs={2} className={classes.clearAllButtonContainer} justifyContent="space-between">
<Button
className={classes.clearAllButton}
startIcon={<Close />}
onClick={handleClearAllFilters}
className={classes.filterButtons}
startIcon={<ContentCopy />}
onClick={() => copyFiltersUrl(currentView, filters)}
>
Copy filters
</Button>
<Button className={classes.filterButtons} startIcon={<Close />} onClick={handleClearAllFilters}>
Clear all filters
</Button>
</Grid>
Expand Down Expand Up @@ -348,19 +399,21 @@ export const ContextualMetadataView = (props: ContextualMetadataViewProperties)
};

return (
<MetadataView
{...props}
metadataLabel={metadataLabel}
facets={facets}
views={views}
filters={filters}
locationContext={currentViewName === RESOURCES_VIEW && locationContext}
currentViewName={currentViewName}
handleViewChangeRedirect={handleViewChangeRedirect}
updateFilters={updateFilters}
clearFilter={clearFilter}
clearAllFilters={clearAllFilters}
/>
<SnackbarProvider maxSnack={3}>
<MetadataView
{...props}
metadataLabel={metadataLabel}
facets={facets}
views={views}
filters={filters}
locationContext={currentViewName === RESOURCES_VIEW && locationContext}
currentViewName={currentViewName}
handleViewChangeRedirect={handleViewChangeRedirect}
updateFilters={updateFilters}
clearFilter={clearFilter}
clearAllFilters={clearAllFilters}
/>
</SnackbarProvider>
);
};

Expand Down
6 changes: 4 additions & 2 deletions projects/mercury/src/metadata/views/MetadataView.styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,11 @@ const styles = theme => ({
clearAllButtonContainer: {
textAlign: 'end'
},
clearAllButton: {
filterButtons: {
color: theme.palette.primary.contrastText,
background: theme.palette.primary.main
background: theme.palette.primary.main,
marginLeft: 1,
marginBottom: 1
},
activeFilters: {
marginBottom: 10
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const MetadataViewActiveFacetFilters = (props: MetadataViewActiveFacetFil
);
}
return filter.values.map(valueIri => {
const value = facet.values.find(val => val.value === valueIri);
const value = facet.values.find(val => val.value.toLowerCase() === valueIri.toLowerCase());
return (
value && (
<Chip
Expand Down Expand Up @@ -95,7 +95,7 @@ export const MetadataViewActiveFacetFilters = (props: MetadataViewActiveFacetFil
) {
return null;
}
const facet = facets.find(f => f.name === filter.field);
const facet = facets.find(f => f.name.toLowerCase() === filter.field.toLowerCase());
if (facet) {
return (
<Grid key={filter.field} item>
Expand Down
8 changes: 7 additions & 1 deletion projects/mercury/src/metadata/views/MetadataViewContext.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
// import {useHistory} from 'react-router-dom';

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to deliberately keep these comments in the code?

import type {MetadataViewFilter} from './MetadataViewAPI';
import MetadataViewAPI from './MetadataViewAPI';
import useAsync from '../../common/hooks/UseAsync';
Expand All @@ -11,6 +12,7 @@ const SESSION_STORAGE_METADATA_FILTERS_KEY = 'FAIRSPACE_METADATA_FILTERS';

export const MetadataViewProvider = ({children, metadataViewAPI = MetadataViewAPI, sourceName = ''}) => {
const {data = {}, error, loading, refresh} = useAsync(() => metadataViewAPI.getViews(), []);
// const history = useHistory();

const [filters: MetadataViewFilter[], setFilters] = useStateWithSessionStorage(
`${SESSION_STORAGE_METADATA_FILTERS_KEY}_${sourceName}`,
Expand All @@ -23,11 +25,15 @@ export const MetadataViewProvider = ({children, metadataViewAPI = MetadataViewAP

const clearAllFilters = () => {
setFilters([]);

const queryParams = new URLSearchParams(window.location.search);
const viewParam = queryParams.get('view');
window.history.replaceState(null, '', `${window.location.pathname}?${viewParam ? `view=${viewParam}` : ''}`);
};

const updateFilters = (filterCandidates: MetadataViewFilter[]) => {
setFilters([
...filters.filter(f => !filterCandidates.some(u => u.field === f.field)),
...filters.filter(f => !filterCandidates.some(u => u.field.toLowerCase() === f.field.toLowerCase())),
...filterCandidates.filter(
f =>
(f.values && f.values.length > 0) ||
Expand Down
4 changes: 3 additions & 1 deletion projects/mercury/src/metadata/views/MetadataViewFacets.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,9 @@ export const MetadataViewFacets = (props: MetadataViewFacetsProperties) => {

const renderSingleFacet = (facet: MetadataViewFacet) => {
const facetOptions = getFilterValues(facet.type, facet);
const activeFilter = [...filterCandidates, ...filters].find(filter => filter.field === facet.name);
const activeFilter = [...filterCandidates, ...filters].find(
filter => filter.field.toLowerCase() === facet.name.toLowerCase()
);
let activeFilterValues = [];
if (activeFilter) {
activeFilterValues = getFilterValues(facet.type, activeFilter);
Expand Down
23 changes: 4 additions & 19 deletions projects/mercury/src/metadata/views/MetadataViewTable.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import React, {useCallback, useEffect} from 'react';
import React, {useCallback} from 'react';
import {Checkbox, Link, Table, TableBody, TableCell, TableHead, TableRow} from '@mui/material';
import {Check, Close} from '@mui/icons-material';
import makeStyles from '@mui/styles/makeStyles';
import {Link as RouterLink} from 'react-router-dom';
import qs from 'qs';
import useDeepCompareEffect from 'use-deep-compare-effect';
import type {MetadataViewColumn, MetadataViewData} from './MetadataViewAPI';
import {TextualValueTypes} from './MetadataViewAPI';
import type {MetadataViewEntity, MetadataViewEntityWithLinkedFiles} from './metadataViewUtils';
import {RESOURCES_VIEW} from './metadataViewUtils';
import {stringToBooleanValueOrNull, formatDate} from '../../common/utils/genericUtils';
import {formatDate, stringToBooleanValueOrNull} from '../../common/utils/genericUtils';
import type {Collection} from '../../collections/CollectionAPI';
import {collectionAccessIcon} from '../../collections/collectionUtils';
import {getPathFromIri, redirectLink} from '../../file/fileUtils';
Expand Down Expand Up @@ -52,12 +51,12 @@ const RESOURCE_TYPE_COLUMN = `${RESOURCES_VIEW}_type`;
export const MetadataViewTable = (props: MetadataViewTableProperties) => {
const {columns, visibleColumnNames, loading, data, toggleRow, selected, view, idColumn, history, collections} =
props;
const classes = useStyles();
const {textFiltersObject, setTextFiltersObject} = props;
const {checkboxes, setCheckboxState} = props;
const classes = useStyles();
const visibleColumns = columns.filter(column => visibleColumnNames.includes(column.name));
const dataLinkColumn = columns.find(c => c.type === 'dataLink');
const isResourcesView = view === RESOURCES_VIEW;
const {checkboxes, setCheckboxState} = props;

const isCustomResourceColumn = (column: MetadataViewColumn) =>
isResourcesView && CUSTOM_RESOURCE_COLUMNS.includes(column.name) && column.type === 'Custom';
Expand All @@ -67,11 +66,6 @@ export const MetadataViewTable = (props: MetadataViewTableProperties) => {
return col ? col.access : 'None';
};

const getIdColumnFilterFromSearchParams = () => {
const idColumnName = idColumn.name.toLowerCase();
return qs.parse(window.location.search, {ignoreQueryPrefix: true})[idColumnName];
};

const getResourceType = (row: Map<string, any>) =>
row[RESOURCE_TYPE_COLUMN] && row[RESOURCE_TYPE_COLUMN][0] && row[RESOURCE_TYPE_COLUMN][0].value;

Expand All @@ -82,15 +76,6 @@ export const MetadataViewTable = (props: MetadataViewTableProperties) => {
toggleRow({label, iri, linkedFiles: linkedFiles || []});
}
};
useEffect(() => {
if (!textFiltersObject || !textFiltersObject.keys || !textFiltersObject.keys.includes(idColumn)) {
const idColumnTextFilter = getIdColumnFilterFromSearchParams();
if (idColumnTextFilter) {
setTextFiltersObject({...textFiltersObject, [idColumn.name]: idColumnTextFilter});
}
}
// eslint-disable-next-line
}, []);

const initializeCheckboxes = useCallback(() => {
if (idColumn && data && data.rows) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import CheckBoxOutlineBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank';
import CheckBoxIcon from '@mui/icons-material/CheckBox';
import GetAppIcon from '@mui/icons-material/GetApp';
import FormGroup from '@mui/material/FormGroup';
import useDeepCompareEffect from 'use-deep-compare-effect';
import useDeepCompareEffect, {useDeepCompareEffectNoCheck} from 'use-deep-compare-effect';
import {useTheme} from '@mui/material/styles';

import type {MetadataViewColumn, MetadataViewFilter} from './MetadataViewAPI';
Expand Down Expand Up @@ -376,7 +376,7 @@ export const MetadataViewTableContainer = (props: MetadataViewTableContainerProp
setPage(0);
}, [filters]);

useDeepCompareEffect(() => {
useDeepCompareEffectNoCheck(() => {
resetRowCheckboxes();
}, [data]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

import io.fairspace.saturn.vocabulary.FS;

import static org.apache.jena.rdf.model.ResourceFactory.createPlainLiteral;

@Component
public class UniqueLabelValidator implements MetadataRequestValidator {
@Override
Expand All @@ -21,7 +23,7 @@ public void validate(Model before, Model after, Model removed, Model added, Viol
.filterDrop(res -> res.hasProperty(FS.dateDeleted))
.hasNext();
if (conflictingResourceExists) {
violationHandler.onViolation("Duplicate label", resource, RDFS.label, null);
violationHandler.onViolation("Duplicate label", resource, RDFS.label, createPlainLiteral(label));
}
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.fairspace.saturn.services.metadata.validation;

import java.util.Set;
import java.util.stream.Collectors;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
Expand All @@ -9,4 +10,13 @@
@Getter
public class ValidationException extends RuntimeException {
private final Set<Violation> violations;

@Override
public String getMessage() {
if (violations == null || violations.isEmpty()) {
return "Validation failed with no specific violations.";
}
return "Validation failed with the following violations: "
+ violations.stream().map(Violation::toString).collect(Collectors.joining(", "));
}
}
Loading
Loading