Commit 34d2ee0f authored by Zack Cuddy's avatar Zack Cuddy

Add Selective Sync Shards to Geo Node Form in Vue

This change is broken down from a very large MR:
https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22719

The overall goal is to convert the Geo Node Form from HAML to Vue
Based on MVC, splitting this into smaller MRs is more feasible

This MR adds the next piece of the Geo Form to Vue
- Selective Sync (Select Field)
- Selective Sync Shards (Multi-Select Dropdown)

There is another type of Selective Sync (Namespaces)
This will be added in the next MR
parent 5b5b4d87
......@@ -119,6 +119,17 @@ module ApplicationSettingsHelper
options_for_select(options, selected)
end
def repository_storages_options_json
options = Gitlab.config.repositories.storages.map do |name, storage|
{
label: "#{name} - #{storage['gitaly_address']}",
value: name
}
end
options.to_json
end
def external_authorization_description
_("If enabled, access to projects will be validated on an external service"\
" using their classification label.")
......
......@@ -7,6 +7,14 @@ export default {
GeoNodeForm,
},
props: {
selectiveSyncTypes: {
type: Object,
required: true,
},
syncShardsOptions: {
type: Array,
required: true,
},
node: {
type: Object,
required: false,
......@@ -19,6 +27,6 @@ export default {
<template>
<article class="geo-node-form-container">
<h3 class="page-title">{{ node ? __('Edit Geo Node') : __('New Geo Node') }}</h3>
<geo-node-form :node="node" />
<geo-node-form v-bind="$props" />
</article>
</template>
......@@ -2,6 +2,7 @@
import { GlFormGroup, GlFormInput, GlFormCheckbox, GlButton } from '@gitlab/ui';
import { visitUrl } from '~/lib/utils/url_utility';
import GeoNodeFormCore from './geo_node_form_core.vue';
import GeoNodeFormSelectiveSync from './geo_node_form_selective_sync.vue';
import GeoNodeFormCapacities from './geo_node_form_capacities.vue';
export default {
......@@ -12,6 +13,7 @@ export default {
GlFormCheckbox,
GlButton,
GeoNodeFormCore,
GeoNodeFormSelectiveSync,
GeoNodeFormCapacities,
},
props: {
......@@ -20,6 +22,14 @@ export default {
required: false,
default: null,
},
selectiveSyncTypes: {
type: Object,
required: true,
},
syncShardsOptions: {
type: Array,
required: true,
},
},
data() {
return {
......@@ -49,6 +59,12 @@ export default {
redirect() {
visitUrl('/admin/geo/nodes');
},
addSyncOption({ key, value }) {
this.nodeData[key].push(value);
},
removeSyncOption({ key, index }) {
this.nodeData[key].splice(index, 1);
},
},
};
</script>
......@@ -74,6 +90,14 @@ export default {
>
<gl-form-input id="node-internal-url-field" v-model="nodeData.internalUrl" type="text" />
</gl-form-group>
<geo-node-form-selective-sync
v-if="!nodeData.primary"
:node-data="nodeData"
:selective-sync-types="selectiveSyncTypes"
:sync-shards-options="syncShardsOptions"
@addSyncOption="addSyncOption"
@removeSyncOption="removeSyncOption"
/>
<geo-node-form-capacities :node-data="nodeData" />
<gl-form-group
v-if="!nodeData.primary"
......
<script>
import { GlFormGroup, GlFormSelect } from '@gitlab/ui';
import GeoNodeFormShards from './geo_node_form_shards.vue';
export default {
name: 'GeoNodeFormSelectiveSync',
components: {
GlFormGroup,
GlFormSelect,
GeoNodeFormShards,
},
props: {
nodeData: {
type: Object,
required: true,
},
selectiveSyncTypes: {
type: Object,
required: true,
},
syncShardsOptions: {
type: Array,
required: true,
},
},
computed: {
selectiveSyncShards() {
return this.nodeData.selectiveSyncType === this.selectiveSyncTypes.SHARDS.value;
},
},
methods: {
addSyncOption({ key, value }) {
this.$emit('addSyncOption', { key, value });
},
removeSyncOption({ key, index }) {
this.$emit('removeSyncOption', { key, index });
},
},
};
</script>
<template>
<div ref="geoNodeFormSelectiveSyncContainer">
<gl-form-group
:label="__('Selective synchronization')"
label-for="node-selective-synchronization-field"
>
<gl-form-select
id="node-selective-synchronization-field"
v-model="nodeData.selectiveSyncType"
:options="selectiveSyncTypes"
value-field="value"
text-field="label"
class="col-sm-6"
/>
</gl-form-group>
<gl-form-group
v-if="selectiveSyncShards"
:label="__('Shards to synchronize')"
label-for="node-synchronization-shards-field"
:description="__('Choose which shards you wish to synchronize to this secondary node')"
>
<geo-node-form-shards
id="node-synchronization-shards-field"
:selected-shards="nodeData.selectiveSyncShards"
:sync-shards-options="syncShardsOptions"
@addSyncOption="addSyncOption"
@removeSyncOption="removeSyncOption"
/>
</gl-form-group>
</div>
</template>
<script>
import { GlIcon, GlDropdown, GlButton } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { SELECTIVE_SYNC_SHARDS } from '../constants';
export default {
name: 'GeoNodeFormShards',
components: {
GlIcon,
GlDropdown,
GlButton,
},
props: {
syncShardsOptions: {
type: Array,
required: true,
},
selectedShards: {
type: Array,
required: true,
},
},
computed: {
dropdownTitle() {
if (this.selectedShards.length === 0) {
return __('Select shards to replicate');
}
return sprintf(__('Shards selected: %{count}'), { count: this.selectedShards.length });
},
noSyncShards() {
return this.syncShardsOptions.length === 0;
},
},
methods: {
toggleShard(shard) {
const index = this.selectedShards.findIndex(value => value === shard.value);
if (index > -1) {
this.$emit('removeSyncOption', { key: SELECTIVE_SYNC_SHARDS, index });
} else {
this.$emit('addSyncOption', { key: SELECTIVE_SYNC_SHARDS, value: shard.value });
}
},
isSelected(shard) {
return this.selectedShards.includes(shard.value);
},
},
};
</script>
<template>
<gl-dropdown :text="dropdownTitle">
<li v-for="shard in syncShardsOptions" :key="shard.value">
<gl-button class="d-flex align-items-center" @click="toggleShard(shard)">
<gl-icon :class="[{ invisible: !isSelected(shard) }]" name="mobile-issue-close" />
<span class="ml-1">{{ shard.label }}</span>
</gl-button>
</li>
<div v-if="noSyncShards" class="text-secondary p-2">{{ __('Nothing found…') }}</div>
</gl-dropdown>
</template>
/* eslint-disable import/prefer-default-export */
export const SELECTIVE_SYNC_SHARDS = 'selectiveSyncShards';
......@@ -15,7 +15,7 @@ export default () => {
},
render(createElement) {
const {
dataset: { nodeData },
dataset: { selectiveSyncTypes, syncShardsOptions, nodeData },
} = this.$options.el;
let node;
......@@ -26,6 +26,8 @@ export default () => {
return createElement('geo-node-form-app', {
props: {
selectiveSyncTypes: JSON.parse(selectiveSyncTypes),
syncShardsOptions: JSON.parse(syncShardsOptions),
node,
},
});
......
......@@ -65,6 +65,25 @@ module EE
)
end
def selective_sync_types_json
options = {
ALL: {
label: s_('Geo|All projects'),
value: ''
},
NAMESPACES: {
label: s_('Geo|Projects in certain groups'),
value: 'namespaces'
},
SHARDS: {
label: s_('Geo|Projects in certain storage shards'),
value: 'shards'
}
}
options.to_json
end
def status_loading_icon
icon "spinner spin fw", class: 'js-geo-node-loading'
end
......
- page_title _('Edit Geo Node')
- if Feature.enabled?(:enable_geo_node_form_js)
#js-geo-node-form{ data: { "node-data" => @serialized_node } }
#js-geo-node-form{ data: { "selective-sync-types" => selective_sync_types_json,
"sync-shards-options" => repository_storages_options_json,
"node-data" => @serialized_node } }
- else
%h3.page-title
Edit Geo Node
......
- page_title _('New Geo Node')
- if Feature.enabled?(:enable_geo_node_form_js)
#js-geo-node-form
#js-geo-node-form{ data: { "selective-sync-types" => selective_sync_types_json,
"sync-shards-options" => repository_storages_options_json } }
- else
%h2.page-title
%span.title-text
......
import { shallowMount } from '@vue/test-utils';
import GeoNodeFormApp from 'ee/geo_node_form/components/app.vue';
import GeoNodeForm from 'ee/geo_node_form/components/geo_node_form.vue';
import { MOCK_NODE } from '../mock_data';
import { MOCK_SELECTIVE_SYNC_TYPES, MOCK_SYNC_SHARDS, MOCK_NODE } from '../mock_data';
describe('GeoNodeFormApp', () => {
let wrapper;
const propsData = {
selectiveSyncTypes: MOCK_SELECTIVE_SYNC_TYPES,
syncShardsOptions: MOCK_SYNC_SHARDS,
node: undefined,
};
......
import { mount } from '@vue/test-utils';
import GeoNodeFormSelectiveSync from 'ee/geo_node_form/components/geo_node_form_selective_sync.vue';
import GeoNodeFormShards from 'ee/geo_node_form/components/geo_node_form_shards.vue';
import { MOCK_NODE, MOCK_SELECTIVE_SYNC_TYPES, MOCK_SYNC_SHARDS } from '../mock_data';
describe('GeoNodeFormSelectiveSync', () => {
let wrapper;
const propsData = {
nodeData: MOCK_NODE,
selectiveSyncTypes: MOCK_SELECTIVE_SYNC_TYPES,
syncShardsOptions: MOCK_SYNC_SHARDS,
};
const createComponent = () => {
wrapper = mount(GeoNodeFormSelectiveSync, {
propsData,
});
};
afterEach(() => {
wrapper.destroy();
});
const findGeoNodeFormSyncContainer = () =>
wrapper.find({ ref: 'geoNodeFormSelectiveSyncContainer' });
const findGeoNodeFormSyncTypeField = () => wrapper.find('#node-selective-synchronization-field');
const findGeoNodeFormShardsField = () => wrapper.find(GeoNodeFormShards);
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders Geo Node Form Sync Container', () => {
expect(findGeoNodeFormSyncContainer().exists()).toBe(true);
});
it('renders Geo Node Sync Type Field', () => {
expect(findGeoNodeFormSyncTypeField().exists()).toBe(true);
});
describe.each`
syncType | showShards
${MOCK_SELECTIVE_SYNC_TYPES.ALL} | ${false}
${MOCK_SELECTIVE_SYNC_TYPES.NAMESPACES} | ${false}
${MOCK_SELECTIVE_SYNC_TYPES.SHARDS} | ${true}
`(`sync type`, ({ syncType, showShards }) => {
beforeEach(() => {
createComponent();
wrapper.setProps({
nodeData: { ...propsData.nodeData, selectiveSyncType: syncType.value },
});
});
it(`${showShards ? 'show' : 'hide'} Shards Field`, () => {
expect(findGeoNodeFormShardsField().exists()).toBe(showShards);
});
});
});
describe('methods', () => {
describe('addSyncOption', () => {
beforeEach(() => {
createComponent();
});
it('emits `addSyncOption`', () => {
wrapper.vm.addSyncOption({ key: 'selectiveSyncShards', value: MOCK_SYNC_SHARDS[0].value });
expect(wrapper.emitted('addSyncOption')).toBeTruthy();
});
});
describe('removeSyncOption', () => {
beforeEach(() => {
createComponent();
wrapper.setProps({
nodeData: { ...propsData.nodeData, selectiveSyncShards: [MOCK_SYNC_SHARDS[0].value] },
});
});
it('should remove value from nodeData', () => {
wrapper.vm.removeSyncOption({ key: 'selectiveSyncShards', index: 0 });
expect(wrapper.emitted('removeSyncOption')).toBeTruthy();
});
});
});
describe('computed', () => {
describe('selectiveSyncShards', () => {
describe('when selectiveSyncType is not `SHARDS`', () => {
beforeEach(() => {
createComponent();
wrapper.setProps({
nodeData: {
...propsData.nodeData,
selectiveSyncType: MOCK_SELECTIVE_SYNC_TYPES.ALL.value,
},
});
});
it('returns `false`', () => {
expect(wrapper.vm.selectiveSyncShards).toBeFalsy();
});
});
describe('when selectiveSyncType is `SHARDS`', () => {
beforeEach(() => {
createComponent();
wrapper.setProps({
nodeData: {
...propsData.nodeData,
selectiveSyncType: MOCK_SELECTIVE_SYNC_TYPES.SHARDS.value,
},
});
});
it('returns `true`', () => {
expect(wrapper.vm.selectiveSyncShards).toBeTruthy();
});
});
});
});
});
import { mount } from '@vue/test-utils';
import { GlIcon, GlDropdown } from '@gitlab/ui';
import GeoNodeFormShards from 'ee/geo_node_form/components/geo_node_form_shards.vue';
import { MOCK_SYNC_SHARDS } from '../mock_data';
describe('GeoNodeFormShards', () => {
let wrapper;
const propsData = {
selectedShards: [],
syncShardsOptions: MOCK_SYNC_SHARDS,
};
const actionSpies = {
toggleShard: jest.fn(),
isSelected: jest.fn(),
};
const createComponent = () => {
wrapper = mount(GeoNodeFormShards, {
propsData,
methods: {
...actionSpies,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findGlDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () => findGlDropdown().findAll('li');
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders GlDropdown', () => {
expect(findGlDropdown().exists()).toBe(true);
});
describe('DropdownItems', () => {
beforeEach(() => {
delete actionSpies.isSelected;
createComponent();
wrapper.setProps({
selectedShards: [MOCK_SYNC_SHARDS[0].value],
});
});
it('renders an instance for each shard', () => {
const dropdownItems = findDropdownItems();
dropdownItems.wrappers.forEach((dI, index) => {
expect(dI.html()).toContain(wrapper.vm.syncShardsOptions[index].label);
});
});
it('hides GlIcon if shard not in selectedShards', () => {
const dropdownItems = findDropdownItems();
dropdownItems.wrappers.forEach((dI, index) => {
const dropdownItemIcon = dI.find(GlIcon);
expect(dropdownItemIcon.classes('invisible')).toBe(
!wrapper.vm.isSelected(wrapper.vm.syncShardsOptions[index]),
);
});
});
});
});
describe('methods', () => {
describe('toggleShard', () => {
beforeEach(() => {
delete actionSpies.toggleShard;
});
describe('when shard is in selectedShards', () => {
beforeEach(() => {
createComponent();
wrapper.setProps({
selectedShards: [MOCK_SYNC_SHARDS[0].value],
});
});
it('emits `removeSyncOption`', () => {
wrapper.vm.toggleShard(MOCK_SYNC_SHARDS[0]);
expect(wrapper.emitted('removeSyncOption')).toBeTruthy();
});
});
describe('when shard is not in selectedShards', () => {
beforeEach(() => {
createComponent();
wrapper.setProps({
selectedShards: [MOCK_SYNC_SHARDS[0].value],
});
});
it('emits `addSyncOption`', () => {
wrapper.vm.toggleShard(MOCK_SYNC_SHARDS[1]);
expect(wrapper.emitted('addSyncOption')).toBeTruthy();
});
});
});
describe('isSelected', () => {
beforeEach(() => {
delete actionSpies.isSelected;
});
describe('when shard is in selectedShards', () => {
beforeEach(() => {
createComponent();
wrapper.setProps({
selectedShards: [MOCK_SYNC_SHARDS[0].value],
});
});
it('returns `true`', () => {
expect(wrapper.vm.isSelected(MOCK_SYNC_SHARDS[0])).toBeTruthy();
});
});
describe('when shard is not in selectedShards', () => {
beforeEach(() => {
createComponent();
wrapper.setProps({
selectedShards: [MOCK_SYNC_SHARDS[0].value],
});
});
it('returns `false`', () => {
expect(wrapper.vm.isSelected(MOCK_SYNC_SHARDS[1])).toBeFalsy();
});
});
});
describe('computed', () => {
describe('dropdownTitle', () => {
describe('when selectedShards is empty', () => {
beforeEach(() => {
createComponent();
wrapper.setProps({
selectedShards: [],
});
});
it('returns `Select shards to replicate`', () => {
expect(wrapper.vm.dropdownTitle).toBe('Select shards to replicate');
});
});
describe('when selectedShards is not empty', () => {
beforeEach(() => {
createComponent();
wrapper.setProps({
selectedShards: [MOCK_SYNC_SHARDS[0].value],
});
});
it('returns Shards selected: `this.selectedShards.length`', () => {
expect(wrapper.vm.dropdownTitle).toBe(
`Shards selected: ${wrapper.vm.selectedShards.length}`,
);
});
});
});
describe('noSyncShards', () => {
describe('when syncShardsOptions.length > 0', () => {
beforeEach(() => {
createComponent();
});
it('returns `false`', () => {
expect(wrapper.vm.noSyncShards).toBeFalsy();
});
});
});
describe('when syncShardsOptions.length === 0', () => {
beforeEach(() => {
createComponent();
wrapper.setProps({
syncShardsOptions: [],
});
});
it('returns `true`', () => {
expect(wrapper.vm.noSyncShards).toBeTruthy();
});
});
});
});
});
......@@ -3,7 +3,7 @@ import { visitUrl } from '~/lib/utils/url_utility';
import GeoNodeForm from 'ee/geo_node_form/components/geo_node_form.vue';
import GeoNodeFormCore from 'ee/geo_node_form/components/geo_node_form_core.vue';
import GeoNodeFormCapacities from 'ee/geo_node_form/components/geo_node_form_capacities.vue';
import { MOCK_NODE } from '../mock_data';
import { MOCK_NODE, MOCK_SELECTIVE_SYNC_TYPES, MOCK_SYNC_SHARDS } from '../mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn().mockName('visitUrlMock'),
......@@ -14,6 +14,8 @@ describe('GeoNodeForm', () => {
const propsData = {
node: MOCK_NODE,
selectiveSyncTypes: MOCK_SELECTIVE_SYNC_TYPES,
syncShardsOptions: MOCK_SYNC_SHARDS,
};
const createComponent = () => {
......@@ -53,7 +55,9 @@ describe('GeoNodeForm', () => {
showObjectStorage,
}) => {
beforeEach(() => {
wrapper.vm.nodeData.primary = primaryNode;
wrapper.setData({
nodeData: { ...wrapper.vm.nodeData, primary: primaryNode },
});
});
it(`it ${showCore ? 'shows' : 'hides'} the Core Field`, () => {
......@@ -90,6 +94,33 @@ describe('GeoNodeForm', () => {
expect(visitUrl).toHaveBeenCalled();
});
});
describe('addSyncOption', () => {
beforeEach(() => {
createComponent();
});
it('should add value to nodeData', () => {
expect(wrapper.vm.nodeData.selectiveSyncShards).toEqual([]);
wrapper.vm.addSyncOption({ key: 'selectiveSyncShards', value: MOCK_SYNC_SHARDS[0].value });
expect(wrapper.vm.nodeData.selectiveSyncShards).toEqual([MOCK_SYNC_SHARDS[0].value]);
});
});
describe('removeSyncOption', () => {
beforeEach(() => {
createComponent();
wrapper.setData({
nodeData: { ...wrapper.vm.nodeData, selectiveSyncShards: [MOCK_SYNC_SHARDS[0].value] },
});
});
it('should remove value from nodeData', () => {
expect(wrapper.vm.nodeData.selectiveSyncShards).toEqual([MOCK_SYNC_SHARDS[0].value]);
wrapper.vm.removeSyncOption({ key: 'selectiveSyncShards', index: 0 });
expect(wrapper.vm.nodeData.selectiveSyncShards).toEqual([]);
});
});
});
describe('created', () => {
......@@ -99,7 +130,7 @@ describe('GeoNodeForm', () => {
});
it('sets nodeData to the correct node', () => {
expect(wrapper.vm.nodeData.id).toBe(propsData.node.id);
expect(wrapper.vm.nodeData.id).toBe(wrapper.vm.node.id);
});
});
......
// eslint-disable-next-line import/prefer-default-export
export const MOCK_SELECTIVE_SYNC_TYPES = {
ALL: {
label: 'All projects',
value: '',
},
NAMESPACES: {
label: 'Projects in certain groups',
value: 'namespaces',
},
SHARDS: {
label: 'Projects in certain storage shards',
value: 'shards',
},
};
export const MOCK_SYNC_SHARDS = [
{
label: 'Shard 1',
value: 'shard1',
},
{
label: 'Shard 2',
value: 'shard2',
},
{
label: 'Shard 3',
value: 'shard3',
},
];
export const MOCK_NODE = {
id: 1,
name: 'Mock Node',
......
......@@ -3578,6 +3578,9 @@ msgstr ""
msgid "Choose which repositories you want to connect and run CI/CD pipelines."
msgstr ""
msgid "Choose which shards you wish to synchronize to this secondary node"
msgstr ""
msgid "Choose which shards you wish to synchronize to this secondary node."
msgstr ""
......@@ -12902,6 +12905,9 @@ msgstr ""
msgid "Notes|This comment has changed since you started editing, please review the %{open_link}updated comment%{close_link} to ensure information is not lost"
msgstr ""
msgid "Nothing found…"
msgstr ""
msgid "Nothing to preview."
msgstr ""
......@@ -17025,6 +17031,9 @@ msgstr ""
msgid "Select projects you want to import."
msgstr ""
msgid "Select shards to replicate"
msgstr ""
msgid "Select source branch"
msgstr ""
......@@ -17055,6 +17064,9 @@ msgstr ""
msgid "Selecting a GitLab user will add a link to the GitLab user in the descriptions of issues and comments (e.g. \"By <a href=\"#\">@johnsmith</a>\"). It will also associate and/or assign these issues and comments with the selected user."
msgstr ""
msgid "Selective synchronization"
msgstr ""
msgid "Self monitoring project does not exist"
msgstr ""
......@@ -17385,6 +17397,12 @@ msgstr ""
msgid "Severity: %{severity}"
msgstr ""
msgid "Shards selected: %{count}"
msgstr ""
msgid "Shards to synchronize"
msgstr ""
msgid "Share"
msgstr ""
......
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