Commit 4022e65d authored by Mark Chao's avatar Mark Chao

Merge branch '323357-mlunoe-transition-fetching-ci-minutes-plans-from-graphql' into 'master'

Refactor(CI Minutes): use customer graphql client

See merge request gitlab-org/gitlab!59370
parents 6d15a015 b72c2c4d
......@@ -3,8 +3,8 @@ import { GlEmptyState } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { __ } from '~/locale';
const ERROR_FETCHING_DATA_HEADER = __('Could not get the data properly');
const ERROR_FETCHING_DATA_DESCRIPTION = __(
export const ERROR_FETCHING_DATA_HEADER = __('Could not get the data properly');
export const ERROR_FETCHING_DATA_DESCRIPTION = __(
'Please try and refresh the page. If the problem persists please contact support.',
);
......
......@@ -18,11 +18,21 @@ export const fetchPolicies = {
};
export default (resolvers = {}, config = {}) => {
let uri = `${gon.relative_url_root || ''}/api/graphql`;
const {
assumeImmutableResults,
baseUrl,
batchMax = 10,
cacheConfig,
fetchPolicy = fetchPolicies.CACHE_FIRST,
typeDefs,
path = '/api/graphql',
useGet = false,
} = config;
let uri = `${gon.relative_url_root || ''}${path}`;
if (config.baseUrl) {
if (baseUrl) {
// Prepend baseUrl and ensure that `///` are replaced with `/`
uri = `${config.baseUrl}${uri}`.replace(/\/{3,}/g, '/');
uri = `${baseUrl}${uri}`.replace(/\/{3,}/g, '/');
}
const httpOptions = {
......@@ -34,7 +44,7 @@ export default (resolvers = {}, config = {}) => {
// We set to `same-origin` which is default value in modern browsers.
// See https://github.com/whatwg/fetch/pull/585 for more information.
credentials: 'same-origin',
batchMax: config.batchMax || 10,
batchMax,
};
const requestCounterLink = new ApolloLink((operation, forward) => {
......@@ -50,7 +60,7 @@ export default (resolvers = {}, config = {}) => {
const uploadsLink = ApolloLink.split(
(operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest,
createUploadLink(httpOptions),
config.useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions),
useGet ? createHttpLink(httpOptions) : new BatchHttpLink(httpOptions),
);
const performanceBarLink = new ApolloLink((operation, forward) => {
......@@ -74,7 +84,7 @@ export default (resolvers = {}, config = {}) => {
});
return new ApolloClient({
typeDefs: config.typeDefs,
typeDefs,
link: ApolloLink.from([
requestCounterLink,
performanceBarLink,
......@@ -83,14 +93,14 @@ export default (resolvers = {}, config = {}) => {
uploadsLink,
]),
cache: new InMemoryCache({
...config.cacheConfig,
freezeResults: config.assumeImmutableResults,
...cacheConfig,
freezeResults: assumeImmutableResults,
}),
resolvers,
assumeImmutableResults: config.assumeImmutableResults,
assumeImmutableResults,
defaultOptions: {
query: {
fetchPolicy: config.fetchPolicy || fetchPolicies.CACHE_FIRST,
fetchPolicy,
},
},
});
......
......@@ -118,6 +118,8 @@ To distinguish queries from mutations and fragments, the following naming conven
- `add_user.mutation.graphql` for mutations;
- `basic_user.fragment.graphql` for fragments.
If you are using queries for the [CustomersDot GraphQL endpoint](https://gitlab.com/gitlab-org/gitlab/-/blob/be78ccd832fd40315c5e63bb48ee1596ae146f56/app/controllers/customers_dot/proxy_controller.rb), end the filename with `.customer.query.graphql`, `.customer.mutation.graphql`, or `.customer.fragment.graphql`.
### Fragments
[Fragments](https://graphql.org/learn/queries/#fragments) are a way to make your complex GraphQL queries more readable and re-usable. Here is an example of GraphQL fragment:
......
<script>
import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg';
import { GlEmptyState } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import StepOrderApp from 'ee/vue_shared/purchase_flow/components/step_order_app.vue';
import { ERROR_FETCHING_DATA_HEADER, ERROR_FETCHING_DATA_DESCRIPTION } from '~/ensure_data';
import plansQuery from '../../graphql/queries/plans.customer.query.graphql';
import { planTags, CUSTOMER_CLIENT } from '../constants';
import Checkout from './checkout.vue';
export default {
components: {
Checkout,
GlEmptyState,
StepOrderApp,
},
i18n: {
ERROR_FETCHING_DATA_HEADER,
ERROR_FETCHING_DATA_DESCRIPTION,
},
emptySvg,
data() {
return {
plans: [],
hasError: false,
};
},
apollo: {
plans: {
client: CUSTOMER_CLIENT,
query: plansQuery,
variables: {
tags: [planTags.CI_1000_MINUTES_PLAN],
},
update(data) {
return data.plans;
},
error(error) {
this.hasError = true;
Sentry.captureException(error);
},
},
},
};
</script>
<template>
<step-order-app>
<gl-empty-state
v-if="hasError"
:title="$options.i18n.ERROR_FETCHING_DATA_HEADER"
:description="$options.i18n.ERROR_FETCHING_DATA_DESCRIPTION"
:svg-path="`data:image/svg+xml;utf8,${encodeURIComponent($options.emptySvg)}`"
/>
<step-order-app v-else>
<template #checkout>
<checkout />
<checkout :plans="plans" />
</template>
<template #order-summary></template>
</step-order-app>
......
......@@ -7,6 +7,12 @@ import SubscriptionDetails from './checkout/subscription_details.vue';
export default {
components: { ProgressBar, SubscriptionDetails },
props: {
plans: {
type: Array,
required: true,
},
},
apollo: {
state: {
query: STATE_QUERY,
......@@ -30,7 +36,7 @@ export default {
<progress-bar v-if="isNewUser" :steps="$options.steps" :current-step="$options.currentStep" />
<div class="flash-container"></div>
<h2 class="gl-mt-4 gl-mb-3 gl-mb-lg-5">{{ $options.i18n.checkout }}</h2>
<subscription-details />
<subscription-details :plans="plans" />
</div>
</div>
</template>
......@@ -20,6 +20,12 @@ export default {
directives: {
autofocusonshow,
},
props: {
plans: {
type: Array,
required: true,
},
},
apollo: {
state: {
query: STATE_QUERY,
......@@ -29,9 +35,6 @@ export default {
subscription() {
return this.state.subscription;
},
plans() {
return this.state.plans;
},
namespaces() {
return this.state.namespaces;
},
......@@ -71,7 +74,12 @@ export default {
},
},
selectedPlan() {
return this.state.plans.find((plan) => plan.code === this.subscription.planId);
const selectedPlan = this.plans.find((plan) => plan.code === this.subscription.planId);
if (!selectedPlan) {
return this.plans[0];
}
return selectedPlan;
},
selectedPlanTextLine() {
return sprintf(this.$options.i18n.selectedPlan, { selectedPlanText: this.selectedPlan.code });
......
/* eslint-disable @gitlab/require-i18n-strings */
export const planTags = {
CI_1000_MINUTES_PLAN: 'CI_1000_MINUTES_PLAN',
};
/* eslint-enable @gitlab/require-i18n-strings */
export const CUSTOMER_CLIENT = 'customerClient';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import createClient from '~/lib/graphql';
import { CUSTOMER_CLIENT } from './constants';
import { resolvers } from './graphql/resolvers';
Vue.use(VueApollo);
const defaultClient = createDefaultClient(resolvers, { assumeImmutableResults: true });
const defaultClient = createClient(resolvers, { assumeImmutableResults: true });
const customerClient = createClient(
{},
{ path: '/-/customers_dot/proxy/graphql', useGet: true, assumeImmutableResults: true },
);
export default new VueApollo({
defaultClient,
clients: {
[CUSTOMER_CLIENT]: customerClient,
},
});
import Vue from 'vue';
import App from 'ee/subscriptions/buy_minutes/components/app.vue';
import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
import { STEPS } from 'ee/subscriptions/new/constants';
import ensureData from '~/ensure_data';
import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils';
import stateQuery from '../graphql/queries/state.query.graphql';
import apolloProvider from './graphql';
import { parseData } from './utils';
const arrayToGraphqlArray = (arr, typename) =>
Array.from(arr, (item) =>
......@@ -13,12 +11,11 @@ const arrayToGraphqlArray = (arr, typename) =>
);
const writeInitialDataToApolloProvider = (dataset) => {
const { groupData, newUser, setupForCompany, fullName, planId } = dataset;
// eslint-disable-next-line @gitlab/require-i18n-strings
const plans = arrayToGraphqlArray(JSON.parse(dataset.ciMinutesPlans), 'Plan');
// eslint-disable-next-line @gitlab/require-i18n-strings
const namespaces = arrayToGraphqlArray(JSON.parse(dataset.groupData), 'Namespace');
const isNewUser = parseBoolean(dataset.newUser);
const isSetupForCompany = parseBoolean(dataset.setupForCompany) || !isNewUser;
const namespaces = arrayToGraphqlArray(JSON.parse(groupData), 'Namespace');
const isNewUser = parseBoolean(newUser);
const isSetupForCompany = parseBoolean(setupForCompany) || !isNewUser;
apolloProvider.clients.defaultClient.cache.writeQuery({
query: stateQuery,
......@@ -26,11 +23,10 @@ const writeInitialDataToApolloProvider = (dataset) => {
state: {
isNewUser,
isSetupForCompany,
plans,
namespaces,
fullName: dataset.fullName,
fullName,
subscription: {
planId: plans[0].code,
planId,
paymentMethodId: null,
quantity: 1,
namespaceId: null,
......@@ -62,18 +58,13 @@ export default (el) => {
return null;
}
const ExtendedApp = ensureData(App, {
parseData,
data: el.dataset,
});
writeInitialDataToApolloProvider(el.dataset);
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(ExtendedApp);
return createElement(App);
},
});
};
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export function parseData(dataset) {
const { ciMinutesPlans } = dataset;
return {
ciMinutesPlans: convertObjectPropsToCamelCase(JSON.parse(ciMinutesPlans), {
deep: true,
}),
};
}
query getPlans($tags: [PlanTag!]) {
plans(planTags: $tags) {
id
name
code
active
deprecated
free
pricePerMonth
pricePerYear
features
aboutPageHref
hideDeprecatedCard
}
}
query state {
state @client {
plans {
name
code
pricePerYear
}
namespaces {
id
name
......
import { GlEmptyState } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import App from 'ee/subscriptions/buy_minutes/components/app.vue';
import StepOrderApp from 'ee/vue_shared/purchase_flow/components/step_order_app.vue';
import waitForPromises from 'helpers/wait_for_promises';
import { createMockApolloProvider } from '../spec_helper';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('App', () => {
let wrapper;
function createComponent(options = {}) {
const { apolloProvider, propsData } = options;
return shallowMount(App, {
localVue,
propsData,
apolloProvider,
});
}
afterEach(() => {
wrapper.destroy();
});
describe('when data is received', () => {
it('should display the StepOrderApp', async () => {
const mockApollo = createMockApolloProvider();
wrapper = createComponent({ apolloProvider: mockApollo });
await waitForPromises();
expect(wrapper.findComponent(StepOrderApp).exists()).toBe(true);
expect(wrapper.findComponent(GlEmptyState).exists()).toBe(false);
});
});
describe('when data is not received', () => {
it('should display the GlEmptyState', async () => {
const mockApollo = createMockApolloProvider({
plansQueryMock: jest.fn().mockRejectedValue(new Error('An error happened!')),
});
wrapper = createComponent({ apolloProvider: mockApollo });
await waitForPromises();
expect(wrapper.findComponent(StepOrderApp).exists()).toBe(false);
expect(wrapper.findComponent(GlEmptyState).exists()).toBe(true);
});
});
});
......@@ -11,6 +11,7 @@ import purchaseFlowResolvers from 'ee/vue_shared/purchase_flow/graphql/resolvers
import {
stateData as initialStateData,
namespaces as defaultNamespaces,
mockCiMinutesPlans,
} from 'ee_jest/subscriptions/buy_minutes/mock_data';
import createMockApollo from 'helpers/mock_apollo_helper';
......@@ -40,6 +41,9 @@ describe('Subscription Details', () => {
return mount(SubscriptionDetails, {
localVue,
apolloProvider,
propsData: {
plans: mockCiMinutesPlans,
},
stubs: {
Step,
},
......
......@@ -6,7 +6,10 @@ import Checkout from 'ee/subscriptions/buy_minutes/components/checkout.vue';
import subscriptionsResolvers from 'ee/subscriptions/buy_minutes/graphql/resolvers';
import stateQuery from 'ee/subscriptions/graphql/queries/state.query.graphql';
import purchaseFlowResolvers from 'ee/vue_shared/purchase_flow/graphql/resolvers';
import { stateData as initialStateData } from 'ee_jest/subscriptions/buy_minutes/mock_data';
import {
stateData as initialStateData,
mockCiMinutesPlans,
} from 'ee_jest/subscriptions/buy_minutes/mock_data';
import createMockApollo from 'helpers/mock_apollo_helper';
const localVue = createLocalVue();
......@@ -35,6 +38,9 @@ describe('Checkout', () => {
wrapper = shallowMount(Checkout, {
apolloProvider,
localVue,
propsData: {
plans: mockCiMinutesPlans,
},
});
};
......
import { GlEmptyState } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { createWrapper } from '@vue/test-utils';
import initBuyMinutesApp from 'ee/subscriptions/buy_minutes';
import * as utils from 'ee/subscriptions/buy_minutes/utils';
import StepOrderApp from 'ee/vue_shared/purchase_flow/components/step_order_app.vue';
import { mockCiMinutesPlans, mockParsedCiMinutesPlans } from './mock_data';
import { mockCiMinutesPlans } from './mock_data';
import { createMockApolloProvider } from './spec_helper';
jest.mock('ee/subscriptions/buy_minutes/utils');
jest.doMock('ee/subscriptions/buy_minutes/graphql', createMockApolloProvider());
describe('initBuyMinutesApp', () => {
let vm;
......@@ -15,7 +13,7 @@ describe('initBuyMinutesApp', () => {
function createComponent() {
const el = document.createElement('div');
Object.assign(el.dataset, {
ciMinutesPlans: mockCiMinutesPlans,
planId: mockCiMinutesPlans[0].code,
groupData: '[]',
fullName: 'GitLab',
});
......@@ -23,39 +21,16 @@ describe('initBuyMinutesApp', () => {
wrapper = createWrapper(vm);
}
beforeEach(() => {
Sentry.captureException = jest.fn();
});
afterEach(() => {
if (vm) {
vm.$destroy();
}
wrapper.destroy();
vm = null;
Sentry.captureException.mockClear();
utils.parseData.mockClear();
});
describe('when parsing fails', () => {
it('displays the EmptyState', () => {
utils.parseData.mockImplementation(() => {
throw new Error();
});
createComponent();
expect(wrapper.find(StepOrderApp).exists()).toBe(false);
expect(wrapper.find(GlEmptyState).exists()).toBe(true);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
});
describe('when parsing succeeds', () => {
it('displays the StepOrderApp', () => {
utils.parseData.mockImplementation(() => mockParsedCiMinutesPlans);
createComponent();
expect(wrapper.find(GlEmptyState).exists()).toBe(false);
expect(wrapper.find(StepOrderApp).exists()).toBe(true);
expect(Sentry.captureException).not.toHaveBeenCalled();
});
it('displays the StepOrderApp', () => {
createComponent();
expect(wrapper.find(StepOrderApp).exists()).toBe(true);
});
});
import { STEPS } from 'ee/subscriptions/new/constants';
export const mockCiMinutesPlans =
'[{"deprecated":false,"name":"1000 CI minutes pack","code":"ci_minutes","active":true,"free":null,"price_per_month":0.8333333333333334,"price_per_year":10.0,"features":null,"about_page_href":null,"hide_deprecated_card":false}]';
export const mockParsedCiMinutesPlans = [
export const mockCiMinutesPlans = [
{
id: 'ci_minutes',
deprecated: false,
name: '1000 CI minutes pack',
code: 'ci_minutes',
......
import VueApollo from 'vue-apollo';
import plansQuery from 'ee/subscriptions/graphql/queries/plans.customer.query.graphql';
import { createMockClient } from 'helpers/mock_apollo_helper';
import { mockCiMinutesPlans } from './mock_data';
export function createMockApolloProvider(mockResponses = {}) {
const {
plansQueryMock = jest.fn().mockResolvedValue({ data: { plans: mockCiMinutesPlans } }),
} = mockResponses;
const mockDefaultClient = createMockClient();
const mockCustomerClient = createMockClient([[plansQuery, plansQueryMock]]);
return new VueApollo({
defaultClient: mockDefaultClient,
clients: { customerClient: mockCustomerClient },
});
}
import { parseData } from 'ee/subscriptions/buy_minutes/utils';
import { mockCiMinutesPlans, mockParsedCiMinutesPlans } from './mock_data';
describe('utils', () => {
describe('#parseData', () => {
describe.each`
ciMinutesPlans | parsedCiMinutesPlans | throws
${'[]'} | ${[]} | ${false}
${'null'} | ${{}} | ${false}
${mockCiMinutesPlans} | ${mockParsedCiMinutesPlans} | ${false}
${''} | ${{}} | ${true}
`('parameter decoding', ({ ciMinutesPlans, parsedCiMinutesPlans, throws }) => {
it(`decodes ${ciMinutesPlans} to ${parsedCiMinutesPlans}`, () => {
if (throws) {
expect(() => {
parseData({ ciMinutesPlans });
}).toThrow();
} else {
const result = parseData({ ciMinutesPlans });
expect(result.ciMinutesPlans).toEqual(parsedCiMinutesPlans);
}
});
});
});
});
mutation updatePlans($tags: [PlanTag!]) {
plans(planTags: $tags) {
name
}
}
query getPlans($tags: [PlanTag!]) {
plans(planTags: $tags) {
name
}
}
......@@ -264,7 +264,7 @@ module Gitlab
definitions = []
::Find.find(root.to_s) do |path|
definitions << Definition.new(path, fragments) if query?(path)
definitions << Definition.new(path, fragments) if query_for_gitlab_schema?(path)
end
definitions
......@@ -288,10 +288,11 @@ module Gitlab
@known_failures.fetch('filenames', []).any? { |known_failure| path.to_s.ends_with?(known_failure) }
end
def self.query?(path)
def self.query_for_gitlab_schema?(path)
path.ends_with?('.graphql') &&
!path.ends_with?('.fragment.graphql') &&
!path.ends_with?('typedefs.graphql')
!path.ends_with?('typedefs.graphql') &&
!/.*\.customer\.(query|mutation)\.graphql$/.match?(path)
end
end
end
......
import { InMemoryCache } from 'apollo-cache-inmemory';
import { createMockClient } from 'mock-apollo-client';
import { createMockClient as createMockApolloClient } from 'mock-apollo-client';
import VueApollo from 'vue-apollo';
const defaultCacheOptions = {
......@@ -7,13 +7,13 @@ const defaultCacheOptions = {
addTypename: false,
};
export default (handlers = [], resolvers = {}, cacheOptions = {}) => {
export function createMockClient(handlers = [], resolvers = {}, cacheOptions = {}) {
const cache = new InMemoryCache({
...defaultCacheOptions,
...cacheOptions,
});
const mockClient = createMockClient({ cache, resolvers });
const mockClient = createMockApolloClient({ cache, resolvers });
if (Array.isArray(handlers)) {
handlers.forEach(([query, value]) => mockClient.setRequestHandler(query, value));
......@@ -21,7 +21,12 @@ export default (handlers = [], resolvers = {}, cacheOptions = {}) => {
throw new Error('You should pass an array of handlers to mock Apollo client');
}
return mockClient;
}
export default function createMockApollo(handlers, resolvers, cacheOptions) {
const mockClient = createMockClient(handlers, resolvers, cacheOptions);
const apolloProvider = new VueApollo({ defaultClient: mockClient });
return apolloProvider;
};
}
# frozen_string_literal: true
require 'spec_helper'
require 'fast_spec_helper'
require "test_prof/recipes/rspec/let_it_be"
......@@ -124,6 +125,18 @@ RSpec.describe Gitlab::Graphql::Queries do
expect(described_class.find(path)).to be_empty
end
it 'ignores customer.query.graphql' do
path = root / 'plans.customer.query.graphql'
expect(described_class.find(path)).to be_empty
end
it 'ignores customer.mutation.graphql' do
path = root / 'plans.customer.mutation.graphql'
expect(described_class.find(path)).to be_empty
end
it 'finds all query definitions under a root directory' do
found = described_class.find(root)
......@@ -137,7 +150,9 @@ RSpec.describe Gitlab::Graphql::Queries do
expect(found).not_to include(
definition_of(root / 'typedefs.graphql'),
definition_of(root / 'author.fragment.graphql')
definition_of(root / 'author.fragment.graphql'),
definition_of(root / 'plans.customer.query.graphql'),
definition_of(root / 'plans.customer.mutation.graphql')
)
end
end
......
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