Commit 8254a1d2 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Natalia Tepluhina

Remove pathGenerator and add docker commands utils

- source
- tests
parent de6d6c5a
<script>
import { GlDropdown } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import Tracking from '~/tracking';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import {
......@@ -20,6 +19,7 @@ export default {
GlDropdown,
CodeInstruction,
},
inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
mixins: [Tracking.mixin({ label: trackingLabel })],
trackingLabel,
i18n: {
......@@ -31,9 +31,6 @@ export default {
PUSH_COMMAND_LABEL,
COPY_PUSH_TITLE,
},
computed: {
...mapGetters(['dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand']),
},
};
</script>
<template>
......
<script>
import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import { mapState } from 'vuex';
export default {
name: 'GroupEmptyState',
inject: ['config'],
components: {
GlEmptyState,
GlSprintf,
GlLink,
},
computed: {
...mapState(['config']),
},
};
</script>
<template>
......
<script>
import { GlEmptyState, GlSprintf, GlLink, GlFormInputGroup, GlFormInput } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import {
......@@ -20,6 +19,7 @@ export default {
GlFormInputGroup,
GlFormInput,
},
inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
i18n: {
quickStart: QUICK_START,
copyLoginTitle: COPY_LOGIN_TITLE,
......@@ -35,10 +35,6 @@ export default {
'ContainerRegistry|You can add an image to this registry with the following commands:',
),
},
computed: {
...mapState(['config']),
...mapGetters(['dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand']),
},
};
</script>
<template>
......
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import Translate from '~/vue_shared/translate';
import { parseBoolean } from '~/lib/utils/common_utils';
import RegistryExplorer from './pages/index.vue';
import RegistryBreadcrumb from './components/registry_breadcrumb.vue';
import { createStore } from './stores';
import createRouter from './router';
import { apolloProvider } from './graphql/index';
......@@ -17,7 +17,7 @@ export default () => {
return null;
}
const { endpoint } = el.dataset;
const { endpoint, expirationPolicy, isGroupPage, isAdmin, ...config } = el.dataset;
// This is a mini state to help the breadcrumb have the correct name in the details page
const breadCrumbState = Vue.observable({
......@@ -27,21 +27,31 @@ export default () => {
},
});
const store = createStore();
const router = createRouter(endpoint, breadCrumbState);
store.dispatch('setInitialState', el.dataset);
const attachMainComponent = () =>
new Vue({
el,
store,
router,
apolloProvider,
components: {
RegistryExplorer,
},
provide() {
return { breadCrumbState };
return {
breadCrumbState,
config: {
...config,
expirationPolicy: expirationPolicy ? JSON.parse(expirationPolicy) : undefined,
isGroupPage: parseBoolean(isGroupPage),
isAdmin: parseBoolean(isAdmin),
},
/* eslint-disable @gitlab/require-i18n-strings */
dockerBuildCommand: `docker build -t ${config.repositoryUrl} .`,
dockerPushCommand: `docker push ${config.repositoryUrl}`,
dockerLoginCommand: `docker login ${config.registryHostUrlWithPort}`,
/* eslint-enable @gitlab/require-i18n-strings */
};
},
render(createElement) {
return createElement('registry-explorer');
......
<script>
import { mapState } from 'vuex';
import { GlKeysetPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import createFlash from '~/flash';
......@@ -36,7 +35,7 @@ export default {
TagsLoader,
EmptyTagsState,
},
inject: ['breadCrumbState'],
inject: ['breadCrumbState', 'config'],
directives: {
GlResizeObserver: GlResizeObserverDirective,
},
......@@ -71,7 +70,6 @@ export default {
};
},
computed: {
...mapState(['config']),
queryVariables() {
return {
id: joinPaths(this.config.gidPrefix, `${this.$route.params.id}`),
......
<script>
import { mapState } from 'vuex';
import {
GlEmptyState,
GlTooltipDirective,
......@@ -54,6 +53,7 @@ export default {
RegistryHeader,
CliCommands,
},
inject: ['config'],
directives: {
GlTooltip: GlTooltipDirective,
},
......@@ -106,7 +106,6 @@ export default {
};
},
computed: {
...mapState(['config']),
graphqlResource() {
return this.config.isGroupPage ? 'group' : 'project';
},
......
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import Api from '~/api';
import * as types from './mutation_types';
import {
FETCH_IMAGES_LIST_ERROR_MESSAGE,
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
FETCH_TAGS_LIST_ERROR_MESSAGE,
FETCH_IMAGE_DETAILS_ERROR_MESSAGE,
} from '../constants/index';
import { pathGenerator } from '../utils';
export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
export const setShowGarbageCollectionTip = ({ commit }, data) =>
commit(types.SET_SHOW_GARBAGE_COLLECTION_TIP, data);
export const receiveImagesListSuccess = ({ commit }, { data, headers }) => {
commit(types.SET_IMAGES_LIST_SUCCESS, data);
commit(types.SET_PAGINATION, headers);
};
export const receiveTagsListSuccess = ({ commit }, { data, headers }) => {
commit(types.SET_TAGS_LIST_SUCCESS, data);
commit(types.SET_TAGS_PAGINATION, headers);
};
export const requestImagesList = (
{ commit, dispatch, state },
{ pagination = {}, name = null } = {},
) => {
commit(types.SET_MAIN_LOADING, true);
const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
return axios
.get(state.config.endpoint, { params: { page, per_page: perPage, name } })
.then(({ data, headers }) => {
dispatch('receiveImagesListSuccess', { data, headers });
})
.catch(() => {
createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
export const requestTagsList = ({ commit, dispatch, state: { imageDetails } }, pagination = {}) => {
commit(types.SET_MAIN_LOADING, true);
const tagsPath = pathGenerator(imageDetails);
const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
return axios
.get(tagsPath, { params: { page, per_page: perPage } })
.then(({ data, headers }) => {
dispatch('receiveTagsListSuccess', { data, headers });
})
.catch(() => {
createFlash({ message: FETCH_TAGS_LIST_ERROR_MESSAGE });
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
export const requestImageDetailsAndTagsList = ({ dispatch, commit }, id) => {
commit(types.SET_MAIN_LOADING, true);
return Api.containerRegistryDetails(id)
.then(({ data }) => {
commit(types.SET_IMAGE_DETAILS, data);
dispatch('requestTagsList');
})
.catch(() => {
createFlash({ message: FETCH_IMAGE_DETAILS_ERROR_MESSAGE });
commit(types.SET_MAIN_LOADING, false);
});
};
export const requestDeleteTag = ({ commit, dispatch, state }, { tag }) => {
commit(types.SET_MAIN_LOADING, true);
return axios
.delete(tag.destroy_path)
.then(() => {
dispatch('setShowGarbageCollectionTip', true);
return dispatch('requestTagsList', state.tagsPagination);
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
export const requestDeleteTags = ({ commit, dispatch, state }, { ids }) => {
commit(types.SET_MAIN_LOADING, true);
const tagsPath = pathGenerator(state.imageDetails, '/bulk_destroy');
return axios
.delete(tagsPath, { params: { ids } })
.then(() => {
dispatch('setShowGarbageCollectionTip', true);
return dispatch('requestTagsList', state.tagsPagination);
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
export const requestDeleteImage = ({ commit }, image) => {
commit(types.SET_MAIN_LOADING, true);
return axios
.delete(image.destroy_path)
.then(() => {
commit(types.UPDATE_IMAGE, { ...image, deleting: true });
})
.finally(() => {
commit(types.SET_MAIN_LOADING, false);
});
};
export const dockerBuildCommand = state => {
/* eslint-disable @gitlab/require-i18n-strings */
return `docker build -t ${state.config.repositoryUrl} .`;
};
export const dockerPushCommand = state => {
/* eslint-disable @gitlab/require-i18n-strings */
return `docker push ${state.config.repositoryUrl}`;
};
export const dockerLoginCommand = state => {
/* eslint-disable @gitlab/require-i18n-strings */
return `docker login ${state.config.registryHostUrlWithPort}`;
};
export const showGarbageCollection = state => {
return state.showGarbageCollectionTip && state.config.isAdmin;
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import * as getters from './getters';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
state,
getters,
actions,
mutations,
});
export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
export const SET_IMAGES_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS';
export const UPDATE_IMAGE = 'UPDATE_IMAGE';
export const SET_PAGINATION = 'SET_PAGINATION';
export const SET_MAIN_LOADING = 'SET_MAIN_LOADING';
export const SET_TAGS_PAGINATION = 'SET_TAGS_PAGINATION';
export const SET_TAGS_LIST_SUCCESS = 'SET_TAGS_LIST_SUCCESS';
export const SET_SHOW_GARBAGE_COLLECTION_TIP = 'SET_SHOW_GARBAGE_COLLECTION_TIP';
export const SET_IMAGE_DETAILS = 'SET_IMAGE_DETAILS';
import * as types from './mutation_types';
import { parseIntPagination, normalizeHeaders, parseBoolean } from '~/lib/utils/common_utils';
import { IMAGE_DELETE_SCHEDULED_STATUS, IMAGE_FAILED_DELETED_STATUS } from '../constants/index';
export default {
[types.SET_INITIAL_STATE](state, config) {
state.config = {
...config,
expirationPolicy: config.expirationPolicy ? JSON.parse(config.expirationPolicy) : undefined,
isGroupPage: parseBoolean(config.isGroupPage),
isAdmin: parseBoolean(config.isAdmin),
};
},
[types.SET_IMAGES_LIST_SUCCESS](state, images) {
state.images = images.map(i => ({
...i,
status: undefined,
deleting: i.status === IMAGE_DELETE_SCHEDULED_STATUS,
failedDelete: i.status === IMAGE_FAILED_DELETED_STATUS,
}));
},
[types.UPDATE_IMAGE](state, image) {
const index = state.images.findIndex(i => i.id === image.id);
state.images.splice(index, 1, { ...image });
},
[types.SET_TAGS_LIST_SUCCESS](state, tags) {
state.tags = tags;
},
[types.SET_MAIN_LOADING](state, isLoading) {
state.isLoading = isLoading;
},
[types.SET_SHOW_GARBAGE_COLLECTION_TIP](state, showGarbageCollectionTip) {
state.showGarbageCollectionTip = showGarbageCollectionTip;
},
[types.SET_PAGINATION](state, headers) {
const normalizedHeaders = normalizeHeaders(headers);
state.pagination = parseIntPagination(normalizedHeaders);
},
[types.SET_TAGS_PAGINATION](state, headers) {
const normalizedHeaders = normalizeHeaders(headers);
state.tagsPagination = parseIntPagination(normalizedHeaders);
},
[types.SET_IMAGE_DETAILS](state, details) {
state.imageDetails = details;
},
};
export default () => ({
isLoading: false,
showGarbageCollectionTip: false,
config: {},
images: [],
imageDetails: {},
tags: [],
pagination: {},
tagsPagination: {},
});
import { joinPaths } from '~/lib/utils/url_utility';
export const pathGenerator = (imageDetails, ending = '?format=json') => {
// this method is a temporary workaround, to be removed with graphql implementation
// https://gitlab.com/gitlab-org/gitlab/-/issues/276432
const splitPath = imageDetails.path.split('/').reverse();
const splitName = imageDetails.name ? imageDetails.name.split('/').reverse() : [];
const basePath = splitPath
.reduce((acc, curr, index) => {
if (splitPath[index] !== splitName[index]) {
acc.unshift(curr);
}
return acc;
}, [])
.join('/');
return joinPaths(
window.gon.relative_url_root,
`/${basePath}`,
'/registry/repository/',
`${imageDetails.id}`,
`tags${ending}`,
);
};
---
title: Refactor container registry to use GraphQL API
merge_request: 49584
author:
type: changed
......@@ -15,12 +15,12 @@ import {
NOT_AVAILABLE_SIZE,
} from '~/registry/explorer/constants/index';
import { tagsListResponse } from '../../mock_data';
import { tagsMock } from '../../mock_data';
import { ListItem } from '../../stubs';
describe('tags list row', () => {
let wrapper;
const [tag] = [...tagsListResponse];
const [tag] = [...tagsMock];
const defaultProps = { tag, isMobile: false, index: 0 };
......@@ -172,19 +172,19 @@ describe('tags list row', () => {
});
it('contains the totalSize and layers', () => {
mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 1024 } });
mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 1024, layers: 10 } });
expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers');
});
it('when totalSize is missing', () => {
mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 0 } });
mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 0, layers: 10 } });
expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 10 layers`);
});
it('when layers are missing', () => {
mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 1024, layers: null } });
mountComponent({ ...defaultProps, tag: { ...tag, totalSize: 1024 } });
expect(findSize().text()).toMatchInterpolatedText('1.00 KiB');
});
......@@ -232,7 +232,7 @@ describe('tags list row', () => {
it('has the correct text', () => {
mountComponent();
expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 9d72ae1');
expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 2cf3d2f');
});
it(`displays ${NOT_AVAILABLE_TEXT} when digest is missing`, () => {
......@@ -294,8 +294,8 @@ describe('tags list row', () => {
describe.each`
name | finderFunction | text | icon | clipboard
${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 GMT+0000 on 2020-11-03'} | ${'clock'} | ${false}
${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:9d72ae1db47404e44e1760eb1ca4cb427b84be8c511f05dfe2089e1b9f741dd7'} | ${'log'} | ${true}
${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:5183b5d133fa864dca2de602f874b0d1bffe0f204ad894e3660432a487935139'} | ${'cloud-gear'} | ${true}
${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true}
${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true}
`('$name details row', ({ finderFunction, text, icon, clipboard }) => {
it(`has ${text} as text`, () => {
expect(finderFunction().text()).toMatchInterpolatedText(text);
......
......@@ -3,11 +3,11 @@ import { GlButton } from '@gitlab/ui';
import component from '~/registry/explorer/components/details_page/tags_list.vue';
import TagsListRow from '~/registry/explorer/components/details_page/tags_list_row.vue';
import { TAGS_LIST_TITLE, REMOVE_TAGS_BUTTON_TITLE } from '~/registry/explorer/constants/index';
import { tagsListResponse } from '../../mock_data';
import { tagsMock } from '../../mock_data';
describe('Tags List', () => {
let wrapper;
const tags = [...tagsListResponse];
const tags = [...tagsMock];
const readOnlyTags = tags.map(t => ({ ...t, canDelete: false }));
const findTagsListRow = () => wrapper.findAll(TagsListRow);
......@@ -92,7 +92,7 @@ describe('Tags List', () => {
.vm.$emit('select');
findDeleteButton().vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[{ 'alpha-11821': true }]]);
expect(wrapper.emitted('delete')).toEqual([[{ 'beta-24753': true }]]);
});
});
......@@ -132,7 +132,7 @@ describe('Tags List', () => {
findTagsListRow()
.at(0)
.vm.$emit('delete');
expect(wrapper.emitted('delete')).toEqual([[{ 'alpha-11821': true }]]);
expect(wrapper.emitted('delete')).toEqual([[{ 'beta-24753': true }]]);
});
});
});
......
......@@ -46,7 +46,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
class="gl-font-monospace!"
readonly=""
type="text"
value="docker login bar"
value="bazbaz"
/>
</gl-form-input-group-stub>
......@@ -67,7 +67,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
class="gl-font-monospace!"
readonly=""
type="text"
value="docker build -t foo ."
value="foofoo"
/>
</gl-form-input-group-stub>
......@@ -79,7 +79,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = `
class="gl-font-monospace!"
readonly=""
type="text"
value="docker push foo"
value="barbar"
/>
</gl-form-input-group-stub>
</div>
......
......@@ -2,10 +2,8 @@ import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import { GlDropdown } from '@gitlab/ui';
import Tracking from '~/tracking';
import * as getters from '~/registry/explorer/stores/getters';
import QuickstartDropdown from '~/registry/explorer/components/list_page/cli_commands.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
import {
QUICK_START,
LOGIN_COMMAND_LABEL,
......@@ -14,31 +12,33 @@ import {
COPY_BUILD_TITLE,
PUSH_COMMAND_LABEL,
COPY_PUSH_TITLE,
} from '~/registry/explorer//constants';
} from '~/registry/explorer/constants';
import { dockerCommands } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('cli_commands', () => {
let wrapper;
let store;
const config = {
repositoryUrl: 'foo',
registryHostUrlWithPort: 'bar',
};
const findDropdownButton = () => wrapper.find(GlDropdown);
const findCodeInstruction = () => wrapper.findAll(CodeInstruction);
const mountComponent = () => {
store = new Vuex.Store({
state: {
config: {
repositoryUrl: 'foo',
registryHostUrlWithPort: 'bar',
},
},
getters,
});
wrapper = mount(QuickstartDropdown, {
localVue,
store,
provide() {
return {
config,
...dockerCommands,
};
},
});
};
......@@ -50,7 +50,6 @@ describe('cli_commands', () => {
afterEach(() => {
wrapper.destroy();
wrapper = null;
store = null;
});
it('shows the correct text on the button', () => {
......@@ -67,11 +66,11 @@ describe('cli_commands', () => {
});
describe.each`
index | labelText | titleText | getter | trackedEvent
${0} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${'dockerLoginCommand'} | ${'click_copy_login'}
${1} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${'dockerBuildCommand'} | ${'click_copy_build'}
${2} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${'dockerPushCommand'} | ${'click_copy_push'}
`('code instructions at $index', ({ index, labelText, titleText, getter, trackedEvent }) => {
index | labelText | titleText | command | trackedEvent
${0} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${dockerCommands.dockerLoginCommand} | ${'click_copy_login'}
${1} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${dockerCommands.dockerBuildCommand} | ${'click_copy_build'}
${2} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${dockerCommands.dockerPushCommand} | ${'click_copy_push'}
`('code instructions at $index', ({ index, labelText, titleText, command, trackedEvent }) => {
let codeInstruction;
beforeEach(() => {
......@@ -85,7 +84,7 @@ describe('cli_commands', () => {
it(`has the correct props`, () => {
expect(codeInstruction.props()).toMatchObject({
label: labelText,
instruction: store.getters[getter],
instruction: command,
copyText: titleText,
trackingAction: trackedEvent,
trackingLabel: 'quickstart_dropdown',
......
......@@ -9,24 +9,21 @@ localVue.use(Vuex);
describe('Registry Group Empty state', () => {
let wrapper;
let store;
const config = {
noContainersImage: 'foo',
helpPagePath: 'baz',
};
beforeEach(() => {
store = new Vuex.Store({
state: {
config: {
noContainersImage: 'foo',
helpPagePath: 'baz',
},
},
});
wrapper = shallowMount(groupEmptyState, {
localVue,
store,
stubs: {
GlEmptyState,
GlSprintf,
},
provide() {
return { config };
},
});
});
......
......@@ -3,36 +3,35 @@ import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlSprintf } from '@gitlab/ui';
import { GlEmptyState } from '../../stubs';
import projectEmptyState from '~/registry/explorer/components/list_page/project_empty_state.vue';
import * as getters from '~/registry/explorer/stores/getters';
import { dockerCommands } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Registry Project Empty state', () => {
let wrapper;
let store;
const config = {
repositoryUrl: 'foo',
registryHostUrlWithPort: 'bar',
helpPagePath: 'baz',
twoFactorAuthHelpLink: 'barBaz',
personalAccessTokensHelpLink: 'fooBaz',
noContainersImage: 'bazFoo',
};
beforeEach(() => {
store = new Vuex.Store({
state: {
config: {
repositoryUrl: 'foo',
registryHostUrlWithPort: 'bar',
helpPagePath: 'baz',
twoFactorAuthHelpLink: 'barBaz',
personalAccessTokensHelpLink: 'fooBaz',
noContainersImage: 'bazFoo',
},
},
getters,
});
wrapper = shallowMount(projectEmptyState, {
localVue,
store,
stubs: {
GlEmptyState,
GlSprintf,
},
provide() {
return {
config,
...dockerCommands,
};
},
});
});
......
export const headers = {
'X-PER-PAGE': 5,
'X-PAGE': 1,
'X-TOTAL': 13,
'X-TOTAL_PAGES': 1,
'X-NEXT-PAGE': null,
'X-PREVIOUS-PAGE': null,
};
export const reposServerResponse = [
{
destroy_path: 'path',
id: '123',
location: 'location',
path: 'foo',
tags_path: 'tags_path',
},
{
destroy_path: 'path_',
id: '456',
location: 'location_',
path: 'bar',
tags_path: 'tags_path_',
},
];
export const registryServerResponse = [
{
name: 'centos7',
short_revision: 'b118ab5b0',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
total_size: 679,
layers: 19,
location: 'location',
created_at: 1505828744434,
destroy_path: 'path_',
},
{
name: 'centos6',
short_revision: 'b118ab5b0',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
total_size: 679,
layers: 19,
location: 'location',
created_at: 1505828744434,
},
];
export const imagesListResponse = [
{
__typename: 'ContainerRepository',
......@@ -72,35 +25,6 @@ export const imagesListResponse = [
},
];
export const tagsListResponse = [
{
canDelete: true,
createdAt: '2020-11-03T13:29:49+00:00',
digest: 'sha256:9d72ae1db47404e44e1760eb1ca4cb427b84be8c511f05dfe2089e1b9f741dd7',
location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:alpha-11821',
name: 'alpha-11821',
path: 'gitlab-org/gitlab-test/rails-12009:alpha-11821',
revision: '5183b5d133fa864dca2de602f874b0d1bffe0f204ad894e3660432a487935139',
shortRevision: '5183b5d13',
totalSize: 104,
layers: 10,
__typename: 'ContainerRepositoryTag',
},
{
canDelete: true,
createdAt: '2020-11-03T13:29:48+00:00',
digest: 'sha256:64f61282a71659f72066f9decd30b9038a465859b277a5e20da8681eb83e72f7',
location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:alpha-20825',
name: 'alpha-20825',
path: 'gitlab-org/gitlab-test/rails-12009:alpha-20825',
revision: 'e4212f1b73c6f9def2c37fa7df6c8d35c345fb1402860ff9a56404821aacf16f',
shortRevision: 'e4212f1b7',
totalSize: 105,
layers: 10,
__typename: 'ContainerRepositoryTag',
},
];
export const pageInfo = {
hasNextPage: true,
hasPreviousPage: true,
......@@ -109,17 +33,6 @@ export const pageInfo = {
__typename: 'ContainerRepositoryConnection',
};
export const imageDetailsMock = {
canDelete: true,
createdAt: '2020-11-03T13:29:21Z',
expirationPolicyStartedAt: null,
id: 'gid://gitlab/ContainerRepository/26',
location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009',
name: 'rails-12009',
path: 'gitlab-org/gitlab-test/rails-12009',
status: null,
};
export const graphQLImageListMock = {
data: {
project: {
......@@ -285,3 +198,9 @@ export const graphQLDeleteImageRepositoryTagsMock = {
},
},
};
export const dockerCommands = {
dockerBuildCommand: 'foofoo',
dockerPushCommand: 'barbar',
dockerLoginCommand: 'bazbaz',
};
......@@ -11,7 +11,6 @@ import DetailsHeader from '~/registry/explorer/components/details_page/details_h
import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue';
import TagsList from '~/registry/explorer/components/details_page/tags_list.vue';
import EmptyTagsState from '~/registry/explorer/components/details_page/empty_tags_state.vue';
import { createStore } from '~/registry/explorer/stores/';
import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.graphql';
import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.graphql';
......@@ -30,7 +29,6 @@ const localVue = createLocalVue();
describe('Details Page', () => {
let wrapper;
let store;
let apolloProvider;
const findDeleteModal = () => wrapper.find(DeleteModal);
......@@ -70,6 +68,7 @@ describe('Details Page', () => {
resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()),
mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock),
options,
config = {},
} = {}) => {
localVue.use(VueApollo);
......@@ -81,7 +80,6 @@ describe('Details Page', () => {
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMount(component, {
store,
localVue,
apolloProvider,
stubs: {
......@@ -97,6 +95,7 @@ describe('Details Page', () => {
provide() {
return {
breadCrumbState,
config,
};
},
...options,
......@@ -104,7 +103,6 @@ describe('Details Page', () => {
};
beforeEach(() => {
store = createStore();
jest.spyOn(Tracking, 'event');
});
......@@ -374,13 +372,13 @@ describe('Details Page', () => {
});
it('has the correct props', async () => {
store.commit('SET_INITIAL_STATE', { ...config });
mountComponent({
options: {
data: () => ({
deleteAlertType,
}),
},
config,
});
await waitForApolloRequestRender();
......@@ -414,9 +412,7 @@ describe('Details Page', () => {
});
it('has the correct props', async () => {
store.commit('SET_INITIAL_STATE', { ...config });
mountComponent({ resolver });
mountComponent({ resolver, config });
await waitForApolloRequestRender();
......
import { shallowMount } from '@vue/test-utils';
import component from '~/registry/explorer/pages/index.vue';
import { createStore } from '~/registry/explorer/stores/';
describe('List Page', () => {
let wrapper;
let store;
const findRouterView = () => wrapper.find({ ref: 'router-view' });
const mountComponent = () => {
wrapper = shallowMount(component, {
store,
stubs: {
RouterView: true,
},
......@@ -18,7 +15,6 @@ describe('List Page', () => {
};
beforeEach(() => {
store = createStore();
mountComponent();
});
......
......@@ -11,8 +11,7 @@ import ProjectEmptyState from '~/registry/explorer/components/list_page/project_
import RegistryHeader from '~/registry/explorer/components/list_page/registry_header.vue';
import ImageList from '~/registry/explorer/components/list_page/image_list.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import { createStore } from '~/registry/explorer/stores/';
import { SET_INITIAL_STATE } from '~/registry/explorer/stores/mutation_types';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
......@@ -40,7 +39,6 @@ const localVue = createLocalVue();
describe('List Page', () => {
let wrapper;
let store;
let apolloProvider;
const findDeleteModal = () => wrapper.find(GlModal);
......@@ -69,6 +67,7 @@ describe('List Page', () => {
resolver = jest.fn().mockResolvedValue(graphQLImageListMock),
groupResolver = jest.fn().mockResolvedValue(graphQLImageListMock),
mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock),
config = {},
} = {}) => {
localVue.use(VueApollo);
......@@ -83,7 +82,6 @@ describe('List Page', () => {
wrapper = shallowMount(component, {
localVue,
apolloProvider,
store,
stubs: {
GlModal,
GlEmptyState,
......@@ -98,13 +96,14 @@ describe('List Page', () => {
},
...mocks,
},
provide() {
return {
config,
};
},
});
};
beforeEach(() => {
store = createStore();
});
afterEach(() => {
wrapper.destroy();
});
......@@ -127,34 +126,26 @@ describe('List Page', () => {
helpPagePath: 'bar',
};
beforeEach(() => {
store.commit(SET_INITIAL_STATE, config);
});
afterEach(() => {
store.commit(SET_INITIAL_STATE, {});
});
it('should show an empty state', () => {
mountComponent();
mountComponent({ config });
expect(findEmptyState().exists()).toBe(true);
});
it('empty state should have an svg-path', () => {
mountComponent();
mountComponent({ config });
expect(findEmptyState().attributes('svg-path')).toBe(config.containersErrorImage);
});
it('empty state should have a description', () => {
mountComponent();
mountComponent({ config });
expect(findEmptyState().html()).toContain('connection error');
});
it('should not show the loading or default state', () => {
mountComponent();
mountComponent({ config });
expect(findSkeletonLoader().exists()).toBe(false);
expect(findImageList().exists()).toBe(false);
......@@ -204,16 +195,12 @@ describe('List Page', () => {
describe('group page', () => {
const groupResolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock);
beforeEach(() => {
store.commit(SET_INITIAL_STATE, { isGroupPage: true });
});
afterEach(() => {
store.commit(SET_INITIAL_STATE, { isGroupPage: undefined });
});
const config = {
isGroupPage: true,
};
it('group empty state is visible', async () => {
mountComponent({ groupResolver });
mountComponent({ groupResolver, config });
await waitForApolloRequestRender();
......@@ -221,7 +208,7 @@ describe('List Page', () => {
});
it('cli commands is not visible', async () => {
mountComponent({ groupResolver });
mountComponent({ groupResolver, config });
await waitForApolloRequestRender();
......@@ -229,7 +216,7 @@ describe('List Page', () => {
});
it('list header is not visible', async () => {
mountComponent({ groupResolver });
mountComponent({ groupResolver, config });
await waitForApolloRequestRender();
......
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import { TEST_HOST } from 'helpers/test_constants';
import createFlash from '~/flash';
import Api from '~/api';
import axios from '~/lib/utils/axios_utils';
import * as actions from '~/registry/explorer/stores/actions';
import * as types from '~/registry/explorer/stores/mutation_types';
import { reposServerResponse, registryServerResponse } from '../mock_data';
import * as utils from '~/registry/explorer/utils';
import {
FETCH_IMAGES_LIST_ERROR_MESSAGE,
FETCH_TAGS_LIST_ERROR_MESSAGE,
FETCH_IMAGE_DETAILS_ERROR_MESSAGE,
} from '~/registry/explorer/constants/index';
jest.mock('~/flash.js');
jest.mock('~/registry/explorer/utils');
describe('Actions RegistryExplorer Store', () => {
let mock;
const endpoint = `${TEST_HOST}/endpoint.json`;
const url = `${endpoint}/1}`;
jest.spyOn(utils, 'pathGenerator').mockReturnValue(url);
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
it('sets initial state', done => {
const initialState = {
config: {
endpoint,
},
};
testAction(
actions.setInitialState,
initialState,
null,
[{ type: types.SET_INITIAL_STATE, payload: initialState }],
[],
done,
);
});
it('setShowGarbageCollectionTip', done => {
testAction(
actions.setShowGarbageCollectionTip,
true,
null,
[{ type: types.SET_SHOW_GARBAGE_COLLECTION_TIP, payload: true }],
[],
done,
);
});
describe('receives api responses', () => {
const response = {
data: [1, 2, 3],
headers: {
page: 1,
perPage: 10,
},
};
it('images list response', done => {
testAction(
actions.receiveImagesListSuccess,
response,
null,
[
{ type: types.SET_IMAGES_LIST_SUCCESS, payload: response.data },
{ type: types.SET_PAGINATION, payload: response.headers },
],
[],
done,
);
});
it('tags list response', done => {
testAction(
actions.receiveTagsListSuccess,
response,
null,
[
{ type: types.SET_TAGS_LIST_SUCCESS, payload: response.data },
{ type: types.SET_TAGS_PAGINATION, payload: response.headers },
],
[],
done,
);
});
});
describe('fetch images list', () => {
it('sets the imagesList and pagination', done => {
mock.onGet(endpoint).replyOnce(200, reposServerResponse, {});
testAction(
actions.requestImagesList,
{},
{
config: {
endpoint,
},
},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[{ type: 'receiveImagesListSuccess', payload: { data: reposServerResponse, headers: {} } }],
done,
);
});
it('should create flash on error', done => {
testAction(
actions.requestImagesList,
{},
{
config: {
endpoint: null,
},
},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
() => {
expect(createFlash).toHaveBeenCalledWith({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
done();
},
);
});
});
describe('fetch tags list', () => {
it('sets the tagsList', done => {
mock.onGet(url).replyOnce(200, registryServerResponse, {});
testAction(
actions.requestTagsList,
{},
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[
{
type: 'receiveTagsListSuccess',
payload: { data: registryServerResponse, headers: {} },
},
],
done,
);
});
it('should create flash on error', done => {
testAction(
actions.requestTagsList,
{},
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
() => {
expect(createFlash).toHaveBeenCalledWith({ message: FETCH_TAGS_LIST_ERROR_MESSAGE });
done();
},
);
});
});
describe('request delete single tag', () => {
it('successfully performs the delete request', done => {
const deletePath = 'delete/path';
mock.onDelete(deletePath).replyOnce(200);
testAction(
actions.requestDeleteTag,
{
tag: {
destroy_path: deletePath,
},
},
{
tagsPagination: {},
},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[
{
type: 'setShowGarbageCollectionTip',
payload: true,
},
{
type: 'requestTagsList',
payload: {},
},
],
done,
);
});
it('should turn off loading on error', done => {
testAction(
actions.requestDeleteTag,
{
tag: {
destroy_path: null,
},
},
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
).catch(() => done());
});
});
describe('requestImageDetailsAndTagsList', () => {
it('sets the imageDetails and dispatch requestTagsList', done => {
const resolvedValue = { foo: 'bar' };
jest.spyOn(Api, 'containerRegistryDetails').mockResolvedValue({ data: resolvedValue });
testAction(
actions.requestImageDetailsAndTagsList,
1,
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_IMAGE_DETAILS, payload: resolvedValue },
],
[
{
type: 'requestTagsList',
},
],
done,
);
});
it('should create flash on error', done => {
jest.spyOn(Api, 'containerRegistryDetails').mockRejectedValue();
testAction(
actions.requestImageDetailsAndTagsList,
1,
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
() => {
expect(createFlash).toHaveBeenCalledWith({ message: FETCH_IMAGE_DETAILS_ERROR_MESSAGE });
done();
},
);
});
});
describe('request delete multiple tags', () => {
it('successfully performs the delete request', done => {
mock.onDelete(url).replyOnce(200);
testAction(
actions.requestDeleteTags,
{
ids: [1, 2],
},
{
tagsPagination: {},
},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[
{
type: 'setShowGarbageCollectionTip',
payload: true,
},
{
type: 'requestTagsList',
payload: {},
},
],
done,
);
});
it('should turn off loading on error', done => {
mock.onDelete(url).replyOnce(500);
testAction(
actions.requestDeleteTags,
{
ids: [1, 2],
},
{
tagsPagination: {},
},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
).catch(() => done());
});
});
describe('request delete single image', () => {
const image = {
destroy_path: 'delete/path',
};
it('successfully performs the delete request', done => {
mock.onDelete(image.destroy_path).replyOnce(200);
testAction(
actions.requestDeleteImage,
image,
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.UPDATE_IMAGE, payload: { ...image, deleting: true } },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
done,
);
});
it('should turn off loading on error', done => {
mock.onDelete(image.destroy_path).replyOnce(400);
testAction(
actions.requestDeleteImage,
image,
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
{ type: types.SET_MAIN_LOADING, payload: false },
],
[],
).catch(() => done());
});
});
});
import * as getters from '~/registry/explorer/stores/getters';
describe('Getters RegistryExplorer store', () => {
let state;
describe.each`
getter | prefix | configParameter | suffix
${'dockerBuildCommand'} | ${'docker build -t'} | ${'repositoryUrl'} | ${'.'}
${'dockerPushCommand'} | ${'docker push'} | ${'repositoryUrl'} | ${null}
${'dockerLoginCommand'} | ${'docker login'} | ${'registryHostUrlWithPort'} | ${null}
`('$getter', ({ getter, prefix, configParameter, suffix }) => {
beforeEach(() => {
state = {
config: { repositoryUrl: 'foo', registryHostUrlWithPort: 'bar' },
};
});
it(`returns ${prefix} concatenated with ${configParameter} and optionally suffixed with ${suffix}`, () => {
const expectedPieces = [prefix, state.config[configParameter], suffix].filter(p => p);
expect(getters[getter](state)).toBe(expectedPieces.join(' '));
});
});
describe('showGarbageCollection', () => {
it.each`
result | showGarbageCollectionTip | isAdmin
${true} | ${true} | ${true}
${false} | ${true} | ${false}
${false} | ${false} | ${true}
`(
'return $result when showGarbageCollectionTip $showGarbageCollectionTip and isAdmin is $isAdmin',
({ result, showGarbageCollectionTip, isAdmin }) => {
state = {
config: { isAdmin },
showGarbageCollectionTip,
};
expect(getters.showGarbageCollection(state)).toBe(result);
},
);
});
});
import mutations from '~/registry/explorer/stores/mutations';
import * as types from '~/registry/explorer/stores/mutation_types';
describe('Mutations Registry Explorer Store', () => {
let mockState;
beforeEach(() => {
mockState = {};
});
describe('SET_INITIAL_STATE', () => {
it('should set the initial state', () => {
const payload = {
endpoint: 'foo',
isGroupPage: '',
expirationPolicy: { foo: 'bar' },
isAdmin: '',
};
const expectedState = {
...mockState,
config: { ...payload, isGroupPage: false, isAdmin: false },
};
mutations[types.SET_INITIAL_STATE](mockState, {
...payload,
expirationPolicy: JSON.stringify(payload.expirationPolicy),
});
expect(mockState).toEqual(expectedState);
});
});
describe('SET_IMAGES_LIST_SUCCESS', () => {
it('should set the images list', () => {
const images = [{ name: 'foo' }, { name: 'bar' }];
const defaultStatus = { deleting: false, failedDelete: false };
const expectedState = {
...mockState,
images: [{ name: 'foo', ...defaultStatus }, { name: 'bar', ...defaultStatus }],
};
mutations[types.SET_IMAGES_LIST_SUCCESS](mockState, images);
expect(mockState).toEqual(expectedState);
});
});
describe('UPDATE_IMAGE', () => {
it('should update an image', () => {
mockState.images = [{ id: 1, name: 'foo' }, { id: 2, name: 'bar' }];
const payload = { id: 1, name: 'baz' };
const expectedState = {
...mockState,
images: [payload, { id: 2, name: 'bar' }],
};
mutations[types.UPDATE_IMAGE](mockState, payload);
expect(mockState).toEqual(expectedState);
});
});
describe('SET_TAGS_LIST_SUCCESS', () => {
it('should set the tags list', () => {
const tags = [1, 2, 3];
const expectedState = { ...mockState, tags };
mutations[types.SET_TAGS_LIST_SUCCESS](mockState, tags);
expect(mockState).toEqual(expectedState);
});
});
describe('SET_MAIN_LOADING', () => {
it('should set the isLoading', () => {
const expectedState = { ...mockState, isLoading: true };
mutations[types.SET_MAIN_LOADING](mockState, true);
expect(mockState).toEqual(expectedState);
});
});
describe('SET_SHOW_GARBAGE_COLLECTION_TIP', () => {
it('should set the showGarbageCollectionTip', () => {
const expectedState = { ...mockState, showGarbageCollectionTip: true };
mutations[types.SET_SHOW_GARBAGE_COLLECTION_TIP](mockState, true);
expect(mockState).toEqual(expectedState);
});
});
describe('SET_PAGINATION', () => {
const generatePagination = () => [
{
'X-PAGE': '1',
'X-PER-PAGE': '20',
'X-TOTAL': '100',
'X-TOTAL-PAGES': '5',
'X-NEXT-PAGE': '2',
'X-PREV-PAGE': '0',
},
{
page: 1,
perPage: 20,
total: 100,
totalPages: 5,
nextPage: 2,
previousPage: 0,
},
];
it('should set the images pagination', () => {
const [headers, expectedResult] = generatePagination();
const expectedState = { ...mockState, pagination: expectedResult };
mutations[types.SET_PAGINATION](mockState, headers);
expect(mockState).toEqual(expectedState);
});
it('should set the tags pagination', () => {
const [headers, expectedResult] = generatePagination();
const expectedState = { ...mockState, tagsPagination: expectedResult };
mutations[types.SET_TAGS_PAGINATION](mockState, headers);
expect(mockState).toEqual(expectedState);
});
});
describe('SET_IMAGE_DETAILS', () => {
it('should set imageDetails', () => {
const expectedState = { ...mockState, imageDetails: { foo: 'bar' } };
mutations[types.SET_IMAGE_DETAILS](mockState, { foo: 'bar' });
expect(mockState).toEqual(expectedState);
});
});
});
import { pathGenerator } from '~/registry/explorer/utils';
describe('Utils', () => {
describe('pathGenerator', () => {
const imageDetails = {
path: 'foo/bar/baz',
name: 'baz',
id: 1,
};
beforeEach(() => {
window.gon.relative_url_root = null;
});
it('returns the fetch url when no ending is passed', () => {
expect(pathGenerator(imageDetails)).toBe('/foo/bar/registry/repository/1/tags?format=json');
});
it('returns the url with an ending when is passed', () => {
expect(pathGenerator(imageDetails, '/foo')).toBe('/foo/bar/registry/repository/1/tags/foo');
});
describe.each`
path | name | result
${'foo/foo'} | ${''} | ${'/foo/foo/registry/repository/1/tags?format=json'}
${'foo/foo/foo'} | ${'foo'} | ${'/foo/foo/registry/repository/1/tags?format=json'}
${'baz/foo/foo/foo'} | ${'foo'} | ${'/baz/foo/foo/registry/repository/1/tags?format=json'}
${'baz/foo/foo/foo'} | ${'foo'} | ${'/baz/foo/foo/registry/repository/1/tags?format=json'}
${'foo/foo/baz/foo/foo'} | ${'foo/foo'} | ${'/foo/foo/baz/registry/repository/1/tags?format=json'}
${'foo/foo/baz/foo/bar'} | ${'foo/bar'} | ${'/foo/foo/baz/registry/repository/1/tags?format=json'}
${'baz/foo/foo'} | ${'foo'} | ${'/baz/foo/registry/repository/1/tags?format=json'}
${'baz/foo/bar'} | ${'foo'} | ${'/baz/foo/bar/registry/repository/1/tags?format=json'}
`('when path is $path and name is $name', ({ name, path, result }) => {
it('returns the correct value', () => {
expect(pathGenerator({ id: 1, name, path })).toBe(result);
});
it('produces a correct relative url', () => {
window.gon.relative_url_root = '/gitlab';
expect(pathGenerator({ id: 1, name, path })).toBe(`/gitlab${result}`);
});
});
it('returns the url unchanged when imageDetails have no name', () => {
const imageDetailsWithoutName = {
path: 'foo/bar/baz',
name: '',
id: 1,
};
expect(pathGenerator(imageDetailsWithoutName)).toBe(
'/foo/bar/baz/registry/repository/1/tags?format=json',
);
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment