Commit 1fc8c2fa authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'xanf-use-local-storage-to-persist-import-data' into 'master'

Group migration: maintain import status across reloads

See merge request gitlab-org/gitlab!54567
parents 43698b7b b0c22b54
...@@ -7,6 +7,7 @@ export const STATUSES = { ...@@ -7,6 +7,7 @@ export const STATUSES = {
FINISHED: 'finished', FINISHED: 'finished',
FAILED: 'failed', FAILED: 'failed',
SCHEDULED: 'scheduled', SCHEDULED: 'scheduled',
CREATED: 'created',
STARTED: 'started', STARTED: 'started',
NONE: 'none', NONE: 'none',
SCHEDULING: 'scheduling', SCHEDULING: 'scheduling',
...@@ -23,6 +24,11 @@ const STATUS_MAP = { ...@@ -23,6 +24,11 @@ const STATUS_MAP = {
text: __('Failed'), text: __('Failed'),
textClass: 'text-danger', textClass: 'text-danger',
}, },
[STATUSES.CREATED]: {
icon: 'pending',
text: __('Scheduled'),
textClass: 'text-warning',
},
[STATUSES.SCHEDULED]: { [STATUSES.SCHEDULED]: {
icon: 'pending', icon: 'pending',
text: __('Scheduled'), text: __('Scheduled'),
......
...@@ -15,52 +15,71 @@ export const clientTypenames = { ...@@ -15,52 +15,71 @@ export const clientTypenames = {
BulkImportPageInfo: 'ClientBulkImportPageInfo', BulkImportPageInfo: 'ClientBulkImportPageInfo',
}; };
export function createResolvers({ endpoints }) { export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
let statusPoller; let statusPoller;
let sourceGroupManager;
const getGroupsManager = (client) => {
if (!sourceGroupManager) {
sourceGroupManager = new GroupsManager({ client, sourceUrl });
}
return sourceGroupManager;
};
return { return {
Query: { Query: {
async bulkImportSourceGroups(_, vars, { client }) { async bulkImportSourceGroups(_, vars, { client }) {
const {
data: { availableNamespaces },
} = await client.query({ query: availableNamespacesQuery });
if (!statusPoller) { if (!statusPoller) {
statusPoller = new StatusPoller({ statusPoller = new StatusPoller({
client, groupManager: getGroupsManager(client),
pollPath: endpoints.jobs, pollPath: endpoints.jobs,
}); });
statusPoller.startPolling(); statusPoller.startPolling();
} }
return axios const groupsManager = getGroupsManager(client);
.get(endpoints.status, { return Promise.all([
axios.get(endpoints.status, {
params: { params: {
page: vars.page, page: vars.page,
per_page: vars.perPage, per_page: vars.perPage,
filter: vars.filter, filter: vars.filter,
}, },
}) }),
.then(({ headers, data }) => { client.query({ query: availableNamespacesQuery }),
]).then(
([
{ headers, data },
{
data: { availableNamespaces },
},
]) => {
const pagination = parseIntPagination(normalizeHeaders(headers)); const pagination = parseIntPagination(normalizeHeaders(headers));
return { return {
__typename: clientTypenames.BulkImportSourceGroupConnection, __typename: clientTypenames.BulkImportSourceGroupConnection,
nodes: data.importable_data.map((group) => ({ nodes: data.importable_data.map((group) => {
__typename: clientTypenames.BulkImportSourceGroup, const cachedImportState = groupsManager.getImportStateFromStorageByGroupId(
...group, group.id,
status: STATUSES.NONE, );
import_target: {
new_name: group.full_path, return {
target_namespace: availableNamespaces[0]?.full_path ?? '', __typename: clientTypenames.BulkImportSourceGroup,
}, ...group,
})), status: cachedImportState?.status ?? STATUSES.NONE,
import_target: cachedImportState?.importTarget ?? {
new_name: group.full_path,
target_namespace: availableNamespaces[0]?.full_path ?? '',
},
};
}),
pageInfo: { pageInfo: {
__typename: clientTypenames.BulkImportPageInfo, __typename: clientTypenames.BulkImportPageInfo,
...pagination, ...pagination,
}, },
}; };
}); },
);
}, },
availableNamespaces: () => availableNamespaces: () =>
...@@ -73,21 +92,21 @@ export function createResolvers({ endpoints }) { ...@@ -73,21 +92,21 @@ export function createResolvers({ endpoints }) {
}, },
Mutation: { Mutation: {
setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) { setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) {
new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => { getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
sourceGroup.import_target.target_namespace = targetNamespace; sourceGroup.import_target.target_namespace = targetNamespace;
}); });
}, },
setNewName(_, { newName, sourceGroupId }, { client }) { setNewName(_, { newName, sourceGroupId }, { client }) {
new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => { getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => {
// eslint-disable-next-line no-param-reassign // eslint-disable-next-line no-param-reassign
sourceGroup.import_target.new_name = newName; sourceGroup.import_target.new_name = newName;
}); });
}, },
async importGroup(_, { sourceGroupId }, { client }) { async importGroup(_, { sourceGroupId }, { client }) {
const groupManager = new SourceGroupsManager({ client }); const groupManager = getGroupsManager(client);
const group = groupManager.findById(sourceGroupId); const group = groupManager.findById(sourceGroupId);
groupManager.setImportStatus(group, STATUSES.SCHEDULING); groupManager.setImportStatus(group, STATUSES.SCHEDULING);
try { try {
...@@ -101,8 +120,7 @@ export function createResolvers({ endpoints }) { ...@@ -101,8 +120,7 @@ export function createResolvers({ endpoints }) {
}, },
], ],
}); });
groupManager.setImportStatus(group, STATUSES.STARTED); groupManager.startImport({ group, importId: response.data.id });
SourceGroupsManager.attachImportId(group, response.data.id);
} catch (e) { } catch (e) {
createFlash({ createFlash({
message: s__('BulkImport|Importing the group failed'), message: s__('BulkImport|Importing the group failed'),
...@@ -116,5 +134,5 @@ export function createResolvers({ endpoints }) { ...@@ -116,5 +134,5 @@ export function createResolvers({ endpoints }) {
}; };
} }
export const createApolloClient = ({ endpoints }) => export const createApolloClient = ({ sourceUrl, endpoints }) =>
createDefaultClient(createResolvers({ endpoints }), { assumeImmutableResults: true }); createDefaultClient(createResolvers({ sourceUrl, endpoints }), { assumeImmutableResults: true });
import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import produce from 'immer'; import produce from 'immer';
import { debounce, merge } from 'lodash';
import { STATUSES } from '../../../constants';
import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql'; import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql';
function extractTypeConditionFromFragment(fragment) { function extractTypeConditionFromFragment(fragment) {
...@@ -13,15 +15,24 @@ function generateGroupId(id) { ...@@ -13,15 +15,24 @@ function generateGroupId(id) {
}); });
} }
export const KEY = 'gl-bulk-imports-import-state';
export const DEBOUNCE_INTERVAL = 200;
export class SourceGroupsManager { export class SourceGroupsManager {
static importMap = new Map(); constructor({ client, sourceUrl, storage = window.localStorage }) {
this.client = client;
this.sourceUrl = sourceUrl;
static attachImportId(group, importId) { this.storage = storage;
SourceGroupsManager.importMap.set(importId, group.id); this.importStates = this.loadImportStatesFromStorage();
} }
constructor({ client }) { loadImportStatesFromStorage() {
this.client = client; try {
return JSON.parse(this.storage.getItem(KEY)) ?? {};
} catch {
return {};
}
} }
findById(id) { findById(id) {
...@@ -42,8 +53,48 @@ export class SourceGroupsManager { ...@@ -42,8 +53,48 @@ export class SourceGroupsManager {
this.update(group, fn); this.update(group, fn);
} }
findByImportId(importId) { saveImportState(importId, group) {
return this.findById(SourceGroupsManager.importMap.get(importId)); this.importStates[this.getStorageKey(importId)] = {
id: group.id,
importTarget: group.import_target,
status: group.status,
};
this.saveImportStatesToStorage();
}
getImportStateFromStorage(importId) {
return this.importStates[this.getStorageKey(importId)];
}
getImportStateFromStorageByGroupId(groupId) {
const PREFIX = this.getStorageKey('');
const [, importState] =
Object.entries(this.importStates).find(
([key, group]) => key.startsWith(PREFIX) && group.id === groupId,
) ?? [];
return importState;
}
getStorageKey(importId) {
return `${this.sourceUrl}|${importId}`;
}
saveImportStatesToStorage = debounce(() => {
try {
// storage might be changed in other tab so fetch first
this.storage.setItem(
KEY,
JSON.stringify(merge({}, this.loadImportStatesFromStorage(), this.importStates)),
);
} catch {
// empty catch intentional: storage might be unavailable or full
}
}, DEBOUNCE_INTERVAL);
startImport({ group, importId }) {
this.setImportStatus(group, STATUSES.CREATED);
this.saveImportState(importId, group);
} }
setImportStatus(group, status) { setImportStatus(group, status) {
...@@ -52,4 +103,22 @@ export class SourceGroupsManager { ...@@ -52,4 +103,22 @@ export class SourceGroupsManager {
sourceGroup.status = status; sourceGroup.status = status;
}); });
} }
setImportStatusByImportId(importId, status) {
const importState = this.getImportStateFromStorage(importId);
if (!importState) {
return;
}
if (importState.status !== status) {
importState.status = status;
}
const group = this.findById(importState.id);
if (group?.id) {
this.setImportStatus(group, status);
}
this.saveImportStatesToStorage();
}
} }
...@@ -3,12 +3,9 @@ import createFlash from '~/flash'; ...@@ -3,12 +3,9 @@ import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll'; import Poll from '~/lib/utils/poll';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { SourceGroupsManager } from './source_groups_manager';
export class StatusPoller { export class StatusPoller {
constructor({ client, pollPath }) { constructor({ groupManager, pollPath }) {
this.client = client;
this.eTagPoll = new Poll({ this.eTagPoll = new Poll({
resource: { resource: {
fetchJobs: () => axios.get(pollPath), fetchJobs: () => axios.get(pollPath),
...@@ -29,7 +26,7 @@ export class StatusPoller { ...@@ -29,7 +26,7 @@ export class StatusPoller {
} }
}); });
this.groupManager = new SourceGroupsManager({ client }); this.groupManager = groupManager;
} }
startPolling() { startPolling() {
...@@ -38,10 +35,7 @@ export class StatusPoller { ...@@ -38,10 +35,7 @@ export class StatusPoller {
async updateImportsStatuses(importStatuses) { async updateImportsStatuses(importStatuses) {
importStatuses.forEach(({ id, status_name: statusName }) => { importStatuses.forEach(({ id, status_name: statusName }) => {
const group = this.groupManager.findByImportId(id); this.groupManager.setImportStatusByImportId(id, statusName);
if (group.id) {
this.groupManager.setImportStatus(group, statusName);
}
}); });
} }
} }
...@@ -21,6 +21,7 @@ export function mountImportGroupsApp(mountElement) { ...@@ -21,6 +21,7 @@ export function mountImportGroupsApp(mountElement) {
} = mountElement.dataset; } = mountElement.dataset;
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createApolloClient({ defaultClient: createApolloClient({
sourceUrl,
endpoints: { endpoints: {
status: statusPath, status: statusPath,
availableNamespaces: availableNamespacesPath, availableNamespaces: availableNamespacesPath,
......
...@@ -20,6 +20,12 @@ export default { ...@@ -20,6 +20,12 @@ export default {
}, },
}, },
watch: {
value() {
$(this.$refs.dropdownInput).val(this.value).trigger('change');
},
},
mounted() { mounted() {
loadCSSFile(gon.select2_css_path) loadCSSFile(gon.select2_css_path)
.then(() => { .then(() => {
......
...@@ -35,15 +35,19 @@ describe('Bulk import resolvers', () => { ...@@ -35,15 +35,19 @@ describe('Bulk import resolvers', () => {
let axiosMockAdapter; let axiosMockAdapter;
let client; let client;
beforeEach(() => { const createClient = (extraResolverArgs) => {
axiosMockAdapter = new MockAdapter(axios); return createMockClient({
client = createMockClient({
cache: new InMemoryCache({ cache: new InMemoryCache({
fragmentMatcher: { match: () => true }, fragmentMatcher: { match: () => true },
addTypename: false, addTypename: false,
}), }),
resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS }), resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS, ...extraResolverArgs }),
}); });
};
beforeEach(() => {
axiosMockAdapter = new MockAdapter(axios);
client = createClient();
}); });
afterEach(() => { afterEach(() => {
...@@ -82,6 +86,35 @@ describe('Bulk import resolvers', () => { ...@@ -82,6 +86,35 @@ describe('Bulk import resolvers', () => {
.reply(httpStatus.OK, availableNamespacesFixture); .reply(httpStatus.OK, availableNamespacesFixture);
}); });
it('respects cached import state when provided by group manager', async () => {
const FAKE_STATUS = 'DEMO_STATUS';
const FAKE_IMPORT_TARGET = {};
const TARGET_INDEX = 0;
const clientWithMockedManager = createClient({
GroupsManager: jest.fn().mockImplementation(() => ({
getImportStateFromStorageByGroupId(groupId) {
if (groupId === statusEndpointFixture.importable_data[TARGET_INDEX].id) {
return {
status: FAKE_STATUS,
importTarget: FAKE_IMPORT_TARGET,
};
}
return null;
},
})),
});
const clientResponse = await clientWithMockedManager.query({
query: bulkImportSourceGroupsQuery,
});
const clientResults = clientResponse.data.bulkImportSourceGroups.nodes;
expect(clientResults[TARGET_INDEX].import_target).toBe(FAKE_IMPORT_TARGET);
expect(clientResults[TARGET_INDEX].status).toBe(FAKE_STATUS);
});
it('populates each result instance with empty import_target when there are no available namespaces', async () => { it('populates each result instance with empty import_target when there are no available namespaces', async () => {
axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(httpStatus.OK, []); axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(httpStatus.OK, []);
...@@ -229,14 +262,14 @@ describe('Bulk import resolvers', () => { ...@@ -229,14 +262,14 @@ describe('Bulk import resolvers', () => {
expect(intermediateResults[0].status).toBe(STATUSES.SCHEDULING); expect(intermediateResults[0].status).toBe(STATUSES.SCHEDULING);
}); });
it('sets group status to STARTED when request completes', async () => { it('sets import status to CREATED when request completes', async () => {
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 }); axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK, { id: 1 });
await client.mutate({ await client.mutate({
mutation: importGroupMutation, mutation: importGroupMutation,
variables: { sourceGroupId: GROUP_ID }, variables: { sourceGroupId: GROUP_ID },
}); });
expect(results[0].status).toBe(STATUSES.STARTED); expect(results[0].status).toBe(STATUSES.CREATED);
}); });
it('resets status to NONE if request fails', async () => { it('resets status to NONE if request fails', async () => {
......
import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory'; import { clientTypenames } from '~/import_entities/import_groups/graphql/client_factory';
import ImportSourceGroupFragment from '~/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql'; import ImportSourceGroupFragment from '~/import_entities/import_groups/graphql/fragments/bulk_import_source_group_item.fragment.graphql';
import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager'; import {
KEY,
SourceGroupsManager,
} from '~/import_entities/import_groups/graphql/services/source_groups_manager';
const FAKE_SOURCE_URL = 'http://demo.host';
describe('SourceGroupsManager', () => { describe('SourceGroupsManager', () => {
let manager; let manager;
let client; let client;
let storage;
const getFakeGroup = () => ({ const getFakeGroup = () => ({
__typename: clientTypenames.BulkImportSourceGroup, __typename: clientTypenames.BulkImportSourceGroup,
...@@ -17,8 +23,53 @@ describe('SourceGroupsManager', () => { ...@@ -17,8 +23,53 @@ describe('SourceGroupsManager', () => {
readFragment: jest.fn(), readFragment: jest.fn(),
writeFragment: jest.fn(), writeFragment: jest.fn(),
}; };
storage = {
getItem: jest.fn(),
setItem: jest.fn(),
};
manager = new SourceGroupsManager({ client, storage, sourceUrl: FAKE_SOURCE_URL });
});
describe('storage management', () => {
const IMPORT_ID = 1;
const IMPORT_TARGET = { destination_name: 'demo', destination_namespace: 'foo' };
const STATUS = 'FAKE_STATUS';
const FAKE_GROUP = { id: 1, import_target: IMPORT_TARGET, status: STATUS };
it('loads state from storage on creation', () => {
expect(storage.getItem).toHaveBeenCalledWith(KEY);
});
it('saves to storage when import is starting', () => {
manager.startImport({
importId: IMPORT_ID,
group: FAKE_GROUP,
});
const storedObject = JSON.parse(storage.setItem.mock.calls[0][1]);
expect(Object.values(storedObject)[0]).toStrictEqual({
id: FAKE_GROUP.id,
importTarget: IMPORT_TARGET,
status: STATUS,
});
});
manager = new SourceGroupsManager({ client }); it('saves to storage when import status is updated', () => {
const CHANGED_STATUS = 'changed';
manager.startImport({
importId: IMPORT_ID,
group: FAKE_GROUP,
});
manager.setImportStatusByImportId(IMPORT_ID, CHANGED_STATUS);
const storedObject = JSON.parse(storage.setItem.mock.calls[1][1]);
expect(Object.values(storedObject)[0]).toStrictEqual({
id: FAKE_GROUP.id,
importTarget: IMPORT_TARGET,
status: CHANGED_STATUS,
});
});
}); });
it('finds item by group id', () => { it('finds item by group id', () => {
......
...@@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter';
import Visibility from 'visibilityjs'; import Visibility from 'visibilityjs';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { STATUSES } from '~/import_entities/constants'; import { STATUSES } from '~/import_entities/constants';
import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager';
import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller'; import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll'; import Poll from '~/lib/utils/poll';
...@@ -18,24 +17,21 @@ jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manage ...@@ -18,24 +17,21 @@ jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manage
})); }));
const FAKE_POLL_PATH = '/fake/poll/path'; const FAKE_POLL_PATH = '/fake/poll/path';
const CLIENT_MOCK = {};
describe('Bulk import status poller', () => { describe('Bulk import status poller', () => {
let poller; let poller;
let mockAdapter; let mockAdapter;
let groupManager;
const getPollHistory = () => mockAdapter.history.get.filter((x) => x.url === FAKE_POLL_PATH); const getPollHistory = () => mockAdapter.history.get.filter((x) => x.url === FAKE_POLL_PATH);
beforeEach(() => { beforeEach(() => {
mockAdapter = new MockAdapter(axios); mockAdapter = new MockAdapter(axios);
mockAdapter.onGet(FAKE_POLL_PATH).reply(200, {}); mockAdapter.onGet(FAKE_POLL_PATH).reply(200, {});
poller = new StatusPoller({ client: CLIENT_MOCK, pollPath: FAKE_POLL_PATH }); groupManager = {
}); setImportStatusByImportId: jest.fn(),
};
it('creates source group manager with proper client', () => { poller = new StatusPoller({ groupManager, pollPath: FAKE_POLL_PATH });
expect(SourceGroupsManager.mock.calls).toHaveLength(1);
const [[{ client }]] = SourceGroupsManager.mock.calls;
expect(client).toBe(CLIENT_MOCK);
}); });
it('creates poller with proper config', () => { it('creates poller with proper config', () => {
...@@ -100,14 +96,9 @@ describe('Bulk import status poller', () => { ...@@ -100,14 +96,9 @@ describe('Bulk import status poller', () => {
it('when success response arrives updates relevant group status', () => { it('when success response arrives updates relevant group status', () => {
const FAKE_ID = 5; const FAKE_ID = 5;
const [[pollConfig]] = Poll.mock.calls; const [[pollConfig]] = Poll.mock.calls;
const [managerInstance] = SourceGroupsManager.mock.instances;
managerInstance.findByImportId.mockReturnValue({ id: FAKE_ID });
pollConfig.successCallback({ data: [{ id: FAKE_ID, status_name: STATUSES.FINISHED }] }); pollConfig.successCallback({ data: [{ id: FAKE_ID, status_name: STATUSES.FINISHED }] });
expect(managerInstance.setImportStatus).toHaveBeenCalledWith( expect(groupManager.setImportStatusByImportId).toHaveBeenCalledWith(FAKE_ID, STATUSES.FINISHED);
expect.objectContaining({ id: FAKE_ID }),
STATUSES.FINISHED,
);
}); });
}); });
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