diff --git a/config/routes.rb b/config/routes.rb index 589d44c3de68c44f03328d60ee3e0f21b61fe76b..b674ed22d524f5867d7e42d0d8f4ed2604a8ea3b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -163,6 +163,7 @@ draw :jira_connect Gitlab.ee do + draw :remote_development draw :security draw :smartcard draw :trial diff --git a/doc/user/workspace/quick_start/index.md b/doc/user/workspace/quick_start/index.md new file mode 100644 index 0000000000000000000000000000000000000000..d2c58299b648c4f9231a227247ba08340eb5f2e2 --- /dev/null +++ b/doc/user/workspace/quick_start/index.md @@ -0,0 +1,18 @@ +--- +stage: Create +group: Editor +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments +type: reference +--- + +> Introduced in GitLab 15.11 [with a flag](../../../administration/feature_flags.md) named `remote_development_feature_flag`. Disabled by default. + +FLAG: +On self-managed GitLab, by default this feature is not available. To make it available, +ask an administrator to [enable the feature flag](../../../administration/feature_flags.md) named `remote_development_feature_flag`. +On GitLab.com, this feature is not available. +The feature is not ready for production use. + +# Tutorial: Create and run your first GitLab Workspace **(ULTIMATE)** + +This tutorial shows you how to configure and run your first Remote Development Workspace in GitLab. diff --git a/ee/app/assets/javascripts/pages/remote_development/index.js b/ee/app/assets/javascripts/pages/remote_development/index.js new file mode 100644 index 0000000000000000000000000000000000000000..35bd2fc85464664685c6a5ace9ebf5e0523a9ff3 --- /dev/null +++ b/ee/app/assets/javascripts/pages/remote_development/index.js @@ -0,0 +1,3 @@ +import { initWorkspacesApp } from 'ee/remote_development/init_workspaces_app'; + +initWorkspacesApp(); diff --git a/ee/app/assets/javascripts/remote_development/components/list/empty_state.vue b/ee/app/assets/javascripts/remote_development/components/list/empty_state.vue new file mode 100644 index 0000000000000000000000000000000000000000..df3eaab14ad6604cb048141ef7342b5484322b2e --- /dev/null +++ b/ee/app/assets/javascripts/remote_development/components/list/empty_state.vue @@ -0,0 +1,36 @@ + + + diff --git a/ee/app/assets/javascripts/remote_development/init_workspaces_app.js b/ee/app/assets/javascripts/remote_development/init_workspaces_app.js new file mode 100644 index 0000000000000000000000000000000000000000..69f2b09ec345832d4039fe6f350f3edd48f23f15 --- /dev/null +++ b/ee/app/assets/javascripts/remote_development/init_workspaces_app.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import App from './pages/app.vue'; +import createRouter from './router'; + +Vue.use(VueApollo); + +const apolloClient = createDefaultClient(); +const apolloProvider = new VueApollo({ + defaultClient: apolloClient, +}); + +const initWorkspacesApp = () => { + const el = document.querySelector('#js-workspaces'); + + if (!el) { + return null; + } + + const { workspacesListPath, emptyStateSvgPath } = el.dataset; + const router = createRouter({ + base: workspacesListPath, + }); + + return new Vue({ + el, + name: 'WorkspacesRoot', + router, + apolloProvider, + provide: { + workspacesListPath, + emptyStateSvgPath, + }, + render: (createElement) => createElement(App), + }); +}; + +export { initWorkspacesApp }; diff --git a/ee/app/assets/javascripts/remote_development/pages/app.vue b/ee/app/assets/javascripts/remote_development/pages/app.vue new file mode 100644 index 0000000000000000000000000000000000000000..a14d0c32cbe04a9e063c6fa1439440c2906ea45c --- /dev/null +++ b/ee/app/assets/javascripts/remote_development/pages/app.vue @@ -0,0 +1,5 @@ + diff --git a/ee/app/assets/javascripts/remote_development/pages/list.vue b/ee/app/assets/javascripts/remote_development/pages/list.vue new file mode 100644 index 0000000000000000000000000000000000000000..522ea3854485534cad0125f4af9c5007b4847f4c --- /dev/null +++ b/ee/app/assets/javascripts/remote_development/pages/list.vue @@ -0,0 +1,15 @@ + + + diff --git a/ee/app/assets/javascripts/remote_development/router/index.js b/ee/app/assets/javascripts/remote_development/router/index.js new file mode 100644 index 0000000000000000000000000000000000000000..ec509457df4165071c74d17b562ef25902603122 --- /dev/null +++ b/ee/app/assets/javascripts/remote_development/router/index.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import WorkspacesList from '../pages/list.vue'; + +Vue.use(VueRouter); + +export default function createRouter({ base }) { + const routes = [ + { + path: '/', + name: 'index', + component: WorkspacesList, + }, + { + path: '*', + redirect: '/', + }, + ]; + + return new VueRouter({ + base, + mode: 'history', + routes, + }); +} diff --git a/ee/app/controllers/remote_development/workspaces_controller.rb b/ee/app/controllers/remote_development/workspaces_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..73fc78ea0da1261d8fd398829b0569afd7d76c88 --- /dev/null +++ b/ee/app/controllers/remote_development/workspaces_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module RemoteDevelopment + class WorkspacesController < ApplicationController + before_action :authorize_remote_development!, only: [:index] + + feature_category :remote_development + urgency :low + + def index; end + + private + + def authorize_remote_development! + render_404 unless can?(current_user, :read_workspace) + end + end +end diff --git a/ee/app/models/gitlab_subscriptions/features.rb b/ee/app/models/gitlab_subscriptions/features.rb index 4020b1dee43b1adca6fbe754e0abd664157079b6..069e5fd13bf194106ce4d083f03cef9e009c0232 100644 --- a/ee/app/models/gitlab_subscriptions/features.rb +++ b/ee/app/models/gitlab_subscriptions/features.rb @@ -151,6 +151,7 @@ class Features protected_environments reject_non_dco_commits reject_unsigned_commits + remote_development saml_group_sync service_accounts scoped_labels diff --git a/ee/app/policies/ee/global_policy.rb b/ee/app/policies/ee/global_policy.rb index 44310703966453ca0e6df80208b5d4cc1b0b10f4..95a9646fe954cc920939a0c3c045f02bdedb250e 100644 --- a/ee/app/policies/ee/global_policy.rb +++ b/ee/app/policies/ee/global_policy.rb @@ -9,6 +9,10 @@ module GlobalPolicy License.feature_available?(:operations_dashboard) end + condition(:remote_development_available) do + ::Feature.enabled?(:remote_development_feature_flag) && License.feature_available?(:remote_development) + end + condition(:pages_size_limit_available) do License.feature_available?(:pages_size_limit) end @@ -43,6 +47,10 @@ module GlobalPolicy rule { ~anonymous & operations_dashboard_available }.enable :read_operations_dashboard + rule { ~anonymous & remote_development_available }.policy do + enable :read_workspace + end + rule { admin & instance_devops_adoption_available }.policy do enable :manage_devops_adoption_namespaces enable :view_instance_devops_adoption diff --git a/ee/app/views/remote_development/workspaces/index.html.haml b/ee/app/views/remote_development/workspaces/index.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..4b161a1dee0eec73c34117ef72e33f86d686a08e --- /dev/null +++ b/ee/app/views/remote_development/workspaces/index.html.haml @@ -0,0 +1,9 @@ +- page_title s_('Workspaces') +- nav 'your_work' +- @left_sidebar = true + +#js-workspaces{ data: { + workspaces_list_path: remote_development_workspaces_path, + empty_state_svg_path: image_path('illustrations/empty-state/empty-workspaces-md.svg') + } +} diff --git a/ee/config/feature_flags/development/remote_development_feature_flag.yml b/ee/config/feature_flags/development/remote_development_feature_flag.yml new file mode 100644 index 0000000000000000000000000000000000000000..ca32b9c764f2a01bf96ae90085f856f32d630cee --- /dev/null +++ b/ee/config/feature_flags/development/remote_development_feature_flag.yml @@ -0,0 +1,8 @@ +--- +name: remote_development_feature_flag +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/112397 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/391543 +milestone: '15.11' +type: development +group: group::editor +default_enabled: false diff --git a/ee/config/routes/remote_development.rb b/ee/config/routes/remote_development.rb new file mode 100644 index 0000000000000000000000000000000000000000..7f3c9626cb62b052dc4f2bdc892c4894f1cb7b31 --- /dev/null +++ b/ee/config/routes/remote_development.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +namespace :remote_development do + resources :workspaces, path: 'workspaces(/*vueroute)' +end diff --git a/ee/lib/ee/sidebars/your_work/panel.rb b/ee/lib/ee/sidebars/your_work/panel.rb index 487b9a4c4d1bcc88aeac3016eb7477353f22b1c7..3d21bb798e127af0c10e21a52aa33dd1b49767b5 100644 --- a/ee/lib/ee/sidebars/your_work/panel.rb +++ b/ee/lib/ee/sidebars/your_work/panel.rb @@ -10,6 +10,7 @@ module Panel def configure_menus super + add_menu(workspaces_menu) add_menu(environments_dashboard_menu) add_menu(operations_dashboard_menu) add_menu(security_dashboard_menu) @@ -19,6 +20,10 @@ def configure_menus private + def workspaces_menu + ::Sidebars::YourWork::Menus::WorkspacesMenu.new(context) + end + def environments_dashboard_menu ::Sidebars::YourWork::Menus::EnvironmentsDashboardMenu.new(context) end diff --git a/ee/lib/sidebars/your_work/menus/workspaces_menu.rb b/ee/lib/sidebars/your_work/menus/workspaces_menu.rb new file mode 100644 index 0000000000000000000000000000000000000000..b0b7c231516ce95e4760ef534a85b1a5b6a030dd --- /dev/null +++ b/ee/lib/sidebars/your_work/menus/workspaces_menu.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Sidebars + module YourWork + module Menus + class WorkspacesMenu < ::Sidebars::Menu + override :link + def link + remote_development_workspaces_path + end + + override :title + def title + _('Workspaces') + end + + override :sprite_icon + def sprite_icon + 'cloud-gear' + end + + override :render? + def render? + can?(context.current_user, :read_workspace) + end + + override :active_routes + def active_routes + { path: 'remote_development/workspaces#index' } + end + end + end + end +end diff --git a/ee/spec/controllers/remote_development/workspaces_controller_spec.rb b/ee/spec/controllers/remote_development/workspaces_controller_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..87e8a645fab3ad57f38d785809247c7fba19c5a5 --- /dev/null +++ b/ee/spec/controllers/remote_development/workspaces_controller_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RemoteDevelopment::WorkspacesController, feature_category: :remote_development do + let_it_be(:user) { create(:user) } + + before do + sign_in(user) + end + + shared_examples 'remote development feature flag' do |feature_flag_enabled, expected_status| + before do + stub_licensed_features(remote_development: true) + stub_feature_flags(remote_development_feature_flag: feature_flag_enabled) + end + + describe 'GET #index' do + it 'responds with the expected status' do + get :index + + expect(response).to have_gitlab_http_status(expected_status) + end + end + end + + context 'with remote development feature flag' do + it_behaves_like 'remote development feature flag', true, :ok + it_behaves_like 'remote development feature flag', false, :not_found + end + + context 'with remote development not licensed' do + before do + stub_licensed_features(remote_development: false) + end + + describe 'GET #index' do + it 'responds with the not found status' do + get :index + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end +end diff --git a/ee/spec/frontend/remote_development/components/empty_state_spec.js b/ee/spec/frontend/remote_development/components/empty_state_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..576232a7b925a5e1a3c6486938a40a78d8d9df8b --- /dev/null +++ b/ee/spec/frontend/remote_development/components/empty_state_spec.js @@ -0,0 +1,35 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import EmptyState, { i18n } from 'ee/remote_development/components/list/empty_state.vue'; + +const QUICK_START_LINK = '/user/workspace/quick_start/index.md'; +const SVG_PATH = '/assets/illustrations/empty_states/empty_workspaces.svg'; + +describe('remote_development/components/list/empty_state.vue', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(GlEmptyState); + + const createComponent = () => { + wrapper = shallowMount(EmptyState, { + provide: { + emptyStateSvgPath: SVG_PATH, + }, + }); + }; + + describe('when no workspaces exist', () => { + it('should render empty workspace state', () => { + createComponent(); + + expect(findEmptyState().props()).toMatchObject({ + title: i18n.title, + description: i18n.description, + primaryButtonText: i18n.primaryButtonText, + primaryButtonLink: helpPagePath(QUICK_START_LINK), + svgPath: SVG_PATH, + }); + }); + }); +}); diff --git a/ee/spec/frontend/remote_development/init_workspaces_app_spec.js b/ee/spec/frontend/remote_development/init_workspaces_app_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..657fcc106a3190682e0d4171540d7a1232b1a24f --- /dev/null +++ b/ee/spec/frontend/remote_development/init_workspaces_app_spec.js @@ -0,0 +1,33 @@ +import { createWrapper } from '@vue/test-utils'; +import { initWorkspacesApp } from 'ee/remote_development/init_workspaces_app'; +import EmptyState from 'ee/remote_development/components/list/empty_state.vue'; + +describe('ee/remote_development/init_workspaces_app', () => { + let wrapper; + + beforeEach(() => { + document.body.innerHTML = '
'; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + describe('initWorkspacesApp - integration', () => { + beforeEach(() => { + wrapper = createWrapper(initWorkspacesApp()); + }); + + it('renders empty state', () => { + expect(wrapper.findComponent(EmptyState).exists()).toBe(true); + }); + }); + + describe('initWorkspacesApp - when mounting element not found', () => { + it('returns null', () => { + document.body.innerHTML = '
Look ma! Code!
'; + + expect(initWorkspacesApp()).toBeNull(); + }); + }); +}); diff --git a/ee/spec/frontend/remote_development/pages/list_spec.js b/ee/spec/frontend/remote_development/pages/list_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..fb13b82e58393c604a63bf35dbdd377d89fe36cd --- /dev/null +++ b/ee/spec/frontend/remote_development/pages/list_spec.js @@ -0,0 +1,20 @@ +import { shallowMount } from '@vue/test-utils'; +import WorkspacesList from 'ee/remote_development/pages/list.vue'; +import EmptyState from 'ee/remote_development/components/list/empty_state.vue'; + +describe('remote_development/pages/list.vue', () => { + let wrapper; + + const findEmptyState = () => wrapper.findComponent(EmptyState); + + const createComponent = () => { + wrapper = shallowMount(WorkspacesList, {}); + }; + + describe('when no workspaces exist', () => { + it('should render empty workspace state', () => { + createComponent(); + expect(findEmptyState().exists()).toBe(true); + }); + }); +}); diff --git a/ee/spec/frontend/remote_development/router/index_spec.js b/ee/spec/frontend/remote_development/router/index_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2eb18718e6647611c5b0150e473cfeb7a39ed0d6 --- /dev/null +++ b/ee/spec/frontend/remote_development/router/index_spec.js @@ -0,0 +1,37 @@ +import { mount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import App from 'ee/remote_development/pages/app.vue'; +import WorkspacesList from 'ee/remote_development/pages/list.vue'; +import createRouter from 'ee/remote_development/router/index'; + +Vue.use(VueRouter); + +const SVG_PATH = '/assets/illustrations/empty_states/empty_workspaces.svg'; + +describe('remote_development/router/index.js', () => { + let router; + + beforeEach(() => { + router = createRouter('/'); + jest.spyOn(router, 'push').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + window.location.hash = ''; + }); + + it('renders WorkspacesList on route /', async () => { + const wrapper = mount(App, { + router, + provide: { + emptyStateSvgPath: SVG_PATH, + }, + }); + + await router.push('/'); + + expect(wrapper.findComponent(WorkspacesList).exists()).toBe(true); + }); +}); diff --git a/ee/spec/lib/ee/sidebars/your_work/menus/workspaces_menu_spec.rb b/ee/spec/lib/ee/sidebars/your_work/menus/workspaces_menu_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7b454b283eb5e7c504ec55821779db42e101cbe4 --- /dev/null +++ b/ee/spec/lib/ee/sidebars/your_work/menus/workspaces_menu_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sidebars::YourWork::Menus::WorkspacesMenu, feature_category: :remote_development do + it_behaves_like 'top-level menu item', + is_super_sidebar: false, + link: '/-/remote_development/workspaces', + title: 'Workspaces', + icon: 'cloud-gear', + active_route: { path: 'remote_development/workspaces#index' } + + it_behaves_like 'top-level menu item', + is_super_sidebar: true, + link: '/-/remote_development/workspaces', + title: 'Workspaces', + icon: 'cloud-gear', + active_route: { path: 'remote_development/workspaces#index' } + + it_behaves_like 'top-level menu item with license feature guard', + access_check: :read_workspace + + it_behaves_like 'menu without sub menu items' +end diff --git a/ee/spec/lib/ee/sidebars/your_work/panel_spec.rb b/ee/spec/lib/ee/sidebars/your_work/panel_spec.rb index e583c189d3da7da429179fb80e968d51d644c855..20dec4ab4da4436b54cee4c898fd1b51c300eef1 100644 --- a/ee/spec/lib/ee/sidebars/your_work/panel_spec.rb +++ b/ee/spec/lib/ee/sidebars/your_work/panel_spec.rb @@ -9,6 +9,10 @@ subject(:panel) { described_class.new(context) } + it 'renders Workspaces' do + expect(contains_menu?(::Sidebars::YourWork::Menus::WorkspacesMenu)).to be(true) + end + it 'renders Environments dashboard' do expect(contains_menu?(::Sidebars::YourWork::Menus::EnvironmentsDashboardMenu)).to be(true) end diff --git a/ee/spec/policies/global_policy_spec.rb b/ee/spec/policies/global_policy_spec.rb index 87ad80b16de667fa889242491c2ff27a74d696be..ef07be1189b08e430d1abaade14a65d319cdcc8e 100644 --- a/ee/spec/policies/global_policy_spec.rb +++ b/ee/spec/policies/global_policy_spec.rb @@ -35,6 +35,38 @@ end end + describe 'reading workspaces' do + context 'when feature flag is disabled' do + before do + stub_feature_flags(remote_development_feature_flag: false) + end + + it { is_expected.to be_disallowed(:read_workspace) } + end + + context 'when licensed' do + before do + stub_licensed_features(remote_development: true) + end + + it { is_expected.to be_allowed(:read_workspace) } + + context 'and the user is not logged in' do + let(:current_user) { nil } + + it { is_expected.to be_disallowed(:read_workspace) } + end + end + + context 'when unlicensed' do + before do + stub_licensed_features(remote_development: false) + end + + it { is_expected.to be_disallowed(:read_workspace) } + end + end + it { is_expected.to be_disallowed(:read_licenses) } it { is_expected.to be_disallowed(:destroy_licenses) } it { is_expected.to be_disallowed(:read_all_geo) } diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 0503067e8cf4bff2b4d36465bc1b6c49faae949c..2397f935ba4465ae445190d7803ae444d7081ddc 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -49803,6 +49803,18 @@ msgstr "" msgid "WorkItem|You're only seeing %{boldStart}other activity%{boldEnd} in the feed. To add a comment, switch to one of the following options." msgstr "" +msgid "Workspaces" +msgstr "" + +msgid "Workspaces|Develop anywhere" +msgstr "" + +msgid "Workspaces|Get started with GitLab Workspaces" +msgstr "" + +msgid "Workspaces|GitLab Workspaces is a powerful collaborative platform that provides a comprehensive set of tools for software development teams to manage their entire development lifecycle." +msgstr "" + msgid "Would you like to create a new branch?" msgstr ""