diff --git a/app/controllers/api/v1/alignments_controller.rb b/app/controllers/api/v1/alignments_controller.rb index 70514545..b2035757 100644 --- a/app/controllers/api/v1/alignments_controller.rb +++ b/app/controllers/api/v1/alignments_controller.rb @@ -6,6 +6,7 @@ module API module V1 class AlignmentsController < BaseController + include ConfigurationProfileQueryable before_action :authorize_with_policy, except: :index ### @@ -14,7 +15,12 @@ class AlignmentsController < BaseController ### def index terms = current_configuration_profile.alignments - .includes(:predicate, mapping: :specification, mapped_terms: %i(organization property vocabularies)) + .includes( + :predicate, + :specification, + mapping: %i(configuration_profile_user organization), + mapped_terms: %i(organization property vocabularies) + ) .where( mappings: { spine_id: params[:spine_id], status: :mapped } ) diff --git a/app/controllers/api/v1/configuration_profile_actions_controller.rb b/app/controllers/api/v1/configuration_profile_actions_controller.rb index ef63a940..f8d1fc62 100644 --- a/app/controllers/api/v1/configuration_profile_actions_controller.rb +++ b/app/controllers/api/v1/configuration_profile_actions_controller.rb @@ -22,7 +22,7 @@ def call_action private def with_instance - @instance = ConfigurationProfile.find(params[:id]) + @instance = policy_scope(ConfigurationProfile).find(params[:id]) end def permitted_actions diff --git a/app/controllers/api/v1/configuration_profiles_controller.rb b/app/controllers/api/v1/configuration_profiles_controller.rb index b4713d89..f7ef1fe0 100644 --- a/app/controllers/api/v1/configuration_profiles_controller.rb +++ b/app/controllers/api/v1/configuration_profiles_controller.rb @@ -3,7 +3,8 @@ module API module V1 class ConfigurationProfilesController < API::V1::ConfigurationProfilesAbstractController - before_action :with_instance, only: %i(destroy show update set_current) + before_action :authorize_with_policy_class, only: %i(create index index_for_user) + before_action :authorize_with_policy, only: %i(destroy show update set_current) def create cp = ConfigurationProfile.create!(creation_params) @@ -13,19 +14,18 @@ def create end def index - fields = ["configuration_profiles.*"] - - configuration_profiles = - if current_user && !current_user.super_admin? - current_user - .configuration_profile_users - .joins(:configuration_profile, :organization) - .select(*fields, :lead_mapper, "organizations.id organization_id, organizations.id AS organization") - else - ConfigurationProfile.select(*fields) - end - - render json: configuration_profiles.order(:name) + render json: policy_scope(ConfigurationProfile).order(:name) + end + + def index_shared_mappings + render json: ConfigurationProfile.active.with_shared_mappings.order(:name), with_shared_mappings: true, + shared_mappings: true + end + + def index_for_user + render json: current_user.configuration_profile_users.includes(:configuration_profile, :organization) + .where(configuration_profiles: { state: :active }) + .order("configuration_profiles.name"), with_shared_mappings: true end def destroy @@ -61,6 +61,14 @@ def import private + def authorize_with_policy_class + authorize ConfigurationProfile + end + + def authorize_with_policy + authorize(with_instance) + end + def creation_params permitted_params.merge({ name: DEFAULT_CP_NAME, administrator: @current_user }) end @@ -68,6 +76,10 @@ def creation_params def permitted_params params.require(:configuration_profile).permit(VALID_PARAMS_LIST) end + + def with_instance + @instance = policy_scope(ConfigurationProfile).find(params[:id]) + end end end end diff --git a/app/controllers/api/v1/domains_controller.rb b/app/controllers/api/v1/domains_controller.rb index 6a872bcb..1e499683 100644 --- a/app/controllers/api/v1/domains_controller.rb +++ b/app/controllers/api/v1/domains_controller.rb @@ -6,6 +6,7 @@ module API module V1 class DomainsController < BaseController + include ConfigurationProfileQueryable before_action :with_instance, only: :show ### diff --git a/app/controllers/api/v1/organizations_controller.rb b/app/controllers/api/v1/organizations_controller.rb index f80858c4..b4f528ee 100644 --- a/app/controllers/api/v1/organizations_controller.rb +++ b/app/controllers/api/v1/organizations_controller.rb @@ -6,6 +6,7 @@ module API module V1 class OrganizationsController < BaseController + include ConfigurationProfileQueryable before_action :authorize_with_policy, except: :index ### diff --git a/app/controllers/api/v1/predicates_controller.rb b/app/controllers/api/v1/predicates_controller.rb index d735fe6e..9f438a25 100644 --- a/app/controllers/api/v1/predicates_controller.rb +++ b/app/controllers/api/v1/predicates_controller.rb @@ -6,6 +6,8 @@ module API module V1 class PredicatesController < BaseController + include ConfigurationProfileQueryable + ### # @description: Lists all the predicates ### diff --git a/app/controllers/api/v1/specifications_controller.rb b/app/controllers/api/v1/specifications_controller.rb index 03a424b5..34f20490 100644 --- a/app/controllers/api/v1/specifications_controller.rb +++ b/app/controllers/api/v1/specifications_controller.rb @@ -57,7 +57,7 @@ def valid_params # @return [ActionController::Parameters] ### def permitted_params - params.require(:specification).permit(:name, :scheme, :use_case, :uri, :version) + params.require(:specification).permit(:name, :scheme, :uri, :version) end end end diff --git a/app/controllers/api/v1/spine_terms_controller.rb b/app/controllers/api/v1/spine_terms_controller.rb index dd8457b5..11ba1f6d 100644 --- a/app/controllers/api/v1/spine_terms_controller.rb +++ b/app/controllers/api/v1/spine_terms_controller.rb @@ -6,6 +6,7 @@ module API module V1 class SpineTermsController < BaseController + include ConfigurationProfileQueryable before_action :validate_mapped_terms, only: [:create] after_action :set_mapped_terms, only: [:create] @@ -15,7 +16,7 @@ def index includes += %i(organization) if params[:with_organization].present? terms = Spine.find(params[:id]).terms.includes(includes) - render json: terms, spine: params[:with_weights].present?, + render json: terms, spine: params[:with_weights].present?, spine_id: params[:id], with_organization: params[:with_organization].present? end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 85b18550..cc671615 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base helper_method :current_configuration_profile_user helper_method :current_organization helper_method :current_user + helper_method :impersonation_mode? # We manage our own security for sessions skip_before_action :verify_authenticity_token @@ -98,4 +99,11 @@ def user_not_authorized(err) end end end + + ### + # @description: Returns `true` if an admin is impersonating an agent + ### + def impersonation_mode? + session[:impostor_id].present? + end end diff --git a/app/controllers/concerns/configuration_profile_queryable.rb b/app/controllers/concerns/configuration_profile_queryable.rb new file mode 100644 index 00000000..b9c8bfa1 --- /dev/null +++ b/app/controllers/concerns/configuration_profile_queryable.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ConfigurationProfileQueryable + extend ActiveSupport::Concern + + included do + before_action :set_configuration_profile, only: :index + + def set_configuration_profile + return unless params[:configuration_profile_id].present? + + @current_configuration_profile = ConfigurationProfile.find(params[:configuration_profile_id]) + raise Pundit::NotAuthorizedError unless @current_configuration_profile.with_shared_mappings? + end + end +end diff --git a/app/controllers/impersonations_controller.rb b/app/controllers/impersonations_controller.rb new file mode 100644 index 00000000..600fdc2c --- /dev/null +++ b/app/controllers/impersonations_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +### +# @description: Manages impersonation of agents by admins +### +class ImpersonationsController < ApplicationController + ### + # @description: Signs in an agent and persists the current user as the impostor + ### + def start + agent = + begin + policy_scope(User, policy_scope_class: AgentPolicy::Scope).find(params[:agent_id]) + rescue ActiveRecord::RecordNotFound + nil + end + + if agent + session[:impostor_id] = current_user.id + session[:user_id] = agent.id + end + + redirect_to root_url + end + + ### + # @description: Signs in the impostor back and redirects them to the agents page + ### + def stop + return redirect_to root_url unless impersonation_mode? + + session[:user_id] = session[:impostor_id] + session[:impostor_id] = nil + redirect_to "/dashboard/agents" + end +end diff --git a/app/interactors/save_alignments.rb b/app/interactors/save_alignments.rb index 631d4e91..eaea75f2 100644 --- a/app/interactors/save_alignments.rb +++ b/app/interactors/save_alignments.rb @@ -37,8 +37,6 @@ def create_alignments_for_existing_mappings(term) def create_synthetic(params) term = Term.find(params.fetch(:mapped_term_ids).first) uri = "#{term.uri}-synthetic" - comment = "Alignment for a synthetic property added to the spine. " \ - "Synthetic uri: #{uri}" begin spine.terms << term @@ -48,7 +46,6 @@ def create_synthetic(params) end context.adds << mapping.alignments.create!( - comment:, spine_term: term, synthetic: true, uri:, diff --git a/app/javascript/components/auth/AuthButton.jsx b/app/javascript/components/auth/AuthButton.jsx index 87c2a554..eaa4a596 100644 --- a/app/javascript/components/auth/AuthButton.jsx +++ b/app/javascript/components/auth/AuthButton.jsx @@ -4,7 +4,7 @@ import { useSelector, useDispatch } from 'react-redux'; import { doLogout, unsetUser } from '../../actions/sessions'; import signOut from '../../services/signOut'; import { AppContext } from '../../contexts/AppContext'; -import { showInfo, showError } from '../../helpers/Messages'; +import { showError } from '../../helpers/Messages'; const AuthButton = () => { const { setLoggedIn, setCurrentConfigurationProfile } = useContext(AppContext); @@ -23,7 +23,9 @@ const AuthButton = () => { dispatch(unsetUser()); setCurrentConfigurationProfile(null); setLoggedIn(false); - showInfo('Signed Out'); + + // Reload the whole app + window.location = '/'; }; /// Show "Sign Out" if the user is already signed in diff --git a/app/javascript/components/auth/MetaTags.jsx b/app/javascript/components/auth/MetaTags.jsx new file mode 100644 index 00000000..7d633e7e --- /dev/null +++ b/app/javascript/components/auth/MetaTags.jsx @@ -0,0 +1,28 @@ +import { Helmet } from 'react-helmet'; +import { snakeCase } from 'lodash'; +import { i18n } from '../../utils/i18n'; + +const MetaTags = ({ pageType = 'default' }) => { + const key = snakeCase(pageType); + const title = i18n.t(`ui.pages.${key}.title`); + const description = i18n.t(`ui.pages.${key}.description`); + + return ( + + {/* Standard metadata tags */} + {title} + + {/* End standard metadata tags */} + {/* OpenGraph tags */} + + + {/* End OpenGraph tags */} + {/* Twitter tags */} + + + {/* End Twitter tags */} + + ); +}; + +export default MetaTags; diff --git a/app/javascript/components/auth/ProtectedRoute.jsx b/app/javascript/components/auth/ProtectedRoute.jsx index 4b143594..6a8053bd 100644 --- a/app/javascript/components/auth/ProtectedRoute.jsx +++ b/app/javascript/components/auth/ProtectedRoute.jsx @@ -1,9 +1,7 @@ import { Route, Redirect } from 'react-router-dom'; import { useSelector } from 'react-redux'; -import { Helmet } from 'react-helmet'; -import { snakeCase } from 'lodash'; +import MetaTags from './MetaTags'; import { showError } from '../../helpers/Messages'; -import { i18n } from '../../utils/i18n'; const ProtectedRoute = ({ component: Component, @@ -13,7 +11,6 @@ const ProtectedRoute = ({ }) => { const isLoggedIn = useSelector((state) => state.loggedIn); const user = useSelector((state) => state.user); - const key = snakeCase(pageType); return ( /// If we have a valid session @@ -29,10 +26,7 @@ const ProtectedRoute = ({ {...rest} render={(props) => ( <> - - {i18n.t(`ui.pages.${key}.title`)} - {i18n.t(`ui.pages.${key}.description`)} - + { - const key = snakeCase(pageType); - return ( <> - - {i18n.t(`ui.pages.${key}.title`)} - {i18n.t(`ui.pages.${key}.description`)} - + ); diff --git a/app/javascript/components/auth/SelectConfigurationProfile.jsx b/app/javascript/components/auth/SelectConfigurationProfile.jsx index db957e56..9436b960 100644 --- a/app/javascript/components/auth/SelectConfigurationProfile.jsx +++ b/app/javascript/components/auth/SelectConfigurationProfile.jsx @@ -6,7 +6,7 @@ const SelectConfigurationProfile = ({ history }) => ( null} />
- history.push('/')} /> + history.push('/')} />
diff --git a/app/javascript/components/dashboard/agents/AgentsIndex.jsx b/app/javascript/components/dashboard/agents/AgentsIndex.jsx index f85c9bca..edfbbc07 100644 --- a/app/javascript/components/dashboard/agents/AgentsIndex.jsx +++ b/app/javascript/components/dashboard/agents/AgentsIndex.jsx @@ -46,6 +46,9 @@ const AgentsIndex = (_props = {}) => {
    {profiles}
+ + Impersonate + ); }; @@ -68,6 +71,7 @@ const AgentsIndex = (_props = {}) => { {i18n.t('ui.dashboard.agents.table.phone')} {i18n.t('ui.dashboard.agents.table.organization')} {i18n.t('ui.dashboard.agents.table.configuration_profile')} + {state.agents.map(buildTableRow)} diff --git a/app/javascript/components/home/LeftCol.jsx b/app/javascript/components/home/LeftCol.jsx index 34c0ce78..6bea67e3 100644 --- a/app/javascript/components/home/LeftCol.jsx +++ b/app/javascript/components/home/LeftCol.jsx @@ -1,38 +1,41 @@ import { Link } from 'react-router-dom'; +import { pageRoutes } from '../../services/pageRoutes'; -const LeftSideHome = () => ( -
-
-
View Specification
-

To see crosswalks currently in process.

- - View Shared Mappings - -
-
-
Map your schema:
-
-
-
    -
  1. Upload your schema
  2. -
  3. Map the schema's properties and concepts
  4. -
  5. Review and download the completed mappings
  6. -
-
-
- - New Mapping - -
-
-); +const LeftSideHome = () => { + return ( +
+
+
View Specification
+

To see crosswalks currently in process.

+ + View Shared Mappings + +
+
+
Map your schema:
+
+
+
    +
  1. Upload your schema
  2. +
  3. Map the schema's properties and concepts
  4. +
  5. Review and download the completed mappings
  6. +
+
+
+ + New Mapping + +
+
+ ); +}; export default LeftSideHome; diff --git a/app/javascript/components/mapping/MappingForm.jsx b/app/javascript/components/mapping/MappingForm.jsx index 069bc916..681712c0 100644 --- a/app/javascript/components/mapping/MappingForm.jsx +++ b/app/javascript/components/mapping/MappingForm.jsx @@ -18,7 +18,6 @@ import checkDomainsInFile from '../../services/checkDomainsInFile'; import filterSpecification from '../../services/filterSpecification'; import mergeFiles from '../../services/mergeFiles'; import { setVocabularies } from '../../actions/vocabularies'; -import { validURL } from '../../helpers/URL'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faArrowRight } from '@fortawesome/free-solid-svg-icons'; import { showError } from '../../helpers/Messages'; @@ -82,10 +81,6 @@ const MappingForm = () => { * Whether the form was submitted or not, in order to show the preview */ const submitted = useSelector((state) => state.submitted); - /** - * Use case for this specification - */ - const [useCase, setUseCase] = useState(''); /** * Version of this specification */ @@ -115,18 +110,6 @@ const MappingForm = () => { return !_.isUndefined(response.error); }; - /** - * Validates the use case to be a valid URL after the user focuses - * out the "use case" input - */ - const handleUseCaseBlur = () => { - if (!_.isEmpty(useCase) && !validURL(useCase)) { - dispatch(setMappingFormErrors(["'Use case' must be a valid URL"])); - } else { - dispatch(unsetMappingFormErrors()); - } - }; - /** * Set multiple domains flag to false */ @@ -147,7 +130,6 @@ const MappingForm = () => { return { name: name, version: version, - useCase: useCase, domainId: selectedDomainId, /// Set the file name to send to the service. This will appear as "scheme" in all /// further properties created. @@ -393,20 +375,6 @@ const MappingForm = () => { disabled={submitted} /> -
- - setUseCase(e.target.value)} - disabled={submitted} - /> - It must be a valid URL -
diff --git a/app/javascript/components/mapping/MultipleDomainsModal.jsx b/app/javascript/components/mapping/MultipleDomainsModal.jsx index 97fe7b1c..ad5970de 100644 --- a/app/javascript/components/mapping/MultipleDomainsModal.jsx +++ b/app/javascript/components/mapping/MultipleDomainsModal.jsx @@ -4,6 +4,8 @@ import HoverableText from '../shared/HoverableText'; import ModalStyles from '../shared/ModalStyles'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faTimes, faSearch } from '@fortawesome/free-solid-svg-icons'; +import { partition } from 'lodash'; +import Pluralize from 'pluralize'; /** * Props: @@ -31,6 +33,8 @@ export default class MultipleDomainsModal extends Component { */ selectedDomains = () => this.state.domainsList.filter((domain) => domain.selected); + allSelected = () => this.selectedDomains().length === this.props.domains.length; + /** * Actions to execute when a domain is clicked * @@ -55,6 +59,15 @@ export default class MultipleDomainsModal extends Component { onSubmit(this.selectedDomains().map((domain) => domain.uri)); }; + /** + * Selects all the domains if there are unselected among them. Otherwise, deselects all the domains. + */ + handleToggleSelectAll = () => { + const { domainsList } = this.state; + const selected = !this.allSelected(); + this.setState({ domainsList: domainsList.map((d) => ({ ...d, selected })) }); + }; + /** * Tasks when mounting this component */ @@ -69,15 +82,42 @@ export default class MultipleDomainsModal extends Component { */ componentDidUpdate(prevProps) { if (this.props.domains !== prevProps.domains) { - this.setState({ domainsList: this.props.domains }); + this.setState({ domainsList: this.props.domains.map((d) => ({ ...d, selected: false })) }); } } + renderDomain(domain, primaryContent, secondaryContent) { + const id = `chk-${domain.id}`; + + return ( +
+ this.handleDomainClick(domain.id)} + tabIndex={0} + type="checkbox" + /> + +
+ ); + } + render() { /** * Elements from props */ - const { domains, inputValue, onFilterChange, modalIsOpen, onRequestClose } = this.props; + const { inputValue, onFilterChange, modalIsOpen, onRequestClose } = this.props; + // `rdfs:Resource` is a dummy domain assigned to properties without a real one + const [absentDomains, domains] = partition( + this.state.domainsList, + (d) => d.uri === 'rdfs:Resource' + ); return (
-
- -
-
+
+ + + +
@@ -138,22 +186,10 @@ export default class MultipleDomainsModal extends Component {
- {domains.map((dom) => ( -
- this.handleDomainClick(dom.id)} - tabIndex={0} - type="checkbox" - /> - -
- ))} + {absentDomains.map((d) => + this.renderDomain(d, 'None', 'properties with no domain declared') + )} + {domains.map((d) => this.renderDomain(d, d.label, d.uri))}
diff --git a/app/javascript/components/property-mapping-list/PropertiesList.jsx b/app/javascript/components/property-mapping-list/PropertiesList.jsx index 9051b930..c077ffe5 100644 --- a/app/javascript/components/property-mapping-list/PropertiesList.jsx +++ b/app/javascript/components/property-mapping-list/PropertiesList.jsx @@ -17,6 +17,7 @@ import { dateLongFormat } from 'utils/dateFormatting'; * * Props: * @param {Boolean} hideSpineTermsWithNoAlignments + * @param {Object} configurationProfile * @param {String} inputValue * @param {Array} organizations * @param {Object} selectedDomain @@ -103,7 +104,7 @@ export default class PropertiesList extends Component { ) ) && /// It matches the selected spine organizations - this.selectedSpineOrganizationIds().includes(property.organizationId))) + this.selectedSpineOrganizationIds().includes(property.organization?.id))) ); return implementSpineSort(filteredProps, selectedSpineOrderOption); @@ -131,7 +132,10 @@ export default class PropertiesList extends Component { * @param {number} spineId */ decoratePropertiesWithAlignments = async (spineId, spineTerms) => { - const response = await fetchAlignmentsForSpine(spineId); + const response = await fetchAlignmentsForSpine({ + spineId, + configurationProfileId: this.props.configurationProfile?.id, + }); if (!this.anyError(response)) { const { alignments } = response; @@ -151,7 +155,10 @@ export default class PropertiesList extends Component { * Use the service to get all the available properties of a spine specification */ handleFetchProperties = async (spineId) => { - let response = await fetchSpineTerms(spineId, { withWeights: true }); + let response = await fetchSpineTerms(spineId, { + withWeights: true, + configurationProfileId: this.props.configurationProfile?.id, + }); if (!this.anyError(response)) { const properties = await this.decoratePropertiesWithAlignments(spineId, response.terms); diff --git a/app/javascript/components/property-mapping-list/PropertyAlignments.jsx b/app/javascript/components/property-mapping-list/PropertyAlignments.jsx index 484d5b83..5854b167 100644 --- a/app/javascript/components/property-mapping-list/PropertyAlignments.jsx +++ b/app/javascript/components/property-mapping-list/PropertyAlignments.jsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react'; -import { flatMap } from 'lodash'; +import { compact, flatMap } from 'lodash'; import { implementAlignmentSort, implementAlignmentTermsSort } from './SortOptions'; import { propertyClassesForAlignmentTerm } from './stores/propertyMappingListStore'; @@ -40,15 +40,17 @@ const PropertyAlignments = (props) => { selectedSpineOrganizationIds.includes(props.spineTerm.organization.id) ); filteredAl = implementAlignmentSort(filteredAl, props.selectedAlignmentOrderOption); - let filteredMappedTerms = flatMap(filteredAl, (alignment) => - alignment.mappedTerms.map((mTerm) => - selectedAlignmentOrganizationIds.includes(mTerm.organization.id) - ? { - ...mTerm, - alignment, - selectedClasses: propertyClassesForAlignmentTerm(alignment, mTerm), - } - : null + let filteredMappedTerms = compact( + flatMap(filteredAl, (alignment) => + alignment.mappedTerms.map((mTerm) => + selectedAlignmentOrganizationIds.includes(mTerm.organization.id) + ? { + ...mTerm, + alignment, + selectedClasses: propertyClassesForAlignmentTerm(alignment, mTerm), + } + : null + ) ) ); return implementAlignmentTermsSort(filteredMappedTerms, props.selectedAlignmentOrderOption); @@ -63,7 +65,7 @@ const PropertyAlignments = (props) => { return filteredMappedTerms.map((mTerm, idx) => ( diff --git a/app/javascript/components/property-mapping-list/PropertyMappingList.jsx b/app/javascript/components/property-mapping-list/PropertyMappingList.jsx index e8a51d40..8b5857a6 100644 --- a/app/javascript/components/property-mapping-list/PropertyMappingList.jsx +++ b/app/javascript/components/property-mapping-list/PropertyMappingList.jsx @@ -8,17 +8,21 @@ import DesmTabs from '../shared/DesmTabs'; import PropertiesList from './PropertiesList'; import PropertyMappingsFilter from './PropertyMappingsFilter'; import SearchBar from './SearchBar'; -import queryString from 'query-string'; import ConfigurationProfileSelect from '../shared/ConfigurationProfileSelect'; import { AppContext } from '../../contexts/AppContext'; -import useDidMountEffect from 'helpers/useDidMountEffect'; import { i18n } from 'utils/i18n'; import { propertyMappingListStore } from './stores/propertyMappingListStore'; +import { camelizeLocationSearch, updateWithRouter } from 'helpers/queryString'; +import { isEmpty } from 'lodash'; const PropertyMappingList = (props) => { const context = useContext(AppContext); - const [state, actions] = useLocalStore(() => propertyMappingListStore()); + const [state, actions] = useLocalStore(() => { + const { cp, abstractClass } = camelizeLocationSearch(props); + return propertyMappingListStore({ cp, abstractClass }); + }); const { + configurationProfile, domains, organizations, predicates, @@ -31,30 +35,41 @@ const PropertyMappingList = (props) => { selectedSpineOrderOption, selectedSpineOrganizations, } = state; + const updateQueryString = updateWithRouter(props); const navCenterOptions = () => { return ; }; - const handleSelectedDomain = () => { - var selectedAbstractClassName = queryString.parse(props.location.search).abstractClass; + const handleSelectedData = () => { + if (isEmpty(domains)) return; - if (selectedAbstractClassName) { - let selectedAbstractClass = state.domains.find( - (d) => d.name.toLowerCase() == selectedAbstractClassName.toLowerCase() - ); + let selectedAbstractClass = state.abstractClass + ? domains.find((d) => d.name.toLowerCase() == state.abstractClass.toLowerCase()) + : domains[0]; + selectedAbstractClass ||= domains[0]; + actions.setSelectedDomain(selectedAbstractClass); + updateQueryString({ abstractClass: selectedAbstractClass?.name }); + }; - if (selectedAbstractClass) { - actions.setSelectedDomain(selectedAbstractClass); - } - } + useEffect(() => loadData(), [configurationProfile]); + useEffect(() => handleSelectedData(), [domains]); + + const updateSelectedDomain = (id) => { + const selectedDomain = domains.find((domain) => domain.id == id); + actions.updateSelectedDomain(selectedDomain); + updateQueryString({ abstractClass: selectedDomain.name }); }; - useEffect(() => loadData(), [context.currentConfigurationProfile]); - useDidMountEffect(() => handleSelectedDomain(), [domains]); + const updateSelectedConfigurationProfile = (configurationProfile) => { + actions.updateSelectedConfigurationProfile(configurationProfile); + if (configurationProfile) { + updateQueryString({ cp: configurationProfile.id }); + } + }; const loadData = async () => { - if (!context.currentConfigurationProfile) { + if (!configurationProfile) { return; } await actions.fetchDataFromAPI(); @@ -69,22 +84,32 @@ const PropertyMappingList = (props) => {
- {!context.loggedIn && } - - {context.currentConfigurationProfile && + {state.withoutSharedMappings && ( +
+ +
+ )} + + {configurationProfile?.withSharedMappings && (state.loading ? ( ) : ( <>

- {context.currentConfigurationProfile.name}:{' '} - {i18n.t('ui.view_mapping.subtitle')} + {configurationProfile.name}: {i18n.t('ui.view_mapping.subtitle')}

- actions.setSelectedDomain(domains.find((domain) => domain.id == id)) - } + onTabClick={(id) => updateSelectedDomain(id)} selectedId={selectedDomain?.id} values={domains} /> @@ -116,6 +141,7 @@ const PropertyMappingList = (props) => { { - const selectedDomains = uniq( - flatMap(term.alignments, (alignment) => alignment.selectedDomains || []) +export const propertyClassesForSpineTerm = (term) => + intersection( + term.compactDomains, + flatMap(term.alignments, (a) => a.compactDomains) ); - const termClasses = term.property.domain || []; - return intersection(selectedDomains, termClasses); -}; -export const propertyClassesForAlignmentTerm = (alignment, term) => { - const selectedDomains = alignment.selectedDomains || []; - const termClasses = term.property.domain || []; - return intersection(selectedDomains, termClasses); -}; +export const propertyClassesForAlignmentTerm = (alignment, term) => + intersection(term.compactDomains, alignment.compactDomains); export const propertyMappingListStore = (initialData = {}) => ({ ...baseModel(initialData), ...easyStateSetters(defaultState, initialData), // computed + selectedConfigurationProfileId: computed((state) => (configurationProfile) => { + if (state.configurationProfile) return null; + return state.cp ? parseInt(state.cp) : configurationProfile?.id; + }), // actions setAllOrganizations: action((state, organizations) => { @@ -67,24 +73,36 @@ export const propertyMappingListStore = (initialData = {}) => ({ state.predicates = predicates; state.selectedPredicates = predicates; }), + updateSelectedDomain: action((state, selectedDomain) => { + state.selectedDomain = selectedDomain; + state.abstractClass = null; + }), + updateSelectedConfigurationProfile: action((state, configurationProfile) => { + state.configurationProfile = configurationProfile; + if (configurationProfile) { + state.cp = null; + state.withoutSharedMappings = false; + } else { + state.withoutSharedMappings = true; + } + }), // thunks // Use the service to get all the available domains - handleFetchDomains: thunk(async (actions, _params = {}, h) => { + handleFetchDomains: thunk(async (actions, params = {}, h) => { const state = h.getState(); - const response = await fetchDomains(); + const response = await fetchDomains(params); if (state.withoutErrors(response)) { actions.setDomains(response.domains); - actions.setSelectedDomain(response.domains[0]); } else { actions.addError(response.error); } return response; }), // Use the service to get all the available organizations - handleFetchOrganizations: thunk(async (actions, _params = {}, h) => { + handleFetchOrganizations: thunk(async (actions, params = {}, h) => { const state = h.getState(); - const response = await fetchOrganizations(); + const response = await fetchOrganizations(params); if (state.withoutErrors(response)) { actions.setAllOrganizations(response.organizations); } else { @@ -93,9 +111,9 @@ export const propertyMappingListStore = (initialData = {}) => ({ return response; }), // Use the service to get all the available predicates - handleFetchPredicates: thunk(async (actions, _params = {}, h) => { + handleFetchPredicates: thunk(async (actions, params = {}, h) => { const state = h.getState(); - const response = await fetchPredicates(); + const response = await fetchPredicates(params); if (state.withoutErrors(response)) { actions.setAllPredicates(response.predicates); } else { @@ -106,10 +124,14 @@ export const propertyMappingListStore = (initialData = {}) => ({ // Fetch all the necessary data from the API fetchDataFromAPI: thunk(async (actions, _params = {}, h) => { actions.setLoading(true); + const state = h.getState(); + const queryParams = { + configurationProfileId: state.configurationProfile?.id, + }; await Promise.all([ - actions.handleFetchDomains(), - actions.handleFetchOrganizations(), - actions.handleFetchPredicates(), + actions.handleFetchDomains(queryParams), + actions.handleFetchOrganizations(queryParams), + actions.handleFetchPredicates(queryParams), ]); if (!h.getState().hasErrors) actions.setLoading(false); }), diff --git a/app/javascript/components/shared/AlertNotice.jsx b/app/javascript/components/shared/AlertNotice.jsx index 2d252f09..158b3335 100644 --- a/app/javascript/components/shared/AlertNotice.jsx +++ b/app/javascript/components/shared/AlertNotice.jsx @@ -15,7 +15,7 @@ const AlertNotice = (props) => { /** * Elements from props */ - const { cssClass, title, message, onClose, withScroll = false } = props; + const { cssClass, title, message, onClose, withScroll = false, withTitle = true } = props; const myRef = useRef(null); useEffect(() => { if (withScroll && !isEmpty(message) && myRef.current) { @@ -28,14 +28,14 @@ const AlertNotice = (props) => { const renderError = () => { if (isArray(message) && message.length > 1) { return ( -
    +
      {message.map((msg, i) => (
    • {msg}
    • ))}
    ); } else { - return

    {isArray(message) ? message[0] : message}

    ; + return

    {isArray(message) ? message[0] : message}

    ; } }; @@ -44,9 +44,11 @@ const AlertNotice = (props) => { ref={myRef} className={'alert alert-dismissible ' + (cssClass ? cssClass : 'alert-danger')} > -

    - {title ? title : 'Attention!'} -

    + {withTitle && ( +

    + {title ? title : 'Attention!'} +

    + )} {renderError()} {onClose && (
- {selectedValue.definition && ( + {selectedValue?.definition && (
{selectedValue.definition}
diff --git a/app/javascript/components/specifications-list/SpecsList.jsx b/app/javascript/components/specifications-list/SpecsList.jsx index c7895b32..11f4b891 100644 --- a/app/javascript/components/specifications-list/SpecsList.jsx +++ b/app/javascript/components/specifications-list/SpecsList.jsx @@ -23,6 +23,7 @@ import { } 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; @@ -296,7 +297,10 @@ export default class SpecsList extends Component { )} diff --git a/app/javascript/helpers/queryString.jsx b/app/javascript/helpers/queryString.jsx new file mode 100644 index 00000000..340f895b --- /dev/null +++ b/app/javascript/helpers/queryString.jsx @@ -0,0 +1,59 @@ +import queryString from 'query-string'; +import { isEqual } from 'lodash'; +import { camelizeKeys } from 'humps'; + +export const parseLocationSearch = ({ location }) => queryString.parse(location.search); +// React Router v6 compatibile +export const parseSearchParams = (search) => { + return Object.fromEntries([...search]); +}; +// React Router v6 compatibile +export const camelizeLocationSearch = (props, params = {}) => + camelizeKeys(params.v6 ? Object.fromEntries([...props]) : parseLocationSearch(props)); + +// React Router v6 compatibile +export const parseWithRouter = ( + params, + effects, + parsingParams = { withCamelizedKeys: false, v6: false } +) => { + let parsed = {}; + + if (parsingParams.v6) { + parsed = parseSearchParams(params); + } else { + const { location } = params; + parsed = queryString.parse(location.search); + } + + if (parsingParams.withCamelizedKeys) parsed = camelizeKeys(parsed); + + if (effects) { + for (const param of Object.keys(effects)) { + const [effect, defaultValue] = effects[param]; + const value = parsed[param]; + if (value) { + effect(value); + } else { + effect(defaultValue); + } + } + } + return parsed; +}; + +// React Router v6 compatibile +export const updateWithRouter = ({ history, location, v6 = false }) => (search) => { + const oldSearch = v6 ? parseSearchParams(location) : queryString.parse(location.search); + const newSearch = { ...oldSearch, ...search }; + for (const k of Object.keys(newSearch)) { + if (!newSearch[k]) { + delete newSearch[k]; + } + } + // don't need to push history if search is the same + if (isEqual(oldSearch, newSearch)) return; + const newQuery = new URLSearchParams(newSearch).toString(); + const params = { pathname: '', search: `?${newQuery}` }; + v6 ? history(params) : history.push(params); +}; diff --git a/app/javascript/services/fetchAlignmentsForSpine.jsx b/app/javascript/services/fetchAlignmentsForSpine.jsx index c19f7272..94ee947b 100644 --- a/app/javascript/services/fetchAlignmentsForSpine.jsx +++ b/app/javascript/services/fetchAlignmentsForSpine.jsx @@ -1,15 +1,14 @@ -import { camelizeKeys } from 'humps'; import apiRequest from './api/apiRequest'; -const fetchAlignmentsForSpine = async (spineId) => { - let response = await apiRequest({ - url: `/api/v1/alignments?spine_id=${spineId}`, +const fetchAlignmentsForSpine = async (queryParams = {}) => { + return await apiRequest({ + url: '/api/v1/alignments', method: 'get', successResponse: 'alignments', defaultResponse: [], + camelizeKeys: true, + queryParams, }); - - return camelizeKeys(response); }; export default fetchAlignmentsForSpine; diff --git a/app/javascript/services/fetchConfigurationProfiles.jsx b/app/javascript/services/fetchConfigurationProfiles.jsx index c2ea8e3b..e1a2f792 100644 --- a/app/javascript/services/fetchConfigurationProfiles.jsx +++ b/app/javascript/services/fetchConfigurationProfiles.jsx @@ -1,15 +1,20 @@ -import { camelizeKeys } from 'humps'; import apiRequest from './api/apiRequest'; -const fetchConfigurationProfiles = async () => { - let response = await apiRequest({ - url: '/api/v1/configuration_profiles', +const REQUEST_TYPES = { + index: 'configuration_profiles', + indexForUser: 'configuration_profiles/index_for_user', + indexWithSharedMappings: 'configuration_profiles/index_shared_mappings', +}; + +const fetchConfigurationProfiles = async (requestType = 'index', queryParams = {}) => { + return await apiRequest({ + url: `/api/v1/${REQUEST_TYPES[requestType]}`, method: 'get', defaultResponse: [], successResponse: 'configurationProfiles', + camelizeKeys: true, + queryParams: queryParams, }); - - return camelizeKeys(response); }; export default fetchConfigurationProfiles; diff --git a/app/javascript/services/fetchDomains.jsx b/app/javascript/services/fetchDomains.jsx index 34ffe945..3647185d 100644 --- a/app/javascript/services/fetchDomains.jsx +++ b/app/javascript/services/fetchDomains.jsx @@ -1,10 +1,11 @@ import apiRequest from './api/apiRequest'; -const fetchDomains = async () => { +const fetchDomains = async (queryParams = {}) => { const response = await apiRequest({ url: '/api/v1/domains', method: 'get', successResponse: 'domains', + queryParams, }); if (!response.error) { diff --git a/app/javascript/services/fetchOrganizations.jsx b/app/javascript/services/fetchOrganizations.jsx index b140f21d..4bee300c 100644 --- a/app/javascript/services/fetchOrganizations.jsx +++ b/app/javascript/services/fetchOrganizations.jsx @@ -1,11 +1,12 @@ import apiRequest from './api/apiRequest'; -const fetchOrganizations = async () => { +const fetchOrganizations = async (queryParams = {}) => { return await apiRequest({ url: '/api/v1/organizations', method: 'get', defaultResponse: [], successResponse: 'organizations', + queryParams, }); }; diff --git a/app/javascript/services/fetchPredicates.jsx b/app/javascript/services/fetchPredicates.jsx index 58a4536c..69d43aa2 100644 --- a/app/javascript/services/fetchPredicates.jsx +++ b/app/javascript/services/fetchPredicates.jsx @@ -1,11 +1,12 @@ import apiRequest from './api/apiRequest'; -const fetchPredicates = async () => { +const fetchPredicates = async (queryParams = {}) => { return await apiRequest({ url: '/api/v1/predicates', method: 'get', defaultResponse: [], successResponse: 'predicates', + queryParams, }); }; diff --git a/app/javascript/services/fetchSpineTerms.jsx b/app/javascript/services/fetchSpineTerms.jsx index 4ae4f2ff..d15928d4 100644 --- a/app/javascript/services/fetchSpineTerms.jsx +++ b/app/javascript/services/fetchSpineTerms.jsx @@ -1,16 +1,15 @@ -import { camelizeKeys } from 'humps'; import apiRequest from './api/apiRequest'; const fetchSpineTerms = async (specId, queryParams = {}) => { - const response = await apiRequest({ + return await apiRequest({ // spine_terms#index url: '/api/v1/spines/' + specId + '/terms', defaultResponse: [], successResponse: 'terms', method: 'get', + camelizeKeys: true, queryParams, }); - return camelizeKeys(response); }; export default fetchSpineTerms; diff --git a/app/javascript/services/pageRoutes.js b/app/javascript/services/pageRoutes.js index eaf4a8e0..f585ee1b 100644 --- a/app/javascript/services/pageRoutes.js +++ b/app/javascript/services/pageRoutes.js @@ -7,4 +7,9 @@ export const pageRoutes = { organizations: () => '/dashboard/organizations', configurationProfiles: () => '/dashboard/configuration-profiles', configurationProfile: (id) => `/dashboard/configuration-profiles/${id}`, + // mappings + mappingsList: (cp, abstractClass = null) => + `/mappings-list${cp ? `?cp=${cp}` : ''}${ + abstractClass ? `${cp ? '&' : '?'}abstractClass=${abstractClass}` : '' + }`, }; diff --git a/app/lib/utils.rb b/app/lib/utils.rb new file mode 100644 index 00000000..a08b0758 --- /dev/null +++ b/app/lib/utils.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class Utils + ## + # Compacts a full URI. + # + # @param uri [String] + # @param context [Hash] + # @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` belongs to a namespace from the `context`, returns its compact version. + # Otherwise, returns `nil`. + def self.compact_uri(uri, context: Desm::CONTEXT) + return uri unless uri.start_with?("http") + return URI(uri).path.split("/").last if uri.start_with?(Desm::DESM_NAMESPACE.to_s) + + context.each do |prefix, namespace| + next unless namespace.is_a?(String) + return uri.sub(namespace, "#{prefix}:") if uri.start_with?(namespace) + end + + nil + end +end diff --git a/app/models/alignment.rb b/app/models/alignment.rb index 4f37941e..238bf8d7 100644 --- a/app/models/alignment.rb +++ b/app/models/alignment.rb @@ -63,6 +63,8 @@ class Alignment < ApplicationRecord ### has_and_belongs_to_many :mapped_terms, join_table: :alignment_mapped_terms, class_name: :Term + has_one :specification, through: :mapping + has_one :spine, through: :mapping ### @@ -99,6 +101,10 @@ class Alignment < ApplicationRecord ### before_destroy :remove_spine_term, if: :synthetic + scope :mapped_for_spine, ->(spine_id) { joins(:mapping).where(mappings: { status: :mapped, spine_id: }) } + + delegate :compact_domains, to: :specification + ### # METHODS ### diff --git a/app/models/configuration_profile.rb b/app/models/configuration_profile.rb index f22ad17f..14bd2b54 100644 --- a/app/models/configuration_profile.rb +++ b/app/models/configuration_profile.rb @@ -88,6 +88,7 @@ class ConfigurationProfile < ApplicationRecord pg_search_scope :search_by_name, against: :name, using: { tsearch: { prefix: true } } scope :activated, -> { where(state: %i(active deactivated)) } + scope :with_shared_mappings, -> { joins(:mappings).where(mappings: { status: Mapping.statuses[:mapped] }).distinct } COMPLETE_SCHEMA = Rails.root.join("ns", "complete.configurationProfile.schema.json") VALID_SCHEMA = Rails.root.join("ns", "valid.configurationProfile.schema.json") @@ -198,6 +199,10 @@ def transition_to!(new_state) public_send(persisted? ? :update_column : :update_attribute, :state, new_state) end + def with_shared_mappings? + active? && mappings.mapped.any? + end + private def update_organizations diff --git a/app/models/property.rb b/app/models/property.rb index 72a51c8c..2e859ae8 100644 --- a/app/models/property.rb +++ b/app/models/property.rb @@ -36,4 +36,11 @@ ### class Property < ApplicationRecord belongs_to :term + + ### + # @description: Returns the property's compact domains + ### + def compact_domains + @compact_domains ||= Array.wrap(domain).map { Utils.compact_uri(_1) }.compact + end end diff --git a/app/models/specification.rb b/app/models/specification.rb index fa210d41..6ddde6e0 100644 --- a/app/models/specification.rb +++ b/app/models/specification.rb @@ -91,11 +91,17 @@ def to_json_ld name:, uri:, version:, - use_case:, domain: domain.uri, terms: terms.map(&:source_uri).sort } end + ### + # @description: Returns the specification's compact domains + ### + def compact_domains + @compact_domains ||= Array.wrap(selected_domains_from_file).map { Utils.compact_uri(_1) }.compact + end + scope :for_dso, ->(dso) { joins(:user).where(users: { id: dso.users }) } end diff --git a/app/models/term.rb b/app/models/term.rb index 1af59c9e..5b2cac2c 100644 --- a/app/models/term.rb +++ b/app/models/term.rb @@ -68,6 +68,8 @@ class Term < ApplicationRecord before_destroy :check_if_alignments_exist + delegate :compact_domains, to: :property + ### # @description: Include additional information about the specification in # json responses. This overrides the ApplicationRecord as_json method. diff --git a/app/policies/agent_policy.rb b/app/policies/agent_policy.rb index 746fc062..a5e5d2fd 100644 --- a/app/policies/agent_policy.rb +++ b/app/policies/agent_policy.rb @@ -5,4 +5,11 @@ # agents records. ### class AgentPolicy < AdminAccessPolicy + class Scope < ApplicationPolicy::Scope + def resolve + return scope.none unless user.user.super_admin? + + User.joins(:roles).where(roles: { name: Desm::MAPPER_ROLE_NAME }).distinct + end + end end diff --git a/app/policies/configuration_profile_policy.rb b/app/policies/configuration_profile_policy.rb index 37af8cc5..e47d27db 100644 --- a/app/policies/configuration_profile_policy.rb +++ b/app/policies/configuration_profile_policy.rb @@ -5,6 +5,26 @@ # configuration profiles records. ### class ConfigurationProfilePolicy < AdminAccessPolicy + def index? + signed_in? && admin_role? + end + + def index_shared_mappings? + signed_in? + end + + def index_for_user? + signed_in? + end + + def show? + signed_in? + end + + def set_current? + signed_in? + end + class Scope < ApplicationPolicy::Scope def resolve return scope.all if user.user.super_admin? diff --git a/app/serializers/alignment_serializer.rb b/app/serializers/alignment_serializer.rb index c0522321..43f80ace 100644 --- a/app/serializers/alignment_serializer.rb +++ b/app/serializers/alignment_serializer.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class AlignmentSerializer < ApplicationSerializer - attributes :comment, :mapping_id, :origin, :predicate_id, :spine_term_id, :synthetic, :uri, :vocabulary_id + attributes :comment, :compact_domains, :mapping_id, :origin, :predicate_id, :spine_term_id, :synthetic, :uri, + :vocabulary_id attributes :mapped_terms, :predicate attribute :mapping, if: -> { params[:with_schema_name] } do { id: object.mapping.id, title: object.mapping.title, description: object.mapping.description, @@ -12,11 +13,11 @@ class AlignmentSerializer < ApplicationSerializer object.uri end attribute :schema_name, if: -> { params[:with_schema_name] } do - schema = object.mapping.specification + schema = object.specification "#{schema.name}#{schema.version.present? ? " (#{schema.version})" : ''}" end attribute :selected_domains, if: -> { params[:with_schema_name] } do - object.mapping.specification.selected_domains_from_file + object.specification.selected_domains_from_file end # pass the params to the serializer diff --git a/app/serializers/configuration_profile_serializer.rb b/app/serializers/configuration_profile_serializer.rb index 9e5259ae..b7f754b1 100644 --- a/app/serializers/configuration_profile_serializer.rb +++ b/app/serializers/configuration_profile_serializer.rb @@ -4,6 +4,9 @@ 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 attribute :standards_organizations, if: -> { params[:with_organizations] } + attribute :with_shared_mappings, if: -> { params[:with_shared_mappings] } do + params[:shared_mappings] || object.with_shared_mappings? + end def standards_organizations ActiveModel::Serializer::CollectionSerializer.new( diff --git a/app/serializers/configuration_profile_user_serializer.rb b/app/serializers/configuration_profile_user_serializer.rb new file mode 100644 index 00000000..f5054417 --- /dev/null +++ b/app/serializers/configuration_profile_user_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ConfigurationProfileUserSerializer < ApplicationSerializer + attributes :description, :slug, :state, :lead_mapper + delegate :id, :name, :created_at, :updated_at, to: :configuration_profile + delegate :description, :slug, :state, to: :configuration_profile + + def configuration_profile + object.configuration_profile + end + + attribute :with_shared_mappings, if: -> { params[:with_shared_mappings] } do + configuration_profile.active? && configuration_profile.mappings.mapped.exists? + end + + attribute :organization do + PreviewSerializer.new(object.organization) + end +end diff --git a/app/serializers/term_serializer.rb b/app/serializers/term_serializer.rb index 93cced29..db21d314 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 :raw, :source_uri, :slug, :uri + attributes :compact_domains, :raw, :source_uri, :slug, :uri has_one :property has_many :vocabularies, serializer: PreviewSerializer has_one :organization, if: -> { params[:spine] || params[:with_organization] }, serializer: PreviewSerializer @@ -10,8 +10,8 @@ class TermSerializer < ApplicationSerializer object.max_mapping_weight end - attribute :current_mapping_weight, if: -> { params[:spine] } do - object.alignments.joins(:predicate).sum("predicates.weight") + attribute :current_mapping_weight, if: -> { params[:spine] && params[:spine_id] } do + object.alignments.mapped_for_spine(params[:spine_id]).joins(:predicate).sum("predicates.weight") end attribute :title do diff --git a/app/services/converters/base.rb b/app/services/converters/base.rb index 7f141dd8..ec9fd23f 100644 --- a/app/services/converters/base.rb +++ b/app/services/converters/base.rb @@ -12,8 +12,6 @@ class Base skos: "http://www.w3.org/2004/02/skos/core#" }.freeze - DESM_NAMESPACE = URI("http://desmsolutions.org/ns/") - attr_reader :concept_scheme_cache, :domain_class_cache, :resources, :spec_id ## @@ -54,7 +52,7 @@ def build_domain_class(*args) # @return [String] def build_desm_uri(value) normalized_value = value.squish.gsub(/\W+/, "_") - (DESM_NAMESPACE + "#{spec_id}/#{normalized_value}").to_s + (Desm::DESM_NAMESPACE + "#{spec_id}/#{normalized_value}").to_s end ## diff --git a/app/services/exporters/configuration_profile.rb b/app/services/exporters/configuration_profile.rb index 4f37a222..34515eb6 100644 --- a/app/services/exporters/configuration_profile.rb +++ b/app/services/exporters/configuration_profile.rb @@ -104,7 +104,7 @@ def export_spines(spines) def export_specification(specification) specification - .slice(*%w(name selected_domains_from_file version use_case)) + .slice(*%w(name selected_domains_from_file version)) .merge( "domain" => specification.domain.source_uri, "terms" => specification.terms.map(&:source_uri) diff --git a/app/services/exporters/mapping.rb b/app/services/exporters/mapping.rb index fd0dd155..1b66744e 100644 --- a/app/services/exporters/mapping.rb +++ b/app/services/exporters/mapping.rb @@ -9,22 +9,6 @@ class Mapping # CONSTANTS ### - ### - # @description: These are for established specs used in the mapping. This block will be the same for all mapping, - # the prefixes an URIs are pre-existing constants. - ### - CONTEXT = { - ceds: "http://desmsolutions.org/ns/ceds/", - credReg: "http://desmsolutions.org/ns/credReg/", - dct: "http://purl.org/dc/terms/", - dcterms: "http://purl.org/dc/terms/", - desm: "http://desmsolutions.org/ns/", - rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", - rdfs: "http://www.w3.org/2000/01/rdf-schema#", - sdo: "http://schema.org/", - xsd: "http://www.w3.org/2001/XMLSchema#" - }.freeze - ### # @description: Initializes this class with the instance to export. ### @@ -37,7 +21,7 @@ def initialize(instance) ### def export { - "@context": CONTEXT, + "@context": Desm::CONTEXT, "@graph": @instance.alignments.map do |alignment| term_nodes(alignment) end.flatten.unshift(main_node) diff --git a/app/services/importers/configuration_profile.rb b/app/services/importers/configuration_profile.rb index de59f38e..493a20bf 100644 --- a/app/services/importers/configuration_profile.rb +++ b/app/services/importers/configuration_profile.rb @@ -108,7 +108,7 @@ def import domain = domain_set.domains.find_by(source_uri: specification_data.fetch("domain")) specification = profile_user.specifications.create!( - **specification_data.slice(*%w(name selected_domains_from_file version use_case)), + **specification_data.slice(*%w(name selected_domains_from_file version)), domain:, terms: profile.terms.where(source_uri: specification_data.fetch("terms")) ) diff --git a/app/services/processors/specifications.rb b/app/services/processors/specifications.rb index 0eccbaeb..89ef0931 100644 --- a/app/services/processors/specifications.rb +++ b/app/services/processors/specifications.rb @@ -29,7 +29,6 @@ def self.create(data) configuration_profile_user: data[:configuration_profile_user], name: data[:name], version: data[:version], - use_case: data[:use_case], domain: Domain.find(data[:domain_id]), selected_domains_from_file: data[:selected_domains] ) diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 3bfabcc8..44250e0c 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -17,6 +17,7 @@ data-logged-in="<%= current_user.present? %>" data-organization="<%= current_organization&.to_json %>" > + <%= render "shared/impersonation_status" %> <%= yield %> diff --git a/app/views/shared/_impersonation_status.html.erb b/app/views/shared/_impersonation_status.html.erb new file mode 100644 index 00000000..02c5ea1b --- /dev/null +++ b/app/views/shared/_impersonation_status.html.erb @@ -0,0 +1,8 @@ +<% if impersonation_mode? %> +
+ + 🎭 Impersonating <%= current_user.fullname %> <%= current_organization ? "@ #{current_organization.name}" : "" %> <<%= current_user.email %>> + + <%= link_to 'Stop Impersonating', stop_impersonating_path, class: 'btn btn-danger btn-sm' %> +
+<% end %> diff --git a/config/application.rb b/config/application.rb index 0d066103..1f3cd0d0 100644 --- a/config/application.rb +++ b/config/application.rb @@ -37,6 +37,7 @@ class Application < Rails::Application config.autoload_paths += [ Rails.root.join("app", "interacotrs", "concerns"), + Rails.root.join("app", "lib"), Rails.root.join("lib", "utils"), ] diff --git a/config/initializers/desm_constants.rb b/config/initializers/desm_constants.rb index 143cb41b..65a41611 100644 --- a/config/initializers/desm_constants.rb +++ b/config/initializers/desm_constants.rb @@ -7,4 +7,52 @@ module Desm PHONE_RE = /\A\+?[0-9 -]{6,18}\z/i PRIVATE_KEY = ENV['PRIVATE_KEY'] || 'BAE4QavZnymiL^c584&nBV*dxEGFzas4KXiHTz!a26##!zsHnS' MIN_PASSWORD_LENGTH = ENV['MIN_PASSWORD_LENGTH'] || 8 + DESM_NAMESPACE = URI("http://desmsolutions.org/ns/") + + ### + # @description: These are for established specs used in the mapping. This block will be the same for all mapping, + # the prefixes an URIs are pre-existing constants. + ### + CONTEXT = { + ceds: "http://desmsolutions.org/ns/ceds/", + credReg: "http://desmsolutions.org/ns/credReg/", + dct: "http://purl.org/dc/terms/", + dcterms: "http://purl.org/dc/terms/", + desm: "http://desmsolutions.org/ns/", + rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + rdfs: "http://www.w3.org/2000/01/rdf-schema#", + sdo: "http://schema.org/", + xsd: "http://www.w3.org/2001/XMLSchema#", + skos: "http://www.w3.org/2004/02/skos/core#", + "desm:inTermMapping": { + "@type": "@id" + }, + "desm:mapper": { + "@type": "@id" + }, + "desm:isClassMappingOf": { + "@type": "@id" + }, + "desm:mappingPredicateType": { + "@type": "@id" + }, + "skos:inScheme": { + "@type": "@id" + }, + "dct:isPartOf": { + "@type": "@id" + }, + "desm:AbstractClass": { + "@type": "@id" + }, + "desm:hasProperty": { + "@type": "@id" + }, + "dct:creator": { + "@type": "@id" + }, + "desm:homepage": { + "@type": "@id" + } + }.freeze end diff --git a/config/locales/ui/en.yml b/config/locales/ui/en.yml index bfab6731..e8893712 100644 --- a/config/locales/ui/en.yml +++ b/config/locales/ui/en.yml @@ -25,6 +25,9 @@ en: state: Filter Agents for Profile's State search: Search Agents view_mapping: + no_mappings: + current_profile: No shared mappings found for requested configuration profile. + all: There are no shared mappings. subtitle: Synthetic Spine Property Mapping By Abstract Class select_abstract_class: "Select Abstract Class:" mapping: diff --git a/config/routes.rb b/config/routes.rb index 4a7bc0ff..2301ac3b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -132,6 +132,8 @@ resources :registrations, only: [:create] delete :logout, to: 'sessions#logout' get :session_status, to: 'sessions#session_status' + get 'agents/:agent_id/impersonate', to: 'impersonations#start' + get :stop_impersonating, to: 'impersonations#stop' post 'password/forgot', to: 'passwords#forgot' post 'password/reset', to: 'passwords#reset' @@ -159,7 +161,11 @@ resources :configuration_profiles, except: %i[new edit] do get :set_current, on: :member - post :import, on: :collection + collection do + post :import + get :index_shared_mappings + get :index_for_user + end end resources :vocabularies, only: [:index, :create, :show] do diff --git a/spec/lib/utils_spec.rb b/spec/lib/utils_spec.rb new file mode 100644 index 00000000..3a22eb70 --- /dev/null +++ b/spec/lib/utils_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Utils do + describe ".compact_uri" do + context "compact URI" do + it "does nothing" do + expect(Utils.compact_uri("sdo:Organization")).to eq("sdo:Organization") + end + end + + context "full DESM URI" do + it "returns original value" do + expect(Utils.compact_uri("http://desmsolutions.org/ns/f401/Program")).to eq("Program") + end + end + + context "full URI" do + context "from context" do + it "returns compact URI" do + expect(Utils.compact_uri("http://www.w3.org/2001/XMLSchema#anyURI")).to eq("xsd:anyURI") + end + end + + context "with custom context" do + it "returns compact URI" do + expect(Utils.compact_uri("http://xmlns.com/foaf/0.1/Person", + context: { foaf: "http://xmlns.com/foaf/0.1/" })).to eq("foaf:Person") + end + end + + context "not from context" do + it "returns nothing" do + expect(Utils.compact_uri("http://xmlns.com/foaf/0.1/Person")).to eq(nil) + end + end + end + end +end diff --git a/spec/models/domain_spec.rb b/spec/models/domain_spec.rb index 1abe6fcc..dfbc884e 100644 --- a/spec/models/domain_spec.rb +++ b/spec/models/domain_spec.rb @@ -59,7 +59,11 @@ source_uri: "http://desm.testing/api/v1/resources/terms/description") end - it "validates presence and uniquiness" do + after(:all) do + DatabaseCleaner.clean_with(:truncation) + end + + it "validates presence and uniqueness" do 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) diff --git a/spec/requests/api/v1/configuration_profiles_spec.rb b/spec/requests/api/v1/configuration_profiles_spec.rb new file mode 100644 index 00000000..f13a4ef5 --- /dev/null +++ b/spec/requests/api/v1/configuration_profiles_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "API::V1::ConfigurationProfiles", type: :request do + let(:user) { create(:user) } + + describe "GET /api/v1/configuration_profiles" do + let(:configuration_profile) { create(:configuration_profile, :basic) } + + context "when user is not authenticated" do + before { get api_v1_mappings_path } + + it_behaves_like "api authorization error" + end + + context "when user is admin" do + let(:user) { create(:user, :admin) } + + before do + stub_authentication_for(user) + get api_v1_configuration_profiles_path + end + + it "returns empty array" do + expect(response).to have_http_status(:ok) + expect(json_parse(response.body)).to be_empty + end + end + + context "when user is mapper" do + let!(:configuration_profile_user) { create(:configuration_profile_user, user:, configuration_profile:) } + + before do + stub_authentication_for(user, configuration_profile:) + get api_v1_configuration_profiles_path + end + + it_behaves_like "api authorization error" + end + end + + shared_context "configuration profiles with and without mappings" do + let(:configuration_profile) { create(:configuration_profile, :basic, :active) } + let!(:configuration_profile_user) { create(:configuration_profile_user, user:, configuration_profile:) } + let(:configuration_profile1) { create(:configuration_profile, :basic, :deactivated) } + let!(:configuration_profile_user1) do + create(:configuration_profile_user, user:, configuration_profile: configuration_profile1) + end + let(:configuration_profile2) { create(:configuration_profile, :basic, :active) } + let!(:configuration_profile_user2) do + create(:configuration_profile_user, user:, configuration_profile: configuration_profile2) + end + let!(:mapping1) { create(:mapping, :uploaded, configuration_profile_user:) } + let!(:mapping) { create(:mapping, :mapped, configuration_profile_user: configuration_profile_user2) } + end + + describe "GET /api/v1/configuration_profiles/index_for_user" do + include_context "configuration profiles with and without mappings" + + before do + stub_authentication_for(user, configuration_profile:) + get index_for_user_api_v1_configuration_profiles_path + end + + it "returns user's active configuration profiles" do + expect(response).to have_http_status(:ok) + data = json_parse(response.body) + expect(data.size).to eq 2 + results = [{ cp: configuration_profile, mappings: false }, + { cp: configuration_profile2, mappings: true }].sort_by do |r| + r[:cp].name + end + results.each_with_index do |result, index| + expect(data[index][:id]).to eq result[:cp].id + expect(data[index][:name]).to eq result[:cp].name + expect(data[index][:with_shared_mappings]).to eq result[:mappings] + end + end + end + + describe "GET /api/v1/configuration_profiles/index_shared_mappings" do + include_context "configuration profiles with and without mappings" + + before do + get index_shared_mappings_api_v1_configuration_profiles_path + end + + it "returns configuration profiles with shared mappings" do + expect(response).to have_http_status(:ok) + data = json_parse(response.body) + expect(data.size).to eq 1 + expect(data[0][:id]).to eq configuration_profile2.id + expect(data[0][:name]).to eq configuration_profile2.name + expect(data[0][:with_shared_mappings]).to be_truthy + end + end +end diff --git a/spec/requests/api/v1/predicates_spec.rb b/spec/requests/api/v1/predicates_spec.rb index b37c7e7e..78767d51 100644 --- a/spec/requests/api/v1/predicates_spec.rb +++ b/spec/requests/api/v1/predicates_spec.rb @@ -7,6 +7,12 @@ let(:configuration_profile) { create(:configuration_profile, :basic) } describe "GET /api/v1/predicates" do + context "when user is not authenticated" do + before { get api_v1_predicates_path } + + it_behaves_like "api authorization error" + end + context "when user is authenticated" do before do stub_authentication_for(user, configuration_profile:) @@ -18,5 +24,29 @@ expect(json_parse(response.body)).to be_empty end end + + context "when configuration profile passed as parameter" do + subject { get api_v1_predicates_path, params: { configuration_profile_id: configuration_profile.id } } + + context "with configuration profile with shared mappings" do + let(:configuration_profile) { create(:configuration_profile, :active, :basic) } + let(:configuration_profile_user) { create(:configuration_profile_user, user:, configuration_profile:) } + let!(:mapping) { create(:mapping, :mapped, configuration_profile_user:) } + + it "returns empty array" do + subject + expect(response).to have_http_status(:ok) + expect(json_parse(response.body)).to be_empty + end + end + + context "with configuration profile without shared mappings" do + before do + subject + end + + it_behaves_like "api authorization error" + end + end end end diff --git a/spec/requests/api/v1/spine_terms_spec.rb b/spec/requests/api/v1/spine_terms_spec.rb index 316224db..2bccf2b5 100644 --- a/spec/requests/api/v1/spine_terms_spec.rb +++ b/spec/requests/api/v1/spine_terms_spec.rb @@ -27,5 +27,34 @@ expect(json_parse(response.body).length).to eq 1 end end + + context "when configuration profile passed as parameter" do + let(:spine) { create(:spine, :with_terms) } + + subject do + get api_v1_spine_terms_list_path(spine.id), params: { configuration_profile_id: configuration_profile.id } + end + + context "with configuration profile with shared mappings" do + let(:configuration_profile) { create(:configuration_profile, :active) } + let(:configuration_profile_user) { create(:configuration_profile_user, user:, configuration_profile:) } + let!(:mapping) { create(:mapping, :mapped, configuration_profile_user:) } + let!(:spine) { create(:spine, :with_terms, configuration_profile_user:) } + + it "returns terms" do + subject + expect(response).to have_http_status(:ok) + expect(json_parse(response.body).length).to eq 1 + end + end + + context "with configuration profile without shared mappings" do + before do + subject + end + + it_behaves_like "api authorization error" + end + end end end diff --git a/spec/services/exporters/configuration_profile_spec.rb b/spec/services/exporters/configuration_profile_spec.rb index 2047362a..4febf309 100644 --- a/spec/services/exporters/configuration_profile_spec.rb +++ b/spec/services/exporters/configuration_profile_spec.rb @@ -109,7 +109,6 @@ "name" => specification.name, "selected_domains_from_file" => [term.source_uri], "version" => specification.version, - "use_case" => specification.use_case, "domain" => specification.domain.source_uri, "terms" => specification.terms.map(&:source_uri) } diff --git a/spec/services/importers/configuration_profile_spec.rb b/spec/services/importers/configuration_profile_spec.rb index 149793b4..e0e33f52 100644 --- a/spec/services/importers/configuration_profile_spec.rb +++ b/spec/services/importers/configuration_profile_spec.rb @@ -40,7 +40,6 @@ let(:selected_domains_from_file) { [term_source_uri] } let(:specification_name) { Faker::Lorem.word } let(:specification_version) { "1" } - let(:specification_use_case) { Faker::Lorem.word } let(:state) { %w(incomplete complete active deactivated).sample } let(:structure) { JSON(Faker::Json.shallow_json) } let(:term_identifier) { Faker::Lorem.word } @@ -119,7 +118,6 @@ "name" => specification_name, "selected_domains_from_file" => selected_domains_from_file, "version" => specification_version, - "use_case" => specification_use_case, "domain" => domain_source_uri, "terms" => [term_source_uri] }, @@ -274,7 +272,6 @@ expect(specification.name).to eq(specification_name) expect(specification.selected_domains_from_file).to eq(selected_domains_from_file) expect(specification.version).to eq(specification_version) - expect(specification.use_case).to eq(specification_use_case) expect(specification.domain).to eq(domain) expect(specification.terms.count).to eq(1) diff --git a/spec/support/database_cleaner.rb b/spec/support/database_cleaner.rb index 68e255b9..32f1a29e 100644 --- a/spec/support/database_cleaner.rb +++ b/spec/support/database_cleaner.rb @@ -1,35 +1,14 @@ # frozen_string_literal: true RSpec.configure do |config| - ### - # This instructs RSpec to use database_cleaner to "truncate" the database before every test suite, it empties every - # table entirely. This is a brute-force approach to database cleaning but for personal projects, it will serve just - # fine. - # Using Active Record, the except statement is essential. Without it, the database_cleaner will destroy Active - # Record's environment data, resulting in a NoEnvironmentInSchemaError every time your tests run. - ### config.before(:suite) do - DatabaseCleaner.clean_with :truncation, except: %w(ar_internal_metadata) - end - - ### - # On a test-by-test basis (i.e. before(:each), not before(:suite)) , truncation is overkill. - # Here, we set the database_cleaner strategy to transaction, which means every test will create a database - # transaction that will simply be rolled back when it ends, as if it never happened. - ### - config.before do DatabaseCleaner.strategy = :transaction + DatabaseCleaner.clean_with(:truncation) end - ### - # DatabaseCleaner.start and DatabaseCleaner.end are simply the triggers to start the cleaning process before and - # after each test. - ### - config.before do - DatabaseCleaner.start - end - - config.after do - DatabaseCleaner.clean + config.around(:each) do |example| + DatabaseCleaner.cleaning do + example.run + end end end