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 = {
FINISHED: 'finished',
FAILED: 'failed',
SCHEDULED: 'scheduled',
CREATED: 'created',
STARTED: 'started',
NONE: 'none',
SCHEDULING: 'scheduling',
......@@ -23,6 +24,11 @@ const STATUS_MAP = {
text: __('Failed'),
textClass: 'text-danger',
},
[STATUSES.CREATED]: {
icon: 'pending',
text: __('Scheduled'),
textClass: 'text-warning',
},
[STATUSES.SCHEDULED]: {
icon: 'pending',
text: __('Scheduled'),
......
......@@ -15,52 +15,71 @@ export const clientTypenames = {
BulkImportPageInfo: 'ClientBulkImportPageInfo',
};
export function createResolvers({ endpoints }) {
export function createResolvers({ endpoints, sourceUrl, GroupsManager = SourceGroupsManager }) {
let statusPoller;
let sourceGroupManager;
const getGroupsManager = (client) => {
if (!sourceGroupManager) {
sourceGroupManager = new GroupsManager({ client, sourceUrl });
}
return sourceGroupManager;
};
return {
Query: {
async bulkImportSourceGroups(_, vars, { client }) {
const {
data: { availableNamespaces },
} = await client.query({ query: availableNamespacesQuery });
if (!statusPoller) {
statusPoller = new StatusPoller({
client,
groupManager: getGroupsManager(client),
pollPath: endpoints.jobs,
});
statusPoller.startPolling();
}
return axios
.get(endpoints.status, {
const groupsManager = getGroupsManager(client);
return Promise.all([
axios.get(endpoints.status, {
params: {
page: vars.page,
per_page: vars.perPage,
filter: vars.filter,
},
})
.then(({ headers, data }) => {
}),
client.query({ query: availableNamespacesQuery }),
]).then(
([
{ headers, data },
{
data: { availableNamespaces },
},
]) => {
const pagination = parseIntPagination(normalizeHeaders(headers));
return {
__typename: clientTypenames.BulkImportSourceGroupConnection,
nodes: data.importable_data.map((group) => ({
__typename: clientTypenames.BulkImportSourceGroup,
...group,
status: STATUSES.NONE,
import_target: {
new_name: group.full_path,
target_namespace: availableNamespaces[0]?.full_path ?? '',
},
})),
nodes: data.importable_data.map((group) => {
const cachedImportState = groupsManager.getImportStateFromStorageByGroupId(
group.id,
);
return {
__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: {
__typename: clientTypenames.BulkImportPageInfo,
...pagination,
},
};
});
},
);
},
availableNamespaces: () =>
......@@ -73,21 +92,21 @@ export function createResolvers({ endpoints }) {
},
Mutation: {
setTargetNamespace(_, { targetNamespace, sourceGroupId }, { client }) {
new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => {
getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => {
// eslint-disable-next-line no-param-reassign
sourceGroup.import_target.target_namespace = targetNamespace;
});
},
setNewName(_, { newName, sourceGroupId }, { client }) {
new SourceGroupsManager({ client }).updateById(sourceGroupId, (sourceGroup) => {
getGroupsManager(client).updateById(sourceGroupId, (sourceGroup) => {
// eslint-disable-next-line no-param-reassign
sourceGroup.import_target.new_name = newName;
});
},
async importGroup(_, { sourceGroupId }, { client }) {
const groupManager = new SourceGroupsManager({ client });
const groupManager = getGroupsManager(client);
const group = groupManager.findById(sourceGroupId);
groupManager.setImportStatus(group, STATUSES.SCHEDULING);
try {
......@@ -101,8 +120,7 @@ export function createResolvers({ endpoints }) {
},
],
});
groupManager.setImportStatus(group, STATUSES.STARTED);
SourceGroupsManager.attachImportId(group, response.data.id);
groupManager.startImport({ group, importId: response.data.id });
} catch (e) {
createFlash({
message: s__('BulkImport|Importing the group failed'),
......@@ -116,5 +134,5 @@ export function createResolvers({ endpoints }) {
};
}
export const createApolloClient = ({ endpoints }) =>
createDefaultClient(createResolvers({ endpoints }), { assumeImmutableResults: true });
export const createApolloClient = ({ sourceUrl, endpoints }) =>
createDefaultClient(createResolvers({ sourceUrl, endpoints }), { assumeImmutableResults: true });
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import produce from 'immer';
import { debounce, merge } from 'lodash';
import { STATUSES } from '../../../constants';
import ImportSourceGroupFragment from '../fragments/bulk_import_source_group_item.fragment.graphql';
function extractTypeConditionFromFragment(fragment) {
......@@ -13,15 +15,24 @@ function generateGroupId(id) {
});
}
export const KEY = 'gl-bulk-imports-import-state';
export const DEBOUNCE_INTERVAL = 200;
export class SourceGroupsManager {
static importMap = new Map();
constructor({ client, sourceUrl, storage = window.localStorage }) {
this.client = client;
this.sourceUrl = sourceUrl;
static attachImportId(group, importId) {
SourceGroupsManager.importMap.set(importId, group.id);
this.storage = storage;
this.importStates = this.loadImportStatesFromStorage();
}
constructor({ client }) {
this.client = client;
loadImportStatesFromStorage() {
try {
return JSON.parse(this.storage.getItem(KEY)) ?? {};
} catch {
return {};
}
}
findById(id) {
......@@ -42,8 +53,48 @@ export class SourceGroupsManager {
this.update(group, fn);
}
findByImportId(importId) {
return this.findById(SourceGroupsManager.importMap.get(importId));
saveImportState(importId, group) {
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) {
......@@ -52,4 +103,22 @@ export class SourceGroupsManager {
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';
import axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
import { s__ } from '~/locale';
import { SourceGroupsManager } from './source_groups_manager';
export class StatusPoller {
constructor({ client, pollPath }) {
this.client = client;
constructor({ groupManager, pollPath }) {
this.eTagPoll = new Poll({
resource: {
fetchJobs: () => axios.get(pollPath),
......@@ -29,7 +26,7 @@ export class StatusPoller {
}
});
this.groupManager = new SourceGroupsManager({ client });
this.groupManager = groupManager;
}
startPolling() {
......@@ -38,10 +35,7 @@ export class StatusPoller {
async updateImportsStatuses(importStatuses) {
importStatuses.forEach(({ id, status_name: statusName }) => {
const group = this.groupManager.findByImportId(id);
if (group.id) {
this.groupManager.setImportStatus(group, statusName);
}
this.groupManager.setImportStatusByImportId(id, statusName);
});
}
}
......@@ -21,6 +21,7 @@ export function mountImportGroupsApp(mountElement) {
} = mountElement.dataset;
const apolloProvider = new VueApollo({
defaultClient: createApolloClient({
sourceUrl,
endpoints: {
status: statusPath,
availableNamespaces: availableNamespacesPath,
......
......@@ -20,6 +20,12 @@ export default {
},
},
watch: {
value() {
$(this.$refs.dropdownInput).val(this.value).trigger('change');
},
},
mounted() {
loadCSSFile(gon.select2_css_path)
.then(() => {
......
......@@ -35,15 +35,19 @@ describe('Bulk import resolvers', () => {
let axiosMockAdapter;
let client;
beforeEach(() => {
axiosMockAdapter = new MockAdapter(axios);
client = createMockClient({
const createClient = (extraResolverArgs) => {
return createMockClient({
cache: new InMemoryCache({
fragmentMatcher: { match: () => true },
addTypename: false,
}),
resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS }),
resolvers: createResolvers({ endpoints: FAKE_ENDPOINTS, ...extraResolverArgs }),
});
};
beforeEach(() => {
axiosMockAdapter = new MockAdapter(axios);
client = createClient();
});
afterEach(() => {
......@@ -82,6 +86,35 @@ describe('Bulk import resolvers', () => {
.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 () => {
axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply(httpStatus.OK, []);
......@@ -229,14 +262,14 @@ describe('Bulk import resolvers', () => {
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 });
await client.mutate({
mutation: importGroupMutation,
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 () => {
......
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
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 { 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', () => {
let manager;
let client;
let storage;
const getFakeGroup = () => ({
__typename: clientTypenames.BulkImportSourceGroup,
......@@ -17,8 +23,53 @@ describe('SourceGroupsManager', () => {
readFragment: 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', () => {
......
......@@ -2,7 +2,6 @@ import MockAdapter from 'axios-mock-adapter';
import Visibility from 'visibilityjs';
import createFlash from '~/flash';
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 axios from '~/lib/utils/axios_utils';
import Poll from '~/lib/utils/poll';
......@@ -18,24 +17,21 @@ jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manage
}));
const FAKE_POLL_PATH = '/fake/poll/path';
const CLIENT_MOCK = {};
describe('Bulk import status poller', () => {
let poller;
let mockAdapter;
let groupManager;
const getPollHistory = () => mockAdapter.history.get.filter((x) => x.url === FAKE_POLL_PATH);
beforeEach(() => {
mockAdapter = new MockAdapter(axios);
mockAdapter.onGet(FAKE_POLL_PATH).reply(200, {});
poller = new StatusPoller({ client: CLIENT_MOCK, pollPath: FAKE_POLL_PATH });
});
it('creates source group manager with proper client', () => {
expect(SourceGroupsManager.mock.calls).toHaveLength(1);
const [[{ client }]] = SourceGroupsManager.mock.calls;
expect(client).toBe(CLIENT_MOCK);
groupManager = {
setImportStatusByImportId: jest.fn(),
};
poller = new StatusPoller({ groupManager, pollPath: FAKE_POLL_PATH });
});
it('creates poller with proper config', () => {
......@@ -100,14 +96,9 @@ describe('Bulk import status poller', () => {
it('when success response arrives updates relevant group status', () => {
const FAKE_ID = 5;
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 }] });
expect(managerInstance.setImportStatus).toHaveBeenCalledWith(
expect.objectContaining({ id: FAKE_ID }),
STATUSES.FINISHED,
);
expect(groupManager.setImportStatusByImportId).toHaveBeenCalledWith(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