diff --git a/.env.example b/.env.example index 3a201496..b9114244 100644 --- a/.env.example +++ b/.env.example @@ -13,15 +13,15 @@ DEFAULT_PASS=t3admint3admin # Secret key used to encode passwords and sensitive information. E.g. 'HS256', 'RS256' PRIVATE_KEY=a-strong-password-to-use-as-secret-key-to-encode-and-or-decode -# Credentials for sending emails from the application -# These credentials are for the email account from which the emails are going -# to be sent (the sender). -MAIL_PASSWORD=apassword -MAIL_USERNAME=t3@mail.org - -# Mailgun configuration -MAILGUN_API_KEY=your-mailgun-api-key -MAILGUN_DOMAIN=the-domain-that-serves-your-emails +# Email configuration. +# Either Mailgun or SMTP settings must be present. +# Mailgun takes precedence over SMTP if both are present. +FROM_EMAIL_ADDRESS= +MAILGUN_API_KEY= +MAILGUN_DOMAIN= +SMTP_ADDRESS= +SMTP_PASSWORD= +SMTP_USERNAME= # The minimum accepted length for passwords MIN_PASSWORD_LENGTH=8 diff --git a/.overcommit.yml b/.overcommit.yml index fdfd9999..9d9f7ce5 100644 --- a/.overcommit.yml +++ b/.overcommit.yml @@ -4,6 +4,9 @@ PreCommit: BundleCheck: enabled: true + CapitalizedSubject: + enabled: false + ExecutePermissions: enabled: true exclude: diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 3ea339d5..9809434c 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -4,6 +4,7 @@ @import 'components/card'; @import 'components/drag-n-drop'; @import 'components/icon'; +@import 'components/multiselect'; @import 'customradio'; @import 'forms'; @import 'navbar'; diff --git a/app/assets/stylesheets/components/_multiselect.scss b/app/assets/stylesheets/components/_multiselect.scss new file mode 100644 index 00000000..f1954cdf --- /dev/null +++ b/app/assets/stylesheets/components/_multiselect.scss @@ -0,0 +1,3 @@ +.dropdown-container { + z-index: 9999; +} diff --git a/app/assets/stylesheets/stepper.scss b/app/assets/stylesheets/stepper.scss index 9e7c4680..e31c14fe 100644 --- a/app/assets/stylesheets/stepper.scss +++ b/app/assets/stylesheets/stepper.scss @@ -13,6 +13,30 @@ top: 0.5vw; } +.indicator-stepnum { + @include e(link) { + margin-left: -0.3vw; + + &:hover { + color: $primary-color-light !important; // stylelint-disable-line + } + } + + @include e(num) { + display: inline-block; + + @include m(text) { + padding-top: 1px; + } + + @include m(link) { + border: 1px solid $primary-color-light; + height: 1rem; + width: 1rem; + } + } +} + .indicator > .indicator-step .indicator-stepnum { color: $foreground-color; font-size: 12px; diff --git a/app/controllers/api/v1/configuration_profile_actions_controller.rb b/app/controllers/api/v1/configuration_profile_actions_controller.rb index f8d1fc62..38679838 100644 --- a/app/controllers/api/v1/configuration_profile_actions_controller.rb +++ b/app/controllers/api/v1/configuration_profile_actions_controller.rb @@ -17,6 +17,15 @@ def call_action @instance.send(action) render json: @instance.reload, with_organizations: true + rescue CpState::NotYetReadyForTransition => e + message = + if @instance.incomplete? + ConfigurationProfile.validate_structure(@instance.structure, "complete") + else + e.message + end + + render json: { message: }, status: :internal_server_error end private diff --git a/app/controllers/api/v1/configuration_profiles_controller.rb b/app/controllers/api/v1/configuration_profiles_controller.rb index f7ef1fe0..2d7e5fa6 100644 --- a/app/controllers/api/v1/configuration_profiles_controller.rb +++ b/app/controllers/api/v1/configuration_profiles_controller.rb @@ -39,9 +39,11 @@ def show end def update - @instance.update(permitted_params) - - render json: @instance, with_organizations: true + if @instance.update(permitted_params) + render json: @instance, with_organizations: true + else + render json: { error: @instance.errors.full_messages.join("\\n") }, status: :unprocessable_entity + end end def set_current diff --git a/app/controllers/api/v1/mapping_exports_controller.rb b/app/controllers/api/v1/mapping_exports_controller.rb new file mode 100644 index 00000000..bda928d9 --- /dev/null +++ b/app/controllers/api/v1/mapping_exports_controller.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +### +# @description: Place all the actions related to mappings +### +module API + module V1 + class MappingExportsController < BaseController + include ConfigurationProfileQueryable + + ### + # @description: Returns exported mappings in a given format as binary + ### + def index + domains = current_configuration_profile + .domains + .where(id: Array.wrap(params.fetch(:domain_ids, "").split(","))) + + mapping = current_configuration_profile + .mappings + .find_by(id: params[:mapping_id]) + + if domains.empty? && mapping.nil? + render json: { error: "Either domain_ids or mapping_id is required" }, status: :bad_request + return + end + + result = ExportMappings.call( + configuration_profile: current_configuration_profile, + domains:, + format: params[:format], + mapping: + ) + + if result.success? + send_data result.data, + filename: result.filename, + type: result.content_type + else + render json: { error: response.error }, status: :unprocessable_entity + end + rescue StandardError => e + Airbrake.notify(e) + render json: { error: e.message }, status: :internal_server_error + end + end + end +end diff --git a/app/controllers/api/v1/mappings_controller.rb b/app/controllers/api/v1/mappings_controller.rb index 376bcfc7..11bb9894 100644 --- a/app/controllers/api/v1/mappings_controller.rb +++ b/app/controllers/api/v1/mappings_controller.rb @@ -33,7 +33,13 @@ def destroy # mapping JSON-LD version. ### def export - render json: @instance.export + exporter = Exporters::Mapping.new(@instance) + + if params[:format] == "csv" + render plain: exporter.to_csv, content_type: "text/csv" + else + render json: exporter.to_jsonld + end end ### diff --git a/app/controllers/api/v1/specifications_controller.rb b/app/controllers/api/v1/specifications_controller.rb index 6d4f6f2f..2601b40d 100644 --- a/app/controllers/api/v1/specifications_controller.rb +++ b/app/controllers/api/v1/specifications_controller.rb @@ -7,6 +7,7 @@ module API module V1 class SpecificationsController < BaseController include ConfigurationProfileQueryable + before_action :authorize_with_policy, only: :update ### # @description: Lists all the specifications @@ -19,11 +20,13 @@ def index Specification.all end - if (domain = params[:domain]).present? - specifications = specifications.joins(:domain).where(domains: { pref_label: domain }) + specifications = specifications.joins(:mappings).where(mappings: { status: "mapped" }).includes(:domain) + + if (domain_id = params[:domain_id]).present? + specifications.where!(domain_id:) end - render json: specifications.joins(:mappings).where(mappings: { status: "mapped" }).distinct.order(name: :asc) + render json: specifications.distinct.order(name: :asc) end ### @@ -56,8 +59,18 @@ def destroy } end + def update + spec = Processors::Specifications.update(valid_params, instance: @instance) + + render json: spec + end + private + def authorize_with_policy + authorize(instance) + end + ### # @description: Clean the parameters with all needed for specifications creation # @return [ActionController::Parameters] @@ -77,7 +90,11 @@ def valid_params # @return [ActionController::Parameters] ### def permitted_params - params.require(:specification).permit(:name, :scheme, :uri, :version) + params.require(:specification).permit(:name, :scheme, :uri, :version, :mapping_id) + end + + def instance + @instance ||= policy_scope(Specification).find(params[:id]) end end end diff --git a/app/controllers/api/v1/spine_specifications_controller.rb b/app/controllers/api/v1/spine_specifications_controller.rb index 758f6ce2..7d8e1ab2 100644 --- a/app/controllers/api/v1/spine_specifications_controller.rb +++ b/app/controllers/api/v1/spine_specifications_controller.rb @@ -10,7 +10,7 @@ class SpineSpecificationsController < BaseController # @description: Returns a filtered list of specifications for an organization ### def index - render json: current_configuration_profile.spines + render json: current_configuration_profile.spines, include: %i(domain) end def show diff --git a/app/interactors/export_mappings.rb b/app/interactors/export_mappings.rb new file mode 100644 index 00000000..23811e7c --- /dev/null +++ b/app/interactors/export_mappings.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +class ExportMappings + include Interactor + + delegate :configuration_profile, :domains, :format, :mapping, to: :context + + def call + case format + when "csv" then export_csv + when "jsonld" then export_jsonld + when "ttl" then export_turtle + else context.fail!(error: "Unsupported format: `#{format}`") + end + + context.filename = "#{filename}.#{extension}" + end + + def bulk_export? + domains.any? + end + + def filename + @filename ||= + if bulk_export? + domains.map(&:name).map { _1.tr(" ", "+") }.join("_") + else + mapping.export_filename + end + end + + def export_csv + context.content_type = bulk_export? ? "application/zip" : "text/csv" + + context.data = + if bulk_export? + io = StringIO.new + + Zip::OutputStream.write_buffer(io) do |zip| + mappings.each do |mapping| + zip.put_next_entry("#{mapping.export_filename}.csv") + zip.write(Exporters::Mapping.new(mapping).csv) + end + end + + io.rewind + io.string + else + exporter.csv + end + end + + def export_jsonld + context.content_type = "application/ld+json" + context.data = jsonld_data + end + + def export_turtle + context.content_type = "text/turtle" + context.data = turtle_data + end + + def exporter + Exporters::Mapping.new(mapping) + end + + def extension + bulk_export? && format == "csv" ? "zip" : format + end + + def jsonld_data + @jsonld_data ||= if bulk_export? + { + "@context": Desm::CONTEXT, + "@graph": mappings.map { Exporters::Mapping::JSONLD.new(_1).graph }.flatten.uniq + }.to_json + else + exporter.jsonld.to_json + end + end + + def mappings + @mappings ||= + begin + relation = configuration_profile.mappings.mapped.select(:id) + + if bulk_export? + relation = relation + .joins(:specification) + .where(specifications: { domain_id: domains }) + end + + relation.where!(id: mapping) if mapping.present? + + mappings = Mapping + .where(id: relation) + .includes( + alignments: [ + :predicate, + { mapped_terms: :property }, + { spine_term: :property } + ], + specification: :domain + ) + + if format == "csv" + mappings.includes(:configuration_profile) + else + mappings + end + end + end + + def turtle_data + @turtle_data ||= begin + repository = RDF::Repository.new + + JSON::LD::Reader.new(jsonld_data) do |reader| + reader.each_statement do |statement| + repository << statement + end + end + + RDF::Writer.for(:turtle).buffer do |writer| + repository.each_statement do |statement| + writer << statement + end + end + end + end +end diff --git a/app/interactors/validate_cp_structure.rb b/app/interactors/validate_cp_structure.rb new file mode 100644 index 00000000..2d787d73 --- /dev/null +++ b/app/interactors/validate_cp_structure.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class ValidateCpStructure + include Interactor + delegate :configuration_profile, :grouped_messages, :messages, to: :context + + before do + context.fail!(error: "configuration profile must be present") unless context.configuration_profile.present? + context.messages = [] + context.grouped_messages = {} + end + + def call + return unless configuration_profile.incomplete? + + collect_vallidation_errors + generate_grouped_scope + end + + private + + GENERAL_PROPERTIES = %w(name description).freeze + private_constant :GENERAL_PROPERTIES + + def collect_vallidation_errors + configuration_profile.complete_structure_validation_errors.map do |error| + path = error[:fragment].split("/")[1..].map { |k| k =~ /^\d+$/ ? k.to_i : k.to_s.underscore } + context.messages << { + path:, + sections: collect_breacrumb_for(path), + message: enhanced_message_for(error, path) + } + end + end + + def collect_breacrumb_for(path) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity + path.map.with_index do |p, idx| + next unless p.is_a?(String) + next if idx.nonzero? && idx == path.size - 1 + + key = if idx.zero? && GENERAL_PROPERTIES.include?(p) + "path" + else + "#{path[0..idx].grep(String).join('.')}.path" + end + data = I18n.t("ui.dashboard.configuration_profiles.structure.#{key}", default: p.to_s.humanize) + next data unless (array_idx = path[idx + 1]).is_a?(Integer) + + base_data = configuration_profile.structure.dig(*path[0..idx + 1]) + value = base_data&.dig("name") || base_data&.dig("origin") || base_data&.dig("fullname") + data + " (#{value.present? ? "#{value}, " : ''}#{(array_idx + 1).ordinalize})" + end.compact + end + + def enhanced_message_for(error, path) + message = error[:message].sub(/.*#{error[:fragment]}'\s+/, "").sub(/\s+in schema.*/, "") + return message if path.size == 1 && GENERAL_PROPERTIES.exclude?(path[0]) + + path[-1].is_a?(String) && message.exclude?("'#{path[-1]}") ? "#{path[-1]} #{message}" : message + end + + def generate_grouped_scope + context.grouped_messages = messages.group_by do |m| + GENERAL_PROPERTIES.include?(m[:path][0]) ? "general" : m[:path][0] + end + context.grouped_messages.transform_values! do |v| + v.group_by { |m| m[:sections].join(" > ") } + end + end +end diff --git a/app/javascript/components/Routes.jsx b/app/javascript/components/Routes.jsx index 6fad367a..ba71aaab 100644 --- a/app/javascript/components/Routes.jsx +++ b/app/javascript/components/Routes.jsx @@ -16,6 +16,7 @@ import SpecsList from './specifications-list/SpecsList'; import AlignAndFineTune from './align-and-fine-tune/AlignAndFineTune'; import MappingToDomains from './mapping-to-domains/MappingToDomains'; import EditSpecification from './edit-specification/EditSpecification'; +import EditMappingProperties from './edit-specification/EditMappingProperties'; import PropertyMappingList from './property-mapping-list/PropertyMappingList'; import ForgotPass from './auth/ForgotPass'; import ResetPass from './auth/ResetPass'; @@ -31,6 +32,11 @@ const adminRoleName = process.env.ADMIN_ROLE_NAME || 'Super Admin'; // eslint-di const allRoles = [adminRoleName, 'Mapper', 'DSO Admin', 'Profile Admin']; const onlySuperAdmin = [adminRoleName]; const onlyMappers = ['Mapper']; +export const MAPPING_PATH_BY_STATUS = { + ready_to_upload: 'upload', + uploaded: 'map', + in_progress: 'align', +}; const Routes = (props) => { const { handleLogin } = props; @@ -66,31 +72,62 @@ const Routes = (props) => { render={(props) => } /> - + - + + + + + { const { leadMapper, organization } = useContext(AppContext); const leftColumnRef = useRef(null); - const [state, actions] = useLocalStore(() => mappingStore()); + const [state, actions] = useLocalStore(() => + mappingStore({ mapping: { id: props.match.params.id || null } }) + ); const { addingSynthetic, alignments, @@ -96,6 +98,7 @@ const AlignAndFineTune = (props) => { mapSpecification={true} stepper={true} stepperStep={3} + mapping={state.mapping} customcontent={alignmentsOptions()} /> ); @@ -134,6 +137,7 @@ const AlignAndFineTune = (props) => { mappedTermsToSpineTerm={state.mappedTermsToSpineTerm} origin={mapping.origin} spineOrigin={mapping.spine_origin} + compactDomains={mapping.specification.compact_domains} onPredicateSelected={onPredicateSelected} onUpdateAlignmentComment={actions.updateAlignmentComment} onRevertMapping={(mappedTerm) => @@ -231,6 +235,7 @@ const AlignAndFineTune = (props) => { isMapped={state.selectedTermIsMapped} alwaysEnabled={true} disableClick={options.disableClick} + compactDomains={mapping.specification.compact_domains} /> ); diff --git a/app/javascript/components/align-and-fine-tune/EditAlignment.jsx b/app/javascript/components/align-and-fine-tune/EditAlignment.jsx index ad2562d7..f5220dd0 100644 --- a/app/javascript/components/align-and-fine-tune/EditAlignment.jsx +++ b/app/javascript/components/align-and-fine-tune/EditAlignment.jsx @@ -3,62 +3,42 @@ import Modal from 'react-modal'; import updateAlignment from '../../services/updateAlignment'; import AlertNotice from '../shared/AlertNotice'; import ModalStyles from '../shared/ModalStyles'; -import PredicateOptions from '../shared/PredicateOptions'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faComment, faTimes } from '@fortawesome/free-solid-svg-icons'; import { editAlignmentStore } from './stores/editAlignmentStore'; +import { noMatchPredicate } from './stores/mappingStore'; import useDidMountEffect from '../../helpers/useDidMountEffect'; const EditAlignment = (props) => { Modal.setAppElement('body'); - const { - alignment, - modalIsOpen, - mode, - onCommentUpdated, - onPredicateUpdated, - onRequestClose, - predicate, - predicates, - spineTerm, - } = props; - - const [state, actions] = useLocalStore(() => - editAlignmentStore({ - comment: alignment?.comment, - selectedPredicate: predicate, - currentMode: mode, - }) - ); - const { comment, commentChanged, currentMode, predicateChanged, selectedPredicate } = state; + const { alignment, modalIsOpen, onCommentUpdated, onRequestClose, predicate, spineTerm } = props; + const [state, actions] = useLocalStore(() => editAlignmentStore({ comment: alignment?.comment })); + const { comment, commentChanged } = state; const handleCommentChange = (e) => actions.handleCommentChange(e.target.value); const handleSaveComment = async () => { - let response = await updateAlignment({ - id: alignment.id, - comment: comment, - }); - - if (response.error) { - actions.setError(response.error); - return; - } + actions.setLoading(true); + try { + let response = await updateAlignment({ + id: alignment.id, + comment: comment, + }); - onCommentUpdated({ saved: true, comment: comment }); - }; + if (response.error) { + actions.setError(response.error); + return; + } - const handleSaveAlignment = async () => { - onPredicateUpdated({ - saved: true, - term: alignment, - predicate: selectedPredicate, - }); + onCommentUpdated({ saved: true, comment: comment }); + } finally { + actions.setLoading(false); + } }; useDidMountEffect(() => { - if (modalIsOpen) actions.setCurrentMode(mode); + if (modalIsOpen) actions.setComment(alignment?.comment); }, [modalIsOpen]); return ( @@ -86,20 +66,12 @@ const EditAlignment = (props) => {
- {currentMode === 'comment' ? ( -
-
{predicate.pref_label}
-
- ) : ( - - )} +
+
{predicate?.pref_label}
+
- {state.selectedNoMatchPredicate + {predicate && noMatchPredicate(predicate) ? null : alignment.mappedTerms.map((mTerm) => { return ( @@ -113,52 +85,30 @@ const EditAlignment = (props) => {
- {currentMode === 'comment' ? ( -