▼
diff --git a/app/javascript/components/shared/ExportMappings.jsx b/app/javascript/components/shared/ExportMappings.jsx
new file mode 100644
index 00000000..43a20907
--- /dev/null
+++ b/app/javascript/components/shared/ExportMappings.jsx
@@ -0,0 +1,68 @@
+import { useCallback, useState } from 'react';
+import { MultiSelect } from 'react-multi-select-component';
+import downloadExportedMappings from '../../services/downloadExportedMappings';
+import { processMessage } from '../../services/api/apiService';
+
+const FORMAT_OPTIONS = { jsonld: 'JSON-LD', ttl: 'Turtle', csv: 'CSV' };
+
+const ExportMappings = ({ configurationProfile, domains, onError }) => {
+ const [downloading, setDownloading] = useState(false);
+ const [selectedDomains, setSelectedDomains] = useState([]);
+ const [selectedFormat, setSelectedFormat] = useState('jsonld');
+
+ const handleSubmit = useCallback(
+ async (e) => {
+ e.preventDefault();
+ setDownloading(true);
+
+ try {
+ await downloadExportedMappings({
+ configurationProfile,
+ domainIds: selectedDomains.map((d) => d.value),
+ format: selectedFormat,
+ });
+ } catch (e) {
+ onError?.(processMessage(e));
+ }
+
+ setDownloading(false);
+ },
+ [downloadExportedMappings, selectedDomains, selectedFormat, setDownloading]
+ );
+
+ return (
+
+ );
+};
+
+export default ExportMappings;
diff --git a/app/javascript/components/shared/PredicateOptions.jsx b/app/javascript/components/shared/PredicateOptions.jsx
index b8a058bf..9477ccce 100644
--- a/app/javascript/components/shared/PredicateOptions.jsx
+++ b/app/javascript/components/shared/PredicateOptions.jsx
@@ -12,7 +12,7 @@ const PredicateOptions = (props) => {
/**
* Elements from props
*/
- const { predicates, cls = '' } = props;
+ const { predicates, cls = '', SelectedComponent } = props;
/**
* The current selected predicate
@@ -48,6 +48,7 @@ const PredicateOptions = (props) => {
options={predicatesAsOptions()}
onClose={(predicate) => handlePredicateSelected(predicate)}
selectedOption={predicate}
+ SelectedComponent={SelectedComponent}
cardCssClass={`with-shadow ${cls}`}
/>
);
diff --git a/app/javascript/components/shared/TopNavOptions.jsx b/app/javascript/components/shared/TopNavOptions.jsx
index b604f5f5..94d77f4b 100644
--- a/app/javascript/components/shared/TopNavOptions.jsx
+++ b/app/javascript/components/shared/TopNavOptions.jsx
@@ -30,7 +30,7 @@ const TopNavOptions = (props) => {
)}
{props.stepper && (
-
+
)}
diff --git a/app/javascript/components/specifications-list/SpecsList.jsx b/app/javascript/components/specifications-list/SpecsList.jsx
index 11f4b891..43400963 100644
--- a/app/javascript/components/specifications-list/SpecsList.jsx
+++ b/app/javascript/components/specifications-list/SpecsList.jsx
@@ -1,268 +1,61 @@
-import {} from 'react';
+import { useEffect, useContext } from 'react';
+import { useLocalStore } from 'easy-peasy';
import TopNav from '../shared/TopNav';
import { Link } from 'react-router-dom';
-import fetchMappings from '../../services/fetchMappings';
import Loader from '../shared/Loader';
import TopNavOptions from '../shared/TopNavOptions';
import SpineSpecsList from './SpineSpecsList';
-import _ from 'lodash';
+import { startCase, toLower } from 'lodash';
import ConfirmDialog from '../shared/ConfirmDialog';
-import deleteMapping from '../../services/deleteMapping';
-import fetchMappingToExport from '../../services/fetchMappingToExport';
-import { downloadFile } from '../../helpers/Export';
-import updateMapping from '../../services/updateMapping';
-import { Component } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faUndo,
faPencilAlt,
+ faFilePen,
faEye,
faDownload,
faLayerGroup,
faTrash,
+ faFileCsv,
+ faFileArrowDown,
} from '@fortawesome/free-solid-svg-icons';
import { AppContext } from '../../contexts/AppContext';
-import { showSuccess } from '../../helpers/Messages';
import { pageRoutes } from '../../services/pageRoutes';
-
-export default class SpecsList extends Component {
- static contextType = AppContext;
-
- state = {
- /**
- * Controls displaying the removal confirmation dialog
- */
- confirmingRemove: false,
- /**
- * Representation of an error on this page process
- */
- errors: [],
- /**
- * Representation of an error on this page process while removing a mapping
- */
- errorsWhileRemoving: [],
- /**
- * Represents the filter value to configure the query to get the specifications
- */
- filter: 'user',
- /**
- * Whether the page is loading results or not
- */
- loading: true,
- /**
- * The list of mappings to display
- */
- mappings: [],
- /**
- * The identifier of the mapping to be removed. Saved in state, because the id is in an iterator,
- * and the clicked handles confirmation, and the confirmation is outside the iterator.
- */
- mappingIdToRemove: null,
- /**
- * The options object to use in the select component
- */
- filterOptions: [
- {
- key: 'user',
- value: 'Only My Mappings',
- },
- {
- key: 'all',
- value: 'All Mappings',
- },
- ],
- };
-
- /**
- * Handle showing the errors on screen, if any
- *
- * @param {HttpResponse} response
- * @param {Array} errorsList
- */
- anyError(response, errorsList = this.state.errors) {
- if (response.error) {
- errorsList.push(response.error);
- this.setState({
- errorsList: [...new Set(errorsList)],
- });
- }
- /// It will return a truthy value (depending no the existence
- /// of the errors on the response object)
- return !_.isUndefined(response.error);
- }
-
- /**
- * Use effect with an empty array as second parameter, will trigger the 'goForTheMapping'
- * action at the 'mounted' event of this functional component (It's not actually mounted,
- * but it mimics the same action).
- */
- componentDidMount() {
- const { errors } = this.state;
-
- this.goForTheMappings().then(() => {
- if (!errors.length) {
- this.setState({
- loading: false,
- });
- }
- });
- }
-
- /**
- * Actions to take when the user confirms to remove a mapping
- */
- handleConfirmRemove = (mappingId) => {
- this.setState({
- confirmingRemove: true,
- mappingIdToRemove: mappingId,
- });
- };
-
- /**
- * Manages to request the mapping in JSON-LD version to export as
- * a JSON file
- *
- * @param {Integer} mappingId
- */
- handleExportMapping = async (mapping) => {
- let response = await fetchMappingToExport(mapping.id);
-
- if (!this.anyError(response)) {
- downloadFile(response.exportedMapping, `${mapping.name}.json`);
- }
- };
-
- /**
- * Send a request to delete the selected mapping.
- */
- handleRemoveMapping = async () => {
- const { errorsWhileRemoving, mappings, mappingIdToRemove } = this.state;
- let response = await deleteMapping(mappingIdToRemove);
-
- if (!this.anyError(response, errorsWhileRemoving)) {
- showSuccess('Mapping removed');
-
- this.setState({
- confirmingRemove: false,
- mappings: mappings.filter((m) => m.id != mappingIdToRemove),
- });
- }
- };
-
- /**
- * Change the filter for the listed mappings
- */
- handleFilterChange = async (value) => {
- this.setState({
- filter: value,
- });
- await this.goForTheMappings(value);
- };
-
- /**
- * Mark a 'mapped' mapping back to 'in-progress'
- *
- * @param {int} mappingId
- */
- handleMarkToInProgress = async (mappingId) => {
- const { mappings } = this.state;
-
- this.setState({ loading: true });
-
- /// Change the mapping status
- let response = await updateMapping({
- id: mappingId,
- status: 'in_progress',
- });
-
- if (!this.anyError(response)) {
- /// Change 'in-memory' status
- let mapping = mappings.find((m) => m.id === mappingId);
- mapping.status = 'in_progress';
- mapping['in_progress?'] = true;
- mapping['mapped?'] = false;
-
- this.setState(
- {
- mappings: [...mappings],
- },
- () => {
- this.setState({ loading: false });
- }
- );
-
- /// Notify the user
- showSuccess('Status changed!');
- }
-
- this.setState({ loading: false });
- };
-
- /**
- * Mark a 'mapped' mapping back to 'uploaded'
- *
- * @param {int} mappingId
- */
- handleMarkToUploaded = async (mappingId) => {
- const { mappings } = this.state;
-
- this.setState({ loading: true });
-
- /// Change the mapping status
- let response = await updateMapping({
- id: mappingId,
- status: 'uploaded',
- });
-
- if (!this.anyError(response)) {
- /// Change 'in-memory' status
- let mapping = mappings.find((m) => m.id === mappingId);
- mapping.status = 'uploaded';
- mapping['mapped?'] = false;
- mapping['in_progress?'] = false;
- mapping['uploaded?'] = true;
-
- this.setState(
- {
- mappings: [...mappings],
- },
- () => {
- this.setState({ loading: false });
- }
- );
-
- /// Notify the user
- showSuccess('Status changed!');
- }
-
- this.setState({ loading: false });
- };
-
- /**
- * Get the mappings from the service
- */
- goForTheMappings = async (value) => {
- const { errors, filter } = this.state;
-
- let filterValue = value || filter;
- let response = await fetchMappings(filterValue);
-
- if (!this.anyError(response, errors)) {
- this.setState({
- mappings: response.mappings,
- });
- }
- };
-
- /**
- * Configure the options to see at the center of the top navigation bar
- */
- navCenterOptions = () => {
- return
;
- };
-
- renderMapping = (mapping) => {
- const fromSameOrg = mapping.organization.id === this.context.organization.id;
+import { FILTER_OPTIONS, specsListStore } from './stores/specsListStore';
+import { i18n } from 'utils/i18n';
+
+const SpecsList = (_props) => {
+ const { currentConfigurationProfile, organization } = useContext(AppContext);
+ const [state, actions] = useLocalStore(() => specsListStore());
+ const { mappings, filter, loading } = state;
+
+ useEffect(() => actions.fetchDataFromAPI(), [filter]);
+
+ // Mark a 'mapped' mapping back to 'in-progress'
+ const handleMarkToInProgress = (mappingId) =>
+ actions.handleUpdateMappingStatus({ mappingId, status: 'in_progress' });
+ // Mark a 'mapped' mapping back to 'uploaded'
+ const handleMarkToUploaded = (mappingId) =>
+ actions.handleUpdateMappingStatus({ mappingId, status: 'uploaded' });
+ // Mark a 'uploaded' mapping back to 'ready to upload'
+ const handleMarkToReady = (mappingId) =>
+ actions.handleUpdateMappingStatus({ mappingId, status: 'ready_to_upload' });
+
+ // Configure the options to see at the center of the top navigation bar
+ const navCenterOptions = () =>
;
+ const renderUndo = (mapping, fn) => (
+
fn(mapping.id)}
+ title={i18n.t(`ui.specifications.mapping.undo.${mapping.status}`)}
+ disabled={state.isDisabled}
+ >
+
+
+ );
+
+ const renderMapping = (mapping) => {
+ const fromSameOrg = mapping.organization.id === organization?.id;
return (
@@ -271,23 +64,16 @@ export default class SpecsList extends Component {
{mapping.specification.version}
{mapping.mapped_terms + '/' + mapping.selected_terms.length}
- {_.startCase(_.toLower(mapping.status))}
+ {startCase(toLower(mapping.status))}
{mapping.specification.user.fullname}
{mapping['mapped?'] ? (
<>
{fromSameOrg && (
<>
- this.handleMarkToInProgress(mapping.id)}
- title="Mark this mapping back to 'in progress'"
- >
-
-
-
+ {renderUndo(mapping, handleMarkToInProgress)}
@@ -297,10 +83,7 @@ export default class SpecsList extends Component {
)}
@@ -309,35 +92,43 @@ export default class SpecsList extends Component {
this.handleExportMapping(mapping)}
- title="Export this mapping"
+ disabled={state.isDisabled}
+ onClick={() => actions.downloadExportedMappings({ format: 'jsonld', mapping })}
+ title="Export as JSON-LD"
>
+
+ actions.downloadExportedMappings({ format: 'ttl', mapping })}
+ title="Export as Turtle"
+ >
+
+
+
+ actions.downloadExportedMappings({ format: 'csv', mapping })}
+ title="Export as CSV"
+ >
+
+
>
) : (
<>
- {mapping['in_progress?']
- ? fromSameOrg && (
- this.handleMarkToUploaded(mapping.id)}
- title="Mark this mapping back to 'uploaded'"
- >
-
-
+ {mapping['in_progress?'] || mapping['uploaded?']
+ ? fromSameOrg &&
+ renderUndo(
+ mapping,
+ mapping['in_progress?'] ? handleMarkToUploaded : handleMarkToReady
)
: ''}
{fromSameOrg && (
@@ -346,12 +137,21 @@ export default class SpecsList extends Component {
)}
>
)}
-
+ {fromSameOrg && (
+
+
+
+ )}
{fromSameOrg && (
this.handleConfirmRemove(mapping.id)}
+ onClick={() => actions.confirmRemove(mapping.id)}
className="btn btn-sm btn-dark ml-2"
title="Remove this mapping"
+ disabled={state.isDisabled}
>
@@ -361,84 +161,82 @@ export default class SpecsList extends Component {
);
};
- render() {
- const { confirmingRemove, filter, filterOptions, loading, mappings } = this.state;
+ const renderTable = () => {
+ if (loading) {
+ return ;
+ }
return (
-
-
-
-
-
My Specifications
-
- Current configuration profile:{' '}
- {this.context.currentConfigurationProfile.name}
-
- {loading ? (
-
- ) : (
- <>
-
this.setState({ confirmingRemove: false })}
- onConfirm={() => this.handleRemoveMapping()}
- visible={confirmingRemove}
- >
- You are removing a specification
- Please confirm this action.
-
-
-
-
-
-
- Specification Name
- Version
- Mapped
- Status
- Author
-
- this.handleFilterChange(e.target.value)}
- >
- {filterOptions.map(function (option) {
- return (
-
- {option.value}
-
- );
- })}
-
-
-
-
-
-
- {mappings.length > 0 ? mappings.map(this.renderMapping) : }
-
-
-
-
+ <>
+
+ You are removing a specification
+ Please confirm this action.
+
+
+
+
+
+
+ Specification Name
+ Version
+ Mapped
+ Status
+ Author
+
+ actions.setFilter(e.target.value)}
+ >
+ {FILTER_OPTIONS.map((option) => (
+
+ {option.value}
+
+ ))}
+
+
+
+
+
+
+ {mappings.length > 0 ? mappings.map(renderMapping) : }
+
+
+
+
+
+ {mappings.length === 0 && (
+
+
+
All the specifications you and your team map will be visible here
+
+
+
+ Map a specification
+
+
+
+ )}
+
+ >
+ );
+ };
- {mappings.length === 0 && (
-
-
-
All the specifications you and your team map will be visible here
-
-
-
- Map a specification
-
-
-
- )}
-
- >
- )}
-
+ return (
+
+
+
+
+
My Specifications
+ {renderTable()}
- );
- }
-}
+
+ );
+};
+
+export default SpecsList;
diff --git a/app/javascript/components/specifications-list/SpineSpecsList.jsx b/app/javascript/components/specifications-list/SpineSpecsList.jsx
index aa7b7c01..a6172902 100644
--- a/app/javascript/components/specifications-list/SpineSpecsList.jsx
+++ b/app/javascript/components/specifications-list/SpineSpecsList.jsx
@@ -1,13 +1,14 @@
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
import deleteSpecification from '../../services/deleteSpecification';
-import fetchSpineSpecifications from '../../services/fetchSpineSpecifications';
import { Link } from 'react-router-dom';
import ConfirmDialog from '../shared/ConfirmDialog';
import AlertNotice from '../shared/AlertNotice';
import Loader from '../shared/Loader';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faPencilAlt, faTrash } from '@fortawesome/free-solid-svg-icons';
+import { faFilePen, faTrash } from '@fortawesome/free-solid-svg-icons';
import { showSuccess } from '../../helpers/Messages';
+import { useSelector } from 'react-redux';
+import { isMapper } from '../../helpers/Auth';
/**
* @description A list of spine specifications from the user or all the users of the organization
@@ -16,11 +17,10 @@ import { showSuccess } from '../../helpers/Messages';
* @prop {String} filter The filter or the list of specifications. It can be either "all or "user",
* this last meaning only those spine specifications belonging to the current user.
*/
-const SpineSpecsList = (props) => {
- /**
- * Elements from props
- */
- const { filter } = props;
+const SpineSpecsList = ({ loading, onRemove, spines }) => {
+ const user = useSelector((state) => state.user);
+ const userIsMapper = isMapper(user);
+
/**
* Controls displaying the removal confirmation dialog
*/
@@ -33,19 +33,11 @@ const SpineSpecsList = (props) => {
* Representation of an error on this page process while removing a spine
*/
const [errorsWhileRemoving, setErrorsWhileRemoving] = useState([]);
- /**
- * Whether the page is loading results or not
- */
- const [loading, setLoading] = useState(true);
/**
* The identifier of the spine to be removed. Saved in state, because the id is in an iterator,
* and the clicked handles confirmation, and the confirmation is outside the iterator.
*/
const [spineIdToRemove, setSpineIdToRemove] = useState(null);
- /**
- * The collection of spine specifications
- */
- const [spines, setSpines] = useState([]);
/**
* Handle showing the errors on screen, if any
@@ -83,35 +75,11 @@ const SpineSpecsList = (props) => {
showSuccess('Spine removed');
/// Update the UI
- setSpines(spines.filter((spine) => spine.id !== spineIdToRemove));
+ onRemove(spineIdToRemove);
setConfirmingRemove(false);
}
};
- /**
- * Retrieve all the spine specification for this user or organization.
- */
- const handleFetchSpineSpecs = async () => {
- let response = await fetchSpineSpecifications(filter);
-
- if (!anyError(response, errors, setErrors)) {
- setSpines(response.specifications);
- }
- };
-
- /**
- * Use effect with an empty array as second parameter, will trigger the 'handleFetchSpineSpecs'
- * action at the 'mounted' event of this functional component (It's not actually mounted,
- * but it mimics the same action).
- */
- useEffect(() => {
- handleFetchSpineSpecs().then(() => {
- if (!errors.length) {
- setLoading(false);
- }
- });
- }, []);
-
return (
<>
{errors.length ?
setErrors([])} /> : null}
@@ -149,20 +117,24 @@ const SpineSpecsList = (props) => {
-
-
-
- handleConfirmRemove(spine.id)}
- className="btn btn-sm btn-dark ml-2"
- title="Remove the spine"
- >
-
-
+ {userIsMapper && (
+ <>
+
+
+
+ handleConfirmRemove(spine.id)}
+ className="btn btn-sm btn-dark ml-2"
+ title="Remove the spine"
+ >
+
+
+ >
+ )}
);
diff --git a/app/javascript/components/specifications-list/stores/specsListStore.js b/app/javascript/components/specifications-list/stores/specsListStore.js
new file mode 100644
index 00000000..4339b249
--- /dev/null
+++ b/app/javascript/components/specifications-list/stores/specsListStore.js
@@ -0,0 +1,171 @@
+import { each, remove } from 'lodash';
+import { baseModel } from '../../stores/baseModel';
+import { easyStateSetters } from '../../stores/easyState';
+import { action, computed, thunk } from 'easy-peasy';
+import { showSuccess } from '../../../helpers/Messages';
+import deleteMapping from '../../../services/deleteMapping';
+import downloadExportedMappings from '../../../services/downloadExportedMappings';
+import fetchMappings from '../../../services/fetchMappings';
+import updateMapping from '../../../services/updateMapping';
+import fetchSpineSpecifications from '../../../services/fetchSpineSpecifications';
+import { processMessage } from '../../../services/api/apiService';
+import { pick } from 'lodash';
+
+/**
+ * The options object to use in the select component
+ */
+export const FILTER_OPTIONS = [
+ {
+ key: 'user',
+ value: 'Only My Mappings',
+ },
+ {
+ key: 'all',
+ value: 'All Mappings',
+ },
+];
+
+export const MAPPING_STATUSES = {
+ readyToUpload: 'ready_to_upload',
+ uploaded: 'uploaded',
+ mapped: 'mapped',
+ inProgress: 'in_progress',
+};
+
+export const defaultState = {
+ // status
+ // Controls displaying the removal confirmation dialog
+ confirmingRemove: false,
+
+ // options
+ // filter value to configure the query to get the specifications
+ filter: 'user',
+
+ // data
+ // The list of mappings to display
+ mappings: [],
+ /**
+ * The identifier of the mapping to be removed. Saved in state, because the id is in an iterator,
+ * and the clicked handles confirmation, and the confirmation is outside the iterator.
+ */
+ mappingIdToRemove: null,
+ // mapping that is being processed (removed/updated/exported)
+ mappingIdLoading: null,
+ // the list of spines
+ spines: [],
+ // The ID of the currently selected configuration profile
+ configurationProfileId: null,
+};
+
+export const specsListStore = (initialData = {}) => ({
+ ...baseModel(initialData),
+ ...easyStateSetters(defaultState, initialData),
+
+ // computed
+ isDisabled: computed(
+ (state) => state.mappingIdLoading !== null || state.mappingIdToRemove !== null
+ ),
+
+ // actions
+ updateMappingStatus: action((state, { mappingId, status }) => {
+ const idx = state.mappings.findIndex((m) => m.id === mappingId);
+ if (idx < 0) return;
+ state.mappings[idx].status = status;
+ each(MAPPING_STATUSES, (value, _k) => {
+ state.mappings[idx][`${value}?`] = value === status;
+ });
+ }),
+ confirmRemove: action((state, mappingId) => {
+ state.confirmingRemove = true;
+ state.mappingIdToRemove = mappingId;
+ }),
+ cancelRemove: action((state) => {
+ state.confirmingRemove = false;
+ state.mappingIdToRemove = null;
+ }),
+ removeMapping: action((state, mappingId) => {
+ remove(state.mappings, (m) => m.id === mappingId);
+ state.confirmRemove = false;
+ state.mappingIdToRemove = null;
+ }),
+
+ // thunks
+ // Send a request to delete the selected mapping.
+ handleRemoveMapping: thunk(async (actions, _params = {}, h) => {
+ const state = h.getState();
+ try {
+ let response = await deleteMapping(state.mappingIdToRemove);
+
+ if (state.withoutErrors(response)) {
+ showSuccess('Mapping removed');
+ actions.removeMapping(state.mappingIdToRemove);
+ } else {
+ actions.setError(response.error);
+ }
+ return response;
+ } finally {
+ actions.setMappingIdToRemove(null);
+ }
+ }),
+ // Use the service to get all the available domains
+ handleUpdateMappingStatus: thunk(async (actions, params = {}, h) => {
+ const { mappingId, status } = params;
+ const state = h.getState();
+ actions.setMappingIdLoading(mappingId);
+ try {
+ const response = await updateMapping({
+ id: mappingId,
+ status,
+ });
+ if (state.withoutErrors(response)) {
+ actions.updateMappingStatus({ mappingId, status });
+ showSuccess('Status changed!');
+ } else {
+ actions.setError(response.error);
+ }
+ return response;
+ } finally {
+ actions.setMappingIdLoading(null);
+ }
+ }),
+ downloadExportedMappings: thunk(async (actions, params = {}, h) => {
+ const state = h.getState();
+ actions.setMappingIdLoading(params.id);
+
+ try {
+ await downloadExportedMappings(pick(params, ['domainIds', 'format', 'mapping']));
+ } catch (e) {
+ actions.setError(processMessage(e));
+ } finally {
+ actions.setMappingIdLoading(null);
+ }
+ }),
+ fetchMappings: thunk(async (actions, _params = {}, h) => {
+ const state = h.getState();
+
+ const response = await fetchMappings(state.filter);
+
+ if (state.withoutErrors(response)) {
+ actions.setMappings(response.mappings);
+ } else {
+ actions.setError(response.error);
+ }
+ }),
+ fetchSpineSpecifications: thunk(async (actions, _params = {}, h) => {
+ const state = h.getState();
+
+ const response = await fetchSpineSpecifications(state.filter);
+
+ if (state.withoutErrors(response)) {
+ actions.setSpines(response.specifications);
+ } else {
+ actions.setError(response.error);
+ }
+ }),
+ // Fetch all the necessary data from the API
+ fetchDataFromAPI: thunk(async (actions, _params = {}, _h) => {
+ actions.setLoading(true);
+ Promise.all([actions.fetchMappings(), actions.fetchSpineSpecifications()]);
+ actions.setLoading(false);
+ }),
+});
diff --git a/app/javascript/helpers/Auth.js b/app/javascript/helpers/Auth.js
index 2e9c996c..cf8c3542 100644
--- a/app/javascript/helpers/Auth.js
+++ b/app/javascript/helpers/Auth.js
@@ -1,8 +1,21 @@
// TODO: check if it'll work the same way if to move from webpacker
const adminRoleName = process.env.ADMIN_ROLE_NAME || 'Super Admin'; // eslint-disable-line no-undef
+const mapperRoleName = process.env.MAPPER_ROLE_NAME || 'Mapper'; // eslint-disable-line no-undef
+
+function hasRole(user, roleName) {
+ if (!user?.roles) {
+ return false;
+ }
-export function isAdmin(user) {
return user.roles.some(
- (r) => r.name.localeCompare(adminRoleName, undefined, { sensitivity: 'accent' }) === 0
+ (r) => r.name.localeCompare(roleName, undefined, { sensitivity: 'accent' }) === 0
);
}
+
+export function isAdmin(user) {
+ return hasRole(user, adminRoleName);
+}
+
+export function isMapper(user) {
+ return hasRole(user, mapperRoleName);
+}
diff --git a/app/javascript/helpers/Export.js b/app/javascript/helpers/Export.js
index 02fd3c65..b2470fdf 100644
--- a/app/javascript/helpers/Export.js
+++ b/app/javascript/helpers/Export.js
@@ -6,35 +6,13 @@ const contentType = 'application/json;charset=utf-8;';
* @param {Object} objectData
* @param {String} name
*/
-export const downloadFile = (objectData, name = null) => {
- let filename = name || 'export.json';
+export const downloadFile = (objectData, name = null, contentType = 'application/json') => {
+ const filename = name || 'export.json';
+ const data = typeof objectData === 'object' ? JSON.stringify(objectData) : objectData;
- if (window.navigator && window.navigator.msSaveOrOpenBlob) {
- /// Build binary large object (BLOB)
- var blob = new Blob([decodeURIComponent(encodeURI(JSON.stringify(objectData)))], {
- type: contentType,
- });
- /// Save it using the navigator api
- navigator.msSaveOrOpenBlob(blob, filename);
- /// If all good, stop execution
- return;
- }
-
- /// Plan B: Use a link appoach, adding it to the DOM, to use it and discard it
- /// when it's done.
- downloadWithLink(objectData, filename);
-};
-
-/**
- * Saves an object to a files and download it, using link approach
- *
- * @param {Object} objectData
- * @param {String} filename
- */
-const downloadWithLink = (objectData, filename) => {
- var a = document.createElement('a');
+ const a = document.createElement('a');
a.download = filename;
- a.href = 'data:' + contentType + ',' + encodeURIComponent(JSON.stringify(objectData));
+ a.href = `data:${contentType},${encodeURIComponent(data)}`;
a.target = '_blank';
document.body.appendChild(a);
a.click();
diff --git a/app/javascript/services/api/apiRequest.jsx b/app/javascript/services/api/apiRequest.jsx
index 872576e8..5ef56f59 100644
--- a/app/javascript/services/api/apiRequest.jsx
+++ b/app/javascript/services/api/apiRequest.jsx
@@ -1,6 +1,18 @@
import { camelizeKeys, decamelizeKeys } from 'humps';
import apiService, { processMessage } from './apiService';
-import { chain, pickBy, identity, isArray, isEmpty, isNil } from 'lodash';
+import {
+ chain,
+ pickBy,
+ identity,
+ isArray,
+ isEmpty,
+ isNil,
+ isObject,
+ isString,
+ map,
+ mapValues,
+ trim,
+} from 'lodash';
/**
* Manages the api requests
*
@@ -29,11 +41,14 @@ const apiRequest = async (props) => {
validateParams(props);
const queryParams = queryString(props.queryParams || {});
+ let data = props.formData ? props.payload : decamelizeKeys(props.payload || {});
+ if (props.trimPayload) data = trimObject(data);
+
// Do the request
const response = await apiService({
url: `${props.url}${queryParams ? `?${queryParams}` : ''}`,
method: props.method,
- data: props.payload,
+ data,
options: props.options,
})
// Process the errors globally
@@ -55,6 +70,7 @@ const apiRequest = async (props) => {
if (props.successResponse) {
responseData = {};
responseData[props.successResponse] = response.data;
+ responseData.contentType = response.headers['content-type'];
}
return props.camelizeKeys ? camelizeKeys(responseData) : responseData;
};
@@ -91,4 +107,11 @@ const queryString = (params) => {
.join('&');
};
+const trimObject = (obj) => {
+ if (isString(obj)) return trim(obj);
+ if (isArray(obj)) return map(obj, trimObject);
+ if (isObject(obj)) return mapValues(obj, trimObject);
+ return obj;
+};
+
export default apiRequest;
diff --git a/app/javascript/services/createCP.jsx b/app/javascript/services/createCP.jsx
index 8061f60b..8a400944 100644
--- a/app/javascript/services/createCP.jsx
+++ b/app/javascript/services/createCP.jsx
@@ -10,6 +10,7 @@ const createCP = async (data) => {
name: `DESM CP - ${new Date().toISOString()}`,
},
},
+ trimPayload: true,
});
return response;
};
diff --git a/app/javascript/services/downloadExportedMappings.js b/app/javascript/services/downloadExportedMappings.js
new file mode 100644
index 00000000..7bd3af21
--- /dev/null
+++ b/app/javascript/services/downloadExportedMappings.js
@@ -0,0 +1,37 @@
+import axios from 'axios';
+import saveAs from 'file-saver';
+import queryString from 'query-string';
+
+const downloadExportedMappings = async ({
+ configurationProfile = null,
+ domainIds,
+ format = 'jsonld',
+ mapping,
+}) => {
+ const params = {
+ configuration_profile_id: configurationProfile?.id,
+ domain_ids: domainIds,
+ mapping_id: mapping?.id,
+ };
+
+ const response = await axios.get(`/api/v1/mapping_exports.${format}`, {
+ params,
+ paramsSerializer: (params) => queryString.stringify(params, { arrayFormat: 'bracket' }),
+ responseType: 'blob',
+ });
+
+ const blob = new Blob([response.data]);
+ const contentDisposition = response.headers['content-disposition'];
+ let filename = 'export';
+
+ if (contentDisposition) {
+ const matches = /filename="([^"]+)"/.exec(contentDisposition);
+ if (matches != null && matches[1]) {
+ filename = matches[1];
+ }
+ }
+
+ saveAs(blob, filename);
+};
+
+export default downloadExportedMappings;
diff --git a/app/javascript/services/fetchConfigurationProfile.jsx b/app/javascript/services/fetchConfigurationProfile.jsx
index 8118adb1..800a4898 100644
--- a/app/javascript/services/fetchConfigurationProfile.jsx
+++ b/app/javascript/services/fetchConfigurationProfile.jsx
@@ -5,6 +5,7 @@ const fetchConfigurationProfile = async (cpId) => {
url: `/api/v1/configuration_profiles/${cpId}`,
method: 'get',
successResponse: 'configurationProfile',
+ camelizeKeys: true,
});
};
diff --git a/app/javascript/services/fetchMappingToExport.jsx b/app/javascript/services/fetchMappingToExport.jsx
deleted file mode 100644
index b3963065..00000000
--- a/app/javascript/services/fetchMappingToExport.jsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import apiRequest from './api/apiRequest';
-
-const fetchMappingToExport = async (mappingId) => {
- return await apiRequest({
- url: '/api/v1/mappings/' + mappingId + '/export',
- method: 'get',
- successResponse: 'exportedMapping',
- });
-};
-
-export default fetchMappingToExport;
diff --git a/app/javascript/services/mergeFiles.jsx b/app/javascript/services/mergeFiles.jsx
index e048e4f9..ec9a6990 100644
--- a/app/javascript/services/mergeFiles.jsx
+++ b/app/javascript/services/mergeFiles.jsx
@@ -17,6 +17,7 @@ const mergeFiles = async (files) => {
'Content-Type': 'multipart/form-data',
},
},
+ formData: true,
});
return response;
};
diff --git a/app/javascript/services/pageRoutes.js b/app/javascript/services/pageRoutes.js
index f585ee1b..4b135290 100644
--- a/app/javascript/services/pageRoutes.js
+++ b/app/javascript/services/pageRoutes.js
@@ -1,3 +1,6 @@
+import queryString from 'query-string';
+import { MAPPING_PATH_BY_STATUS } from '../components/Routes';
+
export const pageRoutes = {
// dashboard:
dashboard: () => '/dashboard',
@@ -8,8 +11,18 @@ export const pageRoutes = {
configurationProfiles: () => '/dashboard/configuration-profiles',
configurationProfile: (id) => `/dashboard/configuration-profiles/${id}`,
// mappings
- mappingsList: (cp, abstractClass = null) =>
- `/mappings-list${cp ? `?cp=${cp}` : ''}${
- abstractClass ? `${cp ? '&' : '?'}abstractClass=${abstractClass}` : ''
- }`,
+ mappingsList: (cp, abstractClass = null) => {
+ const params = queryString.stringify({ cp, abstractClass }, { skipNull: true });
+ return `/mappings-list${params ? `?${params}` : ''}`;
+ },
+ mappingPropertiesList: (mappingId) => `/mappings/${mappingId}/properties`,
+ mappingByStatus: (mappingId, status) => {
+ return `/mappings/${mappingId}/${MAPPING_PATH_BY_STATUS[status]}`;
+ },
+ mappingReadyToUpload: (mappingId) =>
+ `/mappings/${mappingId}/${MAPPING_PATH_BY_STATUS['ready_to_upload']}`,
+ mappingUploaded: (mappingId) => `/mappings/${mappingId}/${MAPPING_PATH_BY_STATUS['uploaded']}`,
+ mappingInProgress: (mappingId) =>
+ `/mappings/${mappingId}/${MAPPING_PATH_BY_STATUS['in_progress']}`,
+ mappingNew: () => '/new-mapping',
};
diff --git a/app/javascript/services/updateCP.jsx b/app/javascript/services/updateCP.jsx
index d7eab8da..40ecdb12 100644
--- a/app/javascript/services/updateCP.jsx
+++ b/app/javascript/services/updateCP.jsx
@@ -1,4 +1,3 @@
-import { camelizeKeys, decamelizeKeys } from 'humps';
import apiRequest from './api/apiRequest';
const updateCP = async (id, data) => {
@@ -6,14 +5,14 @@ const updateCP = async (id, data) => {
configurationProfile: data,
};
- let response = await apiRequest({
+ return await apiRequest({
url: `/api/v1/configuration_profiles/${id}`,
method: 'put',
successResponse: 'configurationProfile',
- payload: decamelizeKeys(payload),
+ payload: payload,
+ trimPayload: true,
+ camelizeKeys: true,
});
-
- return camelizeKeys(response);
};
export default updateCP;
diff --git a/app/javascript/services/updateSpec.js b/app/javascript/services/updateSpec.js
new file mode 100644
index 00000000..b60a3f4b
--- /dev/null
+++ b/app/javascript/services/updateSpec.js
@@ -0,0 +1,14 @@
+import { decamelizeKeys } from 'humps';
+import apiRequest from './api/apiRequest';
+
+const updateSpec = async (data) => {
+ return await apiRequest({
+ url: `/api/v1/specifications/${data.id}`,
+ method: 'put',
+ payload: {
+ specification: decamelizeKeys(data),
+ },
+ });
+};
+
+export default updateSpec;
diff --git a/app/lib/utils.rb b/app/lib/utils.rb
index a08b0758..d3d846c1 100644
--- a/app/lib/utils.rb
+++ b/app/lib/utils.rb
@@ -6,14 +6,20 @@ class Utils
#
# @param uri [String]
# @param context [Hash]
+ # @param non_rdf
# @return [String|nil]
# If the `uri` is a compact URI, returns it as is.
- # If the `uri` is a DESM URI, returns the value from which it was generated.
+ # If the `uri` is a DESM URI and `non_rdf` is true,
+ # returns the value from which it was generated,
+ # otherwise returns the original value
# If the `uri` belongs to a namespace from the `context`, returns its compact version.
# Otherwise, returns `nil`.
- def self.compact_uri(uri, context: Desm::CONTEXT)
+ def self.compact_uri(uri, context: Desm::CONTEXT, non_rdf: true)
return uri unless uri.start_with?("http")
- return URI(uri).path.split("/").last if uri.start_with?(Desm::DESM_NAMESPACE.to_s)
+
+ if uri.start_with?(Desm::DESM_NAMESPACE.to_s)
+ return non_rdf ? URI(uri).path.split("/").last : uri
+ end
context.each do |prefix, namespace|
next unless namespace.is_a?(String)
diff --git a/app/models/configuration_profile.rb b/app/models/configuration_profile.rb
index 14bd2b54..c7f07907 100644
--- a/app/models/configuration_profile.rb
+++ b/app/models/configuration_profile.rb
@@ -65,7 +65,7 @@ class ConfigurationProfile < ApplicationRecord
validates :name, uniqueness: true
after_initialize :setup_schema_validators
- before_save :check_structure, if: :will_save_change_to_structure?
+ before_save :check_and_update_structure, if: :will_save_change_to_structure?
# TODO: check if we really need that check
before_save :check_predicate_strongest_match, if: :will_save_change_to_predicate_strongest_match?
before_destroy :remove_orphan_organizations
@@ -122,11 +122,12 @@ def self.states_for_select(data = ConfigurationProfile.states.keys)
data.map { |state| { id: state, name: state.humanize } }
end
- def self.validate_structure(struct, type = "valid")
+ def self.validate_structure(struct, type = "valid", errors_as_objects: false)
struct = struct.deep_transform_keys { |key| key.to_s.camelize(:lower) }
JSON::Validator.fully_validate(
type.eql?("valid") ? valid_schema : complete_schema,
- struct
+ struct,
+ errors_as_objects:
)
end
@@ -138,7 +139,16 @@ def activated?
active? || deactived?
end
- def check_structure
+ def check_and_update_structure
+ # strip string values from struct
+ structure.deep_transform_values! do |value|
+ if value.is_a?(String)
+ value.strip.present? ? value.strip : ""
+ else
+ value
+ end
+ end
+
if complete? && !structure_complete?
incomplete!
elsif incomplete? && structure_complete?
@@ -146,6 +156,10 @@ def check_structure
end
end
+ def complete_structure_validation_errors
+ self.class.validate_structure(structure, "complete", errors_as_objects: true)
+ end
+
def complete!
state_handler.complete!
end
@@ -191,8 +205,7 @@ def structure_valid?
end
def structure_complete?
- validation = self.class.validate_structure(structure, "complete")
- validation.empty?
+ complete_structure_validation_errors.empty?
end
def transition_to!(new_state)
diff --git a/app/models/domain.rb b/app/models/domain.rb
index 262a6bb7..583ea517 100644
--- a/app/models/domain.rb
+++ b/app/models/domain.rb
@@ -47,8 +47,10 @@ class Domain < ApplicationRecord
belongs_to :domain_set
has_one :spine, dependent: :destroy
has_one :configuration_profile, through: :domain_set
- validates :source_uri, presence: true, uniqueness: { scope: :domain_set_id }
- validates :pref_label, presence: true, uniqueness: { scope: :domain_set_id }
+ validates :source_uri, presence: true,
+ uniqueness: { scope: :domain_set_id, message: "%
s has already been taken." }
+ validates :pref_label, presence: true,
+ uniqueness: { scope: :domain_set_id, message: "%s has already been taken." }
alias_attribute :name, :pref_label
###
diff --git a/app/models/mapping.rb b/app/models/mapping.rb
index fdfdf52d..780a9ee6 100644
--- a/app/models/mapping.rb
+++ b/app/models/mapping.rb
@@ -84,12 +84,13 @@ class Mapping < ApplicationRecord
###
validates :name, presence: true
# The possible status of a mapping
- # 1. "uploaded" It means that there's a specification uploaded but not
- # terms mapped
- # 2. "in-progress" It means that the user is already mapping terms but
+ # 1. "ready-to-upload" It means that the mapping was created, uploaded and reverted back to re-import
+ # 2. "uploaded" It means that there's a specification uploaded but not
+ # terms mapped, default status
+ # 3. "in-progress" It means that the user is already mapping terms but
# not yet finished mapping
- # 3. "mapped" It means the terms are confirmed as mapped to the spine
- enum status: { uploaded: 0, in_progress: 1, mapped: 2 }
+ # 4. "mapped" It means the terms are confirmed as mapped to the spine
+ enum status: { uploaded: 0, in_progress: 1, mapped: 2, ready_to_upload: 3 }
###
# CALLBACKS
@@ -105,6 +106,8 @@ class Mapping < ApplicationRecord
# METHODS
###
+ delegate :compact_domains, to: :specification
+
###
# @description: Include additional information about the mapping in
# json responses. This overrides the ApplicationRecord as_json method.
@@ -144,8 +147,7 @@ def domain
# @description: Exports the mapping into json-ld format
###
def export
- exporter = Exporters::Mapping.new(self)
- exporter.export
+ Exporters::Mapping.new(self).jsonld
end
###
@@ -219,6 +221,16 @@ def update_selected_terms(ids)
generate_alignments(first_upload: true)
end
+ def export_filename
+ [
+ configuration_profile.name,
+ domain,
+ specification.name,
+ specification.version || "",
+ updated_at.to_s.gsub(/[^\d]/, "")
+ ].map { _1.split.join("+") }.join("_").gsub(%r{[/,:*?"<>()|]}, "").gsub(/_+/, "_")
+ end
+
private
def update_mapped_at
diff --git a/app/models/predicate.rb b/app/models/predicate.rb
index 64053831..d8842a62 100644
--- a/app/models/predicate.rb
+++ b/app/models/predicate.rb
@@ -45,8 +45,10 @@ class Predicate < ApplicationRecord
audited
belongs_to :predicate_set
- validates :source_uri, presence: true, uniqueness: { scope: :predicate_set_id }
- validates :pref_label, presence: true, uniqueness: { scope: :predicate_set_id }
+ validates :source_uri, presence: true,
+ uniqueness: { scope: :predicate_set_id, message: "%s has already been taken." }
+ validates :pref_label, presence: true,
+ uniqueness: { scope: :predicate_set_id, message: "%s has already been taken." }
before_create :assign_color, unless: :color?
before_save :default_values
alias_attribute :name, :pref_label
diff --git a/app/models/property.rb b/app/models/property.rb
index 2e859ae8..c96c7ecf 100644
--- a/app/models/property.rb
+++ b/app/models/property.rb
@@ -35,12 +35,33 @@
# uploaded by a user
###
class Property < ApplicationRecord
+ audited
+
belongs_to :term
+ before_update :update_term, if: -> { label_changed? || source_uri_changed? }
+
+ delegate :comments, to: :term
###
# @description: Returns the property's compact domains
###
- def compact_domains
- @compact_domains ||= Array.wrap(domain).map { Utils.compact_uri(_1) }.compact
+ def compact_domains(non_rdf: true)
+ @compact_domains ||= Array.wrap(domain).map { Utils.compact_uri(_1, non_rdf:) }.compact
+ end
+
+ ###
+ # @description: Returns the property's compact ranges
+ ###
+ def compact_ranges
+ @compact_ranges ||= Array.wrap(range).map { Utils.compact_uri(_1) }.compact
+ end
+
+ private
+
+ def update_term
+ return if term.name == label && term.source_uri == source_uri
+
+ term.update!(name: label, source_uri:)
+ self.uri = term.uri
end
end
diff --git a/app/models/specification.rb b/app/models/specification.rb
index 1eb8f647..23611202 100644
--- a/app/models/specification.rb
+++ b/app/models/specification.rb
@@ -101,8 +101,8 @@ def to_json_ld
###
# @description: Returns the specification's compact domains
###
- def compact_domains
- @compact_domains ||= Array.wrap(selected_domains_from_file).map { Utils.compact_uri(_1) }.compact
+ def compact_domains(non_rdf: true)
+ @compact_domains ||= Array.wrap(selected_domains_from_file).map { Utils.compact_uri(_1, non_rdf:) }.compact
end
scope :for_dso, ->(dso) { joins(:user).where(users: { id: dso.users }) }
diff --git a/app/models/term.rb b/app/models/term.rb
index 5b2cac2c..112f69bd 100644
--- a/app/models/term.rb
+++ b/app/models/term.rb
@@ -28,6 +28,7 @@
###
class Term < ApplicationRecord
include Slugable
+ audited
belongs_to :configuration_profile_user
@@ -68,7 +69,7 @@ class Term < ApplicationRecord
before_destroy :check_if_alignments_exist
- delegate :compact_domains, to: :property
+ delegate :compact_domains, :compact_ranges, to: :property
###
# @description: Include additional information about the specification in
@@ -118,4 +119,10 @@ def check_if_alignments_exist
raise "Cannot remove a term with existing alignments. " \
"Please remove corresponding alignments from #{mappings.join(', ')} mappings before removing the term."
end
+
+ def comments
+ node = Parsers::JsonLd::Node.new(raw.slice("rdfs:comment"))
+ sanitizer = Rails::Html::FullSanitizer.new
+ node.read_as_language_map("comment").map { sanitizer.sanitize(_1) }
+ end
end
diff --git a/app/policies/mapping_policy.rb b/app/policies/mapping_policy.rb
index acbe9b86..2b636415 100644
--- a/app/policies/mapping_policy.rb
+++ b/app/policies/mapping_policy.rb
@@ -14,15 +14,6 @@ def index?
signed_in?
end
- ###
- # @description: Determines if the user can export this resource
- # @return [TrueClass]
- ###
- def export?
- # Signed in users
- signed_in?
- end
-
###
# @description: Determines if the user can see this resource
# @return [TrueClass]
diff --git a/app/policies/specification_policy.rb b/app/policies/specification_policy.rb
index eb93a238..ce6a3df0 100644
--- a/app/policies/specification_policy.rb
+++ b/app/policies/specification_policy.rb
@@ -13,4 +13,16 @@ def index?
# Signed in users
signed_in?
end
+
+ def update?
+ signed_in?
+ end
+
+ class Scope < ApplicationPolicy::Scope
+ def resolve
+ return scope.all if user.user.super_admin?
+
+ user.configuration_profile&.specifications || user.user.specifications
+ end
+ end
end
diff --git a/app/serializers/configuration_profile_serializer.rb b/app/serializers/configuration_profile_serializer.rb
index b7f754b1..c2698d49 100644
--- a/app/serializers/configuration_profile_serializer.rb
+++ b/app/serializers/configuration_profile_serializer.rb
@@ -2,7 +2,7 @@
class ConfigurationProfileSerializer < ApplicationSerializer
attributes :administrator_id, :description, :domain_set_id, :json_abstract_classes, :json_mapping_predicates,
- :predicate_set_id, :predicate_strongest_match, :slug, :state, :structure
+ :predicate_set_id, :predicate_strongest_match, :slug, :state, :structure, :structure_errors
attribute :standards_organizations, if: -> { params[:with_organizations] }
attribute :with_shared_mappings, if: -> { params[:with_shared_mappings] } do
params[:shared_mappings] || object.with_shared_mappings?
@@ -14,4 +14,11 @@ def standards_organizations
with_users: true, configuration_profile: object
)
end
+
+ def structure_errors
+ return [] unless object.incomplete?
+
+ interactor = ValidateCpStructure.call(configuration_profile: object)
+ interactor.grouped_messages
+ end
end
diff --git a/app/serializers/mapping_serializer.rb b/app/serializers/mapping_serializer.rb
index 72d6b23e..cce36d5b 100644
--- a/app/serializers/mapping_serializer.rb
+++ b/app/serializers/mapping_serializer.rb
@@ -3,11 +3,14 @@
class MappingSerializer < ApplicationSerializer
attributes :description, :domain, :mapped_at, :mapped_terms, :origin, :slug, :specification_id, :spine_id, :status,
:title
- attributes :uploaded?, :mapped?, :in_progress?
+ attributes :uploaded?, :mapped?, :in_progress?, :ready_to_upload?
belongs_to :specification do
{ id: object.specification.id, name: object.specification.name,
version: object.specification.version,
- user: { id: object.specification.user.id, fullname: object.specification.user.fullname } }
+ domain: { id: object.specification.domain_id, name: object.specification.domain&.pref_label,
+ spine: object.specification.domain&.spine? },
+ user: { id: object.specification.user.id, fullname: object.specification.user.fullname },
+ compact_domains: object.specification.compact_domains || [] }
end
has_many :selected_terms, serializer: PreviewSerializer
belongs_to :organization, serializer: PreviewSerializer
diff --git a/app/serializers/term_serializer.rb b/app/serializers/term_serializer.rb
index 4a29d8c5..81baafb6 100644
--- a/app/serializers/term_serializer.rb
+++ b/app/serializers/term_serializer.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
class TermSerializer < ApplicationSerializer
- attributes :compact_domains, :raw, :source_uri, :slug, :uri
+ attributes :comments, :compact_domains, :compact_ranges, :raw, :source_uri, :slug, :uri
has_one :property
has_many :vocabularies, serializer: PreviewSerializer
has_one :organization, if: -> { params[:spine] || params[:with_organization] }, serializer: PreviewSerializer
diff --git a/app/services/exporters/mapping.rb b/app/services/exporters/mapping.rb
index 1b66744e..90338b31 100644
--- a/app/services/exporters/mapping.rb
+++ b/app/services/exporters/mapping.rb
@@ -2,97 +2,21 @@
module Exporters
###
- # @description: Manages to export a mapping to JSON-LD format to let the user download it.
+ # @description: Exports a mapping in one of supported formats.
###
class Mapping
- ###
- # CONSTANTS
- ###
+ attr_reader :mapping
- ###
- # @description: Initializes this class with the instance to export.
- ###
- def initialize(instance)
- @instance = instance
+ def initialize(mapping)
+ @mapping = mapping
end
- ###
- # @description: Exports the mapping into json-ld format.
- ###
- def export
- {
- "@context": Desm::CONTEXT,
- "@graph": @instance.alignments.map do |alignment|
- term_nodes(alignment)
- end.flatten.unshift(main_node)
- }
+ def csv
+ @csv ||= CSV.new(mapping).export
end
- ###
- # @description: Specifies the format the main node (the node that represents the mapping itself)
- # should have.
- ###
- def main_node
- {
- "@id": "http://desmsolutions.org/TermMapping/#{@instance.id}",
- "@type": "desm:AbstractClassMapping",
- "dcterms:created": @instance.created_at.strftime("%F"),
- "dcterms:dateModified": @instance.updated_at.strftime("%F"),
- "dcterms:title": @instance.title,
- # @todo: Where to take this from
- "dcterms:description": "",
- "desm:abstractClassMapped": { "@id": @instance.specification.domain.uri },
- "dcterms:hasPart": @instance.alignments.map do |alignment|
- { "@id": alignment.uri }
- end
- }
- end
-
- ###
- # @description: For each alignment to a spine term, we build basically 3 nodes, one ofr the
- # alignment, one for the mapped property (more than one if there are many), and the last one
- # for the spine property.
- ###
- def term_nodes(alignment)
- [
- alignment_node(alignment),
- alignment.mapped_terms.map { |term| property_node(term) },
- property_node(alignment.spine_term)
- ].flatten
- end
-
- ###
- # @description: Specifies the format the alignment node should have.
- ###
- def alignment_node(alignment)
- {
- "@id": "http://desmsolutions.org/TermMapping/#{alignment.id}",
- "@type": "desm:TermMapping",
- "dcterms:isPartOf": { "@id": "http://desmsolutions.org/TermMapping/#{@instance.id}" },
- "desm:comment": alignment.comment,
- "desm:mappedterm": alignment.mapped_terms.map do |mapped_term|
- { "@id": mapped_term.uri }
- end,
- "desm:mappingPredicate": { "@id": alignment.predicate&.uri },
- "desm:spineTerm": { "@id": alignment.spine_term.uri }
- }
- end
-
- ###
- # @description: Defines the structure of a generic property term.
- ###
- def property_node(term)
- {
- "@id": term.source_uri,
- "@type": "rdf:Property",
- "desm:sourceURI": { "@id": term.property.source_uri },
- "rdfs:subPropertyOf": { "@id": term.property.subproperty_of },
- "desm:valueSpace": { "@id": term.property.value_space },
- "rdfs:label": term.property.label,
- "rdfs:comment": term.property.comment,
- "rdfs:domain": { "@id": term.property.selected_domain },
- "rdfs:range": { "@id": term.property.selected_range }
- }
+ def jsonld
+ @jsonld ||= JSONLD.new(mapping).export
end
end
end
diff --git a/app/services/exporters/mapping/csv.rb b/app/services/exporters/mapping/csv.rb
new file mode 100644
index 00000000..ceb3ed68
--- /dev/null
+++ b/app/services/exporters/mapping/csv.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+module Exporters
+ class Mapping
+ class CSV
+ HEADERS = [
+ "Spine term name",
+ "Spine term definition",
+ "Spine term class/type",
+ "Spine term origin",
+ "Mapping predicate label",
+ "Mapping predicate definition",
+ "Mapped term name",
+ "Mapped term definition",
+ "Mapped term class/type",
+ "Mapped term origin",
+ "Comments",
+ "Transformation notes"
+ ].freeze
+
+ attr_reader :mapping
+
+ def initialize(mapping)
+ @mapping = mapping
+ end
+
+ def export
+ ::CSV.generate do |csv|
+ csv << HEADERS
+ alignments.map { csv << Row.new(_1).values }
+ end
+ end
+
+ def alignments
+ mapping.alignments
+ end
+
+ class Row
+ attr_reader :alignment
+
+ delegate :mapping, :predicate, to: :alignment
+
+ def initialize(alignment)
+ @alignment = alignment
+ end
+
+ def mapped_term
+ alignment.mapped_terms.first&.property
+ end
+
+ def mapped_term_specification
+ alignment.mapped_terms.first&.specifications&.first
+ end
+
+ def mapped_term_specification_version
+ "(#{mapped_term_specification.version})" if mapped_term_specification&.version?
+ end
+
+ def organization
+ alignment.spine_term.organization
+ end
+
+ def spine_term
+ alignment.spine_term.property
+ end
+
+ def spine_term_specification
+ alignment.spine_term.specifications.first
+ end
+
+ def spine_term_specification_version
+ "(#{spine_term_specification.version})" if spine_term_specification.version?
+ end
+
+ def term_domains(term)
+ return unless term
+
+ domains = mapping.compact_domains(non_rdf: false) & term.compact_domains(non_rdf: false)
+ domains.join(" ")
+ end
+
+ def values
+ [
+ # Spine term name
+ spine_term.label,
+ # Spine term definition
+ spine_term.comments.join("\n"),
+ # Spine term class/type
+ term_domains(spine_term),
+ # Spine term origin
+ [spine_term_specification.name, spine_term_specification_version].compact.join(" "),
+ # Mapping predicate label
+ predicate&.name,
+ # Mapping predicate definition
+ predicate&.definition,
+ # Mapped term name
+ mapped_term&.label,
+ # Mapped term definition
+ mapped_term&.comments&.join("\n"),
+ # Mapped term class/type
+ term_domains(mapped_term),
+ # Mapped term origin
+ [mapped_term_specification&.name, mapped_term_specification_version].compact.join(" "),
+ # Comments
+ alignment.comment,
+ # Transformation notes
+ nil
+ ]
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/exporters/mapping/jsonld.rb b/app/services/exporters/mapping/jsonld.rb
new file mode 100644
index 00000000..dcb716fd
--- /dev/null
+++ b/app/services/exporters/mapping/jsonld.rb
@@ -0,0 +1,142 @@
+# frozen_string_literal: true
+
+module Exporters
+ class Mapping
+ ###
+ # @description: Manages to export a mapping to JSON-LD format to let the user download it.
+ ###
+ class JSONLD
+ attr_reader :mapping
+
+ ###
+ # @description: Initializes this class with the mapping to export.
+ ###
+ def initialize(mapping)
+ @mapping = mapping
+ end
+
+ ###
+ # @description: Exports the mapping into json-ld format.
+ ###
+ def export
+ { "@context": Desm::CONTEXT, "@graph": graph }
+ end
+
+ def graph
+ @graph ||= begin
+ nodes = mapping.alignments.map do |alignment|
+ term_nodes(alignment)
+ end.flatten.unshift(main_node)
+
+ deep_clean(nodes)
+ end
+ end
+
+ ###
+ # @description: Specifies the format the main node (the node that represents the mapping itself)
+ # should have.
+ ###
+ def main_node
+ {
+ "@id": "http://desmsolutions.org/TermMapping/#{mapping.id}",
+ "@type": "desm:AbstractClassMapping",
+ "dcterms:created": mapping.created_at.strftime("%F"),
+ "dcterms:dateModified": mapping.updated_at.strftime("%F"),
+ "dcterms:title": mapping.title,
+ # @todo: Where to take this from
+ "dcterms:description": "",
+ "desm:abstractClassMapped": { "@id": mapping.specification.domain.uri },
+ "dcterms:hasPart": mapping.alignments.map do |alignment|
+ { "@id": alignment.uri }
+ end
+ }
+ end
+
+ ###
+ # @description: For each alignment to a spine term, we build basically 3 nodes, one ofr the
+ # alignment, one for the mapped property (more than one if there are many), and the last one
+ # for the spine property.
+ ###
+ def term_nodes(alignment)
+ [
+ alignment_node(alignment),
+ alignment.mapped_terms.map { |term| property_node(term) },
+ property_node(alignment.spine_term)
+ ].flatten
+ end
+
+ ###
+ # @description: Specifies the format the alignment node should have.
+ ###
+ def alignment_node(alignment)
+ {
+ "@id": "http://desmsolutions.org/TermMapping/#{alignment.id}",
+ "@type": "desm:TermMapping",
+ "dcterms:isPartOf": { "@id": "http://desmsolutions.org/TermMapping/#{mapping.id}" },
+ "desm:comment": alignment.comment,
+ "desm:mappedterm": alignment.mapped_terms.map do |mapped_term|
+ { "@id": mapped_term.uri }
+ end,
+ "desm:mappingPredicate": { "@id": alignment.predicate&.uri },
+ "desm:spineTerm": { "@id": alignment.spine_term.uri }
+ }
+ end
+
+ ###
+ # @description: Returns the compact versions of the term's domains
+ # as well as its full non-RDF domains
+ ###
+ def domain_nodes(term)
+ mapping.compact_domains(non_rdf: false) & term.compact_domains(non_rdf: false)
+ end
+
+ ###
+ # @description: Parses the subproperty's value which is a stringified hash
+ # as well as its full non-RDF domains
+ ###
+ def parse_subproperty_of(value)
+ YAML.load(value.gsub("=>", ": ")) if value
+ rescue StandardError
+ value
+ end
+
+ ###
+ # @description: Defines the structure of a generic property term.
+ ###
+ def property_node(term)
+ {
+ "@id": term.source_uri,
+ "@type": "rdf:Property",
+ "desm:sourceURI": { "@id": term.property.source_uri },
+ "rdfs:subPropertyOf": parse_subproperty_of(term.property.subproperty_of),
+ "desm:valueSpace": { "@id": term.property.value_space },
+ "rdfs:label": term.property.label,
+ "rdfs:comment": term.property.comment,
+ "desm:domainIncludes": domain_nodes(term),
+ "desm:rangeIncludes": term.compact_ranges
+ }
+ end
+
+ private
+
+ ###
+ # @description: Recursively removes all blank values from an enumerable node
+ ###
+ def deep_clean(node)
+ case node
+ when Array
+ node.map { deep_clean(_1) }.select(&:presence)
+ when Hash
+ node.each_with_object({}) do |(key, value), clean_node|
+ clean_value = deep_clean(value)
+ next unless clean_value.presence
+
+ clean_node[key] = clean_value
+ end
+ else
+ node
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/parsers/json_ld/node.rb b/app/services/parsers/json_ld/node.rb
index 88bf477b..050e56bd 100644
--- a/app/services/parsers/json_ld/node.rb
+++ b/app/services/parsers/json_ld/node.rb
@@ -50,6 +50,15 @@ def read_as_array(attribute_name)
values.map { |value| (value.is_a?(Hash) && (value[:@id] || value["@id"])) || value }
end
+ ###
+ # @description: Read a language map attribute from a node and return its content as an array
+ # @param [Array, Hash, String] attribute_name: The node to be evaluated
+ # @return [Array]
+ ###
+ def read_as_language_map(attribute_name)
+ parse_language_map_node(read!(attribute_name)).flatten.compact
+ end
+
###
# @description: See if the property is related to a given node by id (URI)
# @param [String] uri The identifier of the original node to compare
@@ -201,6 +210,23 @@ def uri_eql?(node, uri)
def valid_node_key?(node, key)
node.is_a?(Hash) && (node.key?(key) || node.key?(key.downcase))
end
+
+ def parse_language_map_node(node)
+ values =
+ if node.is_a?(Array)
+ node
+ elsif node.is_a?(Hash)
+ if node.key?("@value")
+ [node.fetch("@value")]
+ else
+ node.values
+ end
+ else
+ return [node]
+ end
+
+ values.map { parse_language_map_node(_1) }
+ end
end
end
end
diff --git a/app/services/processors/specifications.rb b/app/services/processors/specifications.rb
index 89ef0931..1dda331e 100644
--- a/app/services/processors/specifications.rb
+++ b/app/services/processors/specifications.rb
@@ -41,6 +41,27 @@ def self.create(data)
@instance
end
+ ###
+ # @description: Create the specification with its terms
+ # @param [Hash] data The collection of data to create the specification
+ ###
+ def self.update(data, instance:)
+ @instance = instance
+
+ ActiveRecord::Base.transaction do
+ @instance.update!(
+ name: data[:name],
+ version: data[:version],
+ selected_domains_from_file: @instance.selected_domains_from_file.concat(data[:selected_domains]).uniq
+ )
+ new(data[:spec]).create_terms(@instance, data[:configuration_profile_user])
+ mapping = @instance.domain.spine.mappings.find(data[:mapping_id])
+ mapping.update!(status: Mapping.statuses["uploaded"]) if mapping.ready_to_upload?
+ end
+
+ @instance
+ end
+
###
# @description: Create each of the terms related to the specification
# @param instance [Specification]
diff --git a/config/environments/production.rb b/config/environments/production.rb
index 51f40dd8..c889ab86 100644
--- a/config/environments/production.rb
+++ b/config/environments/production.rb
@@ -57,27 +57,32 @@
# config.active_job.queue_name_prefix = "app_production"
config.action_mailer.perform_caching = false
- config.action_mailer.delivery_method = :smtp
+
config.action_mailer.default_options = {
- from: ENV["MAIL_USERNAME"],
+ from: ENV["FROM_EMAIL_ADDRESS"],
host: ENV["APP_DOMAIN"],
protocol: 'http'
}
- config.action_mailer.smtp_settings = {
- address: 'smtp.gmail.com',
- port: 587,
- user_name: ENV["MAIL_USERNAME"],
- password: ENV["MAIL_PASSWORD"],
- authentication: 'plain',
- enable_starttls_auto: true
- }
- # USe mailgun servers to deliver emails
- config.action_mailer.delivery_method = :mailgun
- config.action_mailer.mailgun_settings = {
- api_key: ENV["MAILGUN_API_KEY"],
- domain: ENV["MAILGUN_DOMAIN"]
- }
+ if ENV["MAILGUN_API_KEY"].present?
+ config.action_mailer.delivery_method = :mailgun
+
+ config.action_mailer.mailgun_settings = {
+ api_key: ENV["MAILGUN_API_KEY"],
+ domain: ENV["MAILGUN_DOMAIN"]
+ }
+ else
+ config.action_mailer.delivery_method = :smtp
+
+ config.action_mailer.smtp_settings = {
+ address: ENV["SMTP_ADDRESS"],
+ port: 587,
+ user_name: ENV["SMTP_USERNAME"],
+ password: ENV["SMTP_PASSWORD"],
+ authentication: 'plain',
+ enable_starttls_auto: true
+ }
+ end
# Ignore bad email addresses and do not raise email delivery errors.
# Set this to true and configure the email server for immediate delivery to raise delivery errors.
diff --git a/config/initializers/desm_constants.rb b/config/initializers/desm_constants.rb
index 65a41611..c08c6606 100644
--- a/config/initializers/desm_constants.rb
+++ b/config/initializers/desm_constants.rb
@@ -14,16 +14,24 @@ module Desm
# the prefixes an URIs are pre-existing constants.
###
CONTEXT = {
+ asn: "http://purl.org/ASN/schema/core/",
+ ceasn: "https://purl.org/ctdlasn/terms/",
ceds: "http://desmsolutions.org/ns/ceds/",
+ ceterms: "https://purl.org/ctdl/terms/",
credReg: "http://desmsolutions.org/ns/credReg/",
+ dc: "http://purl.org/dc/elements/1.1/",
dct: "http://purl.org/dc/terms/",
dcterms: "http://purl.org/dc/terms/",
desm: "http://desmsolutions.org/ns/",
+ foaf: "http://xmlns.com/foaf/0.1/",
+ owl: "http://www.w3.org/2002/07/owl#",
+ qdata: "https://credreg.net/qdata/terms/",
rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
rdfs: "http://www.w3.org/2000/01/rdf-schema#",
+ schema: "https://schema.org/",
+ skos: "http://www.w3.org/2004/02/skos/core#",
sdo: "http://schema.org/",
xsd: "http://www.w3.org/2001/XMLSchema#",
- skos: "http://www.w3.org/2004/02/skos/core#",
"desm:inTermMapping": {
"@type": "@id"
},
diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb
index bae3144f..3565fe22 100644
--- a/config/initializers/inflections.rb
+++ b/config/initializers/inflections.rb
@@ -14,5 +14,7 @@
ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.acronym '3D'
inflect.acronym 'API'
+ inflect.acronym 'CSV'
+ inflect.acronym 'JSONLD'
inflect.acronym 'URI'
end
diff --git a/config/locales/ui/en.yml b/config/locales/ui/en.yml
index e8893712..fe698c1c 100644
--- a/config/locales/ui/en.yml
+++ b/config/locales/ui/en.yml
@@ -24,6 +24,57 @@ en:
configuration_profile: Filter Agents for Profile(s)
state: Filter Agents for Profile's State
search: Search Agents
+ configuration_profiles:
+ errors:
+ incomplete: "The following issues prevent the configuration profile from being promoted to the \"Complete\":"
+ structure:
+ base: General Data
+ root: Required sections
+ path: Base
+ standards_organizations:
+ base: DSO's information
+ path: DSO
+ dso_agents:
+ base: DSO's agents
+ path: Agent
+ abstract_classes:
+ base: Abstract Classes
+ path: Abstract Classes
+ mapping_predicates:
+ base: Mapping Predicates
+ path: Mapping Predicates
+ sidebar:
+ base: General data about the configuration profile
+ standards_organizations:
+ base: DSO's information
+ dso_agents:
+ base: DSO's agents
+ abstract_classes:
+ base: Select the abstract classes
+ mapping_predicates:
+ base: Select the mapping predicates
+ mapping:
+ step:
+ new: Upload Specification
+ ready_to_upload: Reimport Specification
+ uploaded: Map to Domains
+ in_progress: Align and Fine Tune
+ mapped: Mapped
+ mapping_upload:
+ new:
+ step_1: 1. Upload Your Specification
+ form:
+ domain: Which domain are you uploading?
+ persisted:
+ step_1: 1. Upload More Properties for Your Specification
+ form:
+ domain: Domain
+ specifications:
+ mapping:
+ undo:
+ mapped: Mark this mapping back to 'in progress'
+ in_progress: Mark this mapping back to 'uploaded'
+ uploaded: Mark this mapping back to 'ready to upload'
view_mapping:
no_mappings:
current_profile: No shared mappings found for requested configuration profile.
@@ -34,6 +85,19 @@ en:
zero: No shared mappings found for this abstract class and configuration profile.
one: "Shared mapping:"
other: "Shared mappings:"
+ properties_list:
+ notice:
+ mapping:
+ zero: No mapping properties found for this mapping.
+ one: "1 property recognized for this mapping"
+ other: "%{count} properties recognized for this mapping"
+ spine:
+ zero: No spine properties found for this spine.
+ one: "1 property recognized for this spine"
+ other: "%{count} properties recognized for this spine"
+ message:
+ mapping: You can edit each term of your specification until you are confident with names, vocabularies, uri's and more.
+ spine: You can edit each term of your specification until you are confident with names, vocabularies, uri's and more.
pages:
default:
title: DESM
@@ -44,4 +108,25 @@ en:
mappings_list:
title: DESM - Shared mappings
description: View shared mappings in the Data Ecosystem Schema Mapper Tool.
+ mappings:
+ title: DESM - Specifications
+ description: View/Update mappings in the Data Ecosystem Schema Mapper Tool.
+ mapping_new:
+ title: DESM - Upload Specification
+ description: Create a new mapping in the Data Ecosystem Schema Mapper Tool.
+ mapping_ready_to_upload:
+ title: DESM - Reimport Specification
+ description: Reimport specification in the Data Ecosystem Schema Mapper Tool.
+ mapping_to_domains:
+ title: DESM - Map to Domains
+ description: Map to Domains in the Data Ecosystem Schema Mapper Tool.
+ mapping_align:
+ title: DESM - Align and Fine Tune
+ description: Align and Fine Tune mapping in the Data Ecosystem Schema Mapper Tool.
+ spine_properties:
+ title: DESM - Spine Properties
+ description: View/Edit spine properties in the Data Ecosystem Schema Mapper Tool.
+ mapping_properties:
+ title: DESM - Mapping Properties
+ description: View/Edit mapping properties in the Data Ecosystem Schema Mapper Tool.
diff --git a/config/routes.rb b/config/routes.rb
index 7b1dfe59..c9d09863 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -153,7 +153,7 @@
resources :organizations, only: [:index, :show, :create, :update, :destroy]
resources :predicates, only: [:index]
resources :roles, only: [:index]
- resources :specifications, only: [:index, :create, :destroy, :show]
+ resources :specifications, only: [:index, :create, :destroy, :show, :update]
resources :spine_terms, only: [:create]
resources :terms, only: [:show, :update, :destroy]
resources :spine_specifications, only: %i[index show]
@@ -172,10 +172,11 @@
post :extract, on: :collection
end
+ resources :mapping_exports, only: :index, defaults: { format: "jsonld" }
+
# Mapping selected terms
post 'mappings/:id/selected_terms' => 'mapping_selected_terms#create'
get 'mappings/:id/selected_terms' => 'mapping_selected_terms#show', as: :mapping_selected_terms
- get 'mappings/:id/export' => 'mappings#export'
delete 'mappings/:id/selected_terms' => 'mapping_selected_terms#destroy'
get 'alignments/:id/vocabulary' => 'alignment_vocabularies#show'
diff --git a/ns/complete.configurationProfile.schema.json b/ns/complete.configurationProfile.schema.json
index a126143c..d61642af 100644
--- a/ns/complete.configurationProfile.schema.json
+++ b/ns/complete.configurationProfile.schema.json
@@ -10,6 +10,7 @@
"properties": {
"fullname": {
"type": "string",
+ "minLength": 3,
"maxLength": 120,
"title": "The name for this agent to display in the profile. Desirable format: .",
"examples": [
@@ -26,9 +27,10 @@
"maxLength": 127
},
"phone": {
- "type": "string",
+ "type": ["string", "null"],
"title": "The international/domestic format phone number of the agent",
"pattern": "^\\+?[0-9 -]{6,18}$",
+ "default": "",
"examples": [
"+1234567890",
"123-456-789",
@@ -36,8 +38,9 @@
]
},
"githubHandle": {
- "type": "string",
- "title": "The github username"
+ "type": ["string", "null"],
+ "title": "The github username",
+ "default": ""
},
"leadMapper": {
"type": "boolean",
@@ -62,19 +65,23 @@
"properties": {
"name": {
"type": "string",
- "title": "The name of the schema file"
+ "title": "The name of the schema file",
+ "minLength": 3
},
"description": {
- "type": "string",
- "title": "A detailed description of the schema file. E.g. what it represents, which concepts should be expected it to contain."
+ "type": ["string", "null"],
+ "title": "A detailed description of the schema file. E.g. what it represents, which concepts should be expected it to contain.",
+ "default": ""
},
"encodingSchema": {
- "type": "string",
- "title": "The encodingSchema for this schema file"
+ "type": ["string", "null"],
+ "title": "The encodingSchema for this schema file",
+ "default": ""
},
"version": {
- "type": "string",
- "title": "The version of the schema file"
+ "type": ["string", "null"],
+ "title": "The version of the schema file",
+ "default": ""
},
"origin": {
"$id": "#/properties/standardsOrganizations/items/anyOf/0/properties/associatedSchema/items/anyOf/0/properties/origin",
@@ -124,19 +131,23 @@
"properties": {
"name": {
"type": "string",
- "title": "The name of the skos file."
+ "title": "The name of the skos file.",
+ "minLength": 3
},
"version": {
- "type": "string",
- "title": "The version of the skos file"
+ "type": ["string", "null"],
+ "title": "The version of the skos file",
+ "default": ""
},
"description": {
- "type": "string",
- "title": "A description what the skos file represents."
+ "type": ["string", "null"],
+ "title": "A description what the skos file represents.",
+ "default": ""
},
"origin": {
"type": "string",
"title": "The URL where the file content is available",
+ "minLength": 3,
"examples": [
"https://example.url.for.skos.predicates.file1,https://example.url.for.skos.predicates.file2"
]
@@ -165,6 +176,7 @@
"name": {
"type": "string",
"title": "The name of the organization",
+ "minLength": 3,
"examples": [
"Schema.org",
"CTDL",
@@ -172,8 +184,9 @@
]
},
"description": {
- "type": "string",
- "title": "A description that provides consistent information about the standards organization"
+ "type": ["string", "null"],
+ "title": "A description that provides consistent information about the standards organization",
+ "default": ""
},
"homepageURL": {
"$ref": "#/definitions/PlausibleUrl",
@@ -222,6 +235,7 @@
"name": {
"$id": "#/properties/name",
"type": "string",
+ "minLength": 3,
"title": "A name that denotes the purpose of this configuration profile. Preferably a single word",
"examples": [
"Medical",
@@ -230,7 +244,7 @@
},
"description": {
"$id": "#/properties/description",
- "type": "string",
+ "type": ["string", "null"],
"title": "The description of the configuration profile",
"description": "An explanation about the purpose of this configuration profile.",
"default": "",
diff --git a/ns/valid.configurationProfile.schema.json b/ns/valid.configurationProfile.schema.json
index 9720d542..1d3f1456 100644
--- a/ns/valid.configurationProfile.schema.json
+++ b/ns/valid.configurationProfile.schema.json
@@ -24,7 +24,7 @@
"maxLength": 127
},
"phone": {
- "type": "string",
+ "type": ["string", "null"],
"title": "The international/domestic format phone number of the agent",
"pattern": "^\\+?[0-9 -]{6,18}$",
"examples": [
@@ -34,7 +34,7 @@
]
},
"githubHandle": {
- "type": "string",
+ "type": ["string", "null"],
"title": "The github username"
}
},
@@ -50,16 +50,19 @@
"title": "The name of the schema file"
},
"description": {
- "type": "string",
- "title": "A detailed description of the schema file. E.g. what it represents, which concepts should be expected it to contain."
+ "type": ["string", "null"],
+ "title": "A detailed description of the schema file. E.g. what it represents, which concepts should be expected it to contain.",
+ "default": ""
},
"encodingSchema": {
- "type": "string",
- "title": "The encodingSchema for this schema file"
+ "type": ["string", "null"],
+ "title": "The encodingSchema for this schema file",
+ "default": ""
},
"version": {
- "type": "string",
- "title": "The version of the schema file"
+ "type": ["string", "null"],
+ "title": "The version of the schema file",
+ "default": ""
},
"origin": {
"$id": "#/properties/standardsOrganizations/items/anyOf/0/properties/associatedSchema/items/anyOf/0/properties/origin",
@@ -108,12 +111,14 @@
"title": "The name of the skos file."
},
"version": {
- "type": "string",
- "title": "The version of the skos file"
+ "type": ["string", "null"],
+ "title": "The version of the skos file",
+ "default": ""
},
"description": {
- "type": "string",
- "title": "A description what the skos file represents."
+ "type": ["string", "null"],
+ "title": "A description what the skos file represents.",
+ "default": ""
},
"origin": {
"type": "string",
@@ -147,8 +152,9 @@
]
},
"description": {
- "type": "string",
- "title": "A description that provides consistent information about the standards organization"
+ "type": ["string", "null"],
+ "title": "A description that provides consistent information about the standards organization",
+ "default": ""
},
"homepageURL": {
"$ref": "#/definitions/PlausibleUrl",
@@ -199,7 +205,7 @@
},
"description": {
"$id": "#/properties/description",
- "type": "string",
+ "type": ["string", "null"],
"title": "The description of the configuration profile",
"description": "An explanation about the purpose of this configuration profile.",
"default": "",
diff --git a/package.json b/package.json
index 3eff25e4..1944551a 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"classnames": "^2.5.1",
"codemirror": "^5.57.0",
"easy-peasy": "^6.0.4",
+ "file-saver": "^2.0.5",
"humps": "^2.0.1",
"i18n-js": "^4.4.3",
"is-valid-json": "^1.0.2",
@@ -31,6 +32,7 @@
"react-dom": "^17.0.2",
"react-helmet": "^6.1.0",
"react-modal": "^3.11.2",
+ "react-multi-select-component": "^4.3.4",
"react-redux": "^7.2.1",
"react-redux-toastr": "^8.0.0",
"react-router-dom": "^5.2.0",
diff --git a/samples/mappingPredicates/pilotMappingPredicates_2.ttl b/samples/mappingPredicates/pilotMappingPredicates_2.ttl
new file mode 100644
index 00000000..b1de8619
--- /dev/null
+++ b/samples/mappingPredicates/pilotMappingPredicates_2.ttl
@@ -0,0 +1,82 @@
+@prefix dcterms: .
+@prefix desm: .
+@prefix skos: .
+@prefix xsd: .
+
+ a skos:Concept ;
+ skos:inScheme ;
+ skos:prefLabel "Identical"@en-us ;
+ skos:definition "The definition is identical in wording and intent."@en-US ;
+ desm:weight 5 .
+
+ a skos:Concept ;
+ skos:inScheme ;
+ skos:prefLabel "Reworded"@en-us ;
+ skos:definition "The definition is identical in intent but reworded; the properties are equivalent."@en-US ;
+ desm:weight 5 .
+
+ a skos:Concept ;
+ skos:inScheme ;
+ skos:prefLabel "Similar"@en-us ;
+ skos:definition "The definition is similar in intent, but with significant wording differences."@en-US ;
+ desm:weight 4 .
+
+ a skos:Concept ;
+ skos:inScheme ;
+ skos:prefLabel "Transformed"@en-us ;
+ skos:definition "A simple data transform will yield a value for the spine term; for example, concatenating values from several properties being mapped. Describe the transform in a comment on the alignment."@en-US ;
+ desm:weight 4 .
+
+ a skos:Concept ;
+ skos:inScheme ;
+ skos:prefLabel "aggregated"@en-us ;
+ skos:definition "The term in the spine aggregates terms from the mapped schema."@en-US ;
+ desm:weight 3 .
+
+ a skos:Concept ;
+ skos:inScheme ;
+ skos:prefLabel "aggregated"@en-us ;
+ skos:definition "The term in the spine disaggregates terms from the mapped schema."@en-US ;
+ desm:weight 3 .
+
+ a skos:Concept ;
+ skos:inScheme ;
+ skos:prefLabel "Concept"@en-us ;
+ skos:definition "The definition is related only at a conceptual level, no simple transformation can render the data from one to the other."@en-US ;
+ desm:weight 2 .
+
+ a skos:Concept ;
+ skos:inScheme ;
+ skos:prefLabel "No Match"@en-us ;
+ skos:definition "There is no match for the spine term in the schema being mapped."@en-US ;
+ desm:weight 0 .
+
+ a skos:Concept ;
+ skos:inScheme ;
+ skos:prefLabel "Not Applicable"@en-us ;
+ skos:definition "For some reason this term is not relevant to the mapping."@en-US ;
+ desm:weight 0 .
+
+ a skos:Concept ;
+ skos:inScheme ;
+ skos:prefLabel "Issue"@en-us ;
+ skos:definition "We have a problem."@en-US ;
+ desm:weight 0 .
+
+ a skos:ConceptScheme ;
+ dcterms:created "2022-10-31"^^xsd:date ;
+ dcterms:creator ;
+ dcterms:description "This concept scheme identifies the mapping predicates used by the DESM Pilot Project."@en-us ;
+ dcterms:title "DESM Pilot Project Mapping Predicates"@en-us ;
+ skos:hasTopConcept
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+ ,
+
+.
diff --git a/spec/factories/predicates.rb b/spec/factories/predicates.rb
index aca65a36..9da89bee 100644
--- a/spec/factories/predicates.rb
+++ b/spec/factories/predicates.rb
@@ -32,7 +32,9 @@
color { Faker::Color.hex_color }
definition { Faker::Lorem.sentence }
predicate_set
- pref_label { Faker::Lorem.word }
+ sequence :pref_label do |n|
+ "#{Faker::Lorem.word}-#{n}"
+ end
source_uri { Faker::Internet.url }
weight { 3.50 }
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
index 7dddeb6f..99eb82a4 100644
--- a/spec/factories/users.rb
+++ b/spec/factories/users.rb
@@ -33,5 +33,11 @@
user.roles << Role.find_or_create_by!(name: Desm::ADMIN_ROLE_NAME)
end
end
+
+ trait :mapper do
+ after(:create) do |user|
+ user.roles << Role.find_or_create_by!(name: Desm::MAPPER_ROLE_NAME)
+ end
+ end
end
end
diff --git a/spec/fixtures/files/schema.jsonld b/spec/fixtures/files/schema.jsonld
new file mode 100644
index 00000000..25e88e39
--- /dev/null
+++ b/spec/fixtures/files/schema.jsonld
@@ -0,0 +1,60 @@
+{
+ "@context": {
+ "brick": "https://brickschema.org/schema/Brick#",
+ "csvw": "http://www.w3.org/ns/csvw#",
+ "dc": "http://purl.org/dc/elements/1.1/",
+ "dcam": "http://purl.org/dc/dcam/",
+ "dcat": "http://www.w3.org/ns/dcat#",
+ "dcmitype": "http://purl.org/dc/dcmitype/",
+ "dcterms": "http://purl.org/dc/terms/",
+ "doap": "http://usefulinc.com/ns/doap#",
+ "foaf": "http://xmlns.com/foaf/0.1/",
+ "geo": "http://www.opengis.net/ont/geosparql#",
+ "odrl": "http://www.w3.org/ns/odrl/2/",
+ "org": "http://www.w3.org/ns/org#",
+ "owl": "http://www.w3.org/2002/07/owl#",
+ "prof": "http://www.w3.org/ns/dx/prof/",
+ "prov": "http://www.w3.org/ns/prov#",
+ "qb": "http://purl.org/linked-data/cube#",
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
+ "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
+ "schema": "https://schema.org/",
+ "sh": "http://www.w3.org/ns/shacl#",
+ "skos": "http://www.w3.org/2004/02/skos/core#",
+ "sosa": "http://www.w3.org/ns/sosa/",
+ "ssn": "http://www.w3.org/ns/ssn/",
+ "time": "http://www.w3.org/2006/time#",
+ "vann": "http://purl.org/vocab/vann/",
+ "void": "http://rdfs.org/ns/void#",
+ "wgs": "https://www.w3.org/2003/01/geo/wgs84_pos#",
+ "xsd": "http://www.w3.org/2001/XMLSchema#"
+ },
+ "@graph": [
+ {
+ "@id": "schema:pickupTime",
+ "@type": "rdf:Property",
+ "rdfs:comment": "When a taxi will pick up a passenger or a rental car can be picked up.",
+ "rdfs:label": "pickupTime",
+ "schema:domainIncludes": [
+ {
+ "@id": "schema:RentalCarReservation"
+ },
+ {
+ "@id": "schema:TaxiReservation"
+ }
+ ],
+ "schema:rangeIncludes": {
+ "@id": "schema:DateTime"
+ }
+ },
+ {
+ "@id": "schema:NotYetRecruiting",
+ "@type": "schema:MedicalStudyStatus",
+ "rdfs:comment": "Not yet recruiting.",
+ "rdfs:label": "NotYetRecruiting",
+ "schema:isPartOf": {
+ "@id": "https://health-lifesci.schema.org"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/spec/interactors/validate_cp_structure_spec.rb b/spec/interactors/validate_cp_structure_spec.rb
new file mode 100644
index 00000000..c4de6019
--- /dev/null
+++ b/spec/interactors/validate_cp_structure_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+describe ValidateCpStructure, type: :interactor do
+ subject { described_class.call({ configuration_profile: }) }
+
+ describe ".call" do
+ context "with complete profile" do
+ let(:configuration_profile) { create(:configuration_profile, :complete) }
+
+ it "returns an array of error messages" do
+ expect(subject.messages).to eq []
+ end
+ end
+
+ context "with incomplete profile" do
+ let(:configuration_profile) { create(:configuration_profile, :incomplete, structure:) }
+ let(:structure) do
+ { "name" => " ",
+ "description" => nil,
+ "abstract_classes" => { "name" => nil, "origin" => nil, "version" => nil, "description" => nil },
+ "mapping_predicates" => {},
+ "standards_organizations" =>
+ [{ "name" => nil, "email" => "rwwww", "dso_agents" => [{ "fullname" => nil, "lead_mapper" => true }],
+ "associated_schemas" => [] },
+ { "name" => "DSO 2", "dso_agents" => [{ "fullname" => "Mapper N1", "lead_mapper" => false }],
+ "associated_schemas" => [] }] }
+ end
+
+ it "returns an array of error messages" do
+ expect(subject.messages.size).to eq 12
+ expect(subject.grouped_messages.keys).to match_array(%w(abstract_classes standards_organizations
+ mapping_predicates general))
+ org = subject.grouped_messages["standards_organizations"]
+ expect(org.size).to eq 4
+ expect(org["DSO (1st)"].size).to eq 3
+ expect(org["DSO (DSO 2, 2nd) > Agent (Mapper N1, 1st)"].size).to eq 1
+ expect(org.values.flatten.any? { |msg| msg[:message].blank? }).to be false
+ end
+ end
+ end
+end
diff --git a/spec/lib/utils_spec.rb b/spec/lib/utils_spec.rb
index 3a22eb70..b487bf33 100644
--- a/spec/lib/utils_spec.rb
+++ b/spec/lib/utils_spec.rb
@@ -11,8 +11,16 @@
end
context "full DESM URI" do
- it "returns original value" do
- expect(Utils.compact_uri("http://desmsolutions.org/ns/f401/Program")).to eq("Program")
+ context "with non-RDF" do
+ it "returns original value" do
+ expect(Utils.compact_uri("http://desmsolutions.org/ns/f401/Program")).to eq("Program")
+ end
+ end
+
+ context "without non-RDF" do
+ it "returns original value" do
+ expect(Utils.compact_uri("http://desmsolutions.org/ns/f401/Program", non_rdf: false)).to eq("http://desmsolutions.org/ns/f401/Program")
+ end
end
end
@@ -32,7 +40,7 @@
context "not from context" do
it "returns nothing" do
- expect(Utils.compact_uri("http://xmlns.com/foaf/0.1/Person")).to eq(nil)
+ expect(Utils.compact_uri("http://example.org/Person")).to eq(nil)
end
end
end
diff --git a/spec/models/configuration_profile_spec.rb b/spec/models/configuration_profile_spec.rb
index 290b36f3..4ab4e4a0 100644
--- a/spec/models/configuration_profile_spec.rb
+++ b/spec/models/configuration_profile_spec.rb
@@ -398,7 +398,7 @@
let(:configuration_profile) { create(:configuration_profile, :active) }
it "triggers the generate_structure callback and organizations update" do
- expect(configuration_profile).to receive(:check_structure).and_return(true)
+ expect(configuration_profile).to receive(:check_and_update_structure).and_return(true)
expect(UpdateDsos).to receive(:call).and_return(double(success?: true))
configuration_profile.update!(structure: { name: "test" })
end
diff --git a/spec/models/domain_spec.rb b/spec/models/domain_spec.rb
index dfbc884e..e547662d 100644
--- a/spec/models/domain_spec.rb
+++ b/spec/models/domain_spec.rb
@@ -67,7 +67,9 @@
is_expected.to validate_presence_of(:source_uri)
is_expected.to validate_presence_of(:pref_label)
is_expected.to validate_uniqueness_of(:source_uri).scoped_to(:domain_set_id)
+ .with_message(match("has already been taken."))
is_expected.to validate_uniqueness_of(:pref_label).scoped_to(:domain_set_id)
+ .with_message(match("has already been taken."))
end
it "generates a spine when the first specification gets linked" do
diff --git a/spec/models/mapping_spec.rb b/spec/models/mapping_spec.rb
index f5ce46ad..7156fa8c 100644
--- a/spec/models/mapping_spec.rb
+++ b/spec/models/mapping_spec.rb
@@ -47,7 +47,7 @@
end
describe "enums" do
- it { should define_enum_for(:status).with_values(uploaded: 0, in_progress: 1, mapped: 2) }
+ it { should define_enum_for(:status).with_values(uploaded: 0, in_progress: 1, mapped: 2, ready_to_upload: 3) }
end
describe "methods" do
@@ -62,7 +62,7 @@
it "exports the mapping into json-ld format" do
exporter = instance_double("Exporters::Mapping")
allow(Exporters::Mapping).to receive(:new).with(mapping).and_return(exporter)
- expect(exporter).to receive(:export)
+ expect(exporter).to receive(:jsonld)
mapping.export
end
diff --git a/spec/models/predicate_spec.rb b/spec/models/predicate_spec.rb
index 6a453d66..9f547a20 100644
--- a/spec/models/predicate_spec.rb
+++ b/spec/models/predicate_spec.rb
@@ -34,6 +34,8 @@
is_expected.to validate_presence_of(:source_uri)
is_expected.to validate_presence_of(:pref_label)
is_expected.to validate_uniqueness_of(:source_uri).scoped_to(:predicate_set_id)
+ .with_message(match("has already been taken."))
is_expected.to validate_uniqueness_of(:pref_label).scoped_to(:predicate_set_id)
+ .with_message(match("has already been taken."))
end
end
diff --git a/spec/models/property_spec.rb b/spec/models/property_spec.rb
index 70172a65..d591e9f6 100644
--- a/spec/models/property_spec.rb
+++ b/spec/models/property_spec.rb
@@ -33,4 +33,38 @@
describe Property do
it { is_expected.to belong_to(:term) }
+
+ describe "#update_term" do
+ let(:property) { create(:property) }
+
+ context "when label is changed" do
+ it "updates the term name and property uri" do
+ new_label = "New Label"
+ expect do
+ property.update(label: new_label)
+ end.to change { property.term.name }.to(new_label)
+ expect(property.uri).to eq(property.term.uri)
+ end
+ end
+
+ context "when source_uri is changed" do
+ it "updates the property uri" do
+ new_source_uri = "https://example.com/new_source_uri"
+ expect do
+ property.update(source_uri: new_source_uri)
+ end.to change { property.term.source_uri }.to(new_source_uri)
+ expect(property.uri).to eq(property.term.uri)
+ end
+ end
+
+ context "when neither label nor source_uri is changed" do
+ it "does not update the term name or property uri" do
+ previous_uri = property.uri
+ expect do
+ property.update(comment: "New Comment")
+ end.not_to change { property.term }
+ expect(property.uri).to eq(previous_uri)
+ end
+ end
+ end
end
diff --git a/spec/requests/api/v1/configuration_profiles_spec.rb b/spec/requests/api/v1/configuration_profiles_spec.rb
index f13a4ef5..da55c493 100644
--- a/spec/requests/api/v1/configuration_profiles_spec.rb
+++ b/spec/requests/api/v1/configuration_profiles_spec.rb
@@ -9,7 +9,7 @@
let(:configuration_profile) { create(:configuration_profile, :basic) }
context "when user is not authenticated" do
- before { get api_v1_mappings_path }
+ before { get api_v1_configuration_profiles_path }
it_behaves_like "api authorization error"
end
diff --git a/spec/services/parsers/json_ld/node_spec.rb b/spec/services/parsers/json_ld/node_spec.rb
index c4dc549d..ca0338c4 100644
--- a/spec/services/parsers/json_ld/node_spec.rb
+++ b/spec/services/parsers/json_ld/node_spec.rb
@@ -26,6 +26,56 @@
end
end
+ describe "#read_as_language_map" do
+ let(:node) { { "rdfs:comment": comment } }
+ let(:parsed_comment) { described_class.new(node).read_as_language_map("comment") }
+
+ context "nothing" do
+ let(:comment) { nil }
+
+ it "returns an empty array" do
+ expect(parsed_comment).to eq([])
+ end
+ end
+
+ context "string" do
+ let(:comment) { "Comment" }
+
+ it "returns an array with a single element" do
+ expect(parsed_comment).to eq(["Comment"])
+ end
+ end
+
+ context "hash with language code keys" do
+ let(:comment) { { "en" => "Comment", "es" => "Comentario", "jp" => "意見" } }
+
+ it "returns an array of the hash's values" do
+ expect(parsed_comment).to eq(%w(Comment Comentario 意見))
+ end
+ end
+
+ context "hash with @value" do
+ let(:comment) { { "@language" => "ko", "@value" => "논평" } }
+
+ it "returns an array with the @value property" do
+ expect(parsed_comment).to eq(["논평"])
+ end
+ end
+
+ context "array of mixed content" do
+ let(:comment) do
+ [
+ "Комментарий",
+ { "@language" => "kk", "@value" => "Түсініктеме" }
+ ]
+ end
+
+ it "returns an array with the @value property" do
+ expect(parsed_comment).to eq(%w(Комментарий Түсініктеме))
+ end
+ end
+ end
+
describe "rdfs_class_nodes returns the same node when it's explicitly an rdfs:Class" do
subject { described_class.new(credential_registry_node) }
diff --git a/spec/services/processors/specifications_spec.rb b/spec/services/processors/specifications_spec.rb
new file mode 100644
index 00000000..c1a53f2b
--- /dev/null
+++ b/spec/services/processors/specifications_spec.rb
@@ -0,0 +1,36 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Processors::Specifications do
+ describe ".update" do
+ let(:data) do
+ {
+ name: "Upd Specification",
+ version: "1.0",
+ domain_id: 1,
+ selected_domains: ["schema:3DModel", "schema:APIReference"],
+ spec: file_fixture("schema.jsonld").read,
+ configuration_profile_user: instance.configuration_profile_user,
+ mapping_id: mapping.id
+ }
+ end
+ let(:instance) do
+ create(:specification, selected_domains_from_file: ["schema:MedicalStudyStatus", "schema:3DModel"])
+ end
+ let!(:mapping) do
+ create(:mapping, :ready_to_upload, spine: instance.domain.spine, specification: instance,
+ configuration_profile_user: instance.configuration_profile_user)
+ end
+ subject { described_class.update(data, instance:) }
+
+ it "updates the specification with the given data" do
+ expect(subject.name).to eq(data[:name])
+ expect(subject.version).to eq(data[:version])
+ expect(subject.selected_domains_from_file).to match_array(
+ ["schema:3DModel", "schema:APIReference", "schema:MedicalStudyStatus"]
+ )
+ expect(mapping.reload.status).to eq("uploaded")
+ end
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index deca992a..e2455ad4 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2225,9 +2225,9 @@ boolbase@^1.0.0, boolbase@~1.0.0:
integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24=
bootstrap@^4.5.0:
- version "4.5.0"
- resolved "https://registry.npmjs.org/bootstrap/-/bootstrap-4.5.0.tgz"
- integrity sha512-Z93QoXvodoVslA+PWNdk23Hze4RBYIkpb5h8I2HY2Tu2h7A0LpAgLcyrhrSUyo2/Oxm2l1fRZPs1e5hnxnliXA==
+ version "4.6.2"
+ resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.6.2.tgz#8e0cd61611728a5bf65a3a2b8d6ff6c77d5d7479"
+ integrity sha512-51Bbp/Uxr9aTuy6ca/8FbFloBUJZLHwnhTcnjIeRn2suQWsWzcuJhGjKDB5eppVte/8oCdOL3VuwxvZDUggwGQ==
brace-expansion@^1.1.7:
version "1.1.11"
@@ -4075,6 +4075,11 @@ file-loader@^6.2.0:
loader-utils "^2.0.0"
schema-utils "^3.0.0"
+file-saver@^2.0.5:
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/file-saver/-/file-saver-2.0.5.tgz#d61cfe2ce059f414d899e9dd6d4107ee25670c38"
+ integrity sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==
+
file-uri-to-path@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd"
@@ -7773,6 +7778,11 @@ react-modal@^3.11.2:
react-lifecycles-compat "^3.0.0"
warning "^4.0.3"
+react-multi-select-component@^4.3.4:
+ version "4.3.4"
+ resolved "https://registry.yarnpkg.com/react-multi-select-component/-/react-multi-select-component-4.3.4.tgz#4f4b354bfa1f0353fa9c3bccf8178c87c9780450"
+ integrity sha512-Ui/bzCbROF4WfKq3OKWyQJHmy/bd1mW7CQM+L83TfiltuVvHElhKEyPM3JzO9urIcWplBUKv+kyxqmEnd9jPcA==
+
react-redux-toastr@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/react-redux-toastr/-/react-redux-toastr-8.0.0.tgz#bb6379e06289e6521cc9e03d277c38acc50d183c"