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>
import { mapState } from 'vuex';
import { GlTable, GlEmptyState } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { GlTable, GlEmptyState, GlDrawer, GlButton } from '@gitlab/ui';
import { s__ } from '~/locale';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { setUrlFragment } from '~/lib/utils/url_utility';
import EnvironmentPicker from './environment_picker.vue';
import NetworkPolicyEditor from './network_policy_editor.vue';
export default {
components: {
GlTable,
GlEmptyState,
GlDrawer,
GlButton,
EnvironmentPicker,
NetworkPolicyEditor,
},
props: {
documentationPath: {
......@@ -18,20 +22,65 @@ export default {
required: true,
},
},
data() {
return { selectedPolicyName: null, initialManifest: null };
},
computed: {
...mapState('networkPolicies', ['policies', 'isLoadingPolicies']),
...mapState('networkPolicies', ['policies', 'isLoadingPolicies', 'isUpdatingPolicy']),
...mapState('threatMonitoring', ['currentEnvironmentId']),
documentationFullPath() {
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: {
...mapActions('networkPolicies', ['updatePolicy']),
getTimeAgoString(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: [
{ 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',
label: s__('NetworkPolicies|Last modified'),
......@@ -41,6 +90,7 @@ export default {
emptyStateDescription: s__(
`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>
......@@ -62,6 +112,11 @@ export default {
thead-class="gl-text-gray-900 border-bottom"
tbody-class="gl-text-gray-900"
show-empty
hover
selectable
select-mode="single"
selected-variant="primary"
@row-selected="presentPolicyDrawer"
>
<template #cell(status)>
{{ s__('NetworkPolicies|Enabled') }}
......@@ -83,5 +138,38 @@ export default {
</slot>
</template>
</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>
</template>
......@@ -33,8 +33,8 @@ const commitUpdatePolicyError = (commit, payload) => {
createFlash(error);
};
export const updatePolicy = ({ state, commit }, { environmentId, policy, manifest }) => {
if (!state.policiesEndpoint || !environmentId || !manifest) {
export const updatePolicy = ({ state, commit }, { environmentId, policy }) => {
if (!state.policiesEndpoint || !environmentId || !policy) {
return commitUpdatePolicyError(commit);
}
......@@ -43,7 +43,7 @@ export const updatePolicy = ({ state, commit }, { environmentId, policy, manifes
return axios
.put(joinPaths(state.policiesEndpoint, policy.name), {
environment_id: environmentId,
manifest,
manifest: policy.manifest,
})
.then(({ data }) => {
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`] = `
<table
aria-busy="false"
aria-colcount="3"
aria-describedby="__BVID__39__caption_"
class="table b-table gl-table b-table-stacked-md"
id="__BVID__39"
aria-describedby="__BVID__41__caption_"
aria-multiselectable="false"
class="table b-table gl-table table-hover b-table-stacked-md b-table-selectable b-table-select-single"
id="__BVID__41"
role="table"
>
<!---->
......@@ -103,8 +104,10 @@ exports[`NetworkPolicyList component renders policies table 1`] = `
>
<!---->
<tr
aria-selected="false"
class=""
role="row"
tabindex="0"
>
<td
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', () => {
let store;
let wrapper;
const factory = ({ propsData, state } = {}) => {
const factory = ({ propsData, state, data } = {}) => {
store = createStore();
Object.assign(store.state.networkPolicies, {
isLoadingPolicies: false,
......@@ -17,11 +17,14 @@ describe('NetworkPolicyList component', () => {
...state,
});
jest.spyOn(store, 'dispatch').mockImplementation(() => Promise.resolve());
wrapper = mount(NetworkPolicyList, {
propsData: {
documentationPath: 'documentation_path',
...propsData,
},
data,
store,
});
};
......@@ -29,6 +32,10 @@ describe('NetworkPolicyList component', () => {
const findEnvironmentsPicker = () => wrapper.find({ ref: 'environmentsPicker' });
const findPoliciesTable = () => wrapper.find(GlTable);
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(() => {
factory({});
......@@ -47,6 +54,86 @@ describe('NetworkPolicyList component', () => {
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', () => {
beforeEach(() => {
factory({
......
......@@ -158,7 +158,7 @@ describe('Network Policy actions', () => {
mock
.onPut(joinPaths(networkPoliciesEndpoint, policy.name), {
environment_id: environmentId,
manifest: updatedPolicy.manifest,
manifest: policy.manifest,
})
.replyOnce(httpStatus.OK, updatedPolicy);
});
......@@ -166,7 +166,7 @@ describe('Network Policy actions', () => {
it('should dispatch the request and success actions', () =>
testAction(
actions.updatePolicy,
{ environmentId, policy, manifest: updatedPolicy.manifest },
{ environmentId, policy },
state,
[
{ type: types.REQUEST_UPDATE_POLICY },
......@@ -186,7 +186,7 @@ describe('Network Policy actions', () => {
mock
.onPut(joinPaths(networkPoliciesEndpoint, policy.name), {
environment_id: environmentId,
manifest: updatedPolicy.manifest,
manifest: policy.manifest,
})
.replyOnce(500, error);
});
......@@ -194,7 +194,7 @@ describe('Network Policy actions', () => {
it('should dispatch the request and error actions', () =>
testAction(
actions.updatePolicy,
{ environmentId, policy, manifest: updatedPolicy.manifest },
{ environmentId, policy },
state,
[
{ type: types.REQUEST_UPDATE_POLICY },
......@@ -212,7 +212,7 @@ describe('Network Policy actions', () => {
it('should dispatch RECEIVE_UPDATE_POLICY_ERROR', () =>
testAction(
actions.updatePolicy,
{ environmentId, policy, manifest: updatedPolicy.manifest },
{ environmentId, policy },
state,
[
{
......@@ -231,7 +231,6 @@ describe('Network Policy actions', () => {
{
environmentId: undefined,
policy,
manifest: updatedPolicy.manifest,
},
state,
[
......
......@@ -2490,6 +2490,9 @@ msgstr ""
msgid "Apply a template"
msgstr ""
msgid "Apply changes"
msgstr ""
msgid "Apply suggestion"
msgstr ""
......@@ -14110,6 +14113,9 @@ msgstr ""
msgid "Network"
msgstr ""
msgid "NetworkPolicies|Define this policy's location, conditions and actions."
msgstr ""
msgid "NetworkPolicies|Enabled"
msgstr ""
......@@ -14137,6 +14143,9 @@ msgstr ""
msgid "NetworkPolicies|Policy %{policyName} was successfully changed"
msgstr ""
msgid "NetworkPolicies|Policy definition"
msgstr ""
msgid "NetworkPolicies|Something went wrong, failed to update policy"
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