+
+
{{ $options.NEXT_LABEL }}
@@ -146,7 +151,7 @@ export default {
-
-
-
-
+
+
diff --git a/app/controllers/clusters/agents/dashboard_controller.rb b/app/controllers/clusters/agents/dashboard_controller.rb
new file mode 100644
index 00000000000..1f72aaa4775
--- /dev/null
+++ b/app/controllers/clusters/agents/dashboard_controller.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Clusters
+ module Agents
+ class DashboardController < ApplicationController
+ include KasCookie
+
+ before_action :check_feature_flag!
+ before_action :find_agent
+ before_action :authorize_read_cluster_agent!
+ before_action :set_kas_cookie, only: [:show], if: -> { current_user }
+
+ feature_category :deployment_management
+
+ def show
+ head :ok
+ end
+
+ private
+
+ def find_agent
+ @agent = ::Clusters::Agent.find(params[:agent_id])
+ end
+
+ def check_feature_flag!
+ not_found unless ::Feature.enabled?(:k8s_dashboard, current_user)
+ end
+
+ def authorize_read_cluster_agent!
+ not_found unless can?(current_user, :read_cluster_agent, @agent)
+ end
+ end
+ end
+end
diff --git a/app/helpers/organizations/organization_helper.rb b/app/helpers/organizations/organization_helper.rb
index ecd76f53d9b..196327835e3 100644
--- a/app/helpers/organizations/organization_helper.rb
+++ b/app/helpers/organizations/organization_helper.rb
@@ -6,7 +6,22 @@ module Organizations
{
organization: organization.slice(:id, :name),
groups_and_projects_organization_path: groups_and_projects_organization_path(organization)
- }.to_json
+ }.merge(shared_groups_and_projects_app_data).to_json
+ end
+
+ def organization_groups_and_projects_app_data
+ shared_groups_and_projects_app_data.to_json
+ end
+
+ private
+
+ def shared_groups_and_projects_app_data
+ {
+ projects_empty_state_svg_path: image_path('illustrations/empty-state/empty-projects-md.svg'),
+ groups_empty_state_svg_path: image_path('illustrations/empty-state/empty-groups-md.svg'),
+ new_group_path: new_group_path,
+ new_project_path: new_project_path
+ }
end
end
end
diff --git a/app/views/organizations/organizations/groups_and_projects.html.haml b/app/views/organizations/organizations/groups_and_projects.html.haml
index 8890f4b1ce5..a993e1c9404 100644
--- a/app/views/organizations/organizations/groups_and_projects.html.haml
+++ b/app/views/organizations/organizations/groups_and_projects.html.haml
@@ -1,3 +1,3 @@
- page_title _('Groups and projects')
-#js-organizations-groups-and-projects
+#js-organizations-groups-and-projects{ data: { app_data: organization_groups_and_projects_app_data } }
diff --git a/config/feature_flags/development/k8s_dashboard.yml b/config/feature_flags/development/k8s_dashboard.yml
new file mode 100644
index 00000000000..1969dce4c2d
--- /dev/null
+++ b/config/feature_flags/development/k8s_dashboard.yml
@@ -0,0 +1,8 @@
+---
+name: k8s_dashboard
+introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/130953"
+rollout_issue_url: "https://gitlab.com/gitlab-org/gitlab/-/issues/424237"
+milestone: '16.4'
+type: development
+group: group::environments
+default_enabled: false
diff --git a/config/metrics/counts_28d/20210216180327_action_monthly_active_users_ide_edit.yml b/config/metrics/counts_28d/20210216180327_action_monthly_active_users_ide_edit.yml
index 3c7acc92050..6953cbc2ce3 100644
--- a/config/metrics/counts_28d/20210216180327_action_monthly_active_users_ide_edit.yml
+++ b/config/metrics/counts_28d/20210216180327_action_monthly_active_users_ide_edit.yml
@@ -18,7 +18,6 @@ options:
- g_edit_by_web_ide
- g_edit_by_sfe
- g_edit_by_snippet_ide
- - g_edit_by_live_preview
distribution:
- ce
- ee
diff --git a/config/metrics/counts_28d/20210216180341_ide_edit_total_unique_counts_monthly.yml b/config/metrics/counts_28d/20210216180341_ide_edit_total_unique_counts_monthly.yml
index 2e50156911d..02c0a290e9e 100644
--- a/config/metrics/counts_28d/20210216180341_ide_edit_total_unique_counts_monthly.yml
+++ b/config/metrics/counts_28d/20210216180341_ide_edit_total_unique_counts_monthly.yml
@@ -18,7 +18,6 @@ options:
- g_edit_by_web_ide
- g_edit_by_sfe
- g_edit_by_snippet_ide
- - g_edit_by_live_preview
distribution:
- ce
- ee
diff --git a/config/metrics/counts_28d/20220428154012_live_preview.yml b/config/metrics/counts_28d/20220428154012_live_preview.yml
index e048398da57..0a5e64bf472 100644
--- a/config/metrics/counts_28d/20220428154012_live_preview.yml
+++ b/config/metrics/counts_28d/20220428154012_live_preview.yml
@@ -6,7 +6,7 @@ product_section: dev
product_stage: create
product_group: ide
value_type: number
-status: active
+status: removed
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/85420
time_frame: 28d
data_source: redis_hll
diff --git a/config/metrics/counts_7d/20210216180339_ide_edit_total_unique_counts_weekly.yml b/config/metrics/counts_7d/20210216180339_ide_edit_total_unique_counts_weekly.yml
index 413a032309e..035c17faef5 100644
--- a/config/metrics/counts_7d/20210216180339_ide_edit_total_unique_counts_weekly.yml
+++ b/config/metrics/counts_7d/20210216180339_ide_edit_total_unique_counts_weekly.yml
@@ -18,7 +18,6 @@ options:
- g_edit_by_web_ide
- g_edit_by_sfe
- g_edit_by_snippet_ide
- - g_edit_by_live_preview
distribution:
- ce
- ee
diff --git a/config/routes.rb b/config/routes.rb
index edea47e8482..663828670dd 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -117,6 +117,12 @@ InitializerConnections.raise_if_new_database_connection do
get 'offline' => "pwa#offline"
get 'manifest' => "pwa#manifest", constraints: lambda { |req| req.format == :json }
+ scope module: 'clusters' do
+ scope module: 'agents' do
+ get '/kubernetes/:agent_id', to: 'dashboard#show', as: 'kubernetes_dashboard'
+ end
+ end
+
# '/-/health' implemented by BasicHealthCheck middleware
get 'liveness' => 'health#liveness'
get 'readiness' => 'health#readiness'
diff --git a/doc/architecture/blueprints/cells/diagrams/cells-and-fulfillment.drawio.png b/doc/architecture/blueprints/cells/diagrams/cells-and-fulfillment.drawio.png
deleted file mode 100644
index fc32c694ddc..00000000000
Binary files a/doc/architecture/blueprints/cells/diagrams/cells-and-fulfillment.drawio.png and /dev/null differ
diff --git a/doc/architecture/blueprints/cells/diagrams/index.md b/doc/architecture/blueprints/cells/diagrams/index.md
index 77d12612819..14db888382e 100644
--- a/doc/architecture/blueprints/cells/diagrams/index.md
+++ b/doc/architecture/blueprints/cells/diagrams/index.md
@@ -22,7 +22,7 @@ Load the `.drawio.png` or `.drawio.svg` file directly into **draw.io**, which yo
To create a diagram from a file:
1. Copy existing file and rename it. Ensure that the extension is `.drawio.png` or `.drawio.svg`.
-1. Edit the diagram.
+1. Edit the diagram.
1. Save the file.
To create a diagram from scratch using [draw.io desktop](https://github.com/jgraph/drawio-desktop/releases):
diff --git a/doc/architecture/blueprints/cells/impacted_features/ci-cd-catalog.md b/doc/architecture/blueprints/cells/impacted_features/ci-cd-catalog.md
index f39de0a4072..3ca2ff042dc 100644
--- a/doc/architecture/blueprints/cells/impacted_features/ci-cd-catalog.md
+++ b/doc/architecture/blueprints/cells/impacted_features/ci-cd-catalog.md
@@ -51,4 +51,4 @@ Today we can have multiple catalogs based on the number of namespaces, making it
### 4.2. Cons
-- A separate catalog that surfaces components across Organizations would need to be implemented to serve the wider community (community catalog). This catalog will be required for GitLab.com only, later on we can evaluate if a similar catalog is needed for self-managed customers.
+- A separate catalog that surfaces components across Organizations would need to be implemented to serve the wider community (community catalog). This catalog will be required for GitLab.com only, later on we can evaluate if a similar catalog is needed for self-managed customers.
diff --git a/doc/architecture/blueprints/cells/impacted_features/personal-namespaces.md b/doc/architecture/blueprints/cells/impacted_features/personal-namespaces.md
index 53d96ad9262..55d974bb351 100644
--- a/doc/architecture/blueprints/cells/impacted_features/personal-namespaces.md
+++ b/doc/architecture/blueprints/cells/impacted_features/personal-namespaces.md
@@ -26,7 +26,7 @@ This is especially the case with the merge request, as it is one of the most com
Today personal Namespaces serve two purposes that are mostly non-overlapping:
-1. They provide a place for users to create personal Projects
+1. They provide a place for users to create personal Projects
that aren't expected to receive contributions from other people. This use case saves them from having to create a Group just for themselves.
1. They provide a default place for a user to put any forks they
create when contributing to Projects where they don't have permission to push a branch. This again saves them from needing to create a Group just to store these forks. But the primary user need here is because they can't push branches to the upstream Project so they create a fork and contribute merge requests from the fork.
diff --git a/doc/architecture/blueprints/container_registry_metadata_database_self_managed_rollout/index.md b/doc/architecture/blueprints/container_registry_metadata_database_self_managed_rollout/index.md
index df9448309ce..84a95e3e7c3 100644
--- a/doc/architecture/blueprints/container_registry_metadata_database_self_managed_rollout/index.md
+++ b/doc/architecture/blueprints/container_registry_metadata_database_self_managed_rollout/index.md
@@ -74,7 +74,7 @@ quick reference for what features we have now, are planning, their statuses, and
an excutive summary of the overall state of the migration experience.
This could be advertised to self-managed users via a simple chart, allowing them
to tell at a glance the status of this project and determine if it is feature-
-complete enough for their needs and level of risk tolerance.
+complete enough for their needs and level of risk tolerance.
This should be documented in the container registry administration documentation,
rather than in this blueprint. Providing this information there will place it in
@@ -86,7 +86,7 @@ For example:
The metadata database is in early beta for self-managed users. The core migration
process for existing registries has been implemented, and online garbage collection
-is fully implemented. Certain database enabled features are only enabled for GitLab.com
+is fully implemented. Certain database enabled features are only enabled for GitLab.com
and automatic database provisioning for the registry database is not available.
Please see the table below for the status of features related to the container
registry database.
diff --git a/doc/development/ai_features/index.md b/doc/development/ai_features/index.md
index b319e57cae9..ea8e52c5271 100644
--- a/doc/development/ai_features/index.md
+++ b/doc/development/ai_features/index.md
@@ -256,7 +256,7 @@ In our component, we then listen on the `aiCompletionResponse` using the `userId
```graphql
subscription aiCompletionResponse($userId: UserID, $resourceId: AiModelID, $clientSubscriptionId: String) {
aiCompletionResponse(userId: $userId, resourceId: $resourceId, clientSubscriptionId: $clientSubscriptionId) {
- responseBody
+ content
errors
}
}
diff --git a/doc/development/code_suggestions/index.md b/doc/development/code_suggestions/index.md
index 5bda95acb87..9b94e9020ba 100644
--- a/doc/development/code_suggestions/index.md
+++ b/doc/development/code_suggestions/index.md
@@ -8,7 +8,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
## Code Suggestions development setup
-The recommended setup for locally developing and debugging Code Suggestions is to have all 3 different components running:
+The recommended setup for locally developing and debugging Code Suggestions is to have all 3 different components running:
- IDE Extension (e.g. VSCode Extension)
- Main application configured correctly
diff --git a/doc/development/fe_guide/img/boards_diagram.png b/doc/development/fe_guide/img/boards_diagram.png
deleted file mode 100644
index 856c9b05bbf..00000000000
Binary files a/doc/development/fe_guide/img/boards_diagram.png and /dev/null differ
diff --git a/doc/operations/img/tracing_details_v16_3.png b/doc/operations/img/tracing_details_v16_3.png
deleted file mode 100644
index 2b371228cec..00000000000
Binary files a/doc/operations/img/tracing_details_v16_3.png and /dev/null differ
diff --git a/doc/user/analytics/img/dora_performers_score_panel_v16_3.png b/doc/user/analytics/img/dora_performers_score_panel_v16_3.png
deleted file mode 100644
index 9f59667f9e9..00000000000
Binary files a/doc/user/analytics/img/dora_performers_score_panel_v16_3.png and /dev/null differ
diff --git a/doc/user/profile/img/profile-preferences-syntax-themes_v15_11.png b/doc/user/profile/img/profile-preferences-syntax-themes_v15_11.png
deleted file mode 100644
index 39cd35f3b5b..00000000000
Binary files a/doc/user/profile/img/profile-preferences-syntax-themes_v15_11.png and /dev/null differ
diff --git a/doc/user/profile/preferences.md b/doc/user/profile/preferences.md
index 233688e9bd1..4592422eee1 100644
--- a/doc/user/profile/preferences.md
+++ b/doc/user/profile/preferences.md
@@ -13,7 +13,7 @@ You can update your preferences to change the look and feel of GitLab.
You can change the color theme of the GitLab UI. These colors are displayed on the left sidebar.
Using individual color themes might help you differentiate between your different
-GitLab instances.
+GitLab instances.
To change the color theme:
@@ -25,7 +25,7 @@ To change the color theme:
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28252) in GitLab 13.1 as an [Experiment](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/28252).
-Dark mode makes elements on the GitLab UI stand out on a dark background.
+Dark mode makes elements on the GitLab UI stand out on a dark background.
- To turn on Dark mode, Select **Preferences > Color theme > Dark Mode**.
@@ -44,11 +44,11 @@ To change the syntax highlighting theme:
1. In the **Syntax highlighting theme** section, select a theme.
1. Select **Save changes**.
-To view the updated syntax highlighting theme, refresh your project's page.
+To view the updated syntax highlighting theme, refresh your project's page.
To customize the syntax highlighting theme, you can also [use the Application settings API](../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls). Use `default_syntax_highlighting_theme` to change the syntax highlighting colors on a more granular level.
-If these steps do not work, your programming language might not be supported by the syntax highlighters.
+If these steps do not work, your programming language might not be supported by the syntax highlighters.
For more information, view [Rouge Ruby Library](https://github.com/rouge-ruby/rouge) for guidance on code files and Snippets. View [Moncaco Editor](https://microsoft.github.io/monaco-editor/) and [Monarch](https://microsoft.github.io/monaco-editor/monarch.html) for guidance on the Web IDE.
## Change the diff colors
@@ -59,7 +59,7 @@ To change the diff colors:
1. On the left sidebar, select your avatar.
1. Select **Preferences**.
-1. Go to the **Diff colors** section.
+1. Go to the **Diff colors** section.
1. Select a color or enter a color code.
1. Select **Save changes**.
@@ -142,7 +142,7 @@ To render whitespace in the Web IDE:
1. Select the **Render whitespace characters in the Web IDE** checkbox.
1. Select **Save changes**.
-You can view changes to whitespace in diffs.
+You can view changes to whitespace in diffs.
To view diffs on the Web IDE, follow these steps:
@@ -315,7 +315,7 @@ To integrate with Gitpod:
### Integrate your GitLab instance with Sourcegraph
-GitLab supports Sourcegraph integration for all public projects on GitLab.
+GitLab supports Sourcegraph integration for all public projects on GitLab.
To integrate with Sourcegraph:
diff --git a/doc/user/project/issues/design_management.md b/doc/user/project/issues/design_management.md
index c2d94aedc6e..65000965f7c 100644
--- a/doc/user/project/issues/design_management.md
+++ b/doc/user/project/issues/design_management.md
@@ -58,7 +58,6 @@ You can upload files of the following types as designs:
- JPEG
- JPG
- PNG
-- SVG
- TIFF
- WEBP
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index d99192e7c0d..48c759dd8b9 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -3028,7 +3028,7 @@ msgstr ""
msgid "AddMember|Invite email is invalid"
msgstr ""
-msgid "AddMember|Invite limit of %{daily_invites} per day exceeded"
+msgid "AddMember|Invite limit of %{daily_invites} per day exceeded."
msgstr ""
msgid "AddMember|Invites cannot be blank"
@@ -10275,12 +10275,24 @@ msgstr ""
msgid "CiVariables|This %{entity} has %{currentVariableCount} defined CI/CD variables. The maximum number of variables per %{entity} is %{maxVariableLimit}. To add new variables, you must reduce the number of defined variables."
msgstr ""
+msgid "CiVariables|This variable value does not meet the masking requirements."
+msgstr ""
+
msgid "CiVariables|Type"
msgstr ""
+msgid "CiVariables|Unselect \"Expand variable reference\" if you want to use the variable value as a raw string."
+msgstr ""
+
msgid "CiVariables|Value"
msgstr ""
+msgid "CiVariables|Value might contain a variable reference"
+msgstr ""
+
+msgid "CiVariables|Variable value will be evaluated as raw string."
+msgstr ""
+
msgid "CiVariables|Variable will be masked in job logs. Requires values to meet regular expression requirements."
msgstr ""
@@ -32821,6 +32833,9 @@ msgstr ""
msgid "Organizations"
msgstr ""
+msgid "Organization|A group is a collection of several projects. If you organize your projects under a group, it works like a folder."
+msgstr ""
+
msgid "Organization|An error occurred loading the groups. Please refresh the page to try again."
msgstr ""
@@ -32860,6 +32875,12 @@ msgstr ""
msgid "Organization|View all"
msgstr ""
+msgid "Organization|You don't have any groups yet."
+msgstr ""
+
+msgid "Organization|You don't have any projects yet."
+msgstr ""
+
msgid "Orphaned member"
msgstr ""
diff --git a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
index a4006f94e5f..edaa675c35a 100644
--- a/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
+++ b/spec/features/merge_request/user_sees_merge_request_pipelines_spec.rb
@@ -416,7 +416,7 @@ RSpec.describe 'Merge request > User sees pipelines triggered by merge request',
before do
detached_merge_request_pipeline.reload.succeed!
- refresh
+ wait_for_requests
end
it 'merges the merge request' do
diff --git a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js
index cb839503931..ab5d914a6a1 100644
--- a/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js
+++ b/spec/frontend/ci/ci_variable_list/components/ci_variable_drawer_spec.js
@@ -1,24 +1,36 @@
-import { GlDrawer, GlFormInput, GlFormSelect } from '@gitlab/ui';
+import { GlDrawer, GlFormCombobox, GlFormInput, GlFormSelect } from '@gitlab/ui';
import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper';
import CiEnvironmentsDropdown from '~/ci/ci_variable_list/components/ci_environments_dropdown.vue';
-import CiVariableDrawer, { i18n } from '~/ci/ci_variable_list/components/ci_variable_drawer.vue';
+import CiVariableDrawer from '~/ci/ci_variable_list/components/ci_variable_drawer.vue';
+import { awsTokenList } from '~/ci/ci_variable_list/components/ci_variable_autocomplete_tokens';
import {
ADD_VARIABLE_ACTION,
+ DRAWER_EVENT_LABEL,
EDIT_VARIABLE_ACTION,
+ EVENT_ACTION,
variableOptions,
projectString,
variableTypes,
} from '~/ci/ci_variable_list/constants';
+import { mockTracking } from 'helpers/tracking_helper';
import { mockVariablesWithScopes } from '../mocks';
describe('CI Variable Drawer', () => {
let wrapper;
+ let trackingSpy;
const mockProjectVariable = mockVariablesWithScopes(projectString)[0];
const mockProjectVariableFileType = mockVariablesWithScopes(projectString)[1];
const mockEnvScope = 'staging';
const mockEnvironments = ['*', 'dev', 'staging', 'production'];
+ // matches strings that contain at least 8 consecutive characters consisting of only
+ // letters (both uppercase and lowercase), digits, or the specified special characters
+ const maskableRegex = '^[a-zA-Z0-9_+=/@:.~-]{8,}$';
+
+ // matches strings that consist of at least 8 or more non-whitespace characters
+ const maskableRawRegex = '^\\S{8,}$';
+
const defaultProps = {
areEnvironmentsLoading: false,
areScopedVariablesAvailable: true,
@@ -31,6 +43,8 @@ describe('CI Variable Drawer', () => {
const defaultProvide = {
isProtectedByDefault: true,
environmentScopeLink: '/help/environments',
+ maskableRawRegex,
+ maskableRegex,
};
const createComponent = ({
@@ -57,8 +71,11 @@ describe('CI Variable Drawer', () => {
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findEnvironmentScopeDropdown = () => wrapper.findComponent(CiEnvironmentsDropdown);
const findExpandedCheckbox = () => wrapper.findByTestId('ci-variable-expanded-checkbox');
+ const findKeyField = () => wrapper.findComponent(GlFormCombobox);
const findMaskedCheckbox = () => wrapper.findByTestId('ci-variable-masked-checkbox');
const findProtectedCheckbox = () => wrapper.findByTestId('ci-variable-protected-checkbox');
+ const findValueField = () => wrapper.findByTestId('ci-variable-value');
+ const findValueLabel = () => wrapper.findByTestId('ci-variable-value-label');
const findTitle = () => findDrawer().find('h2');
const findTypeDropdown = () => wrapper.findComponent(GlFormSelect);
@@ -201,12 +218,131 @@ describe('CI Variable Drawer', () => {
});
it("sets the variable's raw value", async () => {
+ await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
await findExpandedCheckbox().vm.$emit('change');
await findConfirmBtn().vm.$emit('click');
const sentRawValue = wrapper.emitted('add-variable')[0][0].raw;
expect(sentRawValue).toBe(!defaultProps.raw);
});
+
+ it('shows help text when variable is not expanded (will be evaluated as raw)', async () => {
+ expect(findExpandedCheckbox().attributes('checked')).toBeDefined();
+ expect(findDrawer().text()).not.toContain(
+ 'Variable value will be evaluated as raw string.',
+ );
+
+ await findExpandedCheckbox().vm.$emit('change');
+
+ expect(findExpandedCheckbox().attributes('checked')).toBeUndefined();
+ expect(findDrawer().text()).toContain('Variable value will be evaluated as raw string.');
+ });
+
+ it('shows help text when variable is expanded and contains the $ character', async () => {
+ expect(findDrawer().text()).not.toContain(
+ 'Unselect "Expand variable reference" if you want to use the variable value as a raw string.',
+ );
+
+ await findValueField().vm.$emit('input', '$NEW_VALUE');
+
+ expect(findDrawer().text()).toContain(
+ 'Unselect "Expand variable reference" if you want to use the variable value as a raw string.',
+ );
+ });
+ });
+
+ describe('key', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('prompts AWS tokens as options', () => {
+ expect(findKeyField().props('tokenList')).toBe(awsTokenList);
+ });
+
+ it('cannot submit with empty key', async () => {
+ expect(findConfirmBtn().attributes('disabled')).toBeDefined();
+
+ await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
+
+ expect(findConfirmBtn().attributes('disabled')).toBeUndefined();
+ });
+ });
+
+ describe('value', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('can submit empty value', async () => {
+ await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
+
+ // value is empty by default
+ expect(findConfirmBtn().attributes('disabled')).toBeUndefined();
+ });
+
+ describe.each`
+ value | canSubmit | trackingErrorProperty
+ ${'secretValue'} | ${true} | ${null}
+ ${'~v@lid:symbols.'} | ${true} | ${null}
+ ${'short'} | ${false} | ${null}
+ ${'multiline\nvalue'} | ${false} | ${'\n'}
+ ${'dollar$ign'} | ${false} | ${'$'}
+ ${'unsupported|char'} | ${false} | ${'|'}
+ `('masking requirements', ({ value, canSubmit, trackingErrorProperty }) => {
+ beforeEach(async () => {
+ createComponent();
+
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
+ await findValueField().vm.$emit('input', value);
+ await findMaskedCheckbox().vm.$emit('input', true);
+ });
+
+ it(`${
+ canSubmit ? 'can submit' : 'shows validation errors and disables submit button'
+ } when value is '${value}'`, () => {
+ if (canSubmit) {
+ expect(findValueLabel().attributes('invalid-feedback')).toBe('');
+ expect(findConfirmBtn().attributes('disabled')).toBeUndefined();
+ } else {
+ expect(findValueLabel().attributes('invalid-feedback')).toBe(
+ 'This variable value does not meet the masking requirements.',
+ );
+ expect(findConfirmBtn().attributes('disabled')).toBeDefined();
+ }
+ });
+
+ it(`${
+ trackingErrorProperty ? 'sends the correct' : 'does not send the'
+ } variable validation tracking event when value is '${value}'`, () => {
+ const trackingEventSent = trackingErrorProperty ? 1 : 0;
+ expect(trackingSpy).toHaveBeenCalledTimes(trackingEventSent);
+
+ if (trackingErrorProperty) {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, EVENT_ACTION, {
+ label: DRAWER_EVENT_LABEL,
+ property: trackingErrorProperty,
+ });
+ }
+ });
+ });
+
+ it('only sends the tracking event once', async () => {
+ trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
+ await findKeyField().vm.$emit('input', 'NEW_VARIABLE');
+ await findMaskedCheckbox().vm.$emit('input', true);
+
+ expect(trackingSpy).toHaveBeenCalledTimes(0);
+
+ await findValueField().vm.$emit('input', 'unsupported|char');
+
+ expect(trackingSpy).toHaveBeenCalledTimes(1);
+
+ await findValueField().vm.$emit('input', 'dollar$ign');
+
+ expect(trackingSpy).toHaveBeenCalledTimes(1);
+ });
});
});
@@ -227,8 +363,8 @@ describe('CI Variable Drawer', () => {
});
it('title and confirm button renders the correct text', () => {
- expect(findTitle().text()).toBe(i18n.addVariable);
- expect(findConfirmBtn().text()).toBe(i18n.addVariable);
+ expect(findTitle().text()).toBe('Add Variable');
+ expect(findConfirmBtn().text()).toBe('Add Variable');
});
});
@@ -241,8 +377,8 @@ describe('CI Variable Drawer', () => {
});
it('title and confirm button renders the correct text', () => {
- expect(findTitle().text()).toBe(i18n.editVariable);
- expect(findConfirmBtn().text()).toBe(i18n.editVariable);
+ expect(findTitle().text()).toBe('Edit Variable');
+ expect(findConfirmBtn().text()).toBe('Edit Variable');
});
});
});
diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js
index 1a9b0fae52a..526487f6460 100644
--- a/spec/frontend/invite_members/components/invite_members_modal_spec.js
+++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js
@@ -1,4 +1,4 @@
-import { GlLink, GlModal, GlSprintf, GlFormGroup, GlCollapse, GlIcon } from '@gitlab/ui';
+import { GlModal, GlSprintf, GlFormGroup, GlCollapse, GlIcon } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import { stubComponent } from 'helpers/stub_component';
@@ -12,7 +12,6 @@ import ModalConfetti from '~/invite_members/components/confetti.vue';
import MembersTokenSelect from '~/invite_members/components/members_token_select.vue';
import UserLimitNotification from '~/invite_members/components/user_limit_notification.vue';
import {
- INVITE_MEMBERS_FOR_TASK,
MEMBERS_MODAL_CELEBRATE_INTRO,
MEMBERS_MODAL_CELEBRATE_TITLE,
MEMBERS_PLACEHOLDER,
@@ -31,7 +30,6 @@ import {
HTTP_STATUS_CREATED,
HTTP_STATUS_INTERNAL_SERVER_ERROR,
} from '~/lib/utils/http_status';
-import { getParameterValues } from '~/lib/utils/url_utility';
import {
displaySuccessfulInvitationAlert,
reloadOnInvitationSuccess,
@@ -54,10 +52,6 @@ import {
jest.mock('~/invite_members/utils/trigger_successful_invite_alert');
jest.mock('~/experimentation/experiment_tracking');
-jest.mock('~/lib/utils/url_utility', () => ({
- ...jest.requireActual('~/lib/utils/url_utility'),
- getParameterValues: jest.fn(() => []),
-}));
describe('InviteMembersModal', () => {
let wrapper;
@@ -129,7 +123,6 @@ describe('InviteMembersModal', () => {
});
const findModal = () => wrapper.findComponent(GlModal);
- const findBase = () => wrapper.findComponent(InviteModalBase);
const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text();
const findEmptyInvitesAlert = () => wrapper.findByTestId('empty-invites-alert');
const findMemberErrorAlert = () => wrapper.findByTestId('alert-member-error');
@@ -155,10 +148,6 @@ describe('InviteMembersModal', () => {
findMembersFormGroup().attributes('invalid-feedback');
const membersFormGroupDescription = () => findMembersFormGroup().attributes('description');
const findMembersSelect = () => wrapper.findComponent(MembersTokenSelect);
- const findTasksToBeDone = () => wrapper.findByTestId('invite-members-modal-tasks-to-be-done');
- const findTasks = () => wrapper.findByTestId('invite-members-modal-tasks');
- const findProjectSelect = () => wrapper.findByTestId('invite-members-modal-project-select');
- const findNoProjectsAlert = () => wrapper.findByTestId('invite-members-modal-no-projects-alert');
const findCelebrationEmoji = () => wrapper.findComponent(GlEmoji);
const triggerOpenModal = async ({ mode = 'default', source } = {}) => {
eventHub.$emit('openModal', { mode, source });
@@ -168,131 +157,11 @@ describe('InviteMembersModal', () => {
findMembersSelect().vm.$emit('input', val);
await nextTick();
};
- const triggerTasks = async (val) => {
- findTasks().vm.$emit('input', val);
- await nextTick();
- };
- const triggerAccessLevel = async (val) => {
- findBase().vm.$emit('access-level', val);
- await nextTick();
- };
const removeMembersToken = async (val) => {
findMembersSelect().vm.$emit('token-remove', val);
await nextTick();
};
- describe('rendering the tasks to be done', () => {
- const setupComponent = async (props = {}, urlParameter = ['invite_members_for_task']) => {
- getParameterValues.mockImplementation(() => urlParameter);
- createComponent(props);
-
- await triggerAccessLevel(30);
- };
-
- const setupComponentWithTasks = async (...args) => {
- await setupComponent(...args);
- await triggerTasks(['ci', 'code']);
- };
-
- afterAll(() => {
- getParameterValues.mockImplementation(() => []);
- });
-
- it('renders the tasks to be done', async () => {
- await setupComponent();
-
- expect(findTasksToBeDone().exists()).toBe(true);
- });
-
- describe('when the selected access level is lower than 30', () => {
- it('does not render the tasks to be done', async () => {
- await setupComponent();
- await triggerAccessLevel(20);
-
- expect(findTasksToBeDone().exists()).toBe(false);
- });
- });
-
- describe('when the url does not contain the parameter `open_modal=invite_members_for_task`', () => {
- it('does not render the tasks to be done', async () => {
- await setupComponent({}, []);
-
- expect(findTasksToBeDone().exists()).toBe(false);
- });
- });
-
- describe('rendering the tasks', () => {
- it('renders the tasks', async () => {
- await setupComponent();
-
- expect(findTasks().exists()).toBe(true);
- });
-
- it('does not render an alert', async () => {
- await setupComponent();
-
- expect(findNoProjectsAlert().exists()).toBe(false);
- });
-
- describe('when there are no projects passed in the data', () => {
- it('does not render the tasks', async () => {
- await setupComponent({ projects: [] });
-
- expect(findTasks().exists()).toBe(false);
- });
-
- it('renders an alert with a link to the new projects path', async () => {
- await setupComponent({ projects: [] });
-
- expect(findNoProjectsAlert().exists()).toBe(true);
- expect(findNoProjectsAlert().findComponent(GlLink).attributes('href')).toBe(
- newProjectPath,
- );
- });
- });
- });
-
- describe('rendering the project dropdown', () => {
- it('renders the project select', async () => {
- await setupComponentWithTasks();
-
- expect(findProjectSelect().exists()).toBe(true);
- });
-
- describe('when the modal is shown for a project', () => {
- it('does not render the project select', async () => {
- await setupComponentWithTasks({ isProject: true });
-
- expect(findProjectSelect().exists()).toBe(false);
- });
- });
-
- describe('when no tasks are selected', () => {
- it('does not render the project select', async () => {
- await setupComponent();
-
- expect(findProjectSelect().exists()).toBe(false);
- });
- });
- });
-
- describe('tracking events', () => {
- it('tracks the submit for invite_members_for_task', async () => {
- await setupComponentWithTasks();
-
- await triggerMembersTokenSelect([user1]);
-
- trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
-
- clickInviteButton();
-
- expectTracking(INVITE_MEMBERS_FOR_TASK.submit, 'selected_tasks_to_be_done', 'ci,code');
-
- unmockTracking();
- });
- });
- });
-
describe('rendering with tracking considerations', () => {
describe('when inviting to a project', () => {
describe('when inviting members', () => {
@@ -624,6 +493,18 @@ describe('InviteMembersModal', () => {
expect(membersFormGroupInvalidFeedback()).toBe('');
expect(findMembersSelect().props('exceptionState')).not.toBe(false);
});
+
+ it('displays invite limit error message', async () => {
+ mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.INVITE_LIMIT);
+
+ clickInviteButton();
+
+ await waitForPromises();
+
+ expect(membersFormGroupInvalidFeedback()).toBe(
+ invitationsApiResponse.INVITE_LIMIT.message,
+ );
+ });
});
});
diff --git a/spec/frontend/invite_members/mock_data/api_responses.js b/spec/frontend/invite_members/mock_data/api_responses.js
index e3e2426fcfc..4f773009f37 100644
--- a/spec/frontend/invite_members/mock_data/api_responses.js
+++ b/spec/frontend/invite_members/mock_data/api_responses.js
@@ -47,6 +47,11 @@ const EMAIL_TAKEN = {
status: 'error',
};
+const INVITE_LIMIT = {
+ message: 'Invite limit of 5 per day exceeded.',
+ status: 'error',
+};
+
export const GROUPS_INVITATIONS_PATH = '/api/v4/groups/1/invitations';
export const invitationsApiResponse = {
@@ -56,6 +61,7 @@ export const invitationsApiResponse = {
MULTIPLE_RESTRICTED,
EMAIL_TAKEN,
EXPANDED_RESTRICTED,
+ INVITE_LIMIT,
};
export const IMPORT_PROJECT_MEMBERS_PATH = '/api/v4/projects/1/import_project_members/2';
diff --git a/spec/frontend/invite_members/utils/member_utils_spec.js b/spec/frontend/invite_members/utils/member_utils_spec.js
index b6fc70038bb..988715fe309 100644
--- a/spec/frontend/invite_members/utils/member_utils_spec.js
+++ b/spec/frontend/invite_members/utils/member_utils_spec.js
@@ -3,8 +3,6 @@ import {
triggerExternalAlert,
qualifiesForTasksToBeDone,
} from '~/invite_members/utils/member_utils';
-import setWindowLocation from 'helpers/set_window_location_helper';
-import { getParameterValues } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility');
@@ -26,15 +24,7 @@ describe('Trigger External Alert', () => {
});
describe('Qualifies For Tasks To Be Done', () => {
- it.each([
- ['invite_members_for_task', true],
- ['blah', false],
- ])(`returns name from supplied member token: %j`, (value, result) => {
- setWindowLocation(`blah/blah?open_modal=${value}`);
- getParameterValues.mockImplementation(() => {
- return [value];
- });
-
- expect(qualifiesForTasksToBeDone()).toBe(result);
+ it('returns false', () => {
+ expect(qualifiesForTasksToBeDone()).toBe(false);
});
});
diff --git a/spec/frontend/organizations/shared/components/groups_view_spec.js b/spec/frontend/organizations/shared/components/groups_view_spec.js
index 2b47bba3530..8d6ea60ffd2 100644
--- a/spec/frontend/organizations/shared/components/groups_view_spec.js
+++ b/spec/frontend/organizations/shared/components/groups_view_spec.js
@@ -1,6 +1,6 @@
import VueApollo from 'vue-apollo';
import Vue from 'vue';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui';
import GroupsView from '~/organizations/shared/components/groups_view.vue';
import { formatGroups } from '~/organizations/shared/utils';
import resolvers from '~/organizations/shared/graphql/resolvers';
@@ -20,10 +20,19 @@ describe('GroupsView', () => {
let wrapper;
let mockApollo;
- const createComponent = ({ mockResolvers = resolvers } = {}) => {
+ const defaultProvide = {
+ groupsEmptyStateSvgPath: 'illustrations/empty-state/empty-groups-md.svg',
+ newGroupPath: '/groups/new',
+ };
+
+ const createComponent = ({ mockResolvers = resolvers, propsData = {} } = {}) => {
mockApollo = createMockApollo([], mockResolvers);
- wrapper = shallowMountExtended(GroupsView, { apolloProvider: mockApollo });
+ wrapper = shallowMountExtended(GroupsView, {
+ apolloProvider: mockApollo,
+ provide: defaultProvide,
+ propsData,
+ });
};
afterEach(() => {
@@ -47,17 +56,66 @@ describe('GroupsView', () => {
});
describe('when API call is successful', () => {
- beforeEach(() => {
- createComponent();
+ describe('when there are no groups', () => {
+ it('renders empty state without buttons by default', async () => {
+ const mockResolvers = {
+ Query: {
+ organization: jest.fn().mockResolvedValueOnce({
+ groups: { nodes: [] },
+ }),
+ },
+ };
+ createComponent({ mockResolvers });
+
+ jest.runAllTimers();
+ await waitForPromises();
+
+ expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
+ title: "You don't have any groups yet.",
+ description:
+ 'A group is a collection of several projects. If you organize your projects under a group, it works like a folder.',
+ svgHeight: 144,
+ svgPath: defaultProvide.groupsEmptyStateSvgPath,
+ primaryButtonLink: null,
+ primaryButtonText: null,
+ });
+ });
+
+ describe('when `shouldShowEmptyStateButtons` is `true` and `groupsEmptyStateSvgPath` is set', () => {
+ it('renders empty state with buttons', async () => {
+ const mockResolvers = {
+ Query: {
+ organization: jest.fn().mockResolvedValueOnce({
+ groups: { nodes: [] },
+ }),
+ },
+ };
+ createComponent({ mockResolvers, propsData: { shouldShowEmptyStateButtons: true } });
+
+ jest.runAllTimers();
+ await waitForPromises();
+
+ expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
+ primaryButtonLink: defaultProvide.newGroupPath,
+ primaryButtonText: 'New group',
+ });
+ });
+ });
});
- it('renders `GroupsList` component and passes correct props', async () => {
- jest.runAllTimers();
- await waitForPromises();
+ describe('when there are groups', () => {
+ beforeEach(() => {
+ createComponent();
+ });
- expect(wrapper.findComponent(GroupsList).props()).toEqual({
- groups: formatGroups(organizationGroups.nodes),
- showGroupIcon: true,
+ it('renders `GroupsList` component and passes correct props', async () => {
+ jest.runAllTimers();
+ await waitForPromises();
+
+ expect(wrapper.findComponent(GroupsList).props()).toEqual({
+ groups: formatGroups(organizationGroups.nodes),
+ showGroupIcon: true,
+ });
});
});
});
diff --git a/spec/frontend/organizations/shared/components/projects_view_spec.js b/spec/frontend/organizations/shared/components/projects_view_spec.js
index faa8fe2b24c..490b0c89348 100644
--- a/spec/frontend/organizations/shared/components/projects_view_spec.js
+++ b/spec/frontend/organizations/shared/components/projects_view_spec.js
@@ -1,6 +1,6 @@
import VueApollo from 'vue-apollo';
import Vue from 'vue';
-import { GlLoadingIcon } from '@gitlab/ui';
+import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import ProjectsView from '~/organizations/shared/components/projects_view.vue';
import { formatProjects } from '~/organizations/shared/utils';
import resolvers from '~/organizations/shared/graphql/resolvers';
@@ -20,10 +20,19 @@ describe('ProjectsView', () => {
let wrapper;
let mockApollo;
- const createComponent = ({ mockResolvers = resolvers } = {}) => {
+ const defaultProvide = {
+ projectsEmptyStateSvgPath: 'illustrations/empty-state/empty-projects-md.svg',
+ newProjectPath: '/projects/new',
+ };
+
+ const createComponent = ({ mockResolvers = resolvers, propsData = {} } = {}) => {
mockApollo = createMockApollo([], mockResolvers);
- wrapper = shallowMountExtended(ProjectsView, { apolloProvider: mockApollo });
+ wrapper = shallowMountExtended(ProjectsView, {
+ apolloProvider: mockApollo,
+ provide: defaultProvide,
+ propsData,
+ });
};
afterEach(() => {
@@ -47,17 +56,66 @@ describe('ProjectsView', () => {
});
describe('when API call is successful', () => {
- beforeEach(() => {
- createComponent();
+ describe('when there are no projects', () => {
+ it('renders empty state without buttons by default', async () => {
+ const mockResolvers = {
+ Query: {
+ organization: jest.fn().mockResolvedValueOnce({
+ projects: { nodes: [] },
+ }),
+ },
+ };
+ createComponent({ mockResolvers });
+
+ jest.runAllTimers();
+ await waitForPromises();
+
+ expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
+ title: "You don't have any projects yet.",
+ description:
+ 'Projects are where you can store your code, access issues, wiki, and other features of Gitlab.',
+ svgHeight: 144,
+ svgPath: defaultProvide.projectsEmptyStateSvgPath,
+ primaryButtonLink: null,
+ primaryButtonText: null,
+ });
+ });
+
+ describe('when `shouldShowEmptyStateButtons` is `true` and `projectsEmptyStateSvgPath` is set', () => {
+ it('renders empty state with buttons', async () => {
+ const mockResolvers = {
+ Query: {
+ organization: jest.fn().mockResolvedValueOnce({
+ projects: { nodes: [] },
+ }),
+ },
+ };
+ createComponent({ mockResolvers, propsData: { shouldShowEmptyStateButtons: true } });
+
+ jest.runAllTimers();
+ await waitForPromises();
+
+ expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
+ primaryButtonLink: defaultProvide.newProjectPath,
+ primaryButtonText: 'New project',
+ });
+ });
+ });
});
- it('renders `ProjectsList` component and passes correct props', async () => {
- jest.runAllTimers();
- await waitForPromises();
+ describe('when there are projects', () => {
+ beforeEach(() => {
+ createComponent();
+ });
- expect(wrapper.findComponent(ProjectsList).props()).toEqual({
- projects: formatProjects(organizationProjects.nodes),
- showProjectIcon: true,
+ it('renders `ProjectsList` component and passes correct props', async () => {
+ jest.runAllTimers();
+ await waitForPromises();
+
+ expect(wrapper.findComponent(ProjectsList).props()).toEqual({
+ projects: formatProjects(organizationProjects.nodes),
+ showProjectIcon: true,
+ });
});
});
});
diff --git a/spec/frontend/organizations/show/components/groups_and_projects_spec.js b/spec/frontend/organizations/show/components/groups_and_projects_spec.js
index cbf5cd5bdb4..83970d4e76d 100644
--- a/spec/frontend/organizations/show/components/groups_and_projects_spec.js
+++ b/spec/frontend/organizations/show/components/groups_and_projects_spec.js
@@ -72,7 +72,9 @@ describe('OrganizationShowGroupsAndProjects', () => {
});
it('renders expected view', () => {
- expect(wrapper.findComponent(expectedViewComponent).exists()).toBe(true);
+ expect(
+ wrapper.findComponent(expectedViewComponent).props('shouldShowEmptyStateButtons'),
+ ).toBe(true);
});
},
);
diff --git a/spec/helpers/organizations/organization_helper_spec.rb b/spec/helpers/organizations/organization_helper_spec.rb
index 85493348957..0d6d3b34dc9 100644
--- a/spec/helpers/organizations/organization_helper_spec.rb
+++ b/spec/helpers/organizations/organization_helper_spec.rb
@@ -4,6 +4,17 @@ require 'spec_helper'
RSpec.describe Organizations::OrganizationHelper, feature_category: :cell do
let_it_be(:organization) { build_stubbed(:organization) }
+ let_it_be(:new_group_path) { '/groups/new' }
+ let_it_be(:new_project_path) { '/projects/new' }
+ let_it_be(:groups_empty_state_svg_path) { 'illustrations/empty-state/empty-groups-md.svg' }
+ let_it_be(:projects_empty_state_svg_path) { 'illustrations/empty-state/empty-projects-md.svg' }
+
+ before do
+ allow(helper).to receive(:new_group_path).and_return(new_group_path)
+ allow(helper).to receive(:new_project_path).and_return(new_project_path)
+ allow(helper).to receive(:image_path).with(groups_empty_state_svg_path).and_return(groups_empty_state_svg_path)
+ allow(helper).to receive(:image_path).with(projects_empty_state_svg_path).and_return(projects_empty_state_svg_path)
+ end
describe '#organization_show_app_data' do
before do
@@ -20,7 +31,28 @@ RSpec.describe Organizations::OrganizationHelper, feature_category: :cell do
).to eq(
{
'organization' => { 'id' => organization.id, 'name' => organization.name },
- 'groups_and_projects_organization_path' => '/-/organizations/default/groups_and_projects'
+ 'groups_and_projects_organization_path' => '/-/organizations/default/groups_and_projects',
+ 'new_group_path' => new_group_path,
+ 'new_project_path' => new_project_path,
+ 'groups_empty_state_svg_path' => groups_empty_state_svg_path,
+ 'projects_empty_state_svg_path' => projects_empty_state_svg_path
+ }
+ )
+ end
+ end
+
+ describe '#organization_groups_and_projects_app_data' do
+ it 'returns expected json' do
+ expect(
+ Gitlab::Json.parse(
+ helper.organization_groups_and_projects_app_data
+ )
+ ).to eq(
+ {
+ 'new_group_path' => new_group_path,
+ 'new_project_path' => new_project_path,
+ 'groups_empty_state_svg_path' => groups_empty_state_svg_path,
+ 'projects_empty_state_svg_path' => projects_empty_state_svg_path
}
)
end
diff --git a/spec/requests/clusters/agents/dashboard_controller_spec.rb b/spec/requests/clusters/agents/dashboard_controller_spec.rb
new file mode 100644
index 00000000000..c3c16d9b385
--- /dev/null
+++ b/spec/requests/clusters/agents/dashboard_controller_spec.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Clusters::Agents::DashboardController, feature_category: :deployment_management do
+ describe 'GET show' do
+ let_it_be(:organization) { create(:group) }
+ let_it_be(:agent_management_project) { create(:project, group: organization) }
+ let_it_be(:agent) { create(:cluster_agent, project: agent_management_project) }
+ let_it_be(:deployment_project) { create(:project, group: organization) }
+ let(:user) { create(:user) }
+ let(:stub_ff) { true }
+
+ before do
+ allow(::Gitlab::Kas).to receive(:enabled?).and_return(true)
+ end
+
+ context 'with authorized user' do
+ let!(:authorization) do
+ create(
+ :agent_user_access_project_authorization,
+ agent: agent,
+ project: deployment_project
+ )
+ end
+
+ before do
+ stub_feature_flags(k8s_dashboard: stub_ff)
+ deployment_project.add_member(user, :developer)
+ sign_in(user)
+ get kubernetes_dashboard_path(agent.id)
+ end
+
+ it 'sets the kas cookie' do
+ expect(
+ request.env['action_dispatch.cookies'][Gitlab::Kas::COOKIE_KEY]
+ ).to be_present
+ end
+
+ it 'returns not found' do
+ expect(response).to have_gitlab_http_status(:ok)
+ end
+
+ context 'with k8s_dashboard feature flag disabled' do
+ let(:stub_ff) { false }
+
+ it 'does not set the kas cookie' do
+ expect(
+ request.env['action_dispatch.cookies'][Gitlab::Kas::COOKIE_KEY]
+ ).not_to be_present
+ end
+
+ it 'returns not found' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'with unauthorized user' do
+ before do
+ sign_in(user)
+ get kubernetes_dashboard_path(agent.id)
+ end
+
+ it 'does not set the kas cookie' do
+ expect(
+ request.env['action_dispatch.cookies'][Gitlab::Kas::COOKIE_KEY]
+ ).not_to be_present
+ end
+
+ it 'returns not found' do
+ expect(response).to have_gitlab_http_status(:not_found)
+ end
+ end
+ end
+end