From 58eae6d90c94bab98354a346c2df4fa223890eab Mon Sep 17 00:00:00 2001
From: Peter Hegman <phegman@gitlab.com>
Date: Tue, 20 Feb 2024 16:00:23 -0800
Subject: [PATCH] Add visibility level field

To form for adding new group to organization.
---
 .../groups/components/group_path_field.vue    |  2 +-
 .../groups/components/new_group_form.vue      | 36 +++++++++-
 app/assets/javascripts/groups/constants.js    |  1 +
 .../groups/new/components/app.vue             |  2 +
 .../visibility_level_radio_buttons.vue        | 64 +++++++++++++++++
 .../javascripts/visibility_level/constants.js | 18 +++++
 .../javascripts/visibility_level/utils.js     | 16 +++++
 .../groups/components/new_group_form_spec.js  | 29 +++++++-
 .../groups/new/components/app_spec.js         |  2 +
 .../visibility_level_radio_buttons_spec.js    | 69 +++++++++++++++++++
 spec/frontend/visibility_level/utils_spec.js  | 45 ++++++++++++
 11 files changed, 281 insertions(+), 3 deletions(-)
 create mode 100644 app/assets/javascripts/visibility_level/components/visibility_level_radio_buttons.vue
 create mode 100644 app/assets/javascripts/visibility_level/utils.js
 create mode 100644 spec/frontend/visibility_level/components/visibility_level_radio_buttons_spec.js
 create mode 100644 spec/frontend/visibility_level/utils_spec.js

diff --git a/app/assets/javascripts/groups/components/group_path_field.vue b/app/assets/javascripts/groups/components/group_path_field.vue
index d6a7cd00b80f0..53e757ff1b27f 100644
--- a/app/assets/javascripts/groups/components/group_path_field.vue
+++ b/app/assets/javascripts/groups/components/group_path_field.vue
@@ -11,7 +11,7 @@ const DEBOUNCE_DURATION = 1000;
 
 export default {
   i18n: {
-    placeholder: __('My awesome group'),
+    placeholder: __('my-awesome-group'),
     apiErrorMessage: __(
       'An error occurred while checking group path. Please refresh and try again.',
     ),
diff --git a/app/assets/javascripts/groups/components/new_group_form.vue b/app/assets/javascripts/groups/components/new_group_form.vue
index 42ade46f11e41..c48138dbe394c 100644
--- a/app/assets/javascripts/groups/components/new_group_form.vue
+++ b/app/assets/javascripts/groups/components/new_group_form.vue
@@ -3,7 +3,13 @@ import { GlForm, GlFormFields, GlButton } from '@gitlab/ui';
 import { formValidators } from '@gitlab/ui/dist/utils';
 import { __, s__, sprintf } from '~/locale';
 import { slugify } from '~/lib/utils/text_utility';
-import { FORM_FIELD_NAME, FORM_FIELD_PATH } from '../constants';
+import VisibilityLevelRadioButtons from '~/visibility_level/components/visibility_level_radio_buttons.vue';
+import {
+  VISIBILITY_LEVEL_PRIVATE_INTEGER,
+  GROUP_VISIBILITY_LEVEL_DESCRIPTIONS,
+} from '~/visibility_level/constants';
+import { restrictedVisibilityLevelsMessage } from '~/visibility_level/utils';
+import { FORM_FIELD_NAME, FORM_FIELD_PATH, FORM_FIELD_VISIBILITY_LEVEL } from '../constants';
 import GroupPathField from './group_path_field.vue';
 
 export default {
@@ -13,11 +19,13 @@ export default {
     GlFormFields,
     GlButton,
     GroupPathField,
+    VisibilityLevelRadioButtons,
   },
   i18n: {
     cancel: __('Cancel'),
     submitButtonText: __('Create group'),
   },
+  GROUP_VISIBILITY_LEVEL_DESCRIPTIONS,
   formId: 'organization-new-group-form',
   props: {
     basePath: {
@@ -36,6 +44,14 @@ export default {
       required: true,
       type: String,
     },
+    availableVisibilityLevels: {
+      type: Array,
+      required: true,
+    },
+    restrictedVisibilityLevels: {
+      type: Array,
+      required: true,
+    },
   },
   data() {
     return {
@@ -44,6 +60,7 @@ export default {
       formValues: {
         [FORM_FIELD_NAME]: '',
         [FORM_FIELD_PATH]: '',
+        [FORM_FIELD_VISIBILITY_LEVEL]: VISIBILITY_LEVEL_PRIVATE_INTEGER,
       },
     };
   },
@@ -90,6 +107,15 @@ export default {
               : null,
           },
         },
+        [FORM_FIELD_VISIBILITY_LEVEL]: {
+          label: __('Visibility level'),
+          groupAttrs: {
+            description: restrictedVisibilityLevelsMessage({
+              availableVisibilityLevels: this.availableVisibilityLevels,
+              restrictedVisibilityLevels: this.restrictedVisibilityLevels,
+            }),
+          },
+        },
       };
     },
   },
@@ -134,6 +160,14 @@ export default {
           @loading-change="onPathLoading"
         />
       </template>
+      <template #input(visibilityLevel)="{ value, input }">
+        <visibility-level-radio-buttons
+          :checked="value"
+          :visibility-levels="availableVisibilityLevels"
+          :visibility-level-descriptions="$options.GROUP_VISIBILITY_LEVEL_DESCRIPTIONS"
+          @input="input"
+        />
+      </template>
     </gl-form-fields>
     <div class="gl-display-flex gl-gap-3">
       <gl-button
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index d9ec00ab51074..bf1ca39b02c34 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -64,3 +64,4 @@ export const OVERVIEW_TABS_ARCHIVED_PROJECTS_SORTING_ITEMS = [
 
 export const FORM_FIELD_NAME = 'name';
 export const FORM_FIELD_PATH = 'path';
+export const FORM_FIELD_VISIBILITY_LEVEL = 'visibilityLevel';
diff --git a/app/assets/javascripts/organizations/groups/new/components/app.vue b/app/assets/javascripts/organizations/groups/new/components/app.vue
index 4df74cac9120e..934ba730d095b 100644
--- a/app/assets/javascripts/organizations/groups/new/components/app.vue
+++ b/app/assets/javascripts/organizations/groups/new/components/app.vue
@@ -57,6 +57,8 @@ export default {
       :path-maxlength="pathMaxlength"
       :path-pattern="pathPattern"
       :cancel-path="groupsOrganizationPath"
+      :available-visibility-levels="availableVisibilityLevels"
+      :restricted-visibility-levels="restrictedVisibilityLevels"
     />
   </div>
 </template>
diff --git a/app/assets/javascripts/visibility_level/components/visibility_level_radio_buttons.vue b/app/assets/javascripts/visibility_level/components/visibility_level_radio_buttons.vue
new file mode 100644
index 0000000000000..8260b6c1fd2e0
--- /dev/null
+++ b/app/assets/javascripts/visibility_level/components/visibility_level_radio_buttons.vue
@@ -0,0 +1,64 @@
+<script>
+import { GlIcon, GlFormRadio, GlFormRadioGroup } from '@gitlab/ui';
+import {
+  VISIBILITY_LEVEL_LABELS,
+  VISIBILITY_TYPE_ICON,
+  VISIBILITY_LEVELS_INTEGER_TO_STRING,
+} from '~/visibility_level/constants';
+
+export default {
+  name: 'VisibilityLevelRadioButtons',
+  components: {
+    GlIcon,
+    GlFormRadio,
+    GlFormRadioGroup,
+  },
+  model: {
+    prop: 'checked',
+  },
+  props: {
+    checked: {
+      type: Number,
+      required: true,
+    },
+    visibilityLevels: {
+      type: Array,
+      required: true,
+    },
+    visibilityLevelDescriptions: {
+      type: Object,
+      required: true,
+    },
+  },
+  computed: {
+    visibilityLevelsOptions() {
+      return this.visibilityLevels.map((visibilityLevel) => {
+        const stringValue = VISIBILITY_LEVELS_INTEGER_TO_STRING[visibilityLevel];
+
+        return {
+          label: VISIBILITY_LEVEL_LABELS[stringValue],
+          description: this.visibilityLevelDescriptions[stringValue],
+          icon: VISIBILITY_TYPE_ICON[stringValue],
+          value: visibilityLevel,
+        };
+      });
+    },
+  },
+};
+</script>
+
+<template>
+  <gl-form-radio-group :checked="checked" @input="$emit('input', $event)">
+    <gl-form-radio
+      v-for="{ label, description, icon, value } in visibilityLevelsOptions"
+      :key="value"
+      :value="value"
+    >
+      <div>
+        <gl-icon :name="icon" />
+        <span>{{ label }}</span>
+      </div>
+      <template #help>{{ description }}</template>
+    </gl-form-radio>
+  </gl-form-radio-group>
+</template>
diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js
index 0d9ededc550f5..3060b55ce25e9 100644
--- a/app/assets/javascripts/visibility_level/constants.js
+++ b/app/assets/javascripts/visibility_level/constants.js
@@ -51,6 +51,24 @@ export const ORGANIZATION_VISIBILITY_TYPE = {
   ),
 };
 
+export const GROUP_VISIBILITY_LEVEL_DESCRIPTIONS = {
+  [VISIBILITY_LEVEL_PUBLIC_STRING]: s__(
+    'VisibilityLevel|The group and any public projects can be viewed without any authentication.',
+  ),
+  [VISIBILITY_LEVEL_INTERNAL_STRING]: s__(
+    'VisibilityLevel|The group and any internal projects can be viewed by any logged in user except external users.',
+  ),
+  [VISIBILITY_LEVEL_PRIVATE_STRING]: s__(
+    'VisibilityLevel|The group and its projects can only be viewed by members.',
+  ),
+};
+
+export const VISIBILITY_LEVEL_LABELS = {
+  [VISIBILITY_LEVEL_PUBLIC_STRING]: s__('VisibilityLevel|Public'),
+  [VISIBILITY_LEVEL_INTERNAL_STRING]: s__('VisibilityLevel|Internal'),
+  [VISIBILITY_LEVEL_PRIVATE_STRING]: s__('VisibilityLevel|Private'),
+};
+
 export const VISIBILITY_TYPE_ICON = {
   [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth',
   [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
diff --git a/app/assets/javascripts/visibility_level/utils.js b/app/assets/javascripts/visibility_level/utils.js
new file mode 100644
index 0000000000000..c8ac3c97d3e3a
--- /dev/null
+++ b/app/assets/javascripts/visibility_level/utils.js
@@ -0,0 +1,16 @@
+import { __ } from '~/locale';
+
+export const restrictedVisibilityLevelsMessage = ({
+  availableVisibilityLevels,
+  restrictedVisibilityLevels,
+}) => {
+  if (!restrictedVisibilityLevels.length) {
+    return '';
+  }
+
+  if (!availableVisibilityLevels.length) {
+    return __('Visibility settings have been disabled by the administrator.');
+  }
+
+  return __('Other visibility settings have been disabled by the administrator.');
+};
diff --git a/spec/frontend/groups/components/new_group_form_spec.js b/spec/frontend/groups/components/new_group_form_spec.js
index 23dc0f3296780..97ab55b85b210 100644
--- a/spec/frontend/groups/components/new_group_form_spec.js
+++ b/spec/frontend/groups/components/new_group_form_spec.js
@@ -2,6 +2,13 @@ import { nextTick } from 'vue';
 
 import NewGroupForm from '~/groups/components/new_group_form.vue';
 import GroupPathField from '~/groups/components/group_path_field.vue';
+import VisibilityLevelRadioButtons from '~/visibility_level/components/visibility_level_radio_buttons.vue';
+import {
+  VISIBILITY_LEVELS_STRING_TO_INTEGER,
+  VISIBILITY_LEVEL_PRIVATE_INTEGER,
+  VISIBILITY_LEVEL_PUBLIC_INTEGER,
+  GROUP_VISIBILITY_LEVEL_DESCRIPTIONS,
+} from '~/visibility_level/constants';
 import { mountExtended } from 'helpers/vue_test_utils_helper';
 
 describe('NewGroupForm', () => {
@@ -12,6 +19,8 @@ describe('NewGroupForm', () => {
     cancelPath: '/-/organizations/default/groups_and_projects?display=groups',
     pathMaxlength: 10,
     pathPattern: '[a-zA-Z0-9_\\.][a-zA-Z0-9_\\-\\.]{0,254}[a-zA-Z0-9_\\-]|[a-zA-Z0-9_]',
+    availableVisibilityLevels: Object.values(VISIBILITY_LEVELS_STRING_TO_INTEGER),
+    restrictedVisibilityLevels: [],
   };
 
   const createComponent = ({ propsData = {} } = {}) => {
@@ -29,11 +38,16 @@ describe('NewGroupForm', () => {
 
   const findNameField = () => wrapper.findByLabelText('Group name');
   const findPathField = () => wrapper.findComponent(GroupPathField);
+  const findVisibilityLevelField = () => wrapper.findComponent(VisibilityLevelRadioButtons);
 
   const setPathFieldValue = async (value) => {
     findPathField().vm.$emit('input', value);
     await nextTick();
   };
+  const setVisibilityLevelFieldValue = async (value) => {
+    findVisibilityLevelField().vm.$emit('input', value);
+    await nextTick();
+  };
   const submitForm = async () => {
     await wrapper.findByRole('button', { name: 'Create group' }).trigger('click');
   };
@@ -50,6 +64,16 @@ describe('NewGroupForm', () => {
     expect(findPathField().exists()).toBe(true);
   });
 
+  it('renders `Visibility level` field with correct props', () => {
+    createComponent();
+
+    expect(findVisibilityLevelField().props()).toMatchObject({
+      checked: VISIBILITY_LEVEL_PRIVATE_INTEGER,
+      visibilityLevels: defaultPropsData.availableVisibilityLevels,
+      visibilityLevelDescriptions: GROUP_VISIBILITY_LEVEL_DESCRIPTIONS,
+    });
+  });
+
   describe('when form is submitted without filling in required fields', () => {
     beforeEach(async () => {
       createComponent();
@@ -127,11 +151,14 @@ describe('NewGroupForm', () => {
       createComponent();
 
       await findNameField().setValue('Foo bar');
+      await setVisibilityLevelFieldValue(VISIBILITY_LEVEL_PUBLIC_INTEGER);
       await submitForm();
     });
 
     it('emits `submit` event with form values', () => {
-      expect(wrapper.emitted('submit')).toEqual([[{ name: 'Foo bar', path: 'foo-bar' }]]);
+      expect(wrapper.emitted('submit')).toEqual([
+        [{ name: 'Foo bar', path: 'foo-bar', visibilityLevel: VISIBILITY_LEVEL_PUBLIC_INTEGER }],
+      ]);
     });
   });
 
diff --git a/spec/frontend/organizations/groups/new/components/app_spec.js b/spec/frontend/organizations/groups/new/components/app_spec.js
index 68ad940e076c5..649598e00925a 100644
--- a/spec/frontend/organizations/groups/new/components/app_spec.js
+++ b/spec/frontend/organizations/groups/new/components/app_spec.js
@@ -56,6 +56,8 @@ describe('OrganizationGroupsNewApp', () => {
       cancelPath: '/-/organizations/carrot/groups_and_projects?display=groups',
       pathMaxlength: 10,
       pathPattern: 'mockPattern',
+      availableVisibilityLevels: defaultProvide.availableVisibilityLevels,
+      restrictedVisibilityLevels: defaultProvide.restrictedVisibilityLevels,
     });
   });
 });
diff --git a/spec/frontend/visibility_level/components/visibility_level_radio_buttons_spec.js b/spec/frontend/visibility_level/components/visibility_level_radio_buttons_spec.js
new file mode 100644
index 0000000000000..b239c3d93ea84
--- /dev/null
+++ b/spec/frontend/visibility_level/components/visibility_level_radio_buttons_spec.js
@@ -0,0 +1,69 @@
+import { GlIcon, GlFormRadio, GlFormRadioGroup } from '@gitlab/ui';
+
+import {
+  VISIBILITY_LEVEL_PRIVATE_INTEGER,
+  VISIBILITY_LEVEL_PUBLIC_INTEGER,
+  VISIBILITY_LEVELS_STRING_TO_INTEGER,
+  GROUP_VISIBILITY_LEVEL_DESCRIPTIONS,
+} from '~/visibility_level/constants';
+import VisibilityLevelRadioButtons from '~/visibility_level/components/visibility_level_radio_buttons.vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+
+describe('VisibilityLevelRadioButtons', () => {
+  let wrapper;
+
+  const defaultPropsData = {
+    checked: VISIBILITY_LEVEL_PRIVATE_INTEGER,
+    visibilityLevels: Object.values(VISIBILITY_LEVELS_STRING_TO_INTEGER),
+    visibilityLevelDescriptions: GROUP_VISIBILITY_LEVEL_DESCRIPTIONS,
+  };
+
+  const createComponent = ({ propsData = {} } = {}) => {
+    wrapper = mountExtended(VisibilityLevelRadioButtons, {
+      propsData: {
+        ...defaultPropsData,
+        ...propsData,
+      },
+    });
+  };
+
+  const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
+
+  it('renders radio group with `checked` prop correctly set', () => {
+    createComponent();
+
+    expect(findRadioGroup().vm.$attrs.checked).toBe(defaultPropsData.checked);
+  });
+
+  describe('when radio group emits `input` event', () => {
+    beforeEach(() => {
+      createComponent();
+      findRadioGroup().vm.$emit('input', VISIBILITY_LEVEL_PUBLIC_INTEGER);
+    });
+
+    it('emits `input` event', () => {
+      expect(wrapper.emitted('input')).toEqual([[VISIBILITY_LEVEL_PUBLIC_INTEGER]]);
+    });
+  });
+
+  it('renders visibility level radio buttons with label, description, and icon', () => {
+    createComponent();
+
+    const radioButtons = wrapper.findAllComponents(GlFormRadio);
+
+    expect(radioButtons.at(0).text()).toMatchInterpolatedText(
+      'Private The group and its projects can only be viewed by members.',
+    );
+    expect(radioButtons.at(0).findComponent(GlIcon).props('name')).toBe('lock');
+
+    expect(radioButtons.at(1).text()).toMatchInterpolatedText(
+      'Internal The group and any internal projects can be viewed by any logged in user except external users.',
+    );
+    expect(radioButtons.at(1).findComponent(GlIcon).props('name')).toBe('shield');
+
+    expect(radioButtons.at(2).text()).toMatchInterpolatedText(
+      'Public The group and any public projects can be viewed without any authentication.',
+    );
+    expect(radioButtons.at(2).findComponent(GlIcon).props('name')).toBe('earth');
+  });
+});
diff --git a/spec/frontend/visibility_level/utils_spec.js b/spec/frontend/visibility_level/utils_spec.js
new file mode 100644
index 0000000000000..531b6fc4e9052
--- /dev/null
+++ b/spec/frontend/visibility_level/utils_spec.js
@@ -0,0 +1,45 @@
+import { restrictedVisibilityLevelsMessage } from '~/visibility_level/utils';
+import {
+  VISIBILITY_LEVELS_STRING_TO_INTEGER,
+  VISIBILITY_LEVEL_PRIVATE_INTEGER,
+  VISIBILITY_LEVEL_INTERNAL_INTEGER,
+  VISIBILITY_LEVEL_PUBLIC_INTEGER,
+} from '~/visibility_level/constants';
+
+describe('restrictedVisibilityLevelsMessage', () => {
+  describe('when no levels are restricted', () => {
+    it('returns empty string', () => {
+      expect(
+        restrictedVisibilityLevelsMessage({
+          availableVisibilityLevels: Object.values(VISIBILITY_LEVELS_STRING_TO_INTEGER),
+          restrictedVisibilityLevels: [],
+        }),
+      ).toBe('');
+    });
+  });
+
+  describe('when some levels have been restricted', () => {
+    it('returns expected message', () => {
+      expect(
+        restrictedVisibilityLevelsMessage({
+          availableVisibilityLevels: [VISIBILITY_LEVEL_PRIVATE_INTEGER],
+          restrictedVisibilityLevels: [
+            VISIBILITY_LEVEL_INTERNAL_INTEGER,
+            VISIBILITY_LEVEL_PUBLIC_INTEGER,
+          ],
+        }),
+      ).toBe('Other visibility settings have been disabled by the administrator.');
+    });
+  });
+
+  describe('when all visibility levels are restricted', () => {
+    it('returns expected message', () => {
+      expect(
+        restrictedVisibilityLevelsMessage({
+          availableVisibilityLevels: [],
+          restrictedVisibilityLevels: Object.values(VISIBILITY_LEVELS_STRING_TO_INTEGER),
+        }),
+      ).toBe('Visibility settings have been disabled by the administrator.');
+    });
+  });
+});
-- 
GitLab