diff options
32 files changed, 351 insertions, 128 deletions
diff --git a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue index 00e0649deed..5e0c5735bc0 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue @@ -1,14 +1,5 @@ <script> -import { - GlButton, - GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlTruncate, -} from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlCollapsibleListbox } from '@gitlab/ui'; import { createAlert } from '~/flash'; import { MINIMUM_SEARCH_LENGTH } from '~/graphql_shared/constants'; import { s__ } from '~/locale'; @@ -20,12 +11,7 @@ export default { components: { GlButton, GlButtonGroup, - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlTruncate, + GlCollapsibleListbox, }, apollo: { project: { @@ -61,24 +47,25 @@ export default { }; }, computed: { + loading() { + return this.$apollo.queries.project.loading; + }, rootUrl() { return `${gon.gitlab_url}/`; }, namespaces() { return this.project.forkTargets?.nodes || []; }, - hasMatches() { - return this.namespaces.length; - }, dropdownText() { return this.selectedNamespace?.fullPath || s__('ForkProject|Select a namespace'); }, + namespaceItems() { + return this.namespaces?.map(({ id, fullPath }) => ({ value: id, text: fullPath })); + }, }, methods: { - handleDropdownShown() { - this.$refs.search.focusInput(); - }, - setNamespace(namespace) { + setNamespace(namespaceId) { + const namespace = this.namespaces.find(({ id }) => id === namespaceId); const id = getIdFromGraphQLId(namespace.id); this.$emit('select', { @@ -89,6 +76,9 @@ export default { this.selectedNamespace = { id, fullPath: namespace.fullPath }; }, + searchNamespaces(search) { + this.search = search; + }, }, }; </script> @@ -98,39 +88,19 @@ export default { <gl-button class="gl-text-truncate gl-flex-grow-0! gl-max-w-34" label :title="rootUrl">{{ rootUrl }}</gl-button> - - <gl-dropdown + <gl-collapsible-listbox class="gl-flex-grow-1" - toggle-class="gl-rounded-top-right-base! gl-rounded-bottom-right-base! gl-w-20" data-qa-selector="select_namespace_dropdown" data-testid="select_namespace_dropdown" - no-flip - @shown="handleDropdownShown" - > - <template #button-text> - <gl-truncate :text="dropdownText" position="start" with-tooltip /> - </template> - <gl-search-box-by-type - ref="search" - v-model.trim="search" - :is-loading="$apollo.queries.project.loading" - data-qa-selector="select_namespace_dropdown_search_field" - data-testid="select_namespace_dropdown_search_field" - /> - <template v-if="!$apollo.queries.project.loading"> - <template v-if="hasMatches"> - <gl-dropdown-section-header>{{ __('Namespaces') }}</gl-dropdown-section-header> - <gl-dropdown-item - v-for="namespace of namespaces" - :key="namespace.id" - data-qa-selector="select_namespace_dropdown_item" - @click="setNamespace(namespace)" - > - {{ namespace.fullPath }} - </gl-dropdown-item> - </template> - <gl-dropdown-text v-else>{{ __('No matches found') }}</gl-dropdown-text> - </template> - </gl-dropdown> + :items="namespaceItems" + :header-text="__('Namespaces')" + :no-results-text="__('No matches found')" + :searchable="true" + :searching="loading" + toggle-class="gl-flex-direction-column gl-align-items-stretch!" + :toggle-text="dropdownText" + @search="searchNamespaces" + @select="setNamespace" + /> </gl-button-group> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 1efbc226ca2..94fe67fdcf7 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -623,10 +623,12 @@ export default { <work-item-tree v-if="workItemType === $options.WORK_ITEM_TYPE_VALUE_OBJECTIVE" :work-item-type="workItemType" + :parent-work-item-type="workItem.workItemType.name" :work-item-id="workItem.id" :children="children" :can-update="canUpdate" :project-path="fullPath" + :confidential="workItem.confidential" @addWorkItemChild="addChild" @removeChild="removeChild" /> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index faadb5fa6fa..b078711ec5d 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -399,6 +399,7 @@ export default { :parent-iteration="issuableIteration" :parent-milestone="issuableMilestone" :form-type="formType" + :parent-work-item-type="workItem.workItemType.name" @cancel="hideAddForm" @addWorkItemChild="addChild" /> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 5cf0c4154bb..34f7e659600 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -1,5 +1,14 @@ <script> -import { GlAlert, GlFormGroup, GlForm, GlTokenSelector, GlButton, GlFormInput } from '@gitlab/ui'; +import { + GlAlert, + GlFormGroup, + GlForm, + GlTokenSelector, + GlButton, + GlFormInput, + GlFormCheckbox, + GlTooltip, +} from '@gitlab/ui'; import { debounce } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; @@ -17,6 +26,8 @@ import { I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, I18N_WORK_ITEM_ADD_BUTTON_LABEL, I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL, + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP, sprintfWorkItem, } from '../../constants'; @@ -28,6 +39,8 @@ export default { GlButton, GlFormGroup, GlFormInput, + GlFormCheckbox, + GlTooltip, }, mixins: [glFeatureFlagMixin()], inject: ['projectPath', 'hasIterationsFeature'], @@ -61,6 +74,11 @@ export default { type: String, required: true, }, + parentWorkItemType: { + type: String, + required: false, + default: '', + }, childrenType: { type: String, required: false, @@ -108,6 +126,7 @@ export default { error: null, childToCreateTitle: null, workItemsToAdd: [], + confidential: this.parentConfidential, }; }, computed: { @@ -119,7 +138,7 @@ export default { hierarchyWidget: { parentId: this.issuableGid, }, - confidential: this.parentConfidential, + confidential: this.parentConfidential || this.confidential, }; if (this.parentMilestoneId) { @@ -162,6 +181,16 @@ export default { } return sprintfWorkItem(I18N_WORK_ITEM_ADD_BUTTON_LABEL, this.childrenTypeName); }, + confidentialityCheckboxLabel() { + return sprintfWorkItem(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, this.childrenTypeName); + }, + confidentialityCheckboxTooltip() { + return sprintfWorkItem( + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP, + this.childrenTypeName, + this.parentWorkItemType, + ); + }, addOrCreateMethod() { return this.isCreateForm ? this.createChild : this.addChild; }, @@ -192,6 +221,11 @@ export default { }, methods: { getIdFromGraphQLId, + getConfidentialityTooltipTarget() { + // We want tooltip to be anchored to `input` within checkbox component + // but `$el.querySelector('input')` doesn't work. 🤷♂️ + return this.$refs.confidentialityCheckbox?.$el; + }, unsetError() { this.error = null; }, @@ -299,8 +333,22 @@ export default { autofocus /> </gl-form-group> + <gl-form-checkbox + ref="confidentialityCheckbox" + v-model="confidential" + name="isConfidential" + class="gl-md-mt-5 gl-mb-5 gl-md-mb-3!" + :disabled="parentConfidential" + >{{ confidentialityCheckboxLabel }}</gl-form-checkbox + > + <gl-tooltip + v-if="parentConfidential" + :target="getConfidentialityTooltipTarget" + triggers="hover" + >{{ confidentialityCheckboxTooltip }}</gl-tooltip + > <gl-token-selector - v-else + v-if="!isCreateForm" v-model="workItemsToAdd" :dropdown-items="availableWorkItems" :loading="isLoading" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index f06de2ca048..532a3b4aa18 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -40,10 +40,20 @@ export default { type: String, required: true, }, + parentWorkItemType: { + type: String, + required: false, + default: '', + }, workItemId: { type: String, required: true, }, + confidential: { + type: Boolean, + required: false, + default: false, + }, children: { type: Array, required: false, @@ -221,8 +231,10 @@ export default { data-testid="add-tree-form" :issuable-gid="workItemId" :form-type="formType" + :parent-work-item-type="parentWorkItemType" :children-type="childType" :children-ids="childrenIds" + :parent-confidential="confidential" @addWorkItemChild="$emit('addWorkItemChild', $event)" @cancel="hideAddForm" /> @@ -233,6 +245,7 @@ export default { :can-update="canUpdate" :issuable-gid="workItemId" :child-item="child" + :confidential="child.confidential" :work-item-type="workItemType" :has-indirect-children="hasIndirectChildren" @mouseover="prefetchWorkItem(child)" diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 7e4fd1d547d..21af1449e50 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -73,12 +73,19 @@ export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{work export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__( 'WorkItem|Search existing %{workItemType}s', ); +export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL = s__( + 'WorkItem|This %{workItemType} is confidential and should only be visible to team members with at least Reporter access', +); +export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP = s__( + 'WorkItem|A non-confidential %{workItemType} cannot be assigned to a confidential parent %{parentWorkItemType}.', +); -export const sprintfWorkItem = (msg, workItemTypeArg) => { +export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => { const workItemType = workItemTypeArg || s__('WorkItem|Work item'); return capitalizeFirstCharacter( sprintf(msg, { workItemType: workItemType.toLocaleLowerCase(), + parentWorkItemType: parentWorkItemType.toLocaleLowerCase(), }), ); }; diff --git a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql index 7fcf622cdb2..7d7bb9c7fc5 100644 --- a/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item_links.query.graphql @@ -3,6 +3,7 @@ query workItemLinksQuery($id: WorkItemID!) { id workItemType { id + name } title userPermissions { diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 36e6f7941f6..5eb76be4bbf 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -255,6 +255,12 @@ to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1709 } } +.gl-md-mb-3\! { + @include gl-media-breakpoint-up(md) { + margin-bottom: $gl-spacing-scale-3 !important; + } +} + .gl-gap-2 { gap: $gl-spacing-scale-2; diff --git a/app/controllers/groups/usage_quotas_controller.rb b/app/controllers/groups/usage_quotas_controller.rb index 29878f0001d..b660eb3af99 100644 --- a/app/controllers/groups/usage_quotas_controller.rb +++ b/app/controllers/groups/usage_quotas_controller.rb @@ -16,8 +16,7 @@ module Groups private def verify_usage_quotas_enabled! - render_404 unless Feature.enabled?(:usage_quotas_for_all_editions, group) - render_404 if group.has_parent? + render_404 unless group.usage_quotas_enabled? end # To be overriden in ee/app/controllers/ee/groups/usage_quotas_controller.rb diff --git a/app/models/group.rb b/app/models/group.rb index 2067eed9989..37dcbf95e63 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -915,6 +915,10 @@ class Group < Namespace feature_flag_enabled_for_self_or_ancestor?(:work_items_create_from_markdown) end + def usage_quotas_enabled? + ::Feature.enabled?(:usage_quotas_for_all_editions, self) && root? + end + # Check for enabled features, similar to `Project#feature_available?` # NOTE: We still want to keep this after removing `Namespace#feature_available?`. override :feature_available? diff --git a/db/docs/elastic_group_index_statuses.yml b/db/docs/elastic_group_index_statuses.yml new file mode 100644 index 00000000000..a9b0081474c --- /dev/null +++ b/db/docs/elastic_group_index_statuses.yml @@ -0,0 +1,10 @@ +--- +table_name: elastic_group_index_statuses +classes: +- Elastic::GroupIndexStatus +feature_categories: +- global_search +description: Table for tracking Advanced Search indexing statuses for groups +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/107579 +milestone: '15.8' +gitlab_schema: gitlab_main diff --git a/db/migrate/20221221134116_create_elastic_group_index_statuses.rb b/db/migrate/20221221134116_create_elastic_group_index_statuses.rb new file mode 100644 index 00000000000..6084b7e9557 --- /dev/null +++ b/db/migrate/20221221134116_create_elastic_group_index_statuses.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreateElasticGroupIndexStatuses < Gitlab::Database::Migration[2.1] + def change + create_table :elastic_group_index_statuses, id: false do |t| + t.references :namespace, + primary_key: true, + foreign_key: { on_delete: :cascade }, + index: false, + default: nil + + t.timestamps_with_timezone null: false + t.datetime_with_timezone :wiki_indexed_at + + t.binary :last_wiki_commit + end + end +end diff --git a/db/schema_migrations/20221221134116 b/db/schema_migrations/20221221134116 new file mode 100644 index 00000000000..f25d868d150 --- /dev/null +++ b/db/schema_migrations/20221221134116 @@ -0,0 +1 @@ +b528d26acaf408f6d787542626bc8d86520b1058dde20596f7da63c1e5b87aee
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index fe0be30cf53..82248f0cf5f 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -15138,6 +15138,14 @@ CREATE SEQUENCE draft_notes_id_seq ALTER SEQUENCE draft_notes_id_seq OWNED BY draft_notes.id; +CREATE TABLE elastic_group_index_statuses ( + namespace_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + wiki_indexed_at timestamp with time zone, + last_wiki_commit bytea +); + CREATE TABLE elastic_index_settings ( id bigint NOT NULL, created_at timestamp with time zone NOT NULL, @@ -25959,6 +25967,9 @@ ALTER TABLE ONLY dora_daily_metrics ALTER TABLE ONLY draft_notes ADD CONSTRAINT draft_notes_pkey PRIMARY KEY (id); +ALTER TABLE ONLY elastic_group_index_statuses + ADD CONSTRAINT elastic_group_index_statuses_pkey PRIMARY KEY (namespace_id); + ALTER TABLE ONLY elastic_index_settings ADD CONSTRAINT elastic_index_settings_pkey PRIMARY KEY (id); @@ -34591,6 +34602,9 @@ ALTER TABLE ONLY project_repository_storage_moves ALTER TABLE ONLY ml_candidate_metadata ADD CONSTRAINT fk_rails_5117dddf22 FOREIGN KEY (candidate_id) REFERENCES ml_candidates(id) ON DELETE CASCADE; +ALTER TABLE ONLY elastic_group_index_statuses + ADD CONSTRAINT fk_rails_52b9969b12 FOREIGN KEY (namespace_id) REFERENCES namespaces(id) ON DELETE CASCADE; + ALTER TABLE ONLY bulk_import_configurations ADD CONSTRAINT fk_rails_536b96bff1 FOREIGN KEY (bulk_import_id) REFERENCES bulk_imports(id) ON DELETE CASCADE; diff --git a/doc/architecture/blueprints/gitlab_observability_backend/metrics/index.md b/doc/architecture/blueprints/gitlab_observability_backend/metrics/index.md index a6efe68310e..c5bd2440b0c 100644 --- a/doc/architecture/blueprints/gitlab_observability_backend/metrics/index.md +++ b/doc/architecture/blueprints/gitlab_observability_backend/metrics/index.md @@ -673,10 +673,10 @@ Using PromQL directly could be a steep learning curve for users. It would be rea The following section enlists how we intend to implement the aforementioned proposal around building Metrics support into GitLab Observability Service. Each corresponding document and/or issue contains further details of how each next step is planned to be executed. -- **[DONE]** [Research & draft design proposal and/or requirements](https://docs.google.com/document/d/1kHyIoWEcs14sh3CGfKGiI8QbCsdfIHeYkzVstenpsdE/edit?usp=sharing) -- **[IN-PROGRESS]** [Submit system/schema designs (proposal) & gather feedback](https://docs.google.com/document/d/1kHyIoWEcs14sh3CGfKGiI8QbCsdfIHeYkzVstenpsdE/edit?usp=sharing) -- **[IN-PROGRESS]** [Develop table definitions and/or storage interfaces](https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/1666) -- **[IN-PROGRESS]** [Prototype reference implementation, instrument key metrics](https://gitlab.com/gitlab-org/opstrace/opstrace/-/merge_requests/1823) +- **DONE** [Research & draft design proposal and/or requirements](https://docs.google.com/document/d/1kHyIoWEcs14sh3CGfKGiI8QbCsdfIHeYkzVstenpsdE/edit?usp=sharing) +- **IN-PROGRESS** [Submit system/schema designs (proposal) & gather feedback](https://docs.google.com/document/d/1kHyIoWEcs14sh3CGfKGiI8QbCsdfIHeYkzVstenpsdE/edit?usp=sharing) +- **IN-PROGRESS** [Develop table definitions and/or storage interfaces](https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/1666) +- **IN-PROGRESS** [Prototype reference implementation, instrument key metrics](https://gitlab.com/gitlab-org/opstrace/opstrace/-/merge_requests/1823) - [Benchmark Clickhouse and/or proposed schemas, gather expert advice from Clickhouse Inc.](https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/1666) - Develop write path(s) - `remote_write` API - Develop read path(s) - `remote_read` API, `PromQL`-based querier. diff --git a/doc/development/code_review.md b/doc/development/code_review.md index 50487434afe..9dc97a1aa27 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -562,7 +562,7 @@ WARNING: [very specific cases](https://about.gitlab.com/handbook/engineering/workflow/#criteria-for-merging-during-broken-master). For other cases, follow these [handbook instructions](https://about.gitlab.com/handbook/engineering/workflow/#merging-during-broken-master). - If the latest pipeline was created before the merge request was approved, start a new pipeline to ensure that full RSpec suite has been run. You may skip this step only if the merge request does not contain any backend change. - - If the **latest [merged results pipeline](../ci/pipelines/merged_results_pipelines.md)** finished less than 2 hours ago, you + - If the **latest [merged results pipeline](../ci/pipelines/merged_results_pipelines.md)** was **created less than 6 hours ago**, and **finished less than 2 hours ago**, you may merge without starting a new pipeline as the merge request is close enough to `main`. - When you set the MR to "Merge When Pipeline Succeeds", you should take over diff --git a/doc/development/feature_categorization/index.md b/doc/development/feature_categorization/index.md index f62e0275bff..ccaff264e9a 100644 --- a/doc/development/feature_categorization/index.md +++ b/doc/development/feature_categorization/index.md @@ -206,6 +206,6 @@ RSpec.describe Utils, feature_category: :not_owned do ### Tooling feature category -For Engineering Productivity internal tooling we use `feature_category: tooling`. +For Engineering Productivity internal tooling we use `feature_category: :tooling`. For example in [`spec/tooling/danger/specs_spec.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/spec/tooling/danger/specs_spec.rb#L12). diff --git a/doc/development/workspace/index.md b/doc/development/workspace/index.md index f4738e3fc31..b0e7781d889 100644 --- a/doc/development/workspace/index.md +++ b/doc/development/workspace/index.md @@ -87,7 +87,7 @@ After this work completes, we must migrate data as described in ### Phase 2 - [Phase 2 epic](https://gitlab.com/groups/gitlab-org/-/epics/6768). -- **Goal**: Make `ProjectNamespace` the front entity to interact with instead of `Project`. +- **Goal**: Link `ProjectNamespace` to other entities on the database level. In this phase: @@ -97,6 +97,10 @@ In this phase: - Raise awareness to avoid regressions, and conflicting or duplicate work that can be dealt with before phase 3. +### Phase 3 + +- [Phase 3 epic](https://gitlab.com/groups/gitlab-org/-/epics/6585). +- **Goal**: Achieve feature parity between the namespace types. Problems to solve as part of this phase: - Routes handling through `ProjectNamespace` rather than `Project`. @@ -105,14 +109,49 @@ Problems to solve as part of this phase: - Import and export. - Other interactions between project namespace and project models. -### Phase 3 - -- [Phase 3 epic](https://gitlab.com/groups/gitlab-org/-/epics/6585). -- **Goal**: Feature parity between the namespace types. - Phase 3 is when the active migration of features from `Project` to `ProjectNamespace`, or directly to `Namespace`, happens. +### How to plan features that interact with Group and ProjectNamespace + +As of now, every Project in the system has a record in the `namespaces` table. This makes it possible to +use common interface to create features that are shared between Groups and Projects. Shared behavior can be added using +a concerns mechanism. Because the `Namespace` model is responsible for `UserNamespace` methods as well, it is discouraged +to use the `Namespace` model for shared behavior for Projects and Groups. + +#### Resource-based features + +To migrate resource-based features, existing functionality will need to be supported. This can be achieved in two Phases. + +**Phase 1 - Setup** + +- Link into the namespaces table + - Add a column to the table + - For example, in issues a `project id` points to the projects table. We need to establish a link to the `namespaces` table. + - Modify code so that any new record already has the correct data in it + - Backfill + +**Phase 2 - Prerequisite work** + +- Investigate the permission model as well as any performance concerns related to that. + - Permissions need to be checked and kept in place. +- Investigate what other models need to support namespaces for functionality dependent on features you migrate in Phase 1. +- Adjust CRUD services and APIs (REST and GraphQL) to point to the new column you added in Phase 1. +- Consider performance when fetching resources. + +Introducing new functionality is very much dependent on every single team and feature. + +#### Settings-related features + +Right now, cascading settings are available for `NamespaceSettings`. By creating `ProjectNamespace`, +we can use this framework to make sure that some settings are applicable on the project level as well. + +When working on settings, we need to make sure that: + +- They are not used in `join` queries or modify those queries. +- Updating settings is taken into consideration. +- If we want to move from project to project namespace, we follow a similar database process to the one described in [Phase 1](#phase-1). + ## Related topics - [Consolidating groups and projects](../../architecture/blueprints/consolidating_groups_and_projects/index.md) diff --git a/doc/user/clusters/agent/vulnerabilities.md b/doc/user/clusters/agent/vulnerabilities.md index d9a9981d211..37d742e2b08 100644 --- a/doc/user/clusters/agent/vulnerabilities.md +++ b/doc/user/clusters/agent/vulnerabilities.md @@ -84,9 +84,9 @@ Here is an example of a policy which enables operational container scanning with The keys for a schedule rule are: -- cadence (required): a [CRON expression](https://docs.oracle.com/cd/E12058_01/doc/doc.1014/e12030/cron_expressions.htm) for when the scans will be run -- agents:<agent-name> (required): The name of the agent to use for scanning -- agents:<agent-name>:namespaces (optional): The Kubernetes namespaces to scan. If omitted, all namespaces will be scanned +- `cadence` (required): a [CRON expression](https://docs.oracle.com/cd/E12058_01/doc/doc.1014/e12030/cron_expressions.htm) for when the scans will be run +- `agents:<agent-name>` (required): The name of the agent to use for scanning +- `agents:<agent-name>:namespaces` (optional): The Kubernetes namespaces to scan. If omitted, all namespaces will be scanned NOTE: Other elements of the [CRON syntax](https://docs.oracle.com/cd/E12058_01/doc/doc.1014/e12030/cron_expressions.htm) may work in the cadence field if supported by the [cron](https://github.com/robfig/cron) we are using in our implementation, however, GitLab does not officially test or support them. diff --git a/lib/sidebars/groups/menus/settings_menu.rb b/lib/sidebars/groups/menus/settings_menu.rb index ede195a8e59..5b81f22c796 100644 --- a/lib/sidebars/groups/menus/settings_menu.rb +++ b/lib/sidebars/groups/menus/settings_menu.rb @@ -15,6 +15,7 @@ module Sidebars add_item(ci_cd_menu_item) add_item(applications_menu_item) add_item(packages_and_registries_menu_item) + add_item(usage_quotas_menu_item) return true elsif Gitlab.ee? && can?(context.current_user, :change_push_rules, context.group) # Push Rules are the only group setting that can also be edited by maintainers. @@ -115,6 +116,22 @@ module Sidebars ) end + def usage_quotas_menu_item + return ::Sidebars::NilMenuItem.new(item_id: :usage_quotas) unless usage_quotas_menu_enabled? + + ::Sidebars::MenuItem.new( + title: s_('UsageQuota|Usage Quotas'), + link: group_usage_quotas_path(context.group), + active_routes: { path: 'usage_quotas#index' }, + item_id: :usage_quotas + ) + end + + # overriden in ee/lib/ee/sidebars/groups/menus/settings_menu.rb + def usage_quotas_menu_enabled? + context.group.usage_quotas_enabled? + end + def packages_and_registries_menu_item unless context.group.packages_feature_enabled? return ::Sidebars::NilMenuItem.new(item_id: :packages_and_registries) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 8bc96eb4945..24c42cc5433 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -47081,6 +47081,9 @@ msgstr "" msgid "WorkItem|%{workItemType} deleted" msgstr "" +msgid "WorkItem|A non-confidential %{workItemType} cannot be assigned to a confidential parent %{parentWorkItemType}." +msgstr "" + msgid "WorkItem|Activity" msgstr "" @@ -47296,6 +47299,9 @@ msgstr "" msgid "WorkItem|Test case" msgstr "" +msgid "WorkItem|This %{workItemType} is confidential and should only be visible to team members with at least Reporter access" +msgstr "" + msgid "WorkItem|This objective is confidential and should only be visible to team members with at least Reporter access" msgstr "" diff --git a/qa/qa/page/component/dropdown.rb b/qa/qa/page/component/dropdown.rb index f240f82c56b..09ff917b81d 100644 --- a/qa/qa/page/component/dropdown.rb +++ b/qa/qa/page/component/dropdown.rb @@ -25,6 +25,12 @@ module QA find('span.gl-dropdown-button-text').text end + def all_items + raise NotImplementedError if use_select2? + + find_all("li.gl-dropdown-item").map(&:text) + end + def clear_current_selection_if_present return super if use_select2? diff --git a/qa/qa/page/project/fork/new.rb b/qa/qa/page/project/fork/new.rb index b622b341685..2b36766d996 100644 --- a/qa/qa/page/project/fork/new.rb +++ b/qa/qa/page/project/fork/new.rb @@ -5,6 +5,8 @@ module QA module Project module Fork class New < Page::Base + include ::QA::Page::Component::Dropdown + view 'app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue' do element :fork_project_button element :fork_privacy_button @@ -12,9 +14,6 @@ module QA view 'app/assets/javascripts/pages/projects/forks/new/components/project_namespace.vue' do element :select_namespace_dropdown - element :select_namespace_dropdown_item - element :select_namespace_dropdown_search_field - element :select_namespace_dropdown_item end def fork_project(namespace = Runtime::Namespace.path) @@ -25,20 +24,13 @@ module QA def get_list_of_namespaces click_element(:select_namespace_dropdown) - wait_until(reload: false) do - has_element?(:select_namespace_dropdown_item) - end - all_elements(:select_namespace_dropdown_item, minimum: 1).map(&:text) + all_items end def choose_namespace(namespace) retry_on_exception do click_element(:select_namespace_dropdown) - fill_element(:select_namespace_dropdown_search_field, namespace) - wait_until(reload: false) do - has_element?(:select_namespace_dropdown_item, text: namespace) - end - click_button(namespace) + search_and_select(namespace) end end end diff --git a/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb b/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb index 5fc170435e3..9ce028318c3 100644 --- a/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb +++ b/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb @@ -15,7 +15,8 @@ module QA let!(:gitlab_source_project) { ENV["QA_LARGE_IMPORT_REPO"] || "migration-test-project" } let!(:import_wait_duration) { { max_duration: (ENV["QA_LARGE_IMPORT_DURATION"] || 3600).to_i, sleep_interval: 30 } } - let!(:source_admin_user) { "no-op" } + # test uses production as source which doesn't have actual admin user + let!(:source_admin_user) { nil } let!(:source_admin_api_client) do Runtime::API::Client.new( source_gitlab_address, @@ -227,8 +228,8 @@ module QA comment_diff = verify_comments(type, actual, expected) { - "missing_#{type}s": (expected.keys - actual.keys).map { |it| expected[it]&.slice(:title, :url) }.compact, - "extra_#{type}s": (actual.keys - expected.keys).map { |it| actual[it]&.slice(:title, :url) }.compact, + "missing_#{type}s": (expected.keys - actual.keys).filter_map { |it| expected[it]&.slice(:title, :url) }, + "extra_#{type}s": (actual.keys - expected.keys).filter_map { |it| actual[it]&.slice(:title, :url) }, "#{type}_comments": comment_diff } end diff --git a/qa/qa/specs/features/shared_contexts/import/gitlab_group_migration_common.rb b/qa/qa/specs/features/shared_contexts/import/gitlab_group_migration_common.rb index 23623394bd2..853f427db12 100644 --- a/qa/qa/specs/features/shared_contexts/import/gitlab_group_migration_common.rb +++ b/qa/qa/specs/features/shared_contexts/import/gitlab_group_migration_common.rb @@ -93,7 +93,7 @@ module QA end before do - enable_bulk_import(source_admin_api_client) unless source_bulk_import_enabled + enable_bulk_import(source_admin_api_client) if source_admin_user && !source_bulk_import_enabled enable_bulk_import(admin_api_client) unless target_bulk_import_enabled target_sandbox.add_member(user, Resource::Members::AccessLevel::OWNER) diff --git a/scripts/glfm/run-spec-tests.sh b/scripts/glfm/run-spec-tests.sh index 33a5c97ff22..b60f6b05051 100755 --- a/scripts/glfm/run-spec-tests.sh +++ b/scripts/glfm/run-spec-tests.sh @@ -11,7 +11,7 @@ Color_Off='\033[0m' # Text Reset function onexit_err() { local exit_status=${1:-$?} - printf "\n❌❌❌ ${BRed}GLFM snapshot tests failed!${Color_Off} ❌❌❌\n" + printf "\n❌❌❌ ${BRed}GLFM spec tests failed!${Color_Off} ❌❌❌\n" exit "${exit_status}" } trap onexit_err ERR diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb index 3867f7fd086..26c17eb8ba4 100644 --- a/spec/features/projects/fork_spec.rb +++ b/spec/features/projects/fork_spec.rb @@ -137,9 +137,9 @@ RSpec.describe 'Project fork', feature_category: :projects do let(:user) { create(:group_member, :maintainer, user: create(:user), group: group).user } def submit_form(group_obj = group) - find('[data-testid="select_namespace_dropdown"]').click - find('[data-testid="select_namespace_dropdown_search_field"]').fill_in(with: group_obj.name) - click_button group_obj.name + click_button(s_('ForkProject|Select a namespace'), disabled: false) + find('[data-testid="listbox-search-input"]').fill_in(with: group_obj.name) + find('.gl-dropdown-item-text-wrapper', text: group_obj.name).click click_button 'Fork project' end diff --git a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js index f6d3957115f..82f451ed6ef 100644 --- a/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/project_namespace_spec.js @@ -1,11 +1,4 @@ -import { - GlButton, - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlSearchBoxByType, - GlTruncate, -} from '@gitlab/ui'; +import { GlButton, GlListboxItem, GlCollapsibleListbox } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -80,17 +73,16 @@ describe('ProjectNamespace component', () => { }; const findButtonLabel = () => wrapper.findComponent(GlButton); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownText = () => wrapper.findComponent(GlTruncate); - const findInput = () => wrapper.findComponent(GlSearchBoxByType); + const findListBox = () => wrapper.findComponent(GlCollapsibleListbox); + const findListBoxText = () => findListBox().props('toggleText'); - const clickDropdownItem = async () => { - wrapper.findComponent(GlDropdownItem).vm.$emit('click'); + const clickListBoxItem = async (value = '') => { + wrapper.findComponent(GlListboxItem).vm.$emit('select', value); await nextTick(); }; const showDropdown = () => { - findDropdown().vm.$emit('shown'); + findListBox().vm.$emit('shown'); }; beforeAll(() => { @@ -115,7 +107,7 @@ describe('ProjectNamespace component', () => { }); it('renders placeholder text', () => { - expect(findDropdownText().props('text')).toBe('Select a namespace'); + expect(findListBoxText()).toBe('Select a namespace'); }); }); @@ -127,24 +119,18 @@ describe('ProjectNamespace component', () => { showDropdown(); }); - it('focuses on the input when the dropdown is opened', () => { - const spy = jest.spyOn(findInput().vm, 'focusInput'); - showDropdown(); - expect(spy).toHaveBeenCalledTimes(1); - }); - it('displays fetched namespaces', () => { const listItems = wrapper.findAll('li'); - expect(listItems).toHaveLength(3); - expect(listItems.at(0).findComponent(GlDropdownSectionHeader).text()).toBe('Namespaces'); - expect(listItems.at(1).text()).toBe(data.project.forkTargets.nodes[0].fullPath); - expect(listItems.at(2).text()).toBe(data.project.forkTargets.nodes[1].fullPath); + expect(listItems).toHaveLength(2); + expect(listItems.at(0).text()).toBe(data.project.forkTargets.nodes[0].fullPath); + expect(listItems.at(1).text()).toBe(data.project.forkTargets.nodes[1].fullPath); }); it('sets the selected namespace', async () => { const { fullPath } = data.project.forkTargets.nodes[0]; - await clickDropdownItem(); - expect(findDropdownText().props('text')).toBe(fullPath); + await clickListBoxItem(fullPath); + + expect(findListBoxText()).toBe(fullPath); }); }); @@ -155,7 +141,7 @@ describe('ProjectNamespace component', () => { }); it('renders `No matches found`', () => { - expect(wrapper.find('li').text()).toBe('No matches found'); + expect(findListBox().text()).toContain('No matches found'); }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index bbab45c7055..4589e0711a3 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -655,12 +655,17 @@ describe('WorkItemDetail component', () => { it('renders children tree when work item is an Objective', async () => { const objectiveWorkItem = workItemResponseFactory({ workItemType: objectiveType, + confidential: true, }); const handler = jest.fn().mockResolvedValue(objectiveWorkItem); createComponent({ handler }); await waitForPromises(); expect(findHierarchyTree().exists()).toBe(true); + expect(findHierarchyTree().props()).toMatchObject({ + parentWorkItemType: objectiveType.name, + confidential: objectiveWorkItem.data.workItem.confidential, + }); }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js index bbe460a55ba..1f6ccb1c62f 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -1,11 +1,18 @@ import Vue from 'vue'; -import { GlForm, GlFormInput, GlTokenSelector } from '@gitlab/ui'; +import { GlForm, GlFormInput, GlFormCheckbox, GlTooltip, GlTokenSelector } from '@gitlab/ui'; import VueApollo from 'vue-apollo'; +import { sprintf } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue'; -import { FORM_TYPES } from '~/work_items/constants'; +import { + FORM_TYPES, + WORK_ITEM_TYPE_ENUM_TASK, + WORK_ITEM_TYPE_VALUE_ISSUE, + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, + I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP, +} from '~/work_items/constants'; import projectWorkItemsQuery from '~/work_items/graphql/project_work_items.query.graphql'; import projectWorkItemTypesQuery from '~/work_items/graphql/project_work_item_types.query.graphql'; import createWorkItemMutation from '~/work_items/graphql/create_work_item.mutation.graphql'; @@ -36,6 +43,8 @@ describe('WorkItemLinksForm', () => { workItemsMvcEnabled = false, parentIteration = null, formType = FORM_TYPES.create, + parentWorkItemType = WORK_ITEM_TYPE_VALUE_ISSUE, + childrenType = WORK_ITEM_TYPE_ENUM_TASK, } = {}) => { wrapper = shallowMountExtended(WorkItemLinksForm, { apolloProvider: createMockApollo([ @@ -48,6 +57,8 @@ describe('WorkItemLinksForm', () => { issuableGid: 'gid://gitlab/WorkItem/1', parentConfidential, parentIteration, + parentWorkItemType, + childrenType, formType, }, provide: { @@ -65,6 +76,7 @@ describe('WorkItemLinksForm', () => { const findForm = () => wrapper.findComponent(GlForm); const findTokenSelector = () => wrapper.findComponent(GlTokenSelector); const findInput = () => wrapper.findComponent(GlFormInput); + const findConfidentialCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findAddChildButton = () => wrapper.findByTestId('add-child-button'); afterEach(() => { @@ -124,6 +136,37 @@ describe('WorkItemLinksForm', () => { }, }); }); + + describe('confidentiality checkbox', () => { + it('renders confidentiality checkbox', () => { + const confidentialCheckbox = findConfidentialCheckbox(); + + expect(confidentialCheckbox.exists()).toBe(true); + expect(wrapper.findComponent(GlTooltip).exists()).toBe(false); + expect(confidentialCheckbox.text()).toBe( + sprintf(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL, { + workItemType: WORK_ITEM_TYPE_ENUM_TASK.toLocaleLowerCase(), + }), + ); + }); + + it('renders confidentiality tooltip with checkbox checked and disabled when parent is confidential', () => { + createComponent({ parentConfidential: true }); + + const confidentialCheckbox = findConfidentialCheckbox(); + const confidentialTooltip = wrapper.findComponent(GlTooltip); + + expect(confidentialCheckbox.attributes('disabled')).toBe('true'); + expect(confidentialCheckbox.attributes('checked')).toBe('true'); + expect(confidentialTooltip.exists()).toBe(true); + expect(confidentialTooltip.text()).toBe( + sprintf(I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_TOOLTIP, { + workItemType: WORK_ITEM_TYPE_ENUM_TASK.toLocaleLowerCase(), + parentWorkItemType: WORK_ITEM_TYPE_VALUE_ISSUE.toLocaleLowerCase(), + }), + ); + }); + }); }); describe('adding an existing work item', () => { diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js index 96211e12755..2406995ca79 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js @@ -34,6 +34,8 @@ describe('WorkItemTree', () => { const createComponent = ({ workItemType = 'Objective', + parentWorkItemType = 'Objective', + confidential = false, children = childrenWorkItems, apolloProvider = null, } = {}) => { @@ -55,7 +57,9 @@ describe('WorkItemTree', () => { apolloProvider || createMockApollo([[workItemQuery, getWorkItemQueryHandler]]), propsData: { workItemType, + parentWorkItemType, workItemId: 'gid://gitlab/WorkItem/515', + confidential, children, projectPath: 'test/project', }, @@ -90,7 +94,11 @@ describe('WorkItemTree', () => { }); it('renders all hierarchy widget children', () => { - expect(findWorkItemLinkChildItems()).toHaveLength(4); + const workItemLinkChildren = findWorkItemLinkChildItems(); + expect(workItemLinkChildren).toHaveLength(4); + expect(workItemLinkChildren.at(0).props().childItem.confidential).toBe( + childrenWorkItems[0].confidential, + ); }); it('does not display form by default', () => { @@ -110,8 +118,12 @@ describe('WorkItemTree', () => { await nextTick(); expect(findForm().exists()).toBe(true); - expect(findForm().props('formType')).toBe(formType); - expect(findForm().props('childrenType')).toBe(childType); + expect(findForm().props()).toMatchObject({ + formType, + childrenType: childType, + parentWorkItemType: 'Objective', + parentConfidential: false, + }); }, ); diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index f243f639acf..513374e9ca9 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -3560,4 +3560,26 @@ RSpec.describe Group do it { is_expected.to be_nil } end end + + describe '#usage_quotas_enabled?', feature_category: :subscription_cost_management, unless: Gitlab.ee? do + using RSpec::Parameterized::TableSyntax + + where(:feature_enabled, :root_group, :result) do + false | true | false + false | false | false + true | false | false + true | true | true + end + + with_them do + before do + stub_feature_flags(usage_quotas_for_all_editions: feature_enabled) + allow(group).to receive(:root?).and_return(root_group) + end + + it 'returns the expected result' do + expect(group.usage_quotas_enabled?).to eq result + end + end + end end |