Коммит d543b577 создал по автору David O'Regan's avatar David O'Regan
Просмотр файлов

Add Workspace tabs

Add tab filters
for different workspace
states in the list view
владелец d8d6ae4d
---
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.
<script>
import { GlButton, GlButtonGroup, GlIcon, GlLink, GlTab, GlTableLite } from '@gitlab/ui';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
export default {
components: {
GlButtonGroup,
GlTab,
GlIcon,
GlTableLite,
GlLink,
GlButton,
},
props: {
title: {
type: String,
required: true,
},
items: {
type: Array,
required: true,
},
emptyMessage: {
type: String,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
},
fields: [
{
key: 'name',
label: __('Name'),
thClass: 'gl-w-25p',
},
{
key: 'branch',
label: __('Branch'),
thClass: 'gl-w-25p',
},
{
key: 'preview',
label: __('Preview'),
thClass: 'gl-w-30p',
},
{
key: 'lastUsed',
label: __('Last used'),
thClass: 'gl-w-20p',
},
{
key: 'actions',
label: '',
thClass: 'gl-w-10p',
},
],
computed: {
shouldRender() {
return this.items.length && !this.isLoading;
},
},
methods: {
path(id) {
return `/workspaces/${id}`;
},
deleteWorkspace: () => {
// TDOD: implement deleteWorkspace
},
lastUsedAt(lastUsed) {
return getTimeago().format(lastUsed);
},
},
};
</script>
<template>
<gl-tab :title="title">
<gl-table-lite
v-if="shouldRender"
:items="items"
:loading="isLoading"
:fields="$options.fields"
>
<template #cell(name)="{ item }">
<div class="gl-display-flex gl-text-gray-500 gl-align-items-center">
<gl-icon name="status-stopped" class="gl-mr-5" />
<div class="gl-display-flex gl-flex-direction-column">
<span> {{ item.projectFullPath }} </span>
<span> {{ item.name }} </span>
</div>
</div>
</template>
<template #cell(branch)="{ item }">
<div class="gl-display-flex gl-text-gray-500 gl-align-items-center">
<gl-icon name="branch" class="gl-mr-3" />
{{ item.branch }}
</div>
</template>
<template #cell(preview)="{ item }">
<gl-link :href="item.url" target="_blank">{{ item.url }}</gl-link>
</template>
<template #cell(lastUsed)="{ item }">
<div>{{ lastUsedAt(item.lastUsed) }}</div>
</template>
<template #cell(actions)="{ item }">
<gl-button-group>
<gl-button icon="pencil" :aria-label="s__('Workspaces|Edit')" :to="path(item.id)" />
<gl-button icon="remove" @click="deleteWorkspace(item)" />
</gl-button-group>
</template>
</gl-table-lite>
<p v-else-if="!isLoading" class="nothing-here-block">
{{ emptyMessage }}
</p>
</gl-tab>
</template>
/* eslint-disable @gitlab/require-i18n-strings */
const workspace = (
id,
name,
desiredState,
actualState,
url,
editor,
devfile,
branch,
projectFullPath,
lastUsed,
) => ({
id,
name: name,
namespace: 'Namespace',
desiredState,
actualState,
displayedState: actualState,
url: url,
editor: editor,
devfile: devfile,
branch,
projectFullPath,
lastUsed,
});
export const nodes = [
workspace(
1,
'Workspace 1',
'Running',
'Started',
'https://127.0.0.1',
'VSCode',
'devfile',
'master',
'GitLab.org / GitLab',
'2020-10-01T12:00:00.000Z',
),
workspace(
2,
'Workspace 2',
'Terminated',
'Terminated',
'https://127.0.0.1',
'VSCode',
'devfile',
'main',
'GitLab.org / GitLab',
'2022-10-01T12:00:00.000Z',
),
];
/* eslint-enable @gitlab/require-i18n-strings */
/* eslint-disable @gitlab/require-i18n-strings */
const workspaceData = {
id: 1,
name: 'Workspace 1',
const workspace = (
id,
name,
desiredState,
actualState,
url,
editor,
devfile,
branch,
projectFullPath,
lastUsed,
) => ({
id,
name,
branch,
projectFullPath,
namespace: 'Namespace',
projectFullPath: 'GitLab.org / GitLab',
desiredState: 'Running',
actualState: 'Started',
displayedState: 'Started',
url: 'https://127.0.0.1',
editor: 'VSCode',
devfile: 'devfile',
branch: 'master',
lastUsed: '2020-01-01T00:00:00.000Z',
};
desiredState,
actualState,
displayedState: actualState,
url,
editor,
devfile,
lastUsed,
});
const nodes = [
workspace(
1,
'Workspace 1',
'Running',
'Started',
'https://127.0.0.1',
'VSCode',
'devfile',
'master',
'GitLab.org / GitLab',
'2020-10-01T12:00:00.000Z',
),
workspace(
2,
'Workspace 2',
'Terminated',
'Terminated',
'https://127.0.0.1',
'VSCode',
'devfile',
'main',
'GitLab.org / GitLab',
'2022-10-01T12:00:00.000Z',
),
];
/* eslint-enable @gitlab/require-i18n-strings */
const resolvers = {
Query: {
userWorkspacesList: () => {
return {
nodes: [workspaceData],
};
},
userWorkspacesList: () => nodes,
},
};
export { workspaceData, resolvers };
export { nodes, resolvers };
......@@ -3,8 +3,8 @@ import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import App from './pages/app.vue';
import createRouter from './router';
import { workspaceData } from './graphql/resolvers';
import userWorkspacesListQuery from './graphql/queries/user_workspaces_list.query.graphql';
import { nodes } from './graphql/resolvers';
Vue.use(VueApollo);
......@@ -12,7 +12,7 @@ const resolvers = {
Query: {
userWorkspacesList: () => {
return {
nodes: [workspaceData],
nodes,
};
},
},
......
<script>
import { GlAlert, GlButton, GlLink, GlIcon, GlSkeletonLoader, GlTableLite } from '@gitlab/ui';
import { getTimeago } from '~/lib/utils/datetime_utility';
import { __ } from '~/locale';
import { GlAlert, GlSkeletonLoader, GlTabs } from '@gitlab/ui';
import userWorkspacesListQuery from '../graphql/queries/user_workspaces_list.query.graphql';
import WorkspaceEmptyState from '../components/list/empty_state.vue';
import WorkspaceEmptyState from '../components/list/workspace_empty_state.vue';
import WorkspaceTab from '../components/list/workspace_tab.vue';
export default {
components: {
GlAlert,
GlButton,
GlLink,
GlIcon,
GlSkeletonLoader,
GlTableLite,
GlTabs,
WorkspaceEmptyState,
WorkspaceTab,
},
apollo: {
userWorkspacesList: {
......@@ -30,33 +27,6 @@ export default {
},
},
},
fields: [
{
key: 'name',
label: __('Name'),
thClass: 'gl-w-25p',
},
{
key: 'branch',
label: __('Branch'),
thClass: 'gl-w-25p',
},
{
key: 'preview',
label: __('Preview'),
thClass: 'gl-w-30p',
},
{
key: 'lastUsed',
label: __('Last used'),
thClass: 'gl-w-20p',
},
{
key: 'actions',
label: '',
thClass: 'gl-w-10p',
},
],
data() {
return {
userWorkspacesList: [],
......@@ -68,13 +38,20 @@ export default {
hasWorkSpaces() {
return this.userWorkspacesList.length;
},
// TODO: Refactor this code to use a more complex filter set based on ENUM states returned from the API.
// For more details, see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/105783
/* eslint-disable @gitlab/require-i18n-strings */
displayedActiveWorkspaces() {
return this.userWorkspacesList.filter(({ actualState }) => actualState !== 'Terminated');
},
displayedTerminatedWorkspaces() {
return this.userWorkspacesList.filter(({ actualState }) => actualState === 'Terminated');
},
/* eslint-enable @gitlab/require-i18n-strings */
},
methods: {
deleteWorkspace: (item) => {
// TDOD: implement deleteWorkspace
},
lastUsedAt(lastUsed) {
return getTimeago().format(lastUsed);
clearError() {
this.error = '';
},
},
};
......@@ -95,32 +72,21 @@ export default {
<gl-skeleton-loader :lines="4" :equal-width-lines="true" :width="600" />
</div>
<gl-table-lite :items="userWorkspacesList" :loading="isLoading" :fields="$options.fields">
<template #cell(name)="{ item }">
<div class="gl-display-flex gl-text-gray-500 gl-align-items-center">
<gl-icon name="status-stopped" class="gl-mr-5" />
<div class="gl-display-flex gl-flex-direction-column">
<span> {{ item.projectFullPath }} </span>
<span> {{ item.name }} </span>
</div>
</div>
</template>
<template #cell(branch)="{ item }">
<div class="gl-display-flex gl-text-gray-500 gl-align-items-center">
<gl-icon name="branch" class="gl-mr-3" />
{{ item.branch }}
</div>
</template>
<template #cell(preview)="{ item }">
<gl-link :href="item.url" target="_blank">{{ item.url }}</gl-link>
</template>
<template #cell(lastUsed)="{ item }">
<div>{{ lastUsedAt(item.lastUsed) }}</div>
</template>
<template #cell(actions)="{ item }">
<gl-button icon="remove" @click="deleteWorkspace(item)" />
</template>
</gl-table-lite>
<gl-tabs>
<workspace-tab
:title="s__('Workspaces|Active')"
:items="displayedActiveWorkspaces"
:empty-message="s__('Workspaces|No active workspaces to show.')"
:is-loading="isLoading"
/>
<workspace-tab
:title="s__('Workspaces|Terminated')"
:items="displayedTerminatedWorkspaces"
:empty-message="s__('Workspaces|No terminated workspaces to show.')"
:is-loading="isLoading"
/>
</gl-tabs>
</div>
<workspace-empty-state v-else />
......
import Vue from 'vue';
import VueRouter from 'vue-router';
import WorkspacesList from '../pages/list.vue';
import WorkspacesList from '../pages/workspace_list.vue';
Vue.use(VueRouter);
......
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';
import EmptyState, { i18n } from 'ee/remote_development/components/list/workspace_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', () => {
describe('remote_development/components/list/workspace_empty_state.vue', () => {
let wrapper;
const findEmptyState = () => wrapper.findComponent(GlEmptyState);
......
import { mount } from '@vue/test-utils';
import { GlButton, GlLink, GlTableLite } from '@gitlab/ui';
import WorkspaceTab from 'ee/remote_development/components/list/workspace_tab.vue';
describe('remote_development/components/list/workspace_tab.vue', () => {
let wrapper;
const title = 'My Workspaces';
const items = [
{ id: 1, url: 'https://example.com/workspace1' },
{ id: 2, url: 'https://example.com/workspace2' },
];
const emptyMessage = 'No workspaces found';
const isLoading = false;
const findTable = () => wrapper.findComponent(GlTableLite);
const findTableRows = () => findTable().findAll('tbody > tr');
const createComponent = (props = {}) => {
wrapper = mount(WorkspaceTab, {
propsData: {
title,
items,
emptyMessage,
isLoading,
...props,
},
});
};
it('renders the table with the items', () => {
createComponent();
const rows = findTableRows();
expect(rows.length).toBe(items.length);
rows.wrappers.forEach((row, index) => {
const item = items[index];
expect(row.findComponent(GlLink).attributes('href')).toBe(item.url);
expect(row.findComponent(GlLink).text()).toBe(item.url);
expect(row.findComponent(GlButton).attributes('to')).toBe(`/workspaces/${item.id}`);
});
});
it('renders the empty message if there are no items', () => {
createComponent({ items: [] });
expect(wrapper.find('.nothing-here-block').text()).toBe(emptyMessage);
});
it('does not render the table if isLoading is true', () => {
createComponent({ isLoading: true });
expect(findTable().exists()).toBe(false);
});
});
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';
import EmptyState from 'ee/remote_development/components/list/workspace_empty_state.vue';
describe('ee/remote_development/init_workspaces_app', () => {
let wrapper;
......
export const MOCK_GROUP_WORKSPACE_DATA = {
const workspace = {
namespace: 'Namespace',
projectFullPath: 'GitLab.org / GitLab',
url: 'https://127.0.0.1',
editor: 'VSCode',
devfile: 'devfile',
branch: 'master',
lastUsed: '2020-01-01T00',
};
export const MOCK_USER_WORKSPACE_DATA = {
nodes: [
{
id: 1,
name: 'Workspace 1',
namespace: 'Namespace',
projectFullPath: 'GitLab.org / GitLab',
desiredState: 'Running',
actualState: 'Started',
displayedState: 'Started',
url: 'https://127.0.0.1',
editor: 'VSCode',
devfile: 'devfile',
branch: 'master',
lastUsed: '2020-01-01T00:00:00.000Z',
...workspace,
},
{
id: 2,
name: 'Workspace 2',
desiredState: 'Terminated',
actualState: 'Terminated',
displayedState: 'Terminated',
...workspace,
},
],
};
import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Vue from 'vue';
import { GlTableLite } from '@gitlab/ui';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkspaceList from 'ee/remote_development/pages/list.vue';
import WorkspaceEmptyState from 'ee/remote_development/components/list/empty_state.vue';
import { MOCK_GROUP_WORKSPACE_DATA } from '../mock_data';
Vue.use(VueApollo);
function createWrapper() {
const mockApollo = createMockApollo([], {
Query: {
userWorkspacesList: () => MOCK_GROUP_WORKSPACE_DATA,
},
});
return shallowMount(WorkspaceList, {
apolloProvider: mockApollo,
});
}
describe('remote_development/pages/list.vue', () => {
let wrapper;
it('shows empty state when no workspaces are available', () => {
wrapper = createWrapper();
expect(wrapper.findComponent(WorkspaceEmptyState).exists()).toBe(true);
});
it('shows table when workspaces are available', async () => {
wrapper = createWrapper();
await waitForPromises();
expect(wrapper.findComponent(GlTableLite).exists()).toBe(true);
});
});
import { shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Vue from 'vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkspaceList from 'ee/remote_development/pages/workspace_list.vue';
import WorkspaceTab from 'ee/remote_development/components/list/workspace_tab.vue';
import WorkspaceEmptyState from 'ee/remote_development/components/list/workspace_empty_state.vue';
import { MOCK_USER_WORKSPACE_DATA } from '../mock_data';
const QUERY_USER_WORKSPACES_LIST = 'userWorkspacesList';
Vue.use(VueApollo);
describe('remote_development/components/pages/workspace_list.vue', () => {
let wrapper;
const createWrapper = (mockApollo) => {
wrapper = shallowMount(WorkspaceList, {
apolloProvider: mockApollo,
});
};
describe('when there are no workspaces', () => {
beforeEach(async () => {
const mockApollo = createMockApollo([], {
Query: {
[QUERY_USER_WORKSPACES_LIST]: () => ({
[QUERY_USER_WORKSPACES_LIST]: {
nodes: [],
},
}),
},
});
createWrapper(mockApollo);
await waitForPromises();
});
it('shows an empty state', () => {
expect(wrapper.findComponent(WorkspaceEmptyState).exists()).toBe(true);
});
});
describe('when there are workspaces', () => {
beforeEach(async () => {
const mockApollo = createMockApollo([], {
Query: {
[QUERY_USER_WORKSPACES_LIST]: () => MOCK_USER_WORKSPACE_DATA,
},
});
createWrapper(mockApollo);
await waitForPromises();
});
it('shows a tab for active workspaces', () => {
const activeWorkspacesTab = wrapper.findComponent(WorkspaceTab);
expect(activeWorkspacesTab.exists()).toBe(true);
expect(activeWorkspacesTab.props('title')).toBe('Active');
expect(activeWorkspacesTab.props('items')).toEqual(
MOCK_USER_WORKSPACE_DATA.nodes.filter((node) => node.desiredState !== 'Terminated'),
);
expect(activeWorkspacesTab.props('emptyMessage')).toBe('No active workspaces to show.');
});
it('shows a tab for terminated workspaces', () => {
const terminatedWorkspacesTab = wrapper.findAllComponents(WorkspaceTab).at(1);
expect(terminatedWorkspacesTab.exists()).toBe(true);
expect(terminatedWorkspacesTab.props('title')).toBe('Terminated');
expect(terminatedWorkspacesTab.props('items')).toEqual(
MOCK_USER_WORKSPACE_DATA.nodes.filter((node) => node.desiredState === 'Terminated'),
);
expect(terminatedWorkspacesTab.props('emptyMessage')).toBe(
'No terminated workspaces to show.',
);
});
});
});
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import VueRouter from 'vue-router';
import WorkspacesList from 'ee/remote_development/pages/workspace_list.vue';
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);
......
......@@ -49557,15 +49557,30 @@ msgstr ""
msgid "Workspaces"
msgstr ""
 
msgid "Workspaces|Active"
msgstr ""
msgid "Workspaces|Develop anywhere"
msgstr ""
 
msgid "Workspaces|Edit"
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 "Workspaces|No active workspaces to show."
msgstr ""
msgid "Workspaces|No terminated workspaces to show."
msgstr ""
msgid "Workspaces|Terminated"
msgstr ""
msgid "Workspaces|Workspaces"
msgstr ""
 
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать