Commit 27ffa387 authored by ap4y's avatar ap4y

Implement editing components for the network policy management

This commit adds policy editor component based on monaco editor. This
editor is used in a drawer inside policy list component. Store changes
related to the policy update were also added to the Network Policy List.
parent 7df008a5
<script>
import { editor as monacoEditor } from 'monaco-editor';
export default {
props: {
value: {
type: String,
required: true,
},
},
data() {
return { editor: null };
},
watch: {
value(val) {
if (val === this.editor.getValue()) return;
this.editor.setValue(val);
},
},
beforeDestroy() {
this.editor.dispose();
},
mounted() {
if (!this.editor) {
this.setupEditor();
}
},
methods: {
setupEditor() {
this.editor = monacoEditor.create(this.$refs.editor, {
value: this.value,
language: 'yaml',
lineNumbers: 'off',
minimap: { enabled: false },
folding: false,
renderIndentGuides: false,
renderWhitespace: 'boundary',
renderLineHighlight: 'none',
glyphMargin: false,
lineDecorationsWidth: 0,
lineNumbersMinChars: 0,
occurrencesHighlight: false,
hideCursorInOverviewRuler: true,
overviewRulerBorder: false,
readOnly: true,
});
this.editor.onDidChangeModelContent(() => {
this.$emit('input', this.editor.getValue());
});
},
},
};
</script>
<template>
<div
ref="editor"
class="multi-file-editor-holer network-policy-editor gl-bg-gray-50 p-2 gl-overflow-x-hidden"
></div>
</template>
<script> <script>
import { mapState } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlTable, GlEmptyState } from '@gitlab/ui'; import { GlTable, GlEmptyState, GlDrawer, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility'; import { getTimeago } from '~/lib/utils/datetime_utility';
import { setUrlFragment } from '~/lib/utils/url_utility'; import { setUrlFragment } from '~/lib/utils/url_utility';
import EnvironmentPicker from './environment_picker.vue'; import EnvironmentPicker from './environment_picker.vue';
import NetworkPolicyEditor from './network_policy_editor.vue';
export default { export default {
components: { components: {
GlTable, GlTable,
GlEmptyState, GlEmptyState,
GlDrawer,
GlButton,
EnvironmentPicker, EnvironmentPicker,
NetworkPolicyEditor,
}, },
props: { props: {
documentationPath: { documentationPath: {
...@@ -18,20 +22,65 @@ export default { ...@@ -18,20 +22,65 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return { selectedPolicyName: null, initialManifest: null };
},
computed: { computed: {
...mapState('networkPolicies', ['policies', 'isLoadingPolicies']), ...mapState('networkPolicies', ['policies', 'isLoadingPolicies', 'isUpdatingPolicy']),
...mapState('threatMonitoring', ['currentEnvironmentId']),
documentationFullPath() { documentationFullPath() {
return setUrlFragment(this.documentationPath, 'container-network-policy'); return setUrlFragment(this.documentationPath, 'container-network-policy');
}, },
hasSelectedPolicy() {
return Boolean(this.selectedPolicyName);
},
selectedPolicy() {
if (!this.hasSelectedPolicy) return null;
return this.policies.find(policy => policy.name === this.selectedPolicyName);
},
hasPolicyChanges() {
return this.hasSelectedPolicy && this.selectedPolicy.manifest !== this.initialManifest;
},
}, },
methods: { methods: {
...mapActions('networkPolicies', ['updatePolicy']),
getTimeAgoString(creationTimestamp) { getTimeAgoString(creationTimestamp) {
return getTimeago().format(creationTimestamp); return getTimeago().format(creationTimestamp);
}, },
presentPolicyDrawer(rows) {
if (rows.length === 0) return;
const [selectedPolicy] = rows;
this.selectedPolicyName = selectedPolicy?.name;
this.initialManifest = selectedPolicy?.manifest;
},
deselectPolicy() {
this.selectedPolicyName = null;
const bTable = this.$refs.policiesTable.$children[0];
bTable.clearSelected();
},
savePolicy() {
return this.updatePolicy({
environmentId: this.currentEnvironmentId,
policy: this.selectedPolicy,
}).then(() => {
this.initialManifest = this.selectedPolicy.manifest;
});
},
}, },
fields: [ fields: [
{ key: 'name', label: s__('NetworkPolicies|Name'), thClass: 'w-75 font-weight-bold' }, {
{ key: 'status', label: s__('NetworkPolicies|Status'), thClass: 'font-weight-bold' }, key: 'name',
label: s__('NetworkPolicies|Name'),
thClass: 'w-75 font-weight-bold',
},
{
key: 'status',
label: s__('NetworkPolicies|Status'),
thClass: 'font-weight-bold',
},
{ {
key: 'creationTimestamp', key: 'creationTimestamp',
label: s__('NetworkPolicies|Last modified'), label: s__('NetworkPolicies|Last modified'),
...@@ -41,6 +90,7 @@ export default { ...@@ -41,6 +90,7 @@ export default {
emptyStateDescription: s__( emptyStateDescription: s__(
`NetworkPolicies|Policies are a specification of how groups of pods are allowed to communicate with each other network endpoints.`, `NetworkPolicies|Policies are a specification of how groups of pods are allowed to communicate with each other network endpoints.`,
), ),
headerHeight: process.env.NODE_ENV === 'development' ? '75px' : '40px',
}; };
</script> </script>
...@@ -62,6 +112,11 @@ export default { ...@@ -62,6 +112,11 @@ export default {
thead-class="gl-text-gray-900 border-bottom" thead-class="gl-text-gray-900 border-bottom"
tbody-class="gl-text-gray-900" tbody-class="gl-text-gray-900"
show-empty show-empty
hover
selectable
select-mode="single"
selected-variant="primary"
@row-selected="presentPolicyDrawer"
> >
<template #cell(status)> <template #cell(status)>
{{ s__('NetworkPolicies|Enabled') }} {{ s__('NetworkPolicies|Enabled') }}
...@@ -83,5 +138,38 @@ export default { ...@@ -83,5 +138,38 @@ export default {
</slot> </slot>
</template> </template>
</gl-table> </gl-table>
<gl-drawer
ref="editorDrawer"
:z-index="252"
:open="hasSelectedPolicy"
:header-height="$options.headerHeight"
@close="deselectPolicy"
>
<template #header>
<div>
<h3 class="gl-mb-3">{{ selectedPolicy.name }}</h3>
<div>
<gl-button ref="cancelButton" @click="deselectPolicy">{{ __('Cancel') }}</gl-button>
<gl-button
ref="applyButton"
category="primary"
variant="success"
:loading="isUpdatingPolicy"
:disabled="!hasPolicyChanges"
@click="savePolicy"
>{{ __('Apply changes') }}</gl-button
>
</div>
</div>
</template>
<template>
<div v-if="hasSelectedPolicy">
<h5>{{ s__('NetworkPolicies|Policy definition') }}</h5>
<p>{{ s__("NetworkPolicies|Define this policy's location, conditions and actions.") }}</p>
<network-policy-editor ref="policyEditor" v-model="selectedPolicy.manifest" />
</div>
</template>
</gl-drawer>
</div> </div>
</template> </template>
...@@ -33,8 +33,8 @@ const commitUpdatePolicyError = (commit, payload) => { ...@@ -33,8 +33,8 @@ const commitUpdatePolicyError = (commit, payload) => {
createFlash(error); createFlash(error);
}; };
export const updatePolicy = ({ state, commit }, { environmentId, policy, manifest }) => { export const updatePolicy = ({ state, commit }, { environmentId, policy }) => {
if (!state.policiesEndpoint || !environmentId || !manifest) { if (!state.policiesEndpoint || !environmentId || !policy) {
return commitUpdatePolicyError(commit); return commitUpdatePolicyError(commit);
} }
...@@ -43,7 +43,7 @@ export const updatePolicy = ({ state, commit }, { environmentId, policy, manifes ...@@ -43,7 +43,7 @@ export const updatePolicy = ({ state, commit }, { environmentId, policy, manifes
return axios return axios
.put(joinPaths(state.policiesEndpoint, policy.name), { .put(joinPaths(state.policiesEndpoint, policy.name), {
environment_id: environmentId, environment_id: environmentId,
manifest, manifest: policy.manifest,
}) })
.then(({ data }) => { .then(({ data }) => {
commit(types.RECEIVE_UPDATE_POLICY_SUCCESS, { commit(types.RECEIVE_UPDATE_POLICY_SUCCESS, {
......
.network-policy-editor {
min-height: 300px;
.monaco-editor,
.monaco-editor-background,
.monaco-editor .inputarea.ime-input {
background-color: $gray-50;
}
}
...@@ -55,9 +55,10 @@ exports[`NetworkPolicyList component renders policies table 1`] = ` ...@@ -55,9 +55,10 @@ exports[`NetworkPolicyList component renders policies table 1`] = `
<table <table
aria-busy="false" aria-busy="false"
aria-colcount="3" aria-colcount="3"
aria-describedby="__BVID__39__caption_" aria-describedby="__BVID__41__caption_"
class="table b-table gl-table b-table-stacked-md" aria-multiselectable="false"
id="__BVID__39" class="table b-table gl-table table-hover b-table-stacked-md b-table-selectable b-table-select-single"
id="__BVID__41"
role="table" role="table"
> >
<!----> <!---->
...@@ -103,8 +104,10 @@ exports[`NetworkPolicyList component renders policies table 1`] = ` ...@@ -103,8 +104,10 @@ exports[`NetworkPolicyList component renders policies table 1`] = `
> >
<!----> <!---->
<tr <tr
aria-selected="false"
class="" class=""
role="row" role="row"
tabindex="0"
> >
<td <td
aria-colindex="1" aria-colindex="1"
......
import { shallowMount } from '@vue/test-utils';
import NetworkPolicyEditor from 'ee/threat_monitoring/components/network_policy_editor.vue';
describe('NetworkPolicyEditor component', () => {
let wrapper;
const factory = ({ propsData } = {}) => {
wrapper = shallowMount(NetworkPolicyEditor, {
propsData: {
value: 'foo',
...propsData,
},
});
};
beforeEach(() => {
factory();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders container element', () => {
expect(wrapper.find({ ref: 'editor' }).exists()).toBe(true);
});
it('initializes monaco editor with yaml language and provided value', () => {
const {
vm: { editor },
} = wrapper;
expect(editor).not.toBe(null);
expect(editor.getModel().getModeId()).toBe('yaml');
expect(editor.getValue()).toBe('foo');
});
it('emits input event on file changes', () => {
wrapper.vm.editor.setValue('bar');
expect(wrapper.emitted().input).toBeTruthy();
expect(wrapper.emitted().input.length).toBe(1);
expect(wrapper.emitted().input[0]).toEqual(['bar']);
});
});
...@@ -9,7 +9,7 @@ describe('NetworkPolicyList component', () => { ...@@ -9,7 +9,7 @@ describe('NetworkPolicyList component', () => {
let store; let store;
let wrapper; let wrapper;
const factory = ({ propsData, state } = {}) => { const factory = ({ propsData, state, data } = {}) => {
store = createStore(); store = createStore();
Object.assign(store.state.networkPolicies, { Object.assign(store.state.networkPolicies, {
isLoadingPolicies: false, isLoadingPolicies: false,
...@@ -17,11 +17,14 @@ describe('NetworkPolicyList component', () => { ...@@ -17,11 +17,14 @@ describe('NetworkPolicyList component', () => {
...state, ...state,
}); });
jest.spyOn(store, 'dispatch').mockImplementation(() => Promise.resolve());
wrapper = mount(NetworkPolicyList, { wrapper = mount(NetworkPolicyList, {
propsData: { propsData: {
documentationPath: 'documentation_path', documentationPath: 'documentation_path',
...propsData, ...propsData,
}, },
data,
store, store,
}); });
}; };
...@@ -29,6 +32,10 @@ describe('NetworkPolicyList component', () => { ...@@ -29,6 +32,10 @@ describe('NetworkPolicyList component', () => {
const findEnvironmentsPicker = () => wrapper.find({ ref: 'environmentsPicker' }); const findEnvironmentsPicker = () => wrapper.find({ ref: 'environmentsPicker' });
const findPoliciesTable = () => wrapper.find(GlTable); const findPoliciesTable = () => wrapper.find(GlTable);
const findTableEmptyState = () => wrapper.find({ ref: 'tableEmptyState' }); const findTableEmptyState = () => wrapper.find({ ref: 'tableEmptyState' });
const findEditorDrawer = () => wrapper.find({ ref: 'editorDrawer' });
const findPolicyEditor = () => wrapper.find({ ref: 'policyEditor' });
const findApplyButton = () => wrapper.find({ ref: 'applyButton' });
const findCancelButton = () => wrapper.find({ ref: 'cancelButton' });
beforeEach(() => { beforeEach(() => {
factory({}); factory({});
...@@ -47,6 +54,86 @@ describe('NetworkPolicyList component', () => { ...@@ -47,6 +54,86 @@ describe('NetworkPolicyList component', () => {
expect(findPoliciesTable().element).toMatchSnapshot(); expect(findPoliciesTable().element).toMatchSnapshot();
}); });
it('renders closed editor drawer', () => {
const editorDrawer = findEditorDrawer();
expect(editorDrawer.exists()).toBe(true);
expect(editorDrawer.props('open')).toBe(false);
});
it('renders opened editor drawer on row selection', () => {
findPoliciesTable()
.find('td')
.trigger('click');
return wrapper.vm.$nextTick().then(() => {
const editorDrawer = findEditorDrawer();
expect(editorDrawer.exists()).toBe(true);
expect(editorDrawer.props('open')).toBe(true);
});
});
describe('given there is a selected policy', () => {
beforeEach(() => {
factory({
data: () => ({
selectedPolicyName: 'policy',
initialManifest: mockPoliciesResponse[0].manifest,
}),
});
});
it('renders opened editor drawer', () => {
const editorDrawer = findEditorDrawer();
expect(editorDrawer.exists()).toBe(true);
expect(editorDrawer.props('open')).toBe(true);
});
it('renders network policy editor with manifest', () => {
const policyEditor = findPolicyEditor();
expect(policyEditor.exists()).toBe(true);
expect(policyEditor.props('value')).toBe(mockPoliciesResponse[0].manifest);
});
it('renders disabled apply button', () => {
const applyButton = findApplyButton();
expect(applyButton.exists()).toBe(true);
expect(applyButton.props('disabled')).toBe(true);
});
it('renders closed editor drawer on Cancel button click', () => {
const cancelButton = findCancelButton();
expect(cancelButton.exists()).toBe(true);
cancelButton.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
const editorDrawer = findEditorDrawer();
expect(editorDrawer.exists()).toBe(true);
expect(editorDrawer.props('open')).toBe(false);
});
});
describe('given there is a policy change', () => {
beforeEach(() => {
findPolicyEditor().vm.$emit('input', 'foo');
});
it('renders enabled apply button', () => {
const applyButton = findApplyButton();
expect(applyButton.exists()).toBe(true);
expect(applyButton.props('disabled')).toBe(false);
});
it('dispatches updatePolicy action on apply button click', () => {
findApplyButton().vm.$emit('click');
expect(store.dispatch).toHaveBeenCalledWith('networkPolicies/updatePolicy', {
environmentId: -1,
policy: mockPoliciesResponse[0],
});
});
});
});
describe('given there is a default environment with no data to display', () => { describe('given there is a default environment with no data to display', () => {
beforeEach(() => { beforeEach(() => {
factory({ factory({
......
...@@ -158,7 +158,7 @@ describe('Network Policy actions', () => { ...@@ -158,7 +158,7 @@ describe('Network Policy actions', () => {
mock mock
.onPut(joinPaths(networkPoliciesEndpoint, policy.name), { .onPut(joinPaths(networkPoliciesEndpoint, policy.name), {
environment_id: environmentId, environment_id: environmentId,
manifest: updatedPolicy.manifest, manifest: policy.manifest,
}) })
.replyOnce(httpStatus.OK, updatedPolicy); .replyOnce(httpStatus.OK, updatedPolicy);
}); });
...@@ -166,7 +166,7 @@ describe('Network Policy actions', () => { ...@@ -166,7 +166,7 @@ describe('Network Policy actions', () => {
it('should dispatch the request and success actions', () => it('should dispatch the request and success actions', () =>
testAction( testAction(
actions.updatePolicy, actions.updatePolicy,
{ environmentId, policy, manifest: updatedPolicy.manifest }, { environmentId, policy },
state, state,
[ [
{ type: types.REQUEST_UPDATE_POLICY }, { type: types.REQUEST_UPDATE_POLICY },
...@@ -186,7 +186,7 @@ describe('Network Policy actions', () => { ...@@ -186,7 +186,7 @@ describe('Network Policy actions', () => {
mock mock
.onPut(joinPaths(networkPoliciesEndpoint, policy.name), { .onPut(joinPaths(networkPoliciesEndpoint, policy.name), {
environment_id: environmentId, environment_id: environmentId,
manifest: updatedPolicy.manifest, manifest: policy.manifest,
}) })
.replyOnce(500, error); .replyOnce(500, error);
}); });
...@@ -194,7 +194,7 @@ describe('Network Policy actions', () => { ...@@ -194,7 +194,7 @@ describe('Network Policy actions', () => {
it('should dispatch the request and error actions', () => it('should dispatch the request and error actions', () =>
testAction( testAction(
actions.updatePolicy, actions.updatePolicy,
{ environmentId, policy, manifest: updatedPolicy.manifest }, { environmentId, policy },
state, state,
[ [
{ type: types.REQUEST_UPDATE_POLICY }, { type: types.REQUEST_UPDATE_POLICY },
...@@ -212,7 +212,7 @@ describe('Network Policy actions', () => { ...@@ -212,7 +212,7 @@ describe('Network Policy actions', () => {
it('should dispatch RECEIVE_UPDATE_POLICY_ERROR', () => it('should dispatch RECEIVE_UPDATE_POLICY_ERROR', () =>
testAction( testAction(
actions.updatePolicy, actions.updatePolicy,
{ environmentId, policy, manifest: updatedPolicy.manifest }, { environmentId, policy },
state, state,
[ [
{ {
...@@ -231,7 +231,6 @@ describe('Network Policy actions', () => { ...@@ -231,7 +231,6 @@ describe('Network Policy actions', () => {
{ {
environmentId: undefined, environmentId: undefined,
policy, policy,
manifest: updatedPolicy.manifest,
}, },
state, state,
[ [
......
...@@ -2490,6 +2490,9 @@ msgstr "" ...@@ -2490,6 +2490,9 @@ msgstr ""
msgid "Apply a template" msgid "Apply a template"
msgstr "" msgstr ""
msgid "Apply changes"
msgstr ""
msgid "Apply suggestion" msgid "Apply suggestion"
msgstr "" msgstr ""
...@@ -14110,6 +14113,9 @@ msgstr "" ...@@ -14110,6 +14113,9 @@ msgstr ""
msgid "Network" msgid "Network"
msgstr "" msgstr ""
msgid "NetworkPolicies|Define this policy's location, conditions and actions."
msgstr ""
msgid "NetworkPolicies|Enabled" msgid "NetworkPolicies|Enabled"
msgstr "" msgstr ""
...@@ -14137,6 +14143,9 @@ msgstr "" ...@@ -14137,6 +14143,9 @@ msgstr ""
msgid "NetworkPolicies|Policy %{policyName} was successfully changed" msgid "NetworkPolicies|Policy %{policyName} was successfully changed"
msgstr "" msgstr ""
msgid "NetworkPolicies|Policy definition"
msgstr ""
msgid "NetworkPolicies|Something went wrong, failed to update policy" msgid "NetworkPolicies|Something went wrong, failed to update policy"
msgstr "" 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