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 ""