Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/ml/model_registry')
-rw-r--r--spec/frontend/ml/model_registry/apps/index_ml_models_spec.js45
-rw-r--r--spec/frontend/ml/model_registry/apps/new_ml_model_spec.js119
-rw-r--r--spec/frontend/ml/model_registry/apps/show_ml_model_spec.js11
-rw-r--r--spec/frontend/ml/model_registry/components/actions_dropdown_spec.js39
-rw-r--r--spec/frontend/ml/model_registry/components/candidate_list_spec.js94
-rw-r--r--spec/frontend/ml/model_registry/components/model_version_list_spec.js90
-rw-r--r--spec/frontend/ml/model_registry/components/searchable_list_spec.js170
-rw-r--r--spec/frontend/ml/model_registry/graphql_mock_data.js24
-rw-r--r--spec/frontend/ml/model_registry/mock_data.js1
9 files changed, 435 insertions, 158 deletions
diff --git a/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js b/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js
index 66a447e73d3..07d8b4b8b3d 100644
--- a/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js
+++ b/spec/frontend/ml/model_registry/apps/index_ml_models_spec.js
@@ -1,4 +1,4 @@
-import { GlBadge } from '@gitlab/ui';
+import { GlBadge, GlButton } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { IndexMlModels } from '~/ml/model_registry/apps';
import ModelRow from '~/ml/model_registry/components/model_row.vue';
@@ -8,13 +8,22 @@ import { BASE_SORT_FIELDS, MODEL_ENTITIES } from '~/ml/model_registry/constants'
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import EmptyState from '~/ml/model_registry/components/empty_state.vue';
+import ActionsDropdown from '~/ml/model_registry/components/actions_dropdown.vue';
import { mockModels, startCursor, defaultPageInfo } from '../mock_data';
let wrapper;
-const createWrapper = (
- propsData = { models: mockModels, pageInfo: defaultPageInfo, modelCount: 2 },
-) => {
- wrapper = shallowMountExtended(IndexMlModels, { propsData });
+
+const createWrapper = (propsData = {}) => {
+ wrapper = shallowMountExtended(IndexMlModels, {
+ propsData: {
+ models: mockModels,
+ pageInfo: defaultPageInfo,
+ modelCount: 2,
+ createModelPath: 'path/to/create',
+ canWriteModelRegistry: false,
+ ...propsData,
+ },
+ });
};
const findModelRow = (index) => wrapper.findAllComponents(ModelRow).at(index);
@@ -24,8 +33,10 @@ const findSearchBar = () => wrapper.findComponent(SearchBar);
const findTitleArea = () => wrapper.findComponent(TitleArea);
const findModelCountMetadataItem = () => findTitleArea().findComponent(MetadataItem);
const findBadge = () => wrapper.findComponent(GlBadge);
+const findCreateButton = () => findTitleArea().findComponent(GlButton);
+const findActionsDropdown = () => wrapper.findComponent(ActionsDropdown);
-describe('MlModelsIndex', () => {
+describe('ml/model_registry/apps/index_ml_models', () => {
describe('empty state', () => {
beforeEach(() => createWrapper({ models: [], pageInfo: defaultPageInfo }));
@@ -40,6 +51,28 @@ describe('MlModelsIndex', () => {
it('does not show search bar', () => {
expect(findSearchBar().exists()).toBe(false);
});
+
+ it('renders the extra actions button', () => {
+ expect(findActionsDropdown().exists()).toBe(true);
+ });
+ });
+
+ describe('create button', () => {
+ describe('when user has no permission to write model registry', () => {
+ it('does not display create button', () => {
+ createWrapper();
+
+ expect(findCreateButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when user has permission to write model registry', () => {
+ it('displays create button', () => {
+ createWrapper({ canWriteModelRegistry: true });
+
+ expect(findCreateButton().attributes().href).toBe('path/to/create');
+ });
+ });
});
describe('with data', () => {
diff --git a/spec/frontend/ml/model_registry/apps/new_ml_model_spec.js b/spec/frontend/ml/model_registry/apps/new_ml_model_spec.js
new file mode 100644
index 00000000000..204c021c080
--- /dev/null
+++ b/spec/frontend/ml/model_registry/apps/new_ml_model_spec.js
@@ -0,0 +1,119 @@
+import {
+ GlAlert,
+ GlButton,
+ GlFormInput,
+ GlFormTextarea,
+ GlForm,
+ GlSprintf,
+ GlLink,
+} from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import NewMlModel from '~/ml/model_registry/apps/new_ml_model.vue';
+import createModelMutation from '~/ml/model_registry/graphql/mutations/create_model.mutation.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { visitUrl } from '~/lib/utils/url_utility';
+import * as Sentry from '~/sentry/sentry_browser_wrapper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { createModelResponses } from '../graphql_mock_data';
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ ...jest.requireActual('~/lib/utils/url_utility'),
+ visitUrl: jest.fn(),
+}));
+
+describe('ml/model_registry/apps/new_ml_model.vue', () => {
+ let wrapper;
+ let apolloProvider;
+
+ Vue.use(VueApollo);
+
+ beforeEach(() => {
+ jest.spyOn(Sentry, 'captureException').mockImplementation();
+ });
+
+ const mountComponent = (resolver = jest.fn().mockResolvedValue(createModelResponses.success)) => {
+ const requestHandlers = [[createModelMutation, resolver]];
+ apolloProvider = createMockApollo(requestHandlers);
+
+ wrapper = shallowMountExtended(NewMlModel, {
+ apolloProvider,
+ propsData: { projectPath: 'project/path' },
+ stubs: { GlSprintf },
+ });
+ };
+
+ const findButton = () => wrapper.findComponent(GlButton);
+ const findInput = () => wrapper.findComponent(GlFormInput);
+ const findTextarea = () => wrapper.findComponent(GlFormTextarea);
+ const findForm = () => wrapper.findComponent(GlForm);
+ const findDocAlert = () => wrapper.findComponent(GlAlert);
+ const findDocLink = () => findDocAlert().findComponent(GlLink);
+ const findErrorAlert = () => wrapper.findByTestId('new-model-errors');
+
+ const submitForm = async () => {
+ findForm().vm.$emit('submit', { preventDefault: () => {} });
+ await waitForPromises();
+ };
+
+ it('renders the button', () => {
+ mountComponent();
+
+ expect(findButton().text()).toBe('Create model');
+ });
+
+ it('shows link to docs', () => {
+ mountComponent();
+
+ expect(findDocAlert().text()).toBe(
+ 'Creating models is also possible through the MLflow client. Follow the documentation to learn more.',
+ );
+ expect(findDocLink().attributes().href).toBe('/help/user/project/ml/model_registry/index.md');
+ });
+
+ it('submits the query with correct parameters', async () => {
+ const resolver = jest.fn().mockResolvedValue(createModelResponses.success);
+ mountComponent(resolver);
+
+ findInput().vm.$emit('input', 'model_name');
+ findTextarea().vm.$emit('input', 'A description');
+
+ await submitForm();
+
+ expect(resolver).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ projectPath: 'project/path',
+ name: 'model_name',
+ description: 'A description',
+ }),
+ );
+ });
+
+ it('navigates to the new page when result is successful', async () => {
+ mountComponent();
+
+ await submitForm();
+
+ expect(visitUrl).toHaveBeenCalledWith('/some/project/-/ml/models/1');
+ });
+
+ it('shows errors when result is a top level error', async () => {
+ const error = new Error('Failure!');
+ mountComponent(jest.fn().mockRejectedValue({ error }));
+
+ await submitForm();
+
+ expect(findErrorAlert().text()).toBe('An error has occurred when saving the model.');
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+
+ it('shows errors when result is a validation error', async () => {
+ mountComponent(jest.fn().mockResolvedValue(createModelResponses.validationFailure));
+
+ await submitForm();
+
+ expect(findErrorAlert().text()).toBe("Name is invalid, Name can't be blank");
+ expect(visitUrl).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js b/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js
index 1fe0f5f88b3..7e991687496 100644
--- a/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js
+++ b/spec/frontend/ml/model_registry/apps/show_ml_model_spec.js
@@ -1,7 +1,7 @@
import { GlBadge, GlTab } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { ShowMlModel } from '~/ml/model_registry/apps';
import ModelVersionList from '~/ml/model_registry/components/model_version_list.vue';
import CandidateList from '~/ml/model_registry/components/candidate_list.vue';
@@ -19,7 +19,7 @@ let wrapper;
Vue.use(VueApollo);
const createWrapper = (model = MODEL) => {
- wrapper = shallowMount(ShowMlModel, {
+ wrapper = shallowMountExtended(ShowMlModel, {
apolloProvider,
propsData: { model },
stubs: { GlTab },
@@ -37,6 +37,7 @@ const findCandidatesCountBadge = () => findCandidateTab().findComponent(GlBadge)
const findTitleArea = () => wrapper.findComponent(TitleArea);
const findEmptyState = () => wrapper.findComponent(EmptyState);
const findVersionCountMetadataItem = () => findTitleArea().findComponent(MetadataItem);
+const findVersionLink = () => wrapper.findByTestId('model-version-link');
describe('ShowMlModel', () => {
describe('Title', () => {
@@ -67,8 +68,10 @@ describe('ShowMlModel', () => {
expect(findModelVersionDetail().props('modelVersion')).toBe(MODEL.latestVersion);
});
- it('displays the title', () => {
- expect(findDetailTab().text()).toContain('Latest version: 1.2.3');
+ it('displays a link to latest version', () => {
+ expect(findDetailTab().text()).toContain('Latest version:');
+ expect(findVersionLink().attributes('href')).toBe(MODEL.latestVersion.path);
+ expect(findVersionLink().text()).toBe('1.2.3');
});
});
diff --git a/spec/frontend/ml/model_registry/components/actions_dropdown_spec.js b/spec/frontend/ml/model_registry/components/actions_dropdown_spec.js
new file mode 100644
index 00000000000..6285d7360c7
--- /dev/null
+++ b/spec/frontend/ml/model_registry/components/actions_dropdown_spec.js
@@ -0,0 +1,39 @@
+import { mount } from '@vue/test-utils';
+import { GlDisclosureDropdownItem } from '@gitlab/ui';
+import ActionsDropdown from '~/ml/model_registry/components/actions_dropdown.vue';
+
+describe('ml/model_registry/components/actions_dropdown', () => {
+ let wrapper;
+
+ const showToast = jest.fn();
+
+ const createWrapper = () => {
+ wrapper = mount(ActionsDropdown, {
+ mocks: {
+ $toast: {
+ show: showToast,
+ },
+ },
+ provide: {
+ mlflowTrackingUrl: 'path/to/mlflow',
+ },
+ });
+ };
+
+ const findCopyLinkDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
+
+ it('has data-clipboard-text set to the correct url', () => {
+ createWrapper();
+
+ expect(findCopyLinkDropdownItem().text()).toBe('Copy MLflow tracking URL');
+ expect(findCopyLinkDropdownItem().attributes()['data-clipboard-text']).toBe('path/to/mlflow');
+ });
+
+ it('shows a success toast after copying the url to the clipboard', () => {
+ createWrapper();
+
+ findCopyLinkDropdownItem().find('button').trigger('click');
+
+ expect(showToast).toHaveBeenCalledWith('Copied MLflow tracking URL to clipboard');
+ });
+});
diff --git a/spec/frontend/ml/model_registry/components/candidate_list_spec.js b/spec/frontend/ml/model_registry/components/candidate_list_spec.js
index c10222a99fd..8491c7be16f 100644
--- a/spec/frontend/ml/model_registry/components/candidate_list_spec.js
+++ b/spec/frontend/ml/model_registry/components/candidate_list_spec.js
@@ -1,13 +1,11 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { GlAlert } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
+import { mount } from '@vue/test-utils';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import CandidateList from '~/ml/model_registry/components/candidate_list.vue';
-import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
-import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import SearchableList from '~/ml/model_registry/components/searchable_list.vue';
import CandidateListRow from '~/ml/model_registry/components/candidate_list_row.vue';
import getModelCandidatesQuery from '~/ml/model_registry/graphql/queries/get_model_candidates.query.graphql';
import { GRAPHQL_PAGE_SIZE } from '~/ml/model_registry/constants';
@@ -24,10 +22,7 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
let wrapper;
let apolloProvider;
- const findAlert = () => wrapper.findComponent(GlAlert);
- const findLoader = () => wrapper.findComponent(PackagesListLoader);
- const findRegistryList = () => wrapper.findComponent(RegistryList);
- const findListRow = () => wrapper.findComponent(CandidateListRow);
+ const findSearchableList = () => wrapper.findComponent(SearchableList);
const findAllRows = () => wrapper.findAllComponents(CandidateListRow);
const mountComponent = ({
@@ -37,15 +32,12 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
const requestHandlers = [[getModelCandidatesQuery, resolver]];
apolloProvider = createMockApollo(requestHandlers);
- wrapper = shallowMount(CandidateList, {
+ wrapper = mount(CandidateList, {
apolloProvider,
propsData: {
modelId: 2,
...props,
},
- stubs: {
- RegistryList,
- },
});
};
@@ -60,25 +52,9 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
await waitForPromises();
});
- it('displays empty slot message', () => {
+ it('shows empty state', () => {
expect(wrapper.text()).toContain('This model has no candidates');
});
-
- it('does not display loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('does not display rows', () => {
- expect(findListRow().exists()).toBe(false);
- });
-
- it('does not display registry list', () => {
- expect(findRegistryList().exists()).toBe(false);
- });
-
- it('does not display alert', () => {
- expect(findAlert().exists()).toBe(false);
- });
});
describe('if load fails, alert', () => {
@@ -90,19 +66,9 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
});
it('is displayed', () => {
- expect(findAlert().exists()).toBe(true);
- });
-
- it('shows error message', () => {
- expect(findAlert().text()).toContain('Failed to load model candidates with error: Failure!');
- });
-
- it('is not dismissible', () => {
- expect(findAlert().props('dismissible')).toBe(false);
- });
-
- it('is of variant danger', () => {
- expect(findAlert().attributes('variant')).toBe('danger');
+ expect(findSearchableList().props('errorMessage')).toBe(
+ 'Failed to load model candidates with error: Failure!',
+ );
});
it('error is logged in sentry', () => {
@@ -116,21 +82,11 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
await waitForPromises();
});
- it('displays package registry list', () => {
- expect(findRegistryList().exists()).toEqual(true);
+ it('Passes items to list', () => {
+ expect(findSearchableList().props('items')).toEqual(graphqlCandidates);
});
- it('binds the right props', () => {
- expect(findRegistryList().props()).toMatchObject({
- items: graphqlCandidates,
- pagination: {},
- isLoading: false,
- hiddenDelete: true,
- });
- });
-
- it('displays candidate rows', () => {
- expect(findAllRows().exists()).toEqual(true);
+ it('displays package version rows', () => {
expect(findAllRows()).toHaveLength(graphqlCandidates.length);
});
@@ -143,17 +99,9 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
candidate: expect.objectContaining(graphqlCandidates[1]),
});
});
-
- it('does not display loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('does not display empty message', () => {
- expect(findAlert().exists()).toBe(false);
- });
});
- describe('when user interacts with pagination', () => {
+ describe('when list requests update', () => {
const resolver = jest.fn().mockResolvedValue(modelCandidatesQuery());
beforeEach(async () => {
@@ -161,21 +109,17 @@ describe('ml/model_registry/components/candidate_list.vue', () => {
await waitForPromises();
});
- it('when list emits next-page fetches the next set of records', async () => {
- findRegistryList().vm.$emit('next-page');
- await waitForPromises();
-
- expect(resolver).toHaveBeenLastCalledWith(
- expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }),
- );
- });
+ it('when list emits fetch-page fetches the next set of records', async () => {
+ findSearchableList().vm.$emit('fetch-page', {
+ after: 'eyJpZCI6IjIifQ',
+ first: 30,
+ id: 'gid://gitlab/Ml::Model/2',
+ });
- it('when list emits prev-page fetches the prev set of records', async () => {
- findRegistryList().vm.$emit('prev-page');
await waitForPromises();
expect(resolver).toHaveBeenLastCalledWith(
- expect.objectContaining({ before: graphqlPageInfo.startCursor, last: GRAPHQL_PAGE_SIZE }),
+ expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }),
);
});
});
diff --git a/spec/frontend/ml/model_registry/components/model_version_list_spec.js b/spec/frontend/ml/model_registry/components/model_version_list_spec.js
index 41f7e71c543..f5d6acf3bae 100644
--- a/spec/frontend/ml/model_registry/components/model_version_list_spec.js
+++ b/spec/frontend/ml/model_registry/components/model_version_list_spec.js
@@ -1,13 +1,11 @@
import Vue from 'vue';
import VueApollo from 'vue-apollo';
-import { GlAlert } from '@gitlab/ui';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ModelVersionList from '~/ml/model_registry/components/model_version_list.vue';
-import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
-import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import SearchableList from '~/ml/model_registry/components/searchable_list.vue';
import ModelVersionRow from '~/ml/model_registry/components/model_version_row.vue';
import getModelVersionsQuery from '~/ml/model_registry/graphql/queries/get_model_versions.query.graphql';
import EmptyState from '~/ml/model_registry/components/empty_state.vue';
@@ -25,11 +23,8 @@ describe('ModelVersionList', () => {
let wrapper;
let apolloProvider;
- const findAlert = () => wrapper.findComponent(GlAlert);
- const findLoader = () => wrapper.findComponent(PackagesListLoader);
- const findRegistryList = () => wrapper.findComponent(RegistryList);
+ const findSearchableList = () => wrapper.findComponent(SearchableList);
const findEmptyState = () => wrapper.findComponent(EmptyState);
- const findListRow = () => wrapper.findComponent(ModelVersionRow);
const findAllRows = () => wrapper.findAllComponents(ModelVersionRow);
const mountComponent = ({
@@ -39,15 +34,12 @@ describe('ModelVersionList', () => {
const requestHandlers = [[getModelVersionsQuery, resolver]];
apolloProvider = createMockApollo(requestHandlers);
- wrapper = shallowMountExtended(ModelVersionList, {
+ wrapper = mountExtended(ModelVersionList, {
apolloProvider,
propsData: {
modelId: 2,
...props,
},
- stubs: {
- RegistryList,
- },
});
};
@@ -65,22 +57,6 @@ describe('ModelVersionList', () => {
it('shows empty state', () => {
expect(findEmptyState().props('entityType')).toBe(MODEL_ENTITIES.modelVersion);
});
-
- it('does not display loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('does not display rows', () => {
- expect(findListRow().exists()).toBe(false);
- });
-
- it('does not display registry list', () => {
- expect(findRegistryList().exists()).toBe(false);
- });
-
- it('does not display alert', () => {
- expect(findAlert().exists()).toBe(false);
- });
});
describe('if load fails, alert', () => {
@@ -92,19 +68,9 @@ describe('ModelVersionList', () => {
});
it('is displayed', () => {
- expect(findAlert().exists()).toBe(true);
- });
-
- it('shows error message', () => {
- expect(findAlert().text()).toContain('Failed to load model versions with error: Failure!');
- });
-
- it('is not dismissible', () => {
- expect(findAlert().props('dismissible')).toBe(false);
- });
-
- it('is of variant danger', () => {
- expect(findAlert().attributes('variant')).toBe('danger');
+ expect(findSearchableList().props('errorMessage')).toBe(
+ 'Failed to load model versions with error: Failure!',
+ );
});
it('error is logged in sentry', () => {
@@ -118,21 +84,11 @@ describe('ModelVersionList', () => {
await waitForPromises();
});
- it('displays package registry list', () => {
- expect(findRegistryList().exists()).toEqual(true);
- });
-
- it('binds the right props', () => {
- expect(findRegistryList().props()).toMatchObject({
- items: graphqlModelVersions,
- pagination: {},
- isLoading: false,
- hiddenDelete: true,
- });
+ it('Passes items to list', () => {
+ expect(findSearchableList().props('items')).toEqual(graphqlModelVersions);
});
it('displays package version rows', () => {
- expect(findAllRows().exists()).toEqual(true);
expect(findAllRows()).toHaveLength(graphqlModelVersions.length);
});
@@ -145,17 +101,9 @@ describe('ModelVersionList', () => {
modelVersion: expect.objectContaining(graphqlModelVersions[1]),
});
});
-
- it('does not display loader', () => {
- expect(findLoader().exists()).toBe(false);
- });
-
- it('does not display empty state', () => {
- expect(findEmptyState().exists()).toBe(false);
- });
});
- describe('when user interacts with pagination', () => {
+ describe('when list requests update', () => {
const resolver = jest.fn().mockResolvedValue(modelVersionsQuery());
beforeEach(async () => {
@@ -163,21 +111,17 @@ describe('ModelVersionList', () => {
await waitForPromises();
});
- it('when list emits next-page fetches the next set of records', async () => {
- findRegistryList().vm.$emit('next-page');
- await waitForPromises();
-
- expect(resolver).toHaveBeenLastCalledWith(
- expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }),
- );
- });
+ it('when list emits fetch-page fetches the next set of records', async () => {
+ findSearchableList().vm.$emit('fetch-page', {
+ after: 'eyJpZCI6IjIifQ',
+ first: 30,
+ id: 'gid://gitlab/Ml::Model/2',
+ });
- it('when list emits prev-page fetches the prev set of records', async () => {
- findRegistryList().vm.$emit('prev-page');
await waitForPromises();
expect(resolver).toHaveBeenLastCalledWith(
- expect.objectContaining({ before: graphqlPageInfo.startCursor, last: GRAPHQL_PAGE_SIZE }),
+ expect.objectContaining({ after: graphqlPageInfo.endCursor, first: GRAPHQL_PAGE_SIZE }),
);
});
});
diff --git a/spec/frontend/ml/model_registry/components/searchable_list_spec.js b/spec/frontend/ml/model_registry/components/searchable_list_spec.js
new file mode 100644
index 00000000000..ea58a9a830a
--- /dev/null
+++ b/spec/frontend/ml/model_registry/components/searchable_list_spec.js
@@ -0,0 +1,170 @@
+import { GlAlert } from '@gitlab/ui';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import SearchableList from '~/ml/model_registry/components/searchable_list.vue';
+import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue';
+import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
+import { defaultPageInfo } from '../mock_data';
+
+describe('ml/model_registry/components/searchable_list.vue', () => {
+ let wrapper;
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLoader = () => wrapper.findComponent(PackagesListLoader);
+ const findRegistryList = () => wrapper.findComponent(RegistryList);
+ const findEmptyState = () => wrapper.findByTestId('empty-state-slot');
+ const findFirstRow = () => wrapper.findByTestId('element');
+ const findRows = () => wrapper.findAllByTestId('element');
+
+ const defaultProps = {
+ items: ['a', 'b', 'c'],
+ pageInfo: defaultPageInfo,
+ isLoading: false,
+ errorMessage: '',
+ };
+
+ const mountComponent = (props = {}) => {
+ wrapper = shallowMountExtended(SearchableList, {
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ stubs: {
+ RegistryList,
+ },
+ slots: {
+ 'empty-state': '<div data-testid="empty-state-slot">This is empty</div>',
+ item: '<div data-testid="element"></div>',
+ },
+ });
+ };
+
+ describe('when list is loaded and has no data', () => {
+ beforeEach(() => mountComponent({ items: [] }));
+
+ it('shows empty state', () => {
+ expect(findEmptyState().text()).toBe('This is empty');
+ });
+
+ it('does not display loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('does not display rows', () => {
+ expect(findFirstRow().exists()).toBe(false);
+ });
+
+ it('does not display registry list', () => {
+ expect(findRegistryList().exists()).toBe(false);
+ });
+
+ it('does not display alert', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('if errorMessage', () => {
+ beforeEach(() => mountComponent({ errorMessage: 'Failure!' }));
+
+ it('shows error message', () => {
+ expect(findAlert().text()).toContain('Failure!');
+ });
+
+ it('is not dismissible', () => {
+ expect(findAlert().props('dismissible')).toBe(false);
+ });
+
+ it('is of variant danger', () => {
+ expect(findAlert().attributes('variant')).toBe('danger');
+ });
+
+ it('hides loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('hides registry list', () => {
+ expect(findRegistryList().exists()).toBe(false);
+ });
+
+ it('hides empty state', () => {
+ expect(findEmptyState().exists()).toBe(false);
+ });
+ });
+
+ describe('if loading', () => {
+ beforeEach(() => mountComponent({ isLoading: true }));
+
+ it('shows loader', () => {
+ expect(findLoader().exists()).toBe(true);
+ });
+
+ it('hides error message', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+
+ it('hides registry list', () => {
+ expect(findRegistryList().exists()).toBe(false);
+ });
+
+ it('hides empty state', () => {
+ expect(findEmptyState().exists()).toBe(false);
+ });
+ });
+
+ describe('when list is loaded with data', () => {
+ beforeEach(() => mountComponent());
+
+ it('displays package registry list', () => {
+ expect(findRegistryList().exists()).toEqual(true);
+ });
+
+ it('binds the right props', () => {
+ expect(findRegistryList().props()).toMatchObject({
+ items: ['a', 'b', 'c'],
+ isLoading: false,
+ pagination: defaultPageInfo,
+ hiddenDelete: true,
+ });
+ });
+
+ it('displays package version rows', () => {
+ expect(findRows().exists()).toEqual(true);
+ expect(findRows()).toHaveLength(3);
+ });
+
+ it('does not display loader', () => {
+ expect(findLoader().exists()).toBe(false);
+ });
+
+ it('does not display empty state', () => {
+ expect(findEmptyState().exists()).toBe(false);
+ });
+ });
+
+ describe('when user interacts with pagination', () => {
+ beforeEach(() => mountComponent());
+
+ it('when list emits next-page emits fetchPage with correct pageInfo', () => {
+ findRegistryList().vm.$emit('next-page');
+
+ const expectedNewPageInfo = {
+ after: 'eyJpZCI6IjIifQ',
+ first: 30,
+ last: null,
+ };
+
+ expect(wrapper.emitted('fetch-page')).toEqual([[expectedNewPageInfo]]);
+ });
+
+ it('when list emits prev-page emits fetchPage with correct pageInfo', () => {
+ findRegistryList().vm.$emit('prev-page');
+
+ const expectedNewPageInfo = {
+ before: 'eyJpZCI6IjE2In0',
+ first: null,
+ last: 30,
+ };
+
+ expect(wrapper.emitted('fetch-page')).toEqual([[expectedNewPageInfo]]);
+ });
+ });
+});
diff --git a/spec/frontend/ml/model_registry/graphql_mock_data.js b/spec/frontend/ml/model_registry/graphql_mock_data.js
index 1c31ee4627f..27424fbf0df 100644
--- a/spec/frontend/ml/model_registry/graphql_mock_data.js
+++ b/spec/frontend/ml/model_registry/graphql_mock_data.js
@@ -114,3 +114,27 @@ export const emptyCandidateQuery = {
},
},
};
+
+export const createModelResponses = {
+ success: {
+ data: {
+ mlModelCreate: {
+ model: {
+ id: 'gid://gitlab/Ml::Model/1',
+ _links: {
+ showPath: '/some/project/-/ml/models/1',
+ },
+ },
+ errors: [],
+ },
+ },
+ },
+ validationFailure: {
+ data: {
+ mlModelCreate: {
+ model: null,
+ errors: ['Name is invalid', "Name can't be blank"],
+ },
+ },
+ },
+};
diff --git a/spec/frontend/ml/model_registry/mock_data.js b/spec/frontend/ml/model_registry/mock_data.js
index 4399df38990..d8bb6a8eedb 100644
--- a/spec/frontend/ml/model_registry/mock_data.js
+++ b/spec/frontend/ml/model_registry/mock_data.js
@@ -42,6 +42,7 @@ export const newCandidate = () => ({
const LATEST_VERSION = {
version: '1.2.3',
+ path: 'path/to/modelversion',
};
export const makeModel = ({ latestVersion } = { latestVersion: LATEST_VERSION }) => ({