diff options
81 files changed, 1044 insertions, 311 deletions
diff --git a/app/assets/javascripts/blob/components/blob_edit_header.vue b/app/assets/javascripts/blob/components/blob_edit_header.vue index 8fd3f03ff71..cd2872026c1 100644 --- a/app/assets/javascripts/blob/components/blob_edit_header.vue +++ b/app/assets/javascripts/blob/components/blob_edit_header.vue @@ -48,7 +48,7 @@ export default { variant="danger" category="secondary" :disabled="!canDelete" - data-qa-selector="delete_file_button" + data-testid="delete-file-button" @click="$emit('delete')" >{{ s__('Snippets|Delete file') }}</gl-button > diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 94385b6c214..78d58261589 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -87,14 +87,6 @@ export const config = { const incomingWidget = incoming.find( (w) => w.type && w.type === existingWidget.type, ); - // We don't want to override existing notes or award emojis with empty widget on work item updates - if ( - (incomingWidget?.type === WIDGET_TYPE_NOTES || - incomingWidget?.type === WIDGET_TYPE_AWARD_EMOJI) && - !context.variables.pageSize - ) { - return existingWidget; - } // we want to concat next page of awardEmoji to the existing ones if (incomingWidget?.type === WIDGET_TYPE_AWARD_EMOJI && context.variables.after) { @@ -126,7 +118,7 @@ export const config = { }; } - return incomingWidget || existingWidget; + return { ...existingWidget, ...incomingWidget }; }); }, }, diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index 741845e3325..ba1258f8b50 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -176,7 +176,6 @@ export default { type="text" class="form-control" data-testid="file-name-field" - data-qa-selector="file_name_field" :placeholder="placeholder" /> </form> diff --git a/app/assets/javascripts/lazy_loader.js b/app/assets/javascripts/lazy_loader.js index 36f387205f8..4354785e585 100644 --- a/app/assets/javascripts/lazy_loader.js +++ b/app/assets/javascripts/lazy_loader.js @@ -170,7 +170,7 @@ export default class LazyLoader { img.classList.remove('lazy'); img.classList.add('js-lazy-loaded'); // eslint-disable-next-line no-param-reassign - img.dataset.qa_selector = 'js_lazy_loaded_content'; + img.dataset.testid = 'js-lazy-loaded-content'; } } } diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue index 747e92b9e85..8c7460940a0 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue @@ -6,18 +6,12 @@ export default { type: String, required: true, }, - sectionLabel: { - type: String, - required: false, - default: '', - }, }, }; </script> <template> <tr> - <td class="gl-text-secondary gl-font-weight-bold">{{ sectionLabel }}</td> <td class="gl-font-weight-bold">{{ label }}</td> <td> <slot></slot> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue index a68fb7d340a..43d28e3d699 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/ml_candidates_show.vue @@ -1,7 +1,9 @@ <script> -import { GlAvatarLabeled, GlLink } from '@gitlab/ui'; +import { GlAvatarLabeled, GlLink, GlTableLite } from '@gitlab/ui'; +import { isEmpty, maxBy, range } from 'lodash'; import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue'; import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; +import { __, sprintf } from '~/locale'; import DetailRow from './components/candidate_detail_row.vue'; import { @@ -22,6 +24,11 @@ import { JOB_LABEL, CI_USER_LABEL, CI_MR_LABEL, + PERFORMANCE_LABEL, + NO_PARAMETERS_MESSAGE, + NO_METRICS_MESSAGE, + NO_METADATA_MESSAGE, + NO_CI_MESSAGE, } from './translations'; export default { @@ -32,6 +39,7 @@ export default { DetailRow, GlAvatarLabeled, GlLink, + GlTableLite, }, props: { candidate: { @@ -54,6 +62,14 @@ export default { JOB_LABEL, CI_USER_LABEL, CI_MR_LABEL, + PARAMETERS_LABEL, + METRICS_LABEL, + METADATA_LABEL, + PERFORMANCE_LABEL, + NO_PARAMETERS_MESSAGE, + NO_METRICS_MESSAGE, + NO_METADATA_MESSAGE, + NO_CI_MESSAGE, }, computed: { info() { @@ -62,21 +78,38 @@ export default { ciJob() { return Object.freeze(this.info.ci_job); }, - sections() { - return [ - { - sectionName: PARAMETERS_LABEL, - sectionValues: this.candidate.params, - }, - { - sectionName: METRICS_LABEL, - sectionValues: this.candidate.metrics, - }, - { - sectionName: METADATA_LABEL, - sectionValues: this.candidate.metadata, - }, - ]; + hasMetadata() { + return !isEmpty(this.candidate.metadata); + }, + hasParameters() { + return !isEmpty(this.candidate.params); + }, + hasMetrics() { + return !isEmpty(this.candidate.metrics); + }, + metricsTableFields() { + const maxStep = maxBy(this.candidate.metrics, 'step').step; + const rowClass = 'gl-p-3!'; + + const cssClasses = { thClass: rowClass, tdClass: rowClass }; + + const fields = range(maxStep + 1).map((step) => ({ + key: step.toString(), + label: sprintf(__('Step %{step}'), { step }), + ...cssClasses, + })); + + return [{ key: 'name', label: __('Metric'), ...cssClasses }, ...fields]; + }, + metricsTableItems() { + const items = {}; + this.candidate.metrics.forEach((metric) => { + const metricRow = items[metric.name] || { name: metric.name }; + metricRow[metric.step] = metric.value; + items[metric.name] = metricRow; + }); + + return Object.values(items); }, }, }; @@ -93,33 +126,37 @@ export default { /> </model-experiments-header> - <table class="candidate-details gl-w-full"> - <tbody> - <tr class="divider"></tr> - - <detail-row :label="$options.i18n.ID_LABEL" :section-label="$options.i18n.INFO_LABEL"> - {{ info.iid }} - </detail-row> + <section class="gl-mb-6"> + <table class="candidate-details"> + <tbody> + <detail-row :label="$options.i18n.ID_LABEL"> + {{ info.iid }} + </detail-row> - <detail-row :label="$options.i18n.MLFLOW_ID_LABEL">{{ info.eid }}</detail-row> + <detail-row :label="$options.i18n.MLFLOW_ID_LABEL">{{ info.eid }}</detail-row> - <detail-row :label="$options.i18n.STATUS_LABEL">{{ info.status }}</detail-row> + <detail-row :label="$options.i18n.STATUS_LABEL">{{ info.status }}</detail-row> - <detail-row :label="$options.i18n.EXPERIMENT_LABEL"> - <gl-link :href="info.path_to_experiment"> - {{ info.experiment_name }} - </gl-link> - </detail-row> + <detail-row :label="$options.i18n.EXPERIMENT_LABEL"> + <gl-link :href="info.path_to_experiment"> + {{ info.experiment_name }} + </gl-link> + </detail-row> - <detail-row v-if="info.path_to_artifact" :label="$options.i18n.ARTIFACTS_LABEL"> - <gl-link :href="info.path_to_artifact"> - {{ $options.i18n.ARTIFACTS_LABEL }} - </gl-link> - </detail-row> + <detail-row v-if="info.path_to_artifact" :label="$options.i18n.ARTIFACTS_LABEL"> + <gl-link :href="info.path_to_artifact"> + {{ $options.i18n.ARTIFACTS_LABEL }} + </gl-link> + </detail-row> + </tbody> + </table> + </section> - <template v-if="ciJob"> - <tr class="divider"></tr> + <section class="gl-mb-6"> + <h4>{{ $options.i18n.CI_SECTION_LABEL }}</h4> + <table v-if="ciJob" class="candidate-details"> + <tbody> <detail-row :label="$options.i18n.JOB_LABEL" :section-label="$options.i18n.CI_SECTION_LABEL" @@ -142,21 +179,53 @@ export default { !{{ ciJob.merge_request.iid }} {{ ciJob.merge_request.title }} </gl-link> </detail-row> - </template> + </tbody> + </table> - <template v-for="{ sectionName, sectionValues } in sections"> - <tr v-if="sectionValues" :key="sectionName" class="divider"></tr> + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_CI_MESSAGE }}</div> + </section> - <detail-row - v-for="(item, index) in sectionValues" - :key="item.name" - :label="item.name" - :section-label="index === 0 ? sectionName : ''" - > + <section class="gl-mb-6"> + <h4>{{ $options.i18n.PARAMETERS_LABEL }}</h4> + + <table v-if="hasParameters" class="candidate-details"> + <tbody> + <detail-row v-for="item in candidate.params" :key="item.name" :label="item.name"> {{ item.value }} </detail-row> - </template> - </tbody> - </table> + </tbody> + </table> + + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_PARAMETERS_MESSAGE }}</div> + </section> + + <section class="gl-mb-6"> + <h4>{{ $options.i18n.METADATA_LABEL }}</h4> + + <table v-if="hasMetadata" class="candidate-details"> + <tbody> + <detail-row v-for="item in candidate.metadata" :key="item.name" :label="item.name"> + {{ item.value }} + </detail-row> + </tbody> + </table> + + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_METADATA_MESSAGE }}</div> + </section> + + <section class="gl-mb-6"> + <h4>{{ $options.i18n.PERFORMANCE_LABEL }}</h4> + + <div v-if="hasMetrics" class="gl-overflow-x-auto"> + <gl-table-lite + :items="metricsTableItems" + :fields="metricsTableFields" + class="gl-w-auto" + hover + /> + </div> + + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_METRICS_MESSAGE }}</div> + </section> </div> </template> diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js index fa9518f3e27..98988e1db35 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js +++ b/app/assets/javascripts/ml/experiment_tracking/routes/candidates/show/translations.js @@ -9,13 +9,18 @@ export const EXPERIMENT_LABEL = s__('MlExperimentTracking|Experiment'); export const ARTIFACTS_LABEL = s__('MlExperimentTracking|Artifacts'); export const PARAMETERS_LABEL = s__('MlExperimentTracking|Parameters'); export const METRICS_LABEL = s__('MlExperimentTracking|Metrics'); +export const PERFORMANCE_LABEL = s__('MlExperimentTracking|Model performance'); export const METADATA_LABEL = s__('MlExperimentTracking|Metadata'); +export const NO_PARAMETERS_MESSAGE = s__('MlExperimentTracking|No logged parameters'); +export const NO_METRICS_MESSAGE = s__('MlExperimentTracking|No logged metrics'); +export const NO_METADATA_MESSAGE = s__('MlExperimentTracking|No logged metadata'); +export const NO_CI_MESSAGE = s__('MlExperimentTracking|Candidate not linked to a CI build'); export const DELETE_CANDIDATE_CONFIRMATION_MESSAGE = s__( 'MlExperimentTracking|Deleting this candidate will delete the associated parameters, metrics, and metadata.', ); export const DELETE_CANDIDATE_PRIMARY_ACTION_LABEL = s__('MlExperimentTracking|Delete candidate'); export const DELETE_CANDIDATE_MODAL_TITLE = s__('MLExperimentTracking|Delete candidate?'); -export const CI_SECTION_LABEL = __('CI'); +export const CI_SECTION_LABEL = s__('MLExperimentTracking|CI Info'); export const JOB_LABEL = __('Job'); export const CI_USER_LABEL = s__('MlExperimentTracking|Triggered by'); export const CI_MR_LABEL = __('Merge request'); diff --git a/app/assets/javascripts/organizations/index/components/app.vue b/app/assets/javascripts/organizations/index/components/app.vue new file mode 100644 index 00000000000..21a11c82196 --- /dev/null +++ b/app/assets/javascripts/organizations/index/components/app.vue @@ -0,0 +1,32 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import OrganizationsView from './organizations_view.vue'; + +export default { + name: 'OrganizationsIndexApp', + i18n: { + organizations: __('Organizations'), + newOrganization: s__('Organization|New organization'), + }, + components: { + GlButton, + OrganizationsView, + }, + inject: ['newOrganizationUrl'], +}; +</script> + +<template> + <section> + <div class="gl-display-flex gl-align-items-center"> + <h1 class="gl-my-4 gl-font-size-h-display">{{ $options.i18n.organizations }}</h1> + <div class="gl-ml-auto"> + <gl-button :href="newOrganizationUrl" variant="confirm">{{ + $options.i18n.newOrganization + }}</gl-button> + </div> + </div> + <organizations-view /> + </section> +</template> diff --git a/app/assets/javascripts/organizations/index/components/organizations_list.vue b/app/assets/javascripts/organizations/index/components/organizations_list.vue new file mode 100644 index 00000000000..539a4fcfe29 --- /dev/null +++ b/app/assets/javascripts/organizations/index/components/organizations_list.vue @@ -0,0 +1,26 @@ +<script> +import OrganizationsListItem from './organizations_list_item.vue'; + +export default { + name: 'OrganizationsList', + components: { + OrganizationsListItem, + }, + props: { + organizations: { + type: Array, + required: true, + }, + }, +}; +</script> + +<template> + <ul class="gl-p-0 gl-list-style-none"> + <organizations-list-item + v-for="organization in organizations" + :key="organization.id" + :organization="organization" + /> + </ul> +</template> diff --git a/app/assets/javascripts/organizations/index/components/organizations_list_item.vue b/app/assets/javascripts/organizations/index/components/organizations_list_item.vue new file mode 100644 index 00000000000..99fa41e3af8 --- /dev/null +++ b/app/assets/javascripts/organizations/index/components/organizations_list_item.vue @@ -0,0 +1,35 @@ +<script> +import { GlAvatarLabeled } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +export default { + name: 'OrganizationsListItem', + components: { + GlAvatarLabeled, + }, + props: { + organization: { + type: Object, + required: true, + }, + }, + avatarSize: { default: 32, md: 48 }, + getIdFromGraphQLId, +}; +</script> + +<template> + <li class="gl-py-3 gl-border-b gl-display-flex gl-align-items-flex-start"> + <gl-avatar-labeled + :size="$options.avatarSize" + :src="organization.avatarUrl" + :entity-id="$options.getIdFromGraphQLId(organization.id)" + :entity-name="organization.name" + :label="organization.name" + :label-link="organization.webUrl" + shape="rect" + > + <span class="gl-mt-2 gl-text-gray-500">{{ organization.description }}</span> + </gl-avatar-labeled> + </li> +</template> diff --git a/app/assets/javascripts/organizations/index/components/organizations_view.vue b/app/assets/javascripts/organizations/index/components/organizations_view.vue new file mode 100644 index 00000000000..51aff482c8a --- /dev/null +++ b/app/assets/javascripts/organizations/index/components/organizations_view.vue @@ -0,0 +1,66 @@ +<script> +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { s__ } from '~/locale'; +import organizationsQuery from '../graphql/organizations.query.graphql'; +import OrganizationsList from './organizations_list.vue'; + +export default { + name: 'OrganizationsView', + i18n: { + errorMessage: s__( + 'Organization|An error occurred loading user organizations. Please refresh the page to try again.', + ), + emptyStateTitle: s__('Organization|Get started with organizations'), + emptyStateDescription: s__( + 'Organization|Create an organization to contain all of your groups and projects.', + ), + emptyStateButtonText: s__('Organization|New organization'), + }, + components: { + GlLoadingIcon, + OrganizationsList, + GlEmptyState, + }, + inject: ['newOrganizationUrl', 'organizationsEmptyStateSvgPath'], + data() { + return { + organizations: [], + }; + }, + apollo: { + organizations: { + query: organizationsQuery, + update(data) { + return data.currentUser.organizations.nodes; + }, + error(error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.organizations.loading; + }, + }, +}; +</script> + +<template> + <gl-loading-icon v-if="isLoading" class="gl-mt-5" size="md" /> + <organizations-list + v-else-if="organizations.length" + :organizations="organizations" + class="gl-border-t" + /> + <gl-empty-state + v-else + :svg-height="144" + :svg-path="organizationsEmptyStateSvgPath" + :title="$options.i18n.emptyStateTitle" + :description="$options.i18n.emptyStateDescription" + :primary-button-link="newOrganizationUrl" + :primary-button-text="$options.i18n.emptyStateButtonText" + /> +</template> diff --git a/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql b/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql new file mode 100644 index 00000000000..560cd9dd578 --- /dev/null +++ b/app/assets/javascripts/organizations/index/graphql/organizations.query.graphql @@ -0,0 +1,14 @@ +query getCurrentUserOrganizations { + currentUser { + id + organizations @client { + nodes { + id + name + description + avatarUrl + webUrl + } + } + } +} diff --git a/app/assets/javascripts/organizations/index/index.js b/app/assets/javascripts/organizations/index/index.js new file mode 100644 index 00000000000..7cbb9c9165d --- /dev/null +++ b/app/assets/javascripts/organizations/index/index.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import resolvers from '../shared/graphql/resolvers'; +import OrganizationsIndexApp from './components/app.vue'; + +export const initOrganizationsIndex = () => { + const el = document.getElementById('js-organizations-index'); + + if (!el) return false; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }); + + const { newOrganizationUrl, organizationsEmptyStateSvgPath } = convertObjectPropsToCamelCase( + el.dataset, + ); + + return new Vue({ + el, + name: 'OrganizationsIndexRoot', + apolloProvider, + provide: { + newOrganizationUrl, + organizationsEmptyStateSvgPath, + }, + render(createElement) { + return createElement(OrganizationsIndexApp); + }, + }); +}; diff --git a/app/assets/javascripts/organizations/mock_data.js b/app/assets/javascripts/organizations/mock_data.js index 17ab7bd1d34..60c446900c9 100644 --- a/app/assets/javascripts/organizations/mock_data.js +++ b/app/assets/javascripts/organizations/mock_data.js @@ -4,10 +4,32 @@ // https://gitlab.com/gitlab-org/gitlab/-/issues/420777 // https://gitlab.com/gitlab-org/gitlab/-/issues/421441 -export const organization = { - id: 'gid://gitlab/Organization/1', - __typename: 'Organization', -}; +export const organizations = [ + { + id: 'gid://gitlab/Organization/1', + name: 'My First Organization', + description: 'This is where an organization can be explained in detail', + avatarUrl: null, + webUrl: null, + __typename: 'Organization', + }, + { + id: 'gid://gitlab/Organization/2', + name: 'Vegetation Co.', + description: 'Lorem ipsum dolor sit amet', + avatarUrl: null, + webUrl: null, + __typename: 'Organization', + }, + { + id: 'gid://gitlab/Organization/3', + name: 'Dude where is my car?', + description: 'Bacon ipsum dolor amet short ribs', + avatarUrl: null, + webUrl: null, + __typename: 'Organization', + }, +]; export const organizationProjects = { nodes: [ diff --git a/app/assets/javascripts/organizations/shared/graphql/resolvers.js b/app/assets/javascripts/organizations/shared/graphql/resolvers.js index c78266b0476..318b41647bc 100644 --- a/app/assets/javascripts/organizations/shared/graphql/resolvers.js +++ b/app/assets/javascripts/organizations/shared/graphql/resolvers.js @@ -1,18 +1,31 @@ -import { organization, organizationProjects, organizationGroups } from '../../mock_data'; +import { organizations, organizationProjects, organizationGroups } from '../../mock_data'; + +const simulateLoading = () => { + return new Promise((resolve) => { + setTimeout(resolve, 1000); + }); +}; export default { Query: { organization: async () => { // Simulate API loading - await new Promise((resolve) => { - setTimeout(resolve, 1000); - }); + await simulateLoading(); return { - ...organization, + ...organizations[0], projects: organizationProjects, groups: organizationGroups, }; }, }, + UserCore: { + organizations: async () => { + await simulateLoading(); + + return { + nodes: organizations, + }; + }, + }, }; diff --git a/app/assets/javascripts/pages/organizations/organizations/index/index.js b/app/assets/javascripts/pages/organizations/organizations/index/index.js new file mode 100644 index 00000000000..c7e087b81c6 --- /dev/null +++ b/app/assets/javascripts/pages/organizations/organizations/index/index.js @@ -0,0 +1,3 @@ +import { initOrganizationsIndex } from '~/organizations/index'; + +initOrganizationsIndex(); diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index 9e80210de51..aa3f33989c8 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -232,8 +232,7 @@ export default { <gl-form-input id="snippet-title" v-model="snippet.title" - data-testid="snippet-title-input" - data-qa-selector="snippet_title_field" + data-testid="snippet-title-input-field" :autofocus="true" /> </gl-form-group> @@ -261,7 +260,7 @@ export default { category="primary" type="submit" variant="confirm" - data-qa-selector="submit_button" + data-testid="submit-button" :disabled="isUpdating" >{{ saveButtonLabel }}</gl-button > diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue index 59f7c8d8d97..ca1d9f858a5 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue @@ -157,10 +157,9 @@ export default { </gl-form-group> <gl-button :disabled="!canAdd" - data-testid="add_button" + data-testid="add-button" class="gl-my-3" variant="dashed" - data-qa-selector="add_file_button" @click="addBlob" >{{ addLabel }}</gl-button > diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index 021bd23781e..9b0a1db23f2 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -69,11 +69,11 @@ export default { }; </script> <template> - <div class="file-holder snippet" data-qa-selector="file_holder_container"> + <div class="file-holder snippet" data-testid="file-holder-container"> <blob-header-edit :id="inputId" :value="blob.path" - data-qa-selector="file_name_field" + data-testid="file-name-field" :can-delete="canDelete" :show-delete="showDelete" @input="notifyAboutUpdates({ path: $event })" diff --git a/app/assets/javascripts/snippets/components/snippet_description_edit.vue b/app/assets/javascripts/snippets/components/snippet_description_edit.vue index 3ce7ea231ff..93d52890675 100644 --- a/app/assets/javascripts/snippets/components/snippet_description_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_description_edit.vue @@ -36,7 +36,7 @@ export default { <gl-form-input class="form-control" :placeholder="s__('Snippets|Describe what your snippet does or how to use it…')" - data-qa-selector="description_placeholder" + data-testid="description-placeholder" /> </div> <markdown-field @@ -54,7 +54,7 @@ export default { :value="value" class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" - data-qa-selector="snippet_description_field" + data-testid="snippet-description-field" data-supports-quick-actions="false" :aria-label="__('Description')" :placeholder="__('Write a comment or drag your files here…')" diff --git a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue index 24dd978585c..37d10cffc78 100644 --- a/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_visibility_edit.vue @@ -57,7 +57,7 @@ export default { <gl-icon :size="16" :name="option.icon" /> <span class="font-weight-bold ml-1 js-visibility-option" - data-qa-selector="visibility_content" + data-testid="visibility-content" :data-qa-visibility="option.label" >{{ option.label }}</span > diff --git a/app/assets/javascripts/super_sidebar/components/counter.vue b/app/assets/javascripts/super_sidebar/components/counter.vue index c0e1959fba4..49efc5ab5b9 100644 --- a/app/assets/javascripts/super_sidebar/components/counter.vue +++ b/app/assets/javascripts/super_sidebar/components/counter.vue @@ -15,7 +15,7 @@ export default { href: { type: String, required: false, - default: '', + default: null, }, icon: { type: String, diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue index 5da45b52bf4..ea3e9e9df1f 100644 --- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -102,7 +102,7 @@ export default { <draggable v-if="items.length > 0" v-model="draggableItems" - class="gl-p-0 gl-m-0" + class="gl-p-0 gl-m-0 gl-list-style-none" data-testid="pinned-nav-items" handle=".js-draggable-icon" tag="ul" diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql index f303a797e9c..d15e3086560 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql @@ -52,4 +52,12 @@ fragment WorkItemMetadataWidgets on WorkItemWidget { ... on WorkItemWidgetAwardEmoji { type } + + ... on WorkItemWidgetLinkedItems { + type + } + + ... on WorkItemWidgetHierarchy { + type + } } diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss index 24bd8b0fd7f..16fc0e7ebae 100644 --- a/app/assets/stylesheets/page_bundles/build.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -102,7 +102,7 @@ .sidebar-container { @include gl-sticky; top: #{$top-bar-height - 1px}; - max-height: calc(100vh - #{$top-bar-height - 1px}); + max-height: calc(100vh - #{$top-bar-height - 1px} - var(--performance-bar-height)); overflow-y: scroll; overflow-x: hidden; -webkit-overflow-scrolling: touch; diff --git a/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss b/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss index d6f71b12cd9..685719071b5 100644 --- a/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss +++ b/app/assets/stylesheets/page_bundles/ml_experiment_tracking.scss @@ -15,6 +15,6 @@ table.ml-candidate-table { table.candidate-details { td { - padding: $gl-spacing-scale-3; + padding: $gl-spacing-scale-3 $gl-spacing-scale-3 $gl-spacing-scale-3 0; } } diff --git a/app/helpers/organizations/organization_helper.rb b/app/helpers/organizations/organization_helper.rb index 6b5c4342c5c..0f760d87173 100644 --- a/app/helpers/organizations/organization_helper.rb +++ b/app/helpers/organizations/organization_helper.rb @@ -20,6 +20,13 @@ module Organizations shared_groups_and_projects_app_data.to_json end + def organization_index_app_data + { + new_organization_url: new_organization_path, + organizations_empty_state_svg_path: image_path('illustrations/empty-state/empty-organizations-md.svg') + } + end + private def shared_groups_and_projects_app_data diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 9a5765d8074..c7088908de8 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -30,7 +30,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord jitsu_project_xid jitsu_administrator_email ], remove_with: '16.5', remove_after: '2023-09-22' - ignore_columns %i[ai_access_token ai_access_token_iv], remove_with: '16.6', remove_after: '2023-10-22' + ignore_columns %i[encrypted_ai_access_token encrypted_ai_access_token_iv], remove_with: '16.6', remove_after: '2023-10-22' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ diff --git a/app/models/ci/catalog/listing.rb b/app/models/ci/catalog/listing.rb index 1cb030c67c3..c3b18af8c3f 100644 --- a/app/models/ci/catalog/listing.rb +++ b/app/models/ci/catalog/listing.rb @@ -18,6 +18,8 @@ module Ci case sort.to_s when 'name_desc' then all_resources.order_by_name_desc when 'name_asc' then all_resources.order_by_name_asc + when 'latest_released_at_desc' then all_resources.order_by_latest_released_at_desc + when 'latest_released_at_asc' then all_resources.order_by_latest_released_at_asc else all_resources.order_by_created_at_desc end diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb index 799cdce4af7..8ffc0292a69 100644 --- a/app/models/ci/catalog/resource.rb +++ b/app/models/ci/catalog/resource.rb @@ -18,6 +18,8 @@ module Ci scope :order_by_created_at_desc, -> { reorder(created_at: :desc) } scope :order_by_name_desc, -> { joins(:project).merge(Project.sorted_by_name_desc) } scope :order_by_name_asc, -> { joins(:project).merge(Project.sorted_by_name_asc) } + scope :order_by_latest_released_at_desc, -> { reorder(arel_table[:latest_released_at].desc.nulls_last) } + scope :order_by_latest_released_at_asc, -> { reorder(arel_table[:latest_released_at].asc.nulls_last) } delegate :avatar_path, :description, :name, :star_count, :forks_count, to: :project diff --git a/app/presenters/ml/candidate_details_presenter.rb b/app/presenters/ml/candidate_details_presenter.rb index 29d4617903f..057d3bd19d9 100644 --- a/app/presenters/ml/candidate_details_presenter.rb +++ b/app/presenters/ml/candidate_details_presenter.rb @@ -23,7 +23,7 @@ module Ml ci_job: job_info }, params: candidate.params, - metrics: candidate.latest_metrics, + metrics: candidate.metrics, metadata: candidate.metadata } } diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index b72b252a852..74dc2277f54 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -7,7 +7,7 @@ .page-title-controls.gl-display-flex.gl-align-items-center.gl-gap-5 = link_to _("Explore projects"), explore_projects_path - if current_user.can_create_project? - = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm, button_options: { data: { qa_selector: 'new_project_button' } }) do + = render Pajamas::ButtonComponent.new(href: new_project_path, variant: :confirm, button_options: { data: { testid: 'new-project-button' } }) do = _("New project") .top-area diff --git a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml index 94f956896d6..1d2e6e1e332 100644 --- a/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_admin_welcome.html.haml @@ -4,7 +4,7 @@ - if has_start_trial? = render_if_exists "dashboard/projects/blank_state_ee_trial" - = link_to new_project_path, class: link_classes, data: { qa_selector: 'new_project_button' } do + = link_to new_project_path, class: link_classes, data: { testid: 'new-project-button' } do .blank-state-icon = custom_icon("add_new_project", size: 50) .blank-state-body.gl-sm-pl-6 diff --git a/app/views/dashboard/projects/_blank_state_welcome.html.haml b/app/views/dashboard/projects/_blank_state_welcome.html.haml index 08b914a218d..032c5206d99 100644 --- a/app/views/dashboard/projects/_blank_state_welcome.html.haml +++ b/app/views/dashboard/projects/_blank_state_welcome.html.haml @@ -2,7 +2,7 @@ .gl-display-flex.gl-flex-wrap.gl-justify-content-space-between - if current_user.can_create_project? - = link_to new_project_path, class: link_classes, data: { qa_selector: 'new_project_button' } do + = link_to new_project_path, class: link_classes, data: { testid: 'new-project-button' } do .blank-state-icon = custom_icon("add_new_project", size: 50) .blank-state-body.gl-sm-pl-6 diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index ca7798257cb..544acd5ae56 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -35,7 +35,7 @@ - if can_create_projects .gl-sm-w-auto.gl-w-full - = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), variant: :confirm, button_options: { data: { qa_selector: 'new_project_button' }, class: 'gl-sm-w-auto gl-w-full' }) do + = render Pajamas::ButtonComponent.new(href: new_project_path(namespace_id: @group.id), variant: :confirm, button_options: { data: { testid: 'new-project-button' }, class: 'gl-sm-w-auto gl-w-full' }) do = _('New project') - if @group.description.present? diff --git a/app/views/layouts/_img_loader.html.haml b/app/views/layouts/_img_loader.html.haml index c1fe3ae0924..ac00a18f0bc 100644 --- a/app/views/layouts/_img_loader.html.haml +++ b/app/views/layouts/_img_loader.html.haml @@ -13,6 +13,6 @@ img.removeAttribute('data-src'); img.classList.remove('lazy'); img.classList.add('js-lazy-loaded'); - img.dataset.testid = 'js_lazy_loaded_content'; + img.dataset.testid = 'js-lazy-loaded-content'; }); } diff --git a/app/views/organizations/organizations/index.html.haml b/app/views/organizations/organizations/index.html.haml index 04a90b7589f..e108878a15d 100644 --- a/app/views/organizations/organizations/index.html.haml +++ b/app/views/organizations/organizations/index.html.haml @@ -1,2 +1,4 @@ - page_title s_('Organization|Organizations') - header_title _("Your work"), root_path + +#js-organizations-index{ data: organization_index_app_data } diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml index 6fe36d75453..a2457fb0810 100644 --- a/app/views/shared/empty_states/_snippets.html.haml +++ b/app/views/shared/empty_states/_snippets.html.haml @@ -2,7 +2,7 @@ .row.empty-state .col-12 - .svg-content.svg-150{ data: { qa_selector: 'svg_content' } } + .svg-content.svg-150{ data: { testid: 'svg-content' } } = image_tag 'illustrations/empty-state/empty-snippets-md.svg' .text-content.gl-text-center.gl-pt-0 - if current_user @@ -12,7 +12,7 @@ = s_('SnippetsEmptyState|Store, share, and embed small pieces of code and text.') .gl-mt-3< - if button_path - = link_button_to s_('SnippetsEmptyState|New snippet'), button_path, title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { qa_selector: 'create_first_snippet_link' }, variant: :confirm + = link_button_to s_('SnippetsEmptyState|New snippet'), button_path, title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { testid: 'create-first-snippet-link' }, variant: :confirm = link_button_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), title: s_('SnippetsEmptyState|Documentation') - else %h4.gl-text-center= s_('SnippetsEmptyState|There are no snippets to show.') diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 75151da8071..2de4a9d7780 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -24,7 +24,7 @@ - else = render Pajamas::AvatarComponent.new(project, size: 48, alt: '', class: 'gl-mr-5') .project-cell{ class: css_class } - .project-details.gl-pr-9.gl-sm-pr-0.gl-w-full.gl-display-flex.gl-flex-direction-column{ data: { qa_selector: 'project_content', qa_project_name: project.name } } + .project-details.gl-pr-9.gl-sm-pr-0.gl-w-full.gl-display-flex.gl-flex-direction-column{ data: { testid: 'project-content', qa_project_name: project.name } } .gl-display-flex.gl-align-items-center.gl-flex-wrap %h2.gl-font-base.gl-line-height-20.gl-my-0.gl-overflow-wrap-anywhere = link_to project_path(project), class: 'text-plain gl-mr-3 js-prefetch-document', title: project.name do @@ -45,7 +45,7 @@ - if !explore_projects_tab? && access&.nonzero? -# haml-lint:disable UnnecessaryStringOutput = ' ' # prevent haml from eating the space between elements - %span.user-access-role.gl-display-block.gl-m-0{ data: { qa_selector: 'user_role_content' } }= localized_project_human_access(access) + %span.user-access-role.gl-display-block.gl-m-0{ data: { testid: 'user-role-content' } }= localized_project_human_access(access) - if !explore_projects_tab? = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project, additional_classes: 'gl-ml-3!' diff --git a/app/views/shared/projects/_search_form.html.haml b/app/views/shared/projects/_search_form.html.haml index 2388bf2f0be..de54cc2810b 100644 --- a/app/views/shared/projects/_search_form.html.haml +++ b/app/views/shared/projects/_search_form.html.haml @@ -2,7 +2,7 @@ - admin_view ||= false - top_padding = admin_view ? 'gl-lg-pt-3' : '' -= form_tag filter_projects_path, method: :get, class: "project-filter-form gl-display-flex! gl-flex-wrap gl-w-full gl-gap-3 #{top_padding}", data: { qa_selector: 'project_filter_form_container' }, id: 'project-filter-form' do |f| += form_tag filter_projects_path, method: :get, class: "project-filter-form gl-display-flex! gl-flex-wrap gl-w-full gl-gap-3 #{top_padding}", data: { testid: 'project-filter-form-container' }, id: 'project-filter-form' do |f| = search_field_tag :name, params[:name], placeholder: placeholder, class: "project-filter-form-field form-control input-short js-projects-list-filter gl-m-0!", diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index 6caadeb0ba4..9767f7929d0 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -1,13 +1,13 @@ - link_project = local_assigns.fetch(:link_project, false) - notes_count = @noteable_meta_data[snippet.id].user_notes_count -%li.snippet-row.py-3{ data: { qa_selector: 'snippet_link', qa_snippet_title: snippet.title } } +%li.snippet-row.py-3{ data: { testid: 'snippet-link', qa_snippet_title: snippet.title } } = render Pajamas::AvatarComponent.new(snippet.author, size: 48, alt: "", class: 'gl-display-none gl-sm-display-block gl-float-left gl-mr-3') = link_to gitlab_snippet_path(snippet), class: "title" do = snippet.title - %ul.controls{ data: { qa_selector: 'snippet_file_count_content', qa_snippet_files: snippet.statistics&.file_count } } + %ul.controls{ data: { testid: 'snippet-file-count-content', qa_snippet_files: snippet.statistics&.file_count } } %li = snippet_file_count(snippet) %li @@ -15,7 +15,7 @@ = sprite_icon('comments', css_class: 'gl-vertical-align-text-bottom') = notes_count %li - %span.sr-only{ data: { qa_selector: 'snippet_visibility_content', qa_snippet_visibility: visibility_level_label(snippet.visibility_level) } } + %span.sr-only{ data: { testid: 'snippet-visibility-content', qa_snippet_visibility: visibility_level_label(snippet.visibility_level) } } = visibility_level_label(snippet.visibility_level) = visibility_level_icon(snippet.visibility_level) diff --git a/doc/administration/terraform_state.md b/doc/administration/terraform_state.md index 90b030e6e13..bf0774478ee 100644 --- a/doc/administration/terraform_state.md +++ b/doc/administration/terraform_state.md @@ -234,3 +234,29 @@ See [the available connection settings for different providers](object_storage.m 1. [Migrate any existing local states to the object storage](#migrate-to-object-storage) ::EndTabs + +### Find a Terraform state file path + +Terraform state files are stored in the hashed directory path of the relevant project. + +The format of the path is `/var/opt/gitlab/gitlab-rails/shared/terraform_state/<path>/<to>/<projectHashDirectory>/<UUID>/0.tfstate`, where [UUID](https://gitlab.com/gitlab-org/gitlab/-/blob/dcc47a95c7e1664cb15bef9a70f2a4eefa9bd99a/app/models/terraform/state.rb#L33) is randomly defined. + +To find a state file path: + +1. Add `get-terraform-path` to your shell: + + ```shell + get-terraform-path() { + PROJECT_HASH=$(echo -n $1 | openssl dgst -sha256 | sed 's/^.* //') + echo "${PROJECT_HASH:0:2}/${PROJECT_HASH:2:2}/${PROJECT_HASH}" + } + ``` + +1. Run `get-terraform-path <project_id>`. + + ```shell + $ get-terraform-path 650 + 20/99/2099a9b5f777e242d1f9e19d27e232cc71e2fa7964fc988a319fce5671ca7f73 + ``` + +The relative path is displayed. diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 6dd8fbdad50..b4f57847408 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -14126,6 +14126,7 @@ Represents the total number of issues and their weights for a particular day. | <a id="cicatalogresourceforkscount"></a>`forksCount` **{warning-solid}** | [`Int!`](#int) | **Introduced** in 16.1. This feature is an Experiment. It can be changed or removed at any time. Number of times the catalog resource has been forked. | | <a id="cicatalogresourceicon"></a>`icon` **{warning-solid}** | [`String`](#string) | **Introduced** in 15.11. This feature is an Experiment. It can be changed or removed at any time. Icon for the catalog resource. | | <a id="cicatalogresourceid"></a>`id` **{warning-solid}** | [`ID!`](#id) | **Introduced** in 15.11. This feature is an Experiment. It can be changed or removed at any time. ID of the catalog resource. | +| <a id="cicatalogresourcelatestreleasedat"></a>`latestReleasedAt` **{warning-solid}** | [`Time`](#time) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Release date of the catalog resource's latest version. | | <a id="cicatalogresourcelatestversion"></a>`latestVersion` **{warning-solid}** | [`Release`](#release) | **Introduced** in 16.1. This feature is an Experiment. It can be changed or removed at any time. Latest version of the catalog resource. | | <a id="cicatalogresourcename"></a>`name` **{warning-solid}** | [`String`](#string) | **Introduced** in 15.11. This feature is an Experiment. It can be changed or removed at any time. Name of the catalog resource. | | <a id="cicatalogresourceopenissuescount"></a>`openIssuesCount` **{warning-solid}** | [`Int!`](#int) | **Introduced** in 16.3. This feature is an Experiment. It can be changed or removed at any time. Count of open issues that belong to the the catalog resource. | @@ -26964,6 +26965,8 @@ Values for sorting catalog resources. | ----- | ----------- | | <a id="cicatalogresourcesortcreated_asc"></a>`CREATED_ASC` | Created at ascending order. | | <a id="cicatalogresourcesortcreated_desc"></a>`CREATED_DESC` | Created at descending order. | +| <a id="cicatalogresourcesortlatest_released_at_asc"></a>`LATEST_RELEASED_AT_ASC` | Latest release date by ascending order. | +| <a id="cicatalogresourcesortlatest_released_at_desc"></a>`LATEST_RELEASED_AT_DESC` | Latest release date by descending order. | | <a id="cicatalogresourcesortname_asc"></a>`NAME_ASC` | Name by ascending order. | | <a id="cicatalogresourcesortname_desc"></a>`NAME_DESC` | Name by descending order. | | <a id="cicatalogresourcesortupdated_asc"></a>`UPDATED_ASC` | Updated at ascending order. | diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md index 734679cf331..0449848671b 100644 --- a/doc/user/group/saml_sso/index.md +++ b/doc/user/group/saml_sso/index.md @@ -532,6 +532,7 @@ immediately. If the user: - [SAML SSO for self-managed GitLab instances](../../../integration/saml.md) - [Glossary](../../../integration/saml.md#glossary) +- [Blog post: The ultimate guide to enabling SAML and SSO on GitLab.com](https://about.gitlab.com/blog/2023/09/14/the-ultimate-guide-to-enabling-saml/) - [Authentication comparison between SaaS and self-managed](../../../administration/auth/index.md#saas-vs-self-managed-comparison) - [Passwords for users created through integrated authentication](../../../security/passwords_for_integrated_authentication_methods.md) - [SAML Group Sync](group_sync.md) diff --git a/lib/gitlab/experiment/rollout/feature.rb b/lib/gitlab/experiment/rollout/feature.rb index 102d979ecfb..4ff61aa3551 100644 --- a/lib/gitlab/experiment/rollout/feature.rb +++ b/lib/gitlab/experiment/rollout/feature.rb @@ -30,18 +30,7 @@ module Gitlab # assign a variant based on our provided distribution rules. # Otherwise we will assign a variant evenly across the behaviours without control. def execute_assignment - return unless ::Feature.enabled?(feature_flag_name, self, type: :experiment) - - # non-control distribution. - if distribution_rules.present? - # In our setup with Flipper use, we'll want to use distribution rules with control set to 0% since - # the Feature being disabled is the default control experience. - super - else - crc = normalized_id - eligible_behaviors = behavior_names - [:control] - eligible_behaviors.empty? ? nil : eligible_behaviors[crc % eligible_behaviors.length] - end + super if ::Feature.enabled?(feature_flag_name, self, type: :experiment) end # This is what's provided to the `Feature.enabled?` call that will be @@ -79,6 +68,10 @@ module Gitlab def feature_flag_name experiment.name.tr('/', '_') end + + def behavior_names + super - [:control] + end end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 726ce285cba..90170834546 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -442,6 +442,11 @@ msgid_plural "%d tags per image name" msgstr[0] "" msgstr[1] "" +msgid "%d template found" +msgid_plural "%d templates found" +msgstr[0] "" +msgstr[1] "" + msgid "%d unresolved thread" msgid_plural "%d unresolved threads" msgstr[0] "" @@ -3596,7 +3601,7 @@ msgstr "" msgid "AdminSettings|New CI/CD variables in projects and groups default to protected." msgstr "" -msgid "AdminSettings|No required pipeline" +msgid "AdminSettings|No required configuration" msgstr "" msgid "AdminSettings|Only enable search after installing the plugin, enabling indexing, and recreating the index." @@ -3755,6 +3760,9 @@ msgstr "" msgid "AdminSettings|You can't delete projects before the warning email is sent." msgstr "" +msgid "AdminSettings|templates found" +msgstr "" + msgid "AdminStatistics|Active Users" msgstr "" @@ -9010,9 +9018,6 @@ msgstr "" msgid "CHANGELOG" msgstr "" -msgid "CI" -msgstr "" - msgid "CI Lint" msgstr "" @@ -28364,6 +28369,9 @@ msgstr "" msgid "MD5" msgstr "" +msgid "MLExperimentTracking|CI Info" +msgstr "" + msgid "MLExperimentTracking|Delete candidate?" msgstr "" @@ -30220,6 +30228,9 @@ msgstr "" msgid "MlExperimentTracking|CI Job" msgstr "" +msgid "MlExperimentTracking|Candidate not linked to a CI build" +msgstr "" + msgid "MlExperimentTracking|Candidate removed" msgstr "" @@ -30292,6 +30303,9 @@ msgstr "" msgid "MlExperimentTracking|Model experiments" msgstr "" +msgid "MlExperimentTracking|Model performance" +msgstr "" + msgid "MlExperimentTracking|Model registry" msgstr "" @@ -30307,6 +30321,15 @@ msgstr "" msgid "MlExperimentTracking|No candidates logged for the query. Create new candidates using the MLflow client." msgstr "" +msgid "MlExperimentTracking|No logged metadata" +msgstr "" + +msgid "MlExperimentTracking|No logged metrics" +msgstr "" + +msgid "MlExperimentTracking|No logged parameters" +msgstr "" + msgid "MlExperimentTracking|No name" msgstr "" @@ -32968,15 +32991,24 @@ msgstr "" msgid "Organization|An error occurred loading the projects. Please refresh the page to try again." msgstr "" +msgid "Organization|An error occurred loading user organizations. Please refresh the page to try again." +msgstr "" + msgid "Organization|Copy organization ID" msgstr "" +msgid "Organization|Create an organization to contain all of your groups and projects." +msgstr "" + msgid "Organization|Frequently visited groups" msgstr "" msgid "Organization|Frequently visited projects" msgstr "" +msgid "Organization|Get started with organizations" +msgstr "" + msgid "Organization|Manage" msgstr "" @@ -45745,6 +45777,9 @@ msgstr "" msgid "Step %{currentStep} of %{stepCount}" msgstr "" +msgid "Step %{step}" +msgstr "" + msgid "Step 1." msgstr "" diff --git a/qa/qa/page/component/lazy_loader.rb b/qa/qa/page/component/lazy_loader.rb index 1b166efbbff..2e2d99b28d5 100644 --- a/qa/qa/page/component/lazy_loader.rb +++ b/qa/qa/page/component/lazy_loader.rb @@ -10,7 +10,7 @@ module QA super base.view 'app/views/layouts/_img_loader.html.haml' do - element :js_lazy_loaded_content + element 'js-lazy-loaded-content' end end end diff --git a/qa/qa/page/component/new_snippet.rb b/qa/qa/page/component/new_snippet.rb index 657008749f2..b939c2ca750 100644 --- a/qa/qa/page/component/new_snippet.rb +++ b/qa/qa/page/component/new_snippet.rb @@ -10,22 +10,22 @@ module QA super base.view 'app/assets/javascripts/snippets/components/edit.vue' do - element :snippet_title_field, required: true - element :submit_button + element 'snippet-title-input-field', required: true + element 'submit-button' end base.view 'app/assets/javascripts/snippets/components/snippet_description_edit.vue' do - element :snippet_description_field - element :description_placeholder, required: true + element 'snippet-description-field' + element 'description-placeholder', required: true end base.view 'app/assets/javascripts/snippets/components/snippet_blob_edit.vue' do - element :file_name_field - element :file_holder_container + element 'file-name-field' + element 'file-holder-container' end base.view 'app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue' do - element :add_file_button + element 'add-button' end base.view 'app/views/shared/_zen.html.haml' do @@ -34,36 +34,36 @@ module QA end base.view 'app/assets/javascripts/snippets/components/snippet_visibility_edit.vue' do - element :visibility_content + element 'visibility-content' end end def fill_title(title) - fill_element :snippet_title_field, title + fill_element 'snippet-title-input-field', title end def fill_description(description) - click_element :description_placeholder - fill_element :snippet_description_field, description + click_element 'description-placeholder' + fill_element 'snippet-description-field', description end def set_visibility(visibility) - click_element(:visibility_content, visibility: visibility) + click_element('visibility-content', visibility: visibility) end def fill_file_name(name, file_number = nil) if file_number - within_element_by_index(:file_holder_container, file_number - 1) do - fill_element(:file_name_field, name) + within_element_by_index('file-holder-container', file_number - 1) do + fill_element('file-name-field', name) end else - fill_element(:file_name_field, name) + fill_element('file-name-field', name) end end def fill_file_content(content, file_number = nil) if file_number - within_element_by_index(:file_holder_container, file_number - 1) do + within_element_by_index('file-holder-container', file_number - 1) do text_area.set(content) end else @@ -72,13 +72,13 @@ module QA end def click_add_file - click_element(:add_file_button) + click_element('add-button') end def click_create_snippet_button - click_element_coordinates(:submit_button) + click_element_coordinates('submit-button') wait_until(reload: false) do - has_no_element?(:snippet_title_field) + has_no_element?('snippet-title-input-field') end end diff --git a/qa/qa/page/component/snippet.rb b/qa/qa/page/component/snippet.rb index 807f0e5f2fe..91c915877e2 100644 --- a/qa/qa/page/component/snippet.rb +++ b/qa/qa/page/component/snippet.rb @@ -201,7 +201,7 @@ module QA click_element('comment-button') unless has_element?('note-author-content') - raise ElementNotFound, "Comment did not appear as expected" + raise QA::Page::Base::ElementNotFound, "Comment did not appear as expected" end end @@ -233,7 +233,7 @@ module QA click_element('save-comment-button') unless has_element?('note-author-content') - raise ElementNotFound, "Comment did not appear as expected" + raise QA::Page::Base::ElementNotFound, "Comment did not appear as expected" end end @@ -243,7 +243,7 @@ module QA click_confirmation_ok_button unless has_no_element?('note-content', text: comment) - raise ElementNotFound, "Comment was not removed as expected" + raise QA::Page::Base::ElementNotFound, "Comment was not removed as expected" end end @@ -265,7 +265,7 @@ module QA click_element('comment-button') unless has_element?('note-author-content') - raise ElementNotFound, "Comment did not appear as expected" + raise QA::Page::Base::ElementNotFound, "Comment did not appear as expected" end end diff --git a/qa/qa/page/component/web_ide/modal/create_new_file.rb b/qa/qa/page/component/web_ide/modal/create_new_file.rb index 2869bb9c331..22cac83d913 100644 --- a/qa/qa/page/component/web_ide/modal/create_new_file.rb +++ b/qa/qa/page/component/web_ide/modal/create_new_file.rb @@ -7,7 +7,7 @@ module QA module Modal class CreateNewFile < Page::Base view 'app/assets/javascripts/ide/components/new_dropdown/modal.vue' do - element :file_name_field, required: true + element 'file-name-field', required: true element :new_file_modal, required: true element :template_list_content end diff --git a/qa/qa/page/component/wiki.rb b/qa/qa/page/component/wiki.rb index ed68052f997..e34e1f4b589 100644 --- a/qa/qa/page/component/wiki.rb +++ b/qa/qa/page/component/wiki.rb @@ -38,7 +38,7 @@ module QA # webdriver to miss the hit so we wait for the svg to load before # clicking the button. within_element(:svg_content) do - has_element?(:js_lazy_loaded_content) + has_element?('js-lazy-loaded-content') end click_element(:create_first_page_link) diff --git a/qa/qa/page/dashboard/projects.rb b/qa/qa/page/dashboard/projects.rb index 4a81d34b32e..ff822a6b117 100644 --- a/qa/qa/page/dashboard/projects.rb +++ b/qa/qa/page/dashboard/projects.rb @@ -5,34 +5,34 @@ module QA module Dashboard class Projects < Page::Base view 'app/views/shared/projects/_search_form.html.haml' do - element :project_filter_form_container, required: true + element 'project-filter-form-container', required: true end view 'app/views/shared/projects/_project.html.haml' do - element :project_content - element :user_role_content + element 'project-content' + element 'user-role-content' end view 'app/views/dashboard/_projects_head.html.haml' do - element :new_project_button + element 'new-project-button' end view 'app/views/dashboard/projects/_blank_state_welcome.html.haml' do - element :new_project_button + element 'new-project-button' end view 'app/views/dashboard/projects/_blank_state_admin_welcome.html.haml' do - element :new_project_button + element 'new-project-button' end def has_project_with_access_role?(project_name, access_role) - within_element(:project_content, text: project_name) do - has_element?(:user_role_content, text: access_role) + within_element('project-content', text: project_name) do + has_element?('user-role-content', text: access_role) end end def filter_by_name(name) - within_element(:project_filter_form_container) do + within_element('project-filter-form-container') do fill_in :name, with: name end end @@ -44,7 +44,7 @@ module QA end def click_new_project_button - click_element(:new_project_button, Page::Project::New) + click_element('new-project-button', Page::Project::New) end def self.path @@ -52,7 +52,7 @@ module QA end def clear_project_filter - fill_element(:project_filter_form_container, "") + fill_element('project-filter-form-container', "") end end end diff --git a/qa/qa/page/dashboard/snippet/edit.rb b/qa/qa/page/dashboard/snippet/edit.rb index 8af3eb5693c..f930e35994c 100644 --- a/qa/qa/page/dashboard/snippet/edit.rb +++ b/qa/qa/page/dashboard/snippet/edit.rb @@ -6,20 +6,20 @@ module QA module Snippet class Edit < Page::Base view 'app/assets/javascripts/snippets/components/edit.vue' do - element :submit_button, required: true + element 'submit-button', required: true end view 'app/assets/javascripts/snippets/components/snippet_blob_edit.vue' do - element :file_name_field - element :file_holder_container + element 'file-name-field' + element 'file-holder-container' end view 'app/assets/javascripts/blob/components/blob_edit_header.vue' do - element :delete_file_button + element 'delete-file-button' end view 'app/assets/javascripts/snippets/components/snippet_visibility_edit.vue' do - element :visibility_content + element 'visibility-content' end def add_to_file_content(content) @@ -29,26 +29,26 @@ module QA end def change_visibility_to(visibility_type) - click_element(:visibility_content, visibility: visibility_type) + click_element('visibility-content', visibility: visibility_type) end def click_add_file - click_element(:add_file_button) + click_element('add-button') end def fill_file_name(name, file_number = nil) if file_number - within_element_by_index(:file_holder_container, file_number - 1) do - fill_element(:file_name_field, name) + within_element_by_index('file-holder-container', file_number - 1) do + fill_element('file-name-field', name) end else - fill_element(:file_name_field, name) + fill_element('file-name-field', name) end end def fill_file_content(content, file_number = nil) if file_number - within_element_by_index(:file_holder_container, file_number - 1) do + within_element_by_index('file-holder-container', file_number - 1) do text_area.set(content) end else @@ -57,15 +57,15 @@ module QA end def click_delete_file(file_number) - within_element_by_index(:file_holder_container, file_number - 1) do - click_element(:delete_file_button) + within_element_by_index('file-holder-container', file_number - 1) do + click_element('delete-file-button') end end def save_changes - click_element_coordinates(:submit_button) + click_element_coordinates('submit-button') wait_until(reload: false) do - has_no_element?(:file_name_field) + has_no_element?('file-name-field') end end diff --git a/qa/qa/page/dashboard/snippet/index.rb b/qa/qa/page/dashboard/snippet/index.rb index 51d7bd3f20b..3b479ddebb1 100644 --- a/qa/qa/page/dashboard/snippet/index.rb +++ b/qa/qa/page/dashboard/snippet/index.rb @@ -6,25 +6,25 @@ module QA module Snippet class Index < Page::Base view 'app/views/shared/snippets/_snippet.html.haml' do - element :snippet_link - element :snippet_visibility_content - element :snippet_file_count_content + element 'snippet-link' + element 'snippet-visibility-content' + element 'snippet-file-count-content' end def has_snippet_title?(snippet_title) - has_element?(:snippet_link, snippet_title: snippet_title) + has_element?('snippet-link', snippet_title: snippet_title) end def has_visibility_level?(snippet_title, visibility) - within_element(:snippet_link, snippet_title: snippet_title) do - has_element?(:snippet_visibility_content, snippet_visibility: visibility) + within_element('snippet-link', snippet_title: snippet_title) do + has_element?('snippet-visibility-content', snippet_visibility: visibility) end end def has_number_of_files?(snippet_title, number) retry_until(max_attempts: 5, reload: true, sleep_interval: 1) do # snippet statistics computation can take a few moments - within_element(:snippet_link, snippet_title: snippet_title) do - has_element?(:snippet_file_count_content, snippet_files: number, wait: 5) + within_element('snippet-link', snippet_title: snippet_title) do + has_element?('snippet-file-count-content', snippet_files: number, wait: 5) end end end diff --git a/qa/qa/page/group/show.rb b/qa/qa/page/group/show.rb index 0eaab078950..4dd0f3a5704 100644 --- a/qa/qa/page/group/show.rb +++ b/qa/qa/page/group/show.rb @@ -8,7 +8,7 @@ module QA include QA::Page::Component::ConfirmModal view 'app/views/groups/_home_panel.html.haml' do - element :new_project_button + element 'new-project-button' element :new_subgroup_button element :group_id_content end @@ -23,7 +23,7 @@ module QA end def has_new_project_and_new_subgroup_buttons? - has_element?(:new_project_button) + has_element?('new_project_button') has_element?(:new_subgroup_button) end @@ -36,7 +36,7 @@ module QA end def go_to_new_project - click_element :new_project_button + click_element 'new-project-button' end def group_id diff --git a/qa/qa/page/label/index.rb b/qa/qa/page/label/index.rb index 70df2e4dc9e..788c0a6378a 100644 --- a/qa/qa/page/label/index.rb +++ b/qa/qa/page/label/index.rb @@ -23,7 +23,7 @@ module QA # This can cause webdriver to miss the hit so we wait for the svg to load (implicitly with has_element?) # before clicking the button. within_element(:label_svg_content) do - has_element?(:js_lazy_loaded_content) + has_element?('js-lazy-loaded-content') end click_element :create_new_label_button diff --git a/qa/qa/page/project/snippet/index.rb b/qa/qa/page/project/snippet/index.rb index 704698dc9d8..0ed2453bf77 100644 --- a/qa/qa/page/project/snippet/index.rb +++ b/qa/qa/page/project/snippet/index.rb @@ -9,15 +9,15 @@ module QA include Page::Component::BlobContent view 'app/views/shared/snippets/_snippet.html.haml' do - element :snippet_link + element 'snippet-link' end def has_project_snippet?(title) - has_element?(:snippet_link, snippet_title: title) + has_element?('snippet-link', snippet_title: title) end def click_snippet_link(title) - within_element(:snippet_link, text: title) do + within_element('snippet-link', text: title) do click_link(title) end end diff --git a/qa/qa/page/project/snippet/new.rb b/qa/qa/page/project/snippet/new.rb index 4a13e597e15..1a7e771ce7b 100644 --- a/qa/qa/page/project/snippet/new.rb +++ b/qa/qa/page/project/snippet/new.rb @@ -8,8 +8,8 @@ module QA include Page::Component::NewSnippet include Component::LazyLoader view 'app/views/shared/empty_states/_snippets.html.haml' do - element :create_first_snippet_link - element :svg_content + element 'create-first-snippet-link' + element 'svg-content' end def click_create_first_snippet @@ -19,10 +19,10 @@ module QA # "New snippet" button shifts up a bit. This can cause # webdriver to miss the hit so we wait for the svg to load before # clicking the button. - within_element(:svg_content) do - has_element?(:js_lazy_loaded_content) + within_element('svg-content') do + has_element?('js-lazy-loaded-content') end - click_element(:create_first_snippet_link) + click_element('create-first-snippet-link') end end end diff --git a/qa/qa/page/project/web_ide/edit.rb b/qa/qa/page/project/web_ide/edit.rb index 16eaa7efba9..a835ba9e85f 100644 --- a/qa/qa/page/project/web_ide/edit.rb +++ b/qa/qa/page/project/web_ide/edit.rb @@ -267,13 +267,13 @@ module QA def create_first_file(file_name) click_element(:first_file_button, Page::Component::WebIDE::Modal::CreateNewFile) - fill_element(:file_name_field, file_name) + fill_element('file-name-field', file_name) click_button('Create file') end def add_file(file_name, file_text) click_element(:new_file_button, Page::Component::WebIDE::Modal::CreateNewFile) - fill_element(:file_name_field, file_name) + fill_element('file-name-field', file_name) click_button('Create file') wait_until(reload: false) { has_file?(file_name) } within_element(:editor_container) do @@ -283,7 +283,7 @@ module QA def add_directory(directory_name) click_element(:new_directory_button, Page::Component::WebIDE::Modal::CreateNewFile) - fill_element(:file_name_field, directory_name) + fill_element('file-name-field', directory_name) click_button('Create directory') wait_until(reload: false) { has_file?(directory_name) } end @@ -292,7 +292,7 @@ module QA click_element('file-row-name-container', file_name: file_name) click_element(:dropdown_button) click_element(:rename_move_button, Page::Component::WebIDE::Modal::CreateNewFile) - fill_element(:file_name_field, new_file_name) + fill_element('file-name-field', new_file_name) click_button('Rename file') end diff --git a/qa/qa/page/search/results.rb b/qa/qa/page/search/results.rb index a04fd9092d1..1fbd9a75dc5 100644 --- a/qa/qa/page/search/results.rb +++ b/qa/qa/page/search/results.rb @@ -16,7 +16,7 @@ module QA end view 'app/views/shared/projects/_project.html.haml' do - element :project_content + element 'project-content' end def switch_to_code diff --git a/scripts/generate_rspec_pipeline.rb b/scripts/generate_rspec_pipeline.rb index 292b3d85b20..1fc37374ba5 100755 --- a/scripts/generate_rspec_pipeline.rb +++ b/scripts/generate_rspec_pipeline.rb @@ -110,7 +110,7 @@ class GenerateRspecPipeline end def optimal_nodes_count(test_level, rspec_files) - nodes_count = (rspec_files.size / optimal_test_file_count_per_node_per_test_level(test_level)).ceil + nodes_count = (rspec_files.size / optimal_test_file_count_per_node_per_test_level(test_level, rspec_files)).ceil info "Optimal node count for #{rspec_files.size} #{test_level} RSpec files is #{nodes_count}." if nodes_count > MAX_NODES_COUNT @@ -123,14 +123,27 @@ class GenerateRspecPipeline end end - def optimal_test_file_count_per_node_per_test_level(test_level) + def optimal_test_file_count_per_node_per_test_level(test_level, rspec_files) [ - (OPTIMAL_TEST_RUNTIME_DURATION_IN_SECONDS / average_test_file_duration_in_seconds_per_test_level[test_level]), + (OPTIMAL_TEST_RUNTIME_DURATION_IN_SECONDS / average_test_file_duration(test_level, rspec_files)), 1 ].max end - def average_test_file_duration_in_seconds_per_test_level + def average_test_file_duration(test_level, rspec_files) + if rspec_files.any? && knapsack_report.any? + rspec_files_duration = rspec_files.sum do |rspec_file| + knapsack_report.fetch( + rspec_file, average_test_file_duration_per_test_level[test_level]) + end + + rspec_files_duration / rspec_files.size + else + average_test_file_duration_per_test_level[test_level] + end + end + + def average_test_file_duration_per_test_level @optimal_test_file_count_per_node_per_test_level ||= if knapsack_report.any? remaining_knapsack_report = knapsack_report.dup diff --git a/spec/factories/ml/candidate_metrics.rb b/spec/factories/ml/candidate_metrics.rb index 28e3974d39f..633234e5962 100644 --- a/spec/factories/ml/candidate_metrics.rb +++ b/spec/factories/ml/candidate_metrics.rb @@ -6,7 +6,7 @@ FactoryBot.define do sequence(:name) { |n| "metric#{n}" } value { 2.0 } - step { 1 } + step { 0 } tracked_at { 1234 } end end diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js index 53dbd796d85..cd252560590 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row_spec.js @@ -2,15 +2,14 @@ import { shallowMount } from '@vue/test-utils'; import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue'; describe('CandidateDetailRow', () => { - const SECTION_LABEL_CELL = 0; - const ROW_LABEL_CELL = 1; - const ROW_VALUE_CELL = 2; + const ROW_LABEL_CELL = 0; + const ROW_VALUE_CELL = 1; let wrapper; const createWrapper = ({ slots = {} } = {}) => { wrapper = shallowMount(DetailRow, { - propsData: { sectionLabel: 'Section', label: 'Item' }, + propsData: { label: 'Item' }, slots, }); }; @@ -19,10 +18,6 @@ describe('CandidateDetailRow', () => { beforeEach(() => createWrapper()); - it('renders section label', () => { - expect(findCellAt(SECTION_LABEL_CELL).text()).toBe('Section'); - }); - it('renders row label', () => { expect(findCellAt(ROW_LABEL_CELL).text()).toBe('Item'); }); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js index 0b3b780cb3f..296728af46a 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/ml_candidates_show_spec.js @@ -1,32 +1,51 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlAvatarLabeled, GlLink } from '@gitlab/ui'; +import { GlAvatarLabeled, GlLink, GlTableLite } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import MlCandidatesShow from '~/ml/experiment_tracking/routes/candidates/show'; import DetailRow from '~/ml/experiment_tracking/routes/candidates/show/components/candidate_detail_row.vue'; -import { TITLE_LABEL } from '~/ml/experiment_tracking/routes/candidates/show/translations'; +import { + TITLE_LABEL, + NO_PARAMETERS_MESSAGE, + NO_METRICS_MESSAGE, + NO_METADATA_MESSAGE, + NO_CI_MESSAGE, +} from '~/ml/experiment_tracking/routes/candidates/show/translations'; import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue'; +import { stubComponent } from 'helpers/stub_component'; import { newCandidate } from './mock_data'; describe('MlCandidatesShow', () => { let wrapper; const CANDIDATE = newCandidate(); - const USER_ROW = 6; + const USER_ROW = 1; + + const INFO_SECTION = 0; + const CI_SECTION = 1; + const PARAMETER_SECTION = 2; + const METADATA_SECTION = 3; const createWrapper = (createCandidate = () => CANDIDATE) => { - wrapper = shallowMount(MlCandidatesShow, { + wrapper = shallowMountExtended(MlCandidatesShow, { propsData: { candidate: createCandidate() }, + stubs: { + GlTableLite: { ...stubComponent(GlTableLite), props: ['items', 'fields'] }, + }, }); }; const findDeleteButton = () => wrapper.findComponent(DeleteButton); const findHeader = () => wrapper.findComponent(ModelExperimentsHeader); - const findNthDetailRow = (index) => wrapper.findAllComponents(DetailRow).at(index); - const findLinkInNthDetailRow = (index) => findNthDetailRow(index).findComponent(GlLink); - const findSectionLabel = (label) => wrapper.find(`[sectionLabel='${label}']`); + const findSection = (section) => wrapper.findAll('section').at(section); + const findRowInSection = (section, row) => + findSection(section).findAllComponents(DetailRow).at(row); + const findLinkAtRow = (section, rowIndex) => + findRowInSection(section, rowIndex).findComponent(GlLink); + const findNoDataMessage = (label) => wrapper.findByText(label); const findLabel = (label) => wrapper.find(`[label='${label}']`); - const findCiUserDetailRow = () => findNthDetailRow(USER_ROW); + const findCiUserDetailRow = () => findRowInSection(CI_SECTION, USER_ROW); const findCiUserAvatar = () => findCiUserDetailRow().findComponent(GlAvatarLabeled); const findCiUserAvatarNameLink = () => findCiUserAvatar().findComponent(GlLink); + const findMetricsTable = () => wrapper.findComponent(GlTableLite); describe('Header', () => { beforeEach(() => createWrapper()); @@ -50,42 +69,57 @@ describe('MlCandidatesShow', () => { const mrText = `!${CANDIDATE.info.ci_job.merge_request.iid} ${CANDIDATE.info.ci_job.merge_request.title}`; const expectedTable = [ - ['Info', 'ID', CANDIDATE.info.iid], - ['', 'MLflow run ID', CANDIDATE.info.eid], - ['', 'Status', CANDIDATE.info.status], - ['', 'Experiment', CANDIDATE.info.experiment_name], - ['', 'Artifacts', 'Artifacts'], - ['CI', 'Job', CANDIDATE.info.ci_job.name], - ['', 'Triggered by', 'CI User'], - ['', 'Merge request', mrText], - ['Parameters', CANDIDATE.params[0].name, CANDIDATE.params[0].value], - ['', CANDIDATE.params[1].name, CANDIDATE.params[1].value], - ['Metrics', CANDIDATE.metrics[0].name, CANDIDATE.metrics[0].value], - ['', CANDIDATE.metrics[1].name, CANDIDATE.metrics[1].value], - ['Metadata', CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value], - ['', CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value], - ].map((row, index) => [index, ...row]); - - it.each(expectedTable)( - 'row %s is created correctly', - (rowIndex, sectionLabel, label, text) => { - const row = findNthDetailRow(rowIndex); - - expect(row.props()).toMatchObject({ sectionLabel, label }); - expect(row.text()).toBe(text); - }, - ); + [INFO_SECTION, 0, 'ID', CANDIDATE.info.iid], + [INFO_SECTION, 1, 'MLflow run ID', CANDIDATE.info.eid], + [INFO_SECTION, 2, 'Status', CANDIDATE.info.status], + [INFO_SECTION, 3, 'Experiment', CANDIDATE.info.experiment_name], + [INFO_SECTION, 4, 'Artifacts', 'Artifacts'], + [CI_SECTION, 0, 'Job', CANDIDATE.info.ci_job.name], + [CI_SECTION, 1, 'Triggered by', 'CI User'], + [CI_SECTION, 2, 'Merge request', mrText], + [PARAMETER_SECTION, 0, CANDIDATE.params[0].name, CANDIDATE.params[0].value], + [PARAMETER_SECTION, 1, CANDIDATE.params[1].name, CANDIDATE.params[1].value], + [METADATA_SECTION, 0, CANDIDATE.metadata[0].name, CANDIDATE.metadata[0].value], + [METADATA_SECTION, 1, CANDIDATE.metadata[1].name, CANDIDATE.metadata[1].value], + ]; + + it.each(expectedTable)('row %s is created correctly', (section, rowIndex, label, text) => { + const row = findRowInSection(section, rowIndex); + + expect(row.props()).toMatchObject({ label }); + expect(row.text()).toBe(text); + }); describe('Table links', () => { const linkRows = [ - [3, CANDIDATE.info.path_to_experiment], - [4, CANDIDATE.info.path_to_artifact], - [5, CANDIDATE.info.ci_job.path], - [7, CANDIDATE.info.ci_job.merge_request.path], + [INFO_SECTION, 3, CANDIDATE.info.path_to_experiment], + [INFO_SECTION, 4, CANDIDATE.info.path_to_artifact], + [CI_SECTION, 0, CANDIDATE.info.ci_job.path], + [CI_SECTION, 2, CANDIDATE.info.ci_job.merge_request.path], ]; - it.each(linkRows)('row %s is created correctly', (rowIndex, href) => { - expect(findLinkInNthDetailRow(rowIndex).attributes().href).toBe(href); + it.each(linkRows)('row %s is created correctly', (section, rowIndex, href) => { + expect(findLinkAtRow(section, rowIndex).attributes().href).toBe(href); + }); + }); + + describe('Metrics table', () => { + it('computes metrics table items correctly', () => { + expect(findMetricsTable().props('items')).toEqual([ + { name: 'AUC', 0: '.55' }, + { name: 'Accuracy', 1: '.99', 2: '.98', 3: '.97' }, + { name: 'F1', 3: '.1' }, + ]); + }); + + it('computes metrics table fields correctly', () => { + expect(findMetricsTable().props('fields')).toEqual([ + expect.objectContaining({ key: 'name', label: 'Metric' }), + expect.objectContaining({ key: '0', label: 'Step 0' }), + expect.objectContaining({ key: '1', label: 'Step 1' }), + expect.objectContaining({ key: '2', label: 'Step 2' }), + expect.objectContaining({ key: '3', label: 'Step 3' }), + ]); }); }); @@ -105,22 +139,6 @@ describe('MlCandidatesShow', () => { expect(nameLink.text()).toEqual('CI User'); }); }); - - it('does not render params', () => { - expect(findSectionLabel('Parameters').exists()).toBe(true); - }); - - it('renders all conditional rows', () => { - // This is a bit of a duplicated test from the above table test, but having this makes sure that the - // tests that test the negatives are implemented correctly - expect(findLabel('Artifacts').exists()).toBe(true); - expect(findSectionLabel('Parameters').exists()).toBe(true); - expect(findSectionLabel('Metadata').exists()).toBe(true); - expect(findSectionLabel('Metrics').exists()).toBe(true); - expect(findSectionLabel('CI').exists()).toBe(true); - expect(findLabel('Merge request').exists()).toBe(true); - expect(findLabel('Triggered by').exists()).toBe(true); - }); }); describe('No artifact path', () => { @@ -150,19 +168,19 @@ describe('MlCandidatesShow', () => { ); it('does not render params', () => { - expect(findSectionLabel('Parameters').exists()).toBe(false); + expect(findNoDataMessage(NO_PARAMETERS_MESSAGE).exists()).toBe(true); }); it('does not render metadata', () => { - expect(findSectionLabel('Metadata').exists()).toBe(false); + expect(findNoDataMessage(NO_METADATA_MESSAGE).exists()).toBe(true); }); it('does not render metrics', () => { - expect(findSectionLabel('Metrics').exists()).toBe(false); + expect(findNoDataMessage(NO_METRICS_MESSAGE).exists()).toBe(true); }); it('does not render CI info', () => { - expect(findSectionLabel('CI').exists()).toBe(false); + expect(findNoDataMessage(NO_CI_MESSAGE).exists()).toBe(true); }); }); diff --git a/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js b/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js index 3fbcf122997..4ea23ed2513 100644 --- a/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js +++ b/spec/frontend/ml/experiment_tracking/routes/candidates/show/mock_data.js @@ -4,8 +4,11 @@ export const newCandidate = () => ({ { name: 'MaxDepth', value: '3' }, ], metrics: [ - { name: 'AUC', value: '.55' }, - { name: 'Accuracy', value: '.99' }, + { name: 'AUC', value: '.55', step: 0 }, + { name: 'Accuracy', value: '.99', step: 1 }, + { name: 'Accuracy', value: '.98', step: 2 }, + { name: 'Accuracy', value: '.97', step: 3 }, + { name: 'F1', value: '.1', step: 3 }, ], metadata: [ { name: 'FileName', value: 'test.py' }, diff --git a/spec/frontend/organizations/index/components/app_spec.js b/spec/frontend/organizations/index/components/app_spec.js new file mode 100644 index 00000000000..4bb90903725 --- /dev/null +++ b/spec/frontend/organizations/index/components/app_spec.js @@ -0,0 +1,34 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import OrganizationsIndexApp from '~/organizations/index/components/app.vue'; +import OrganizationsView from '~/organizations/index/components/organizations_view.vue'; +import { MOCK_NEW_ORG_URL } from '../mock_data'; + +describe('OrganizationsIndexApp', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(OrganizationsIndexApp, { + provide: { + newOrganizationUrl: MOCK_NEW_ORG_URL, + }, + }); + }; + + const findNewOrganizationButton = () => wrapper.findComponent(GlButton); + const findOrganizationsView = () => wrapper.findComponent(OrganizationsView); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders new organization button with correct link', () => { + expect(findNewOrganizationButton().attributes('href')).toBe(MOCK_NEW_ORG_URL); + }); + + it('renders the organizations view', () => { + expect(findOrganizationsView().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/organizations/index/components/organizations_list_item_spec.js b/spec/frontend/organizations/index/components/organizations_list_item_spec.js new file mode 100644 index 00000000000..77d07a93775 --- /dev/null +++ b/spec/frontend/organizations/index/components/organizations_list_item_spec.js @@ -0,0 +1,45 @@ +import { GlAvatarLabeled } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import OrganizationsListItem from '~/organizations/index/components/organizations_list_item.vue'; +import { organizations } from '~/organizations/mock_data'; + +const MOCK_ORGANIZATION = organizations[0]; + +describe('OrganizationsListItem', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(OrganizationsListItem, { + propsData: { + organization: MOCK_ORGANIZATION, + }, + }); + }; + + const findGlAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders GlAvatarLabeled with correct description', () => { + expect(findGlAvatarLabeled().text()).toBe(MOCK_ORGANIZATION.description); + }); + + it('renders GlAvatarLabeled with correct data', () => { + expect(findGlAvatarLabeled().attributes('entity-id')).toBe( + getIdFromGraphQLId(MOCK_ORGANIZATION.id).toString(), + ); + expect(findGlAvatarLabeled().attributes('src')).toBe( + MOCK_ORGANIZATION.avatarUrl || undefined, + ); + expect(findGlAvatarLabeled().attributes('entity-name')).toBe(MOCK_ORGANIZATION.name); + expect(findGlAvatarLabeled().attributes('label')).toBe(MOCK_ORGANIZATION.name); + expect(findGlAvatarLabeled().attributes('label-link')).toBe( + MOCK_ORGANIZATION.webUrl || undefined, + ); + }); + }); +}); diff --git a/spec/frontend/organizations/index/components/organizations_list_spec.js b/spec/frontend/organizations/index/components/organizations_list_spec.js new file mode 100644 index 00000000000..0b59c212314 --- /dev/null +++ b/spec/frontend/organizations/index/components/organizations_list_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; +import OrganizationsList from '~/organizations/index/components/organizations_list.vue'; +import OrganizationsListItem from '~/organizations/index/components/organizations_list_item.vue'; +import { organizations } from '~/organizations/mock_data'; + +describe('OrganizationsList', () => { + let wrapper; + + const createComponent = () => { + wrapper = shallowMount(OrganizationsList, { + propsData: { + organizations, + }, + }); + }; + + const findAllOrganizationsListItem = () => wrapper.findAllComponents(OrganizationsListItem); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders a list item for each organization', () => { + expect(findAllOrganizationsListItem()).toHaveLength(organizations.length); + }); + }); +}); diff --git a/spec/frontend/organizations/index/components/organizations_view_spec.js b/spec/frontend/organizations/index/components/organizations_view_spec.js new file mode 100644 index 00000000000..9c694fdd508 --- /dev/null +++ b/spec/frontend/organizations/index/components/organizations_view_spec.js @@ -0,0 +1,130 @@ +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import Vue from 'vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/alert'; +import resolvers from '~/organizations/shared/graphql/resolvers'; +import { organizations } from '~/organizations/mock_data'; +import organizationsQuery from '~/organizations/index/graphql/organizations.query.graphql'; +import OrganizationsView from '~/organizations/index/components/organizations_view.vue'; +import OrganizationsList from '~/organizations/index/components/organizations_list.vue'; +import { MOCK_NEW_ORG_URL, MOCK_ORG_EMPTY_STATE_SVG } from '../mock_data'; + +jest.mock('~/alert'); + +Vue.use(VueApollo); + +describe('OrganizationsView', () => { + let wrapper; + let mockApollo; + + const createComponent = (mockResolvers = resolvers) => { + mockApollo = createMockApollo([[organizationsQuery, mockResolvers]]); + + wrapper = shallowMount(OrganizationsView, { + apolloProvider: mockApollo, + provide: { + newOrganizationUrl: MOCK_NEW_ORG_URL, + organizationsEmptyStateSvgPath: MOCK_ORG_EMPTY_STATE_SVG, + }, + }); + }; + + afterEach(() => { + mockApollo = null; + }); + + const findGlLoading = () => wrapper.findComponent(GlLoadingIcon); + const findOrganizationsList = () => wrapper.findComponent(OrganizationsList); + const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); + + describe('when API call is loading', () => { + beforeEach(() => { + const mockResolvers = jest.fn().mockReturnValue(new Promise(() => {})); + + createComponent(mockResolvers); + }); + + it('renders loading icon', () => { + expect(findGlLoading().exists()).toBe(true); + }); + + it('does not render organizations list', () => { + expect(findOrganizationsList().exists()).toBe(false); + }); + + it('does not render empty state', () => { + expect(findGlEmptyState().exists()).toBe(false); + }); + }); + + describe('when API returns successful with results', () => { + beforeEach(async () => { + const mockResolvers = jest.fn().mockResolvedValue({ + data: { currentUser: { id: 1, organizations: { nodes: organizations } } }, + }); + + createComponent(mockResolvers); + await waitForPromises(); + }); + + it('does not render loading icon', () => { + expect(findGlLoading().exists()).toBe(false); + }); + + it('renders organizations list', () => { + expect(findOrganizationsList().exists()).toBe(true); + }); + + it('does not render empty state', () => { + expect(findGlEmptyState().exists()).toBe(false); + }); + }); + + describe('when API returns successful without results', () => { + beforeEach(async () => { + const mockResolvers = jest + .fn() + .mockResolvedValue({ data: { currentUser: { id: 1, organizations: { nodes: [] } } } }); + + createComponent(mockResolvers); + await waitForPromises(); + }); + + it('does not render loading icon', () => { + expect(findGlLoading().exists()).toBe(false); + }); + + it('does not render organizations list', () => { + expect(findOrganizationsList().exists()).toBe(false); + }); + + it('does render empty state with correct SVG and URL', () => { + expect(findGlEmptyState().exists()).toBe(true); + expect(findGlEmptyState().attributes('svgpath')).toBe(MOCK_ORG_EMPTY_STATE_SVG); + expect(findGlEmptyState().attributes('primarybuttonlink')).toBe(MOCK_NEW_ORG_URL); + }); + }); + + describe('when API returns error', () => { + const error = new Error(); + + beforeEach(async () => { + const mockResolvers = jest.fn().mockRejectedValue(error); + + createComponent(mockResolvers); + await waitForPromises(); + }); + + it('creates a flash message', () => { + expect(createAlert).toHaveBeenCalledWith({ + message: + 'An error occurred loading user organizations. Please refresh the page to try again.', + error, + captureError: true, + }); + }); + }); +}); diff --git a/spec/frontend/organizations/index/mock_data.js b/spec/frontend/organizations/index/mock_data.js new file mode 100644 index 00000000000..50b20b4f79c --- /dev/null +++ b/spec/frontend/organizations/index/mock_data.js @@ -0,0 +1,3 @@ +export const MOCK_NEW_ORG_URL = 'gitlab.com/organizations/new'; + +export const MOCK_ORG_EMPTY_STATE_SVG = 'illustrations/empty-state/empty-organizations-md.svg'; diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap index 1c60c3af310..6414ab6dfba 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap @@ -3,11 +3,11 @@ exports[`Snippet Blob Edit component with loaded blob matches snapshot 1`] = ` <div class="file-holder snippet" - data-qa-selector="file_holder_container" + data-testid="file-holder-container" > <blob-header-edit-stub candelete="true" - data-qa-selector="file_name_field" + data-testid="file-name-field" id="reference-0" showdelete="true" value="foo/bar/test.md" diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index 5ed3b520b70..92511acc4f8 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -17,7 +17,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = > <gl-form-input-stub class="form-control" - data-qa-selector="description_placeholder" + data-testid="description-placeholder" placeholder="Describe what your snippet does or how to use it…" /> </div> @@ -46,8 +46,8 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = <textarea aria-label="Description" class="js-autosize js-gfm-input js-gfm-input-initialized markdown-area note-textarea" - data-qa-selector="snippet_description_field" data-supports-quick-actions="false" + data-testid="snippet-description-field" dir="auto" id="reference-0" placeholder="Write a comment or drag your files here…" diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap index 3274f41e4af..ab96d1a3653 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap @@ -44,8 +44,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = /> <span class="font-weight-bold js-visibility-option ml-1" - data-qa-selector="visibility_content" data-qa-visibility="Private" + data-testid="visibility-content" > Private </span> @@ -64,8 +64,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = /> <span class="font-weight-bold js-visibility-option ml-1" - data-qa-selector="visibility_content" data-qa-visibility="Internal" + data-testid="visibility-content" > Internal </span> @@ -84,8 +84,8 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = /> <span class="font-weight-bold js-visibility-option ml-1" - data-qa-selector="visibility_content" data-qa-visibility="Public" + data-testid="visibility-content" > Public </span> diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index 17862953920..5fbc16ff430 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -117,7 +117,8 @@ describe('Snippet Edit app', () => { .map((path) => `<input name="files[]" value="${path}">`) .join(''); }; - const setTitle = (val) => wrapper.findByTestId('snippet-title-input').vm.$emit('input', val); + const setTitle = (val) => + wrapper.findByTestId('snippet-title-input-field').vm.$emit('input', val); const setDescription = (val) => wrapper.findComponent(SnippetDescriptionEdit).vm.$emit('input', val); diff --git a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js index cb11e98cd35..fab65434c3a 100644 --- a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js @@ -40,7 +40,7 @@ describe('snippets/components/snippet_blob_actions_edit', () => { classes: x.classes(), })); const findFirstBlobEdit = () => findBlobEdits().at(0); - const findAddButton = () => wrapper.find('[data-testid="add_button"]'); + const findAddButton = () => wrapper.find('[data-testid="add-button"]'); const findLimitationsText = () => wrapper.find('[data-testid="limitations_text"]'); const getLastActions = () => { const events = wrapper.emitted().actions; diff --git a/spec/helpers/organizations/organization_helper_spec.rb b/spec/helpers/organizations/organization_helper_spec.rb index ec99d928059..67aa57dcad7 100644 --- a/spec/helpers/organizations/organization_helper_spec.rb +++ b/spec/helpers/organizations/organization_helper_spec.rb @@ -6,12 +6,15 @@ RSpec.describe Organizations::OrganizationHelper, feature_category: :cell do let_it_be(:organization) { build_stubbed(:organization) } let_it_be(:new_group_path) { '/groups/new' } let_it_be(:new_project_path) { '/projects/new' } + let_it_be(:organizations_empty_state_svg_path) { 'illustrations/empty-state/empty-organizations-md.svg' } let_it_be(:groups_empty_state_svg_path) { 'illustrations/empty-state/empty-groups-md.svg' } let_it_be(:projects_empty_state_svg_path) { 'illustrations/empty-state/empty-projects-md.svg' } before do allow(helper).to receive(:new_group_path).and_return(new_group_path) allow(helper).to receive(:new_project_path).and_return(new_project_path) + allow(helper).to receive(:image_path).with(organizations_empty_state_svg_path) + .and_return(organizations_empty_state_svg_path) allow(helper).to receive(:image_path).with(groups_empty_state_svg_path).and_return(groups_empty_state_svg_path) allow(helper).to receive(:image_path).with(projects_empty_state_svg_path).and_return(projects_empty_state_svg_path) end @@ -62,4 +65,15 @@ RSpec.describe Organizations::OrganizationHelper, feature_category: :cell do ) end end + + describe '#organization_index_app_data' do + it 'returns expected data object' do + expect(helper.organization_index_app_data).to eq( + { + new_organization_url: new_organization_path, + organizations_empty_state_svg_path: organizations_empty_state_svg_path + } + ) + end + end end diff --git a/spec/lib/gitlab/experiment/rollout/feature_spec.rb b/spec/lib/gitlab/experiment/rollout/feature_spec.rb index bc90b4d74b4..6d01b7a175f 100644 --- a/spec/lib/gitlab/experiment/rollout/feature_spec.rb +++ b/spec/lib/gitlab/experiment/rollout/feature_spec.rb @@ -111,7 +111,7 @@ RSpec.describe Gitlab::Experiment::Rollout::Feature, :experiment, feature_catego context "when distribution is specified as an array" do before do - subject_experiment.rollout(described_class, distribution: [0, 32, 25, 43]) + subject_experiment.rollout(described_class, distribution: [32, 25, 43]) end it "rolls out with the expected distribution" do @@ -119,19 +119,19 @@ RSpec.describe Gitlab::Experiment::Rollout::Feature, :experiment, feature_catego 100.times { |i| run_cycle(subject_experiment, value: i) } - expect(counts).to eq(control: 2, variant1: 37, variant2: 24, variant3: 37) + expect(counts).to eq(variant1: 39, variant2: 24, variant3: 37) end end context "when distribution is specified as a hash" do before do - subject_experiment.rollout(described_class, distribution: { control: 0, variant1: 90, variant2: 10 }) + subject_experiment.rollout(described_class, distribution: { variant1: 90, variant2: 10 }) end it "rolls out with the expected distribution" do 100.times { |i| run_cycle(subject_experiment, value: i) } - expect(counts).to eq(control: 2, variant1: 93, variant2: 5) + expect(counts).to eq(variant1: 95, variant2: 5) end end diff --git a/spec/models/ci/catalog/listing_spec.rb b/spec/models/ci/catalog/listing_spec.rb index f28a0e82bbd..7524d908252 100644 --- a/spec/models/ci/catalog/listing_spec.rb +++ b/spec/models/ci/catalog/listing_spec.rb @@ -6,7 +6,8 @@ RSpec.describe Ci::Catalog::Listing, feature_category: :pipeline_composition do let_it_be(:namespace) { create(:group) } let_it_be(:project_1) { create(:project, namespace: namespace, name: 'X Project') } let_it_be(:project_2) { create(:project, namespace: namespace, name: 'B Project') } - let_it_be(:project_3) { create(:project) } + let_it_be(:project_3) { create(:project, namespace: namespace, name: 'A Project') } + let_it_be(:project_4) { create(:project) } let_it_be(:user) { create(:user) } let(:list) { described_class.new(namespace, user) } @@ -34,12 +35,20 @@ RSpec.describe Ci::Catalog::Listing, feature_category: :pipeline_composition do end context 'when the namespace has catalog resources' do - let_it_be(:resource) { create(:ci_catalog_resource, project: project_1) } - let_it_be(:resource_2) { create(:ci_catalog_resource, project: project_2) } - let_it_be(:other_namespace_resource) { create(:ci_catalog_resource, project: project_3) } + let_it_be(:today) { Time.zone.now } + let_it_be(:yesterday) { today - 1.day } + let_it_be(:tomorrow) { today + 1.day } + + let_it_be(:resource) { create(:ci_catalog_resource, project: project_1, latest_released_at: yesterday) } + let_it_be(:resource_2) { create(:ci_catalog_resource, project: project_2, latest_released_at: today) } + let_it_be(:resource_3) { create(:ci_catalog_resource, project: project_3, latest_released_at: nil) } + + let_it_be(:other_namespace_resource) do + create(:ci_catalog_resource, project: project_4, latest_released_at: tomorrow) + end it 'contains only catalog resources for projects in that namespace' do - is_expected.to contain_exactly(resource, resource_2) + is_expected.to contain_exactly(resource, resource_2, resource_3) end context 'with a sort parameter' do @@ -48,16 +57,32 @@ RSpec.describe Ci::Catalog::Listing, feature_category: :pipeline_composition do context 'when the sort is name ascending' do let_it_be(:sort) { :name_asc } - it 'contains catalog resources for projects sorted by name' do - is_expected.to eq([resource_2, resource]) + it 'contains catalog resources for projects sorted by name ascending' do + is_expected.to eq([resource_3, resource_2, resource]) end end context 'when the sort is name descending' do let_it_be(:sort) { :name_desc } - it 'contains catalog resources for projects sorted by name' do - is_expected.to eq([resource, resource_2]) + it 'contains catalog resources for projects sorted by name descending' do + is_expected.to eq([resource, resource_2, resource_3]) + end + end + + context 'when the sort is latest_released_at ascending' do + let_it_be(:sort) { :latest_released_at_asc } + + it 'contains catalog resources sorted by latest_released_at ascending with nulls last' do + is_expected.to eq([resource, resource_2, resource_3]) + end + end + + context 'when the sort is latest_released_at descending' do + let_it_be(:sort) { :latest_released_at_desc } + + it 'contains catalog resources sorted by latest_released_at descending with nulls last' do + is_expected.to eq([resource_2, resource, resource_3]) end end end diff --git a/spec/models/ci/catalog/resource_spec.rb b/spec/models/ci/catalog/resource_spec.rb index 082283bb7bc..4ce1433e015 100644 --- a/spec/models/ci/catalog/resource_spec.rb +++ b/spec/models/ci/catalog/resource_spec.rb @@ -3,16 +3,20 @@ require 'spec_helper' RSpec.describe Ci::Catalog::Resource, feature_category: :pipeline_composition do + let_it_be(:today) { Time.zone.now } + let_it_be(:yesterday) { today - 1.day } + let_it_be(:tomorrow) { today + 1.day } + let_it_be(:project) { create(:project, name: 'A') } let_it_be(:project_2) { build(:project, name: 'Z') } let_it_be(:project_3) { build(:project, name: 'L') } - let_it_be(:resource) { create(:ci_catalog_resource, project: project) } - let_it_be(:resource_2) { create(:ci_catalog_resource, project: project_2) } - let_it_be(:resource_3) { create(:ci_catalog_resource, project: project_3) } + let_it_be(:resource) { create(:ci_catalog_resource, project: project, latest_released_at: tomorrow) } + let_it_be(:resource_2) { create(:ci_catalog_resource, project: project_2, latest_released_at: today) } + let_it_be(:resource_3) { create(:ci_catalog_resource, project: project_3, latest_released_at: nil) } - let_it_be(:release1) { create(:release, project: project, released_at: Time.zone.now - 2.days) } - let_it_be(:release2) { create(:release, project: project, released_at: Time.zone.now - 1.day) } - let_it_be(:release3) { create(:release, project: project, released_at: Time.zone.now) } + let_it_be(:release1) { create(:release, project: project, released_at: yesterday) } + let_it_be(:release2) { create(:release, project: project, released_at: today) } + let_it_be(:release3) { create(:release, project: project, released_at: tomorrow) } it { is_expected.to belong_to(:project) } it { is_expected.to have_many(:components).class_name('Ci::Catalog::Resources::Component') } @@ -58,6 +62,22 @@ RSpec.describe Ci::Catalog::Resource, feature_category: :pipeline_composition do end end + describe '.order_by_latest_released_at_desc' do + it 'returns catalog resources sorted by latest_released_at descending with nulls last' do + ordered_resources = described_class.order_by_latest_released_at_desc + + expect(ordered_resources).to eq([resource, resource_2, resource_3]) + end + end + + describe '.order_by_latest_released_at_asc' do + it 'returns catalog resources sorted by latest_released_at ascending with nulls last' do + ordered_resources = described_class.order_by_latest_released_at_asc + + expect(ordered_resources).to eq([resource_2, resource, resource_3]) + end + end + describe '#versions' do it 'returns releases ordered by released date descending' do expect(resource.versions).to eq([release3, release2, release1]) diff --git a/spec/presenters/ml/candidate_details_presenter_spec.rb b/spec/presenters/ml/candidate_details_presenter_spec.rb index 0ecf80b683e..34de1e66a8a 100644 --- a/spec/presenters/ml/candidate_details_presenter_spec.rb +++ b/spec/presenters/ml/candidate_details_presenter_spec.rb @@ -13,6 +13,8 @@ RSpec.describe ::Ml::CandidateDetailsPresenter, feature_category: :mlops do let_it_be(:metrics) do [ build_stubbed(:ml_candidate_metrics, name: 'metric1', value: 0.1, candidate: candidate), + build_stubbed(:ml_candidate_metrics, name: 'metric1', value: 0.2, step: 1, candidate: candidate), + build_stubbed(:ml_candidate_metrics, name: 'metric1', value: 0.3, step: 2, candidate: candidate), build_stubbed(:ml_candidate_metrics, name: 'metric2', value: 0.2, candidate: candidate), build_stubbed(:ml_candidate_metrics, name: 'metric3', value: 0.3, candidate: candidate) ] @@ -30,7 +32,7 @@ RSpec.describe ::Ml::CandidateDetailsPresenter, feature_category: :mlops do subject { Gitlab::Json.parse(described_class.new(candidate, include_ci_job).present)['candidate'] } before do - allow(candidate).to receive(:latest_metrics).and_return(metrics) + allow(candidate).to receive(:metrics).and_return(metrics) allow(candidate).to receive(:params).and_return(params) end @@ -45,9 +47,11 @@ RSpec.describe ::Ml::CandidateDetailsPresenter, feature_category: :mlops do it 'generates the correct metrics' do expect(subject['metrics']).to include( - hash_including('name' => 'metric1', 'value' => 0.1), - hash_including('name' => 'metric2', 'value' => 0.2), - hash_including('name' => 'metric3', 'value' => 0.3) + hash_including('name' => 'metric1', 'value' => 0.1, 'step' => 0), + hash_including('name' => 'metric1', 'value' => 0.2, 'step' => 1), + hash_including('name' => 'metric1', 'value' => 0.3, 'step' => 2), + hash_including('name' => 'metric2', 'value' => 0.2, 'step' => 0), + hash_including('name' => 'metric3', 'value' => 0.3, 'step' => 0) ) end diff --git a/spec/scripts/generate_rspec_pipeline_spec.rb b/spec/scripts/generate_rspec_pipeline_spec.rb index 91b5739cf63..894c33968b8 100644 --- a/spec/scripts/generate_rspec_pipeline_spec.rb +++ b/spec/scripts/generate_rspec_pipeline_spec.rb @@ -9,7 +9,7 @@ RSpec.describe GenerateRspecPipeline, :silence_stdout, feature_category: :toolin describe '#generate!' do let!(:rspec_files) { Tempfile.new(['rspec_files_path', '.txt']) } let(:rspec_files_content) do - "spec/migrations/a_spec.rb spec/migrations/b_spec.rb " \ + "spec/migrations/a_spec.rb spec/migrations/b_spec.rb spec/migrations/c_spec.rb spec/migrations/d_spec.rb " \ "spec/lib/gitlab/background_migration/a_spec.rb spec/lib/gitlab/background_migration/b_spec.rb " \ "spec/models/a_spec.rb spec/models/b_spec.rb " \ "spec/controllers/a_spec.rb spec/controllers/b_spec.rb " \ @@ -63,8 +63,13 @@ RSpec.describe GenerateRspecPipeline, :silence_stdout, feature_category: :toolin let(:knapsack_report_content) do <<~JSON { - "spec/migrations/a_spec.rb": 360.3, - "spec/migrations/b_spec.rb": 180.1, + "spec/migrations/a_spec.rb": 620.3, + "spec/migrations/b_spec.rb": 610.1, + "spec/migrations/c_spec.rb": 20.1, + "spec/migrations/d_spec.rb": 20.1, + "spec/migrations/e_spec.rb": 20.1, + "spec/migrations/f_spec.rb": 20.1, + "spec/migrations/g_spec.rb": 20.1, "spec/lib/gitlab/background_migration/a_spec.rb": 60.5, "spec/lib/gitlab/background_migration/b_spec.rb": 180.3, "spec/models/a_spec.rb": 360.2, @@ -123,7 +128,7 @@ RSpec.describe GenerateRspecPipeline, :silence_stdout, feature_category: :toolin expect(File.read("#{pipeline_template.path}.yml")) .to eq( - "rspec migration:\n parallel: 2\nrspec background_migration:\n parallel: 2\n" \ + "rspec migration:\n parallel: 4\nrspec background_migration:\n parallel: 2\n" \ "rspec unit:\n parallel: 2\nrspec integration:\n parallel: 2\n" \ "rspec system:\n parallel: 2" ) @@ -164,12 +169,27 @@ RSpec.describe GenerateRspecPipeline, :silence_stdout, feature_category: :toolin expect(File.read("#{pipeline_template.path}.yml")) .to eq( - "rspec migration:\n parallel: 2\nrspec background_migration:\n" \ + "rspec migration:\n parallel: 4\nrspec background_migration:\n" \ "rspec unit:\n parallel: 2\nrspec integration:\n" \ "rspec system:\n parallel: 2" ) end + context 'and RSpec files have a high duration' do + let(:rspec_files_content) do + "spec/migrations/a_spec.rb spec/migrations/b_spec.rb" + end + + it 'generates the pipeline config with parallelization based on Knapsack' do + subject.generate! + + expect(File.read("#{pipeline_template.path}.yml")) + .to eq( + "rspec migration:\n parallel: 2" + ) + end + end + context 'and Knapsack report does not contain valid JSON' do let(:knapsack_report_content) { "#{super()}," } |