diff options
Diffstat (limited to 'app')
22 files changed, 384 insertions, 55 deletions
diff --git a/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue new file mode 100644 index 00000000000..3af83ffa8ed --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/components/ci_group_variables.vue @@ -0,0 +1,104 @@ +<script> +import createFlash from '~/flash'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import getGroupVariables from '../graphql/queries/group_variables.query.graphql'; +import { + ADD_MUTATION_ACTION, + DELETE_MUTATION_ACTION, + GRAPHQL_GROUP_TYPE, + UPDATE_MUTATION_ACTION, + genericMutationErrorText, + variableFetchErrorText, +} from '../constants'; +import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql'; +import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql'; +import updateGroupVariable from '../graphql/mutations/group_update_variable.mutation.graphql'; +import ciVariableSettings from './ci_variable_settings.vue'; + +export default { + components: { + ciVariableSettings, + }, + mixins: [glFeatureFlagsMixin()], + inject: ['endpoint', 'groupPath', 'groupId'], + data() { + return { + groupVariables: [], + }; + }, + apollo: { + groupVariables: { + query: getGroupVariables, + variables() { + return { + fullPath: this.groupPath, + }; + }, + update(data) { + return data?.group?.ciVariables?.nodes || []; + }, + error() { + createFlash({ message: variableFetchErrorText }); + }, + }, + }, + computed: { + areScopedVariablesAvailable() { + return this.glFeatures.groupScopedCiVariables; + }, + isLoading() { + return this.$apollo.queries.groupVariables.loading; + }, + }, + methods: { + addVariable(variable) { + this.variableMutation(ADD_MUTATION_ACTION, variable); + }, + deleteVariable(variable) { + this.variableMutation(DELETE_MUTATION_ACTION, variable); + }, + updateVariable(variable) { + this.variableMutation(UPDATE_MUTATION_ACTION, variable); + }, + async variableMutation(mutationAction, variable) { + try { + const currentMutation = this.$options.mutationData[mutationAction]; + const { data } = await this.$apollo.mutate({ + mutation: currentMutation.action, + variables: { + endpoint: this.endpoint, + fullPath: this.groupPath, + groupId: convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId), + variable, + }, + }); + + const { errors } = data[currentMutation.name]; + + if (errors.length > 0) { + createFlash({ message: errors[0] }); + } + } catch { + createFlash({ message: genericMutationErrorText }); + } + }, + }, + mutationData: { + [ADD_MUTATION_ACTION]: { action: addGroupVariable, name: 'addGroupVariable' }, + [UPDATE_MUTATION_ACTION]: { action: updateGroupVariable, name: 'updateGroupVariable' }, + [DELETE_MUTATION_ACTION]: { action: deleteGroupVariable, name: 'deleteGroupVariable' }, + }, +}; +</script> + +<template> + <ci-variable-settings + :are-scoped-variables-available="areScopedVariablesAvailable" + :is-loading="isLoading" + :variables="groupVariables" + @add-variable="addVariable" + @delete-variable="deleteVariable" + @update-variable="updateVariable" + /> +</template> diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql new file mode 100644 index 00000000000..f8e4dc55fa4 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql @@ -0,0 +1,30 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation addGroupVariable( + $variable: CiVariable! + $endpoint: String! + $fullPath: ID! + $groupId: ID! +) { + addGroupVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + groupId: $groupId + ) @client { + group { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql new file mode 100644 index 00000000000..310e4a6e551 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql @@ -0,0 +1,30 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation deleteGroupVariable( + $variable: CiVariable! + $endpoint: String! + $fullPath: ID! + $groupId: ID! +) { + deleteGroupVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + groupId: $groupId + ) @client { + group { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql new file mode 100644 index 00000000000..5291942eb87 --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql @@ -0,0 +1,30 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +mutation updateGroupVariable( + $variable: CiVariable! + $endpoint: String! + $fullPath: ID! + $groupId: ID! +) { + updateGroupVariable( + variable: $variable + endpoint: $endpoint + fullPath: $fullPath + groupId: $groupId + ) @client { + group { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql new file mode 100644 index 00000000000..c6dd6d4faaf --- /dev/null +++ b/app/assets/javascripts/ci_variable_list/graphql/queries/group_variables.query.graphql @@ -0,0 +1,17 @@ +#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql" + +query getGroupVariables($fullPath: ID!) { + group(fullPath: $fullPath) { + id + ciVariables { + nodes { + ...BaseCiVariable + ... on CiGroupVariable { + environmentScope + masked + protected + } + } + } + } +} diff --git a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js index 7b57e97a4b8..be7e3f88cfd 100644 --- a/app/assets/javascripts/ci_variable_list/graphql/resolvers.js +++ b/app/assets/javascripts/ci_variable_list/graphql/resolvers.js @@ -4,8 +4,9 @@ import { convertObjectPropsToSnakeCase, } from '../../lib/utils/common_utils'; import { getIdFromGraphQLId } from '../../graphql_shared/utils'; -import { instanceString } from '../constants'; +import { GRAPHQL_GROUP_TYPE, groupString, instanceString } from '../constants'; import getAdminVariables from './queries/variables.query.graphql'; +import getGroupVariables from './queries/group_variables.query.graphql'; const prepareVariableForApi = ({ variable, destroy = false }) => { return { @@ -27,6 +28,20 @@ const mapVariableTypes = (variables = [], kind) => { }); }; +const prepareGroupGraphQLResponse = ({ data, groupId, errors = [] }) => { + return { + errors, + group: { + __typename: GRAPHQL_GROUP_TYPE, + id: groupId, + ciVariables: { + __typename: 'CiVariableConnection', + nodes: mapVariableTypes(data.variables, groupString), + }, + }, + }; +}; + const prepareAdminGraphQLResponse = ({ data, errors = [] }) => { return { errors, @@ -37,6 +52,28 @@ const prepareAdminGraphQLResponse = ({ data, errors = [] }) => { }; }; +const callGroupEndpoint = async ({ + endpoint, + fullPath, + variable, + groupId, + cache, + destroy = false, +}) => { + try { + const { data } = await axios.patch(endpoint, { + variables_attributes: [prepareVariableForApi({ variable, destroy })], + }); + return prepareGroupGraphQLResponse({ data, groupId }); + } catch (e) { + return prepareGroupGraphQLResponse({ + data: cache.readQuery({ query: getGroupVariables, variables: { fullPath } }), + groupId, + errors: [...e.response.data], + }); + } +}; + const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false }) => { try { const { data } = await axios.patch(endpoint, { @@ -54,6 +91,15 @@ const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false }) export const resolvers = { Mutation: { + addGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => { + return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache }); + }, + updateGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => { + return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache }); + }, + deleteGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => { + return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache, destroy: true }); + }, addAdminVariable: async (_, { endpoint, variable }, { cache }) => { return callAdminEndpoint({ endpoint, variable, cache }); }, diff --git a/app/assets/javascripts/ci_variable_list/index.js b/app/assets/javascripts/ci_variable_list/index.js index 713a453561e..a74af8aed12 100644 --- a/app/assets/javascripts/ci_variable_list/index.js +++ b/app/assets/javascripts/ci_variable_list/index.js @@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import CiAdminVariables from './components/ci_admin_variables.vue'; +import CiGroupVariables from './components/ci_group_variables.vue'; import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue'; import { resolvers } from './graphql/resolvers'; import createStore from './store'; @@ -32,7 +33,11 @@ const mountCiVariableListApp = (containerEl) => { const parsedIsGroup = parseBoolean(isGroup); const isProtectedByDefault = parseBoolean(protectedByDefault); - const component = CiAdminVariables; + let component = CiAdminVariables; + + if (parsedIsGroup) { + component = CiGroupVariables; + } Vue.use(VueApollo); diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue index 72fec17ac9d..f4b939fb20f 100644 --- a/app/assets/javascripts/header_search/components/app.vue +++ b/app/assets/javascripts/header_search/components/app.vue @@ -23,6 +23,9 @@ import { SEARCH_SHORTCUTS_MIN_CHARACTERS, SCOPE_TOKEN_MAX_LENGTH, INPUT_FIELD_PADDING, + IS_SEARCHING, + IS_FOCUSED, + IS_NOT_FOCUSED, } from '../constants'; import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue'; import HeaderSearchDefaultItems from './header_search_default_items.vue'; @@ -65,6 +68,7 @@ export default { data() { return { showDropdown: false, + isFocused: false, currentFocusIndex: SEARCH_BOX_INDEX, }; }, @@ -92,20 +96,18 @@ export default { if (!this.showDropdown || !this.isLoggedIn) { return false; } - return this.searchOptions?.length > 0; }, showDefaultItems() { return !this.searchText; }, - showScopes() { + searchTermOverMin() { return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; }, defaultIndex() { if (this.showDefaultItems) { return SEARCH_BOX_INDEX; } - return FIRST_DROPDOWN_INDEX; }, @@ -132,12 +134,15 @@ export default { count: this.searchOptions.length, }); }, - searchBarStateIndicator() { - const hasIcon = - this.searchContext?.project || this.searchContext?.group ? 'has-icon' : 'has-no-icon'; - const isSearching = this.showScopes ? 'is-searching' : 'is-not-searching'; - const isActive = this.showSearchDropdown ? 'is-active' : 'is-not-active'; - return `${isActive} ${isSearching} ${hasIcon}`; + searchBarClasses() { + return { + [IS_SEARCHING]: this.searchTermOverMin, + [IS_FOCUSED]: this.isFocused, + [IS_NOT_FOCUSED]: !this.isFocused, + }; + }, + showScopeHelp() { + return this.searchTermOverMin && this.isFocused; }, searchBarItem() { return this.searchOptions?.[0]; @@ -158,11 +163,22 @@ export default { ...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']), openDropdown() { this.showDropdown = true; - this.$emit('toggleDropdown', this.showDropdown); + this.isFocused = true; + this.$emit('expandSearchBar', true); }, closeDropdown() { this.showDropdown = false; - this.$emit('toggleDropdown', this.showDropdown); + }, + collapseAndCloseSearchBar() { + // we need a delay on this method + // for the search bar not to remove + // the clear button from dom + // and register clicks on dropdown items + setTimeout(() => { + this.showDropdown = false; + this.isFocused = false; + this.$emit('collapseSearchBar'); + }, 200); }, submitSearch() { if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) { @@ -171,6 +187,7 @@ export default { return visitUrl(this.currentFocusedOption?.url || this.searchQuery); }, getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) { + this.openDropdown(); if (!searchTerm) { this.clearAutocomplete(); } else { @@ -201,7 +218,7 @@ export default { role="search" :aria-label="$options.i18n.searchGitlab" class="header-search gl-relative gl-rounded-base gl-w-full" - :class="searchBarStateIndicator" + :class="searchBarClasses" data-testid="header-search-form" > <gl-search-box-by-type @@ -217,12 +234,13 @@ export default { :aria-describedby="$options.SEARCH_INPUT_DESCRIPTION" @focus="openDropdown" @click="openDropdown" + @blur="collapseAndCloseSearchBar" @input="getAutocompleteOptions" @keydown.enter.stop.prevent="submitSearch" @keydown.esc.stop.prevent="closeDropdown" /> <gl-token - v-if="showScopes" + v-if="showScopeHelp" v-gl-resize-observer-directive="observeTokenWidth" class="in-search-scope-help" :view-only="true" @@ -242,6 +260,7 @@ export default { }} </gl-token> <kbd + v-show="!isFocused" v-gl-tooltip.bottom.hover.html class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper" :title="$options.i18n.kbdHelp" @@ -278,7 +297,7 @@ export default { /> <template v-else> <header-search-scoped-items - v-if="showScopes" + v-if="searchTermOverMin" :current-focused-option="currentFocusedOption" /> <header-search-autocomplete-items :current-focused-option="currentFocusedOption" /> diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js index a026386b2bd..3a20fb0216d 100644 --- a/app/assets/javascripts/header_search/constants.js +++ b/app/assets/javascripts/header_search/constants.js @@ -51,3 +51,7 @@ export const SCOPE_TOKEN_MAX_LENGTH = 36; export const INPUT_FIELD_PADDING = 52; export const HEADER_INIT_EVENTS = ['input', 'focus']; + +export const IS_SEARCHING = 'is-searching'; +export const IS_FOCUSED = 'is-focused'; +export const IS_NOT_FOCUSED = 'is-not-focused'; diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js index b2c505d569f..f6f5c6a14fa 100644 --- a/app/assets/javascripts/header_search/index.js +++ b/app/assets/javascripts/header_search/index.js @@ -26,12 +26,11 @@ export const initHeaderSearchApp = (search = '') => { render(createElement) { return createElement(HeaderSearchApp, { on: { - toggleDropdown: (isVisible = false) => { - if (isVisible) { - navBarEl?.classList.add('header-search-is-active'); - } else { - navBarEl?.classList.remove('header-search-is-active'); - } + expandSearchBar: () => { + navBarEl?.classList.add('header-search-is-active'); + }, + collapseSearchBar: () => { + navBarEl?.classList.remove('header-search-is-active'); }, }, }); diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index 6748a62e777..9cce6723bf7 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -68,7 +68,7 @@ export default { }), tableCell({ key: 'created_at', - label: __('Date'), + label: __('Start date'), }), tableCell({ key: 'status', diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 8e862aa8b32..a5580c14a7a 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -264,9 +264,15 @@ export default { data-testid="work-item-type" /> <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" /> - <gl-badge v-if="workItem.confidential" variant="warning" icon="eye-slash" class="gl-mr-3">{{ - __('Confidential') - }}</gl-badge> + <gl-badge + v-if="workItem.confidential" + v-gl-tooltip.bottom + :title="$options.i18n.confidentialTooltip" + variant="warning" + icon="eye-slash" + class="gl-mr-3 gl-cursor-help" + >{{ __('Confidential') }}</gl-badge + > <work-item-actions v-if="canUpdate || canDelete" :work-item-id="workItem.id" diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index f1fc21b74f4..a2aea3cd327 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -26,6 +26,9 @@ export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; export const i18n = { fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'), updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'), + confidentialTooltip: s__( + 'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.', + ), }; export const WIDGET_ICONS = { diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 012407cf09c..6c909b8d9fa 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -82,19 +82,17 @@ input[type='checkbox']:hover { min-width: $search-input-field-x-min-width; } - &.is-active { - &.is-searching { - .in-search-scope-help { - position: absolute; - top: $gl-spacing-scale-2; - right: 2.125rem; - z-index: 2; - } + &.is-searching { + .in-search-scope-help { + position: absolute; + top: $gl-spacing-scale-2; + right: 2.125rem; + z-index: 2; } } - &.is-not-searching { - .in-search-scope-help { + &.is-not-focused { + .gl-search-box-by-type-clear { display: none; } } @@ -104,19 +102,6 @@ input[type='checkbox']:hover { box-shadow: none; border-color: transparent; } - - &.is-active { - .keyboard-shortcut-helper { - display: none; - } - } - - &.is-not-active { - .btn.gl-clear-icon-button, - .in-search-scope-help { - display: none; - } - } } .header-search-dropdown-menu { diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index a86e489439f..ffe4d5dde9d 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -1892,7 +1892,7 @@ body.gl-dark .header-search input::placeholder { body.gl-dark .header-search input:active::placeholder { color: #868686; } -body.gl-dark .header-search.is-not-active .keyboard-shortcut-helper { +body.gl-dark .header-search .keyboard-shortcut-helper { color: #fafafa; background-color: rgba(250, 250, 250, 0.2); } diff --git a/app/assets/stylesheets/themes/theme_helper.scss b/app/assets/stylesheets/themes/theme_helper.scss index 2b6221a6c87..042e21cebd6 100644 --- a/app/assets/stylesheets/themes/theme_helper.scss +++ b/app/assets/stylesheets/themes/theme_helper.scss @@ -176,11 +176,9 @@ } } - &.is-not-active { - .keyboard-shortcut-helper { - color: $search-and-nav-links; - background-color: rgba($search-and-nav-links, 0.2); - } + .keyboard-shortcut-helper { + color: $search-and-nav-links; + background-color: rgba($search-and-nav-links, 0.2); } } diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index b1afac1f1c7..e164a834519 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -10,6 +10,9 @@ module Groups before_action :define_variables, only: [:show] before_action :push_licensed_features, only: [:show] before_action :assign_variables_to_gon, only: [:show] + before_action do + push_frontend_feature_flag(:ci_variable_settings_graphql, @group) + end feature_category :continuous_integration urgency :low diff --git a/app/models/ml.rb b/app/models/ml.rb new file mode 100644 index 00000000000..e426ce851eb --- /dev/null +++ b/app/models/ml.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Ml + def self.table_name_prefix + 'ml_' + end +end diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb new file mode 100644 index 00000000000..e181217f01c --- /dev/null +++ b/app/models/ml/candidate.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Ml + class Candidate < ApplicationRecord + validates :iid, :experiment, presence: true + + belongs_to :experiment, class_name: 'Ml::Experiment' + belongs_to :user + has_many :metrics, class_name: 'Ml::CandidateMetric' + has_many :params, class_name: 'Ml::CandidateParam' + end +end diff --git a/app/models/ml/candidate_metric.rb b/app/models/ml/candidate_metric.rb new file mode 100644 index 00000000000..e03a8b83ee6 --- /dev/null +++ b/app/models/ml/candidate_metric.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Ml + class CandidateMetric < ApplicationRecord + validates :candidate, presence: true + validates :name, length: { maximum: 250 }, presence: true + + belongs_to :candidate, class_name: 'Ml::Candidate' + end +end diff --git a/app/models/ml/candidate_param.rb b/app/models/ml/candidate_param.rb new file mode 100644 index 00000000000..cbdddcc8a1a --- /dev/null +++ b/app/models/ml/candidate_param.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Ml + class CandidateParam < ApplicationRecord + validates :candidate, presence: true + validates :name, :value, length: { maximum: 250 }, presence: true + + belongs_to :candidate, class_name: 'Ml::Candidate' + end +end diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb new file mode 100644 index 00000000000..7ef9c70ba7e --- /dev/null +++ b/app/models/ml/experiment.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Ml + class Experiment < ApplicationRecord + validates :name, :iid, :project, presence: true + validates :iid, :name, uniqueness: { scope: :project, message: "should be unique in the project" } + + belongs_to :project + belongs_to :user + has_many :candidates, class_name: 'Ml::Candidate' + end +end |