Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-03-24 00:13:49 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-03-24 00:13:49 +0300
commit00ab3a60fed93cb3c6c9148d9c9c68fb11d325ee (patch)
treebef28d84085f23cfbd30a3a1ed74a601eaa2eef5
parent68caf5fd883a7fd5a3395c2e5ae2a5c511445613 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue4
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql8
-rw-r--r--app/assets/javascripts/clusters_list/components/agent_table.vue25
-rw-r--r--app/assets/javascripts/notebook/cells/code/index.vue2
-rw-r--r--app/assets/javascripts/notebook/cells/output/dataframe.vue46
-rw-r--r--app/assets/javascripts/notebook/cells/output/dataframe_util.js44
-rw-r--r--app/assets/javascripts/notebook/cells/output/index.vue4
-rw-r--r--app/assets/stylesheets/framework/files.scss2
-rw-r--r--app/assets/stylesheets/pages/projects.scss2
-rw-r--r--app/graphql/types/merge_request_type.rb7
-rw-r--r--app/views/notify/access_token_created_email.html.haml2
-rw-r--r--doc/topics/gitlab_flow.md103
-rw-r--r--doc/user/clusters/agent/index.md2
-rw-r--r--doc/user/clusters/agent/install/index.md17
-rw-r--r--locale/gitlab.pot3
-rw-r--r--package.json5
-rw-r--r--scripts/frontend/extract_gettext_all.js10
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js20
-rw-r--r--spec/frontend/ci/pipeline_editor/mock_data.js81
-rw-r--r--spec/frontend/clusters_list/components/agent_table_spec.js192
-rw-r--r--spec/frontend/clusters_list/components/mock_data.js98
-rw-r--r--spec/frontend/notebook/cells/output/dataframe_spec.js59
-rw-r--r--spec/frontend/notebook/cells/output/dataframe_util_spec.js113
-rw-r--r--spec/frontend/notebook/cells/output/index_spec.js18
-rw-r--r--spec/frontend/notebook/mock_data.js44
-rw-r--r--yarn.lock16
26 files changed, 712 insertions, 215 deletions
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
index 0f974364600..41f66c8d33c 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
@@ -4,7 +4,7 @@ import { stringify, parse } from 'yaml';
import { set, omit, trim } from 'lodash';
import { getContentWrapperHeight } from '~/lib/utils/dom_utils';
import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
-import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql';
+import getRunnerTags from '../../graphql/queries/runner_tags.query.graphql';
import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, i18n } from './constants';
import { removeEmptyObj, trimFields } from './utils';
import JobSetupItem from './accordion_items/job_setup_item.vue';
@@ -48,7 +48,7 @@ export default {
},
apollo: {
runners: {
- query: getAllRunners,
+ query: getRunnerTags,
update(data) {
return data?.runners?.nodes || [];
},
diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql
new file mode 100644
index 00000000000..aab30257d13
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql
@@ -0,0 +1,8 @@
+query getRunnerTags {
+ runners {
+ nodes {
+ id
+ tagList
+ }
+ }
+}
diff --git a/app/assets/javascripts/clusters_list/components/agent_table.vue b/app/assets/javascripts/clusters_list/components/agent_table.vue
index e0e3b961c51..ef228370133 100644
--- a/app/assets/javascripts/clusters_list/components/agent_table.vue
+++ b/app/assets/javascripts/clusters_list/components/agent_table.vue
@@ -8,6 +8,9 @@ import {
GlTooltipDirective,
GlPopover,
} from '@gitlab/ui';
+import semverLt from 'semver/functions/lt';
+import semverInc from 'semver/functions/inc';
+import semverPrerelease from 'semver/functions/prerelease';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { helpPagePath } from '~/helpers/help_page_helper';
@@ -134,18 +137,26 @@ export default {
isVersionMismatch(agent) {
return agent.versions.length > 1;
},
+ // isVersionOutdated determines if the agent version is outdated compared to the KAS / GitLab version
+ // using the following heuristics:
+ // - KAS Version is used as *server* version if available, otherwise the GitLab version is used.
+ // - returns `outdated` if the agent has a different major version than the server
+ // - returns `outdated` if the agents minor version is at least two proper versions older than the server
+ // - *proper* -> not a prerelease version. Meaning that server prereleases (with `-rcN`) suffix are counted as the previous minor version
+ //
+ // Note that it does NOT support if the agent is newer than the server version.
isVersionOutdated(agent) {
if (!agent.versions.length) return false;
- const [agentMajorVersion, agentMinorVersion] = this.getAgentVersionString(agent).split('.');
- const [serverMajorVersion, serverMinorVersion] = this.serverVersion.split('.');
+ const agentVersion = this.getAgentVersionString(agent);
+ let allowableAgentVersion = semverInc(agentVersion, 'minor');
- const majorVersionMismatch = agentMajorVersion !== serverMajorVersion;
-
- // We should warn user if their current GitLab and agent versions are more than 1 minor version apart:
- const minorVersionMismatch = Math.abs(agentMinorVersion - serverMinorVersion) > 1;
+ const isServerPrerelease = Boolean(semverPrerelease(this.serverVersion));
+ if (isServerPrerelease) {
+ allowableAgentVersion = semverInc(allowableAgentVersion, 'minor');
+ }
- return majorVersionMismatch || minorVersionMismatch;
+ return semverLt(allowableAgentVersion, this.serverVersion);
},
getVersionPopoverTitle(agent) {
diff --git a/app/assets/javascripts/notebook/cells/code/index.vue b/app/assets/javascripts/notebook/cells/code/index.vue
index 64e801a7516..211a12208c1 100644
--- a/app/assets/javascripts/notebook/cells/code/index.vue
+++ b/app/assets/javascripts/notebook/cells/code/index.vue
@@ -51,7 +51,7 @@ export default {
language="python"
:code="code"
:max-height="maxHeight"
- class="gl-border"
+ class="gl-border gl-p-4!"
/>
</div>
</template>
diff --git a/app/assets/javascripts/notebook/cells/output/dataframe.vue b/app/assets/javascripts/notebook/cells/output/dataframe.vue
new file mode 100644
index 00000000000..4fe02ee6edf
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/dataframe.vue
@@ -0,0 +1,46 @@
+<script>
+import JSONTable from '~/behaviors/components/json_table.vue';
+import Prompt from '../prompt.vue';
+import { convertHtmlTableToJson } from './dataframe_util';
+
+export default {
+ name: 'DataframeOutput',
+ components: {
+ Prompt,
+ JSONTable,
+ },
+ props: {
+ count: {
+ type: Number,
+ required: true,
+ },
+ rawCode: {
+ type: String,
+ required: true,
+ },
+ index: {
+ type: Number,
+ required: true,
+ },
+ },
+ computed: {
+ showOutput() {
+ return this.index === 0;
+ },
+ dataframeAsJSONTable() {
+ return {
+ ...convertHtmlTableToJson(this.rawCode),
+ caption: '',
+ hasFilter: true,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="output">
+ <prompt type="Out" :count="count" :show-output="showOutput" />
+ <j-s-o-n-table v-bind="dataframeAsJSONTable" class="gl-overflow-auto" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/notebook/cells/output/dataframe_util.js b/app/assets/javascripts/notebook/cells/output/dataframe_util.js
new file mode 100644
index 00000000000..2fdaaced0b9
--- /dev/null
+++ b/app/assets/javascripts/notebook/cells/output/dataframe_util.js
@@ -0,0 +1,44 @@
+import { sanitize } from '~/lib/dompurify';
+
+/**
+ * Converts a dataframe in the output of a Jupyter Notebook cell to a json object
+ *
+ * @param {string} input - the dataframe
+ * @param {DOMParser} parser - the html parser
+ * @returns {Object} The converted JSON object with an `items` property containing the rows.
+ */
+export function convertHtmlTableToJson(input, domParser) {
+ const parser = domParser || new DOMParser();
+ const htmlDoc = parser.parseFromString(sanitize(input), 'text/html');
+
+ if (!htmlDoc) return { fields: [], items: [] };
+
+ const columnNames = [...htmlDoc.querySelectorAll('table > thead th')].map(
+ (head) => head.innerText,
+ );
+
+ if (!columnNames) return { fields: [], items: [] };
+
+ const itemValues = [...htmlDoc.querySelectorAll('table > tbody > tr')].map((row) =>
+ [...row.querySelectorAll('td')].map((item) => item.innerText),
+ );
+
+ return {
+ fields: columnNames.map((column) => ({
+ key: column === '' ? 'index' : column,
+ label: column,
+ sortable: true,
+ })),
+ items: itemValues.map((values, itemIndex) => ({
+ index: itemIndex,
+ ...Object.fromEntries(values.map((value, index) => [columnNames[index + 1], value])),
+ })),
+ };
+}
+
+export function isDataframe(output) {
+ const htmlData = output.data['text/html'];
+ if (!htmlData) return false;
+
+ return htmlData.slice(0, 20).some((line) => line.includes('dataframe'));
+}
diff --git a/app/assets/javascripts/notebook/cells/output/index.vue b/app/assets/javascripts/notebook/cells/output/index.vue
index 22bcb5dd66a..0437b85913b 100644
--- a/app/assets/javascripts/notebook/cells/output/index.vue
+++ b/app/assets/javascripts/notebook/cells/output/index.vue
@@ -5,6 +5,8 @@ import ImageOutput from './image.vue';
import LatexOutput from './latex.vue';
import MarkdownOutput from './markdown.vue';
import ErrorOutput from './error.vue';
+import DataframeOutput from './dataframe.vue';
+import { isDataframe } from './dataframe_util';
const TEXT_MARKDOWN = 'text/markdown';
const ERROR_OUTPUT_TYPE = 'error';
@@ -66,6 +68,8 @@ export default {
return ImageOutput;
} else if (output.data['image/jpeg']) {
return ImageOutput;
+ } else if (isDataframe(output)) {
+ return DataframeOutput;
} else if (output.data['text/html']) {
return HtmlOutput;
} else if (output.data['text/latex']) {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 83237198f1e..e4025eb8b8d 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -491,7 +491,7 @@ span.idiff {
// element stretching over multiple rows we instead create a repeating background image
// for the line
background: repeating-linear-gradient(to right, var(--gray-100, $gray-100), var(--gray-100, $gray-100) 1px, transparent 1px, transparent 14px);
- background-size: calc(var(--level) * 14px);
+ background-size: calc(var(--level) * 14px) 100%;
background-repeat: no-repeat;
background-position: 14px;
}
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index ee91d955019..e6c7e265cdb 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -291,9 +291,9 @@
.project-cell {
@include gl-display-table-cell;
- @include gl-border-b;
@include gl-vertical-align-top;
@include gl-py-4;
+ border-bottom: 1px solid $gray-50;
}
.project-row:last-of-type {
diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb
index 3c288c1d496..94470290082 100644
--- a/app/graphql/types/merge_request_type.rb
+++ b/app/graphql/types/merge_request_type.rb
@@ -295,6 +295,13 @@ module Types
def detailed_merge_status
::MergeRequests::Mergeability::DetailedMergeStatusService.new(merge_request: object).execute
end
+
+ # This is temporary to fix a bug where `committers` is already loaded and memoized
+ # and calling it again with a certain GraphQL query can cause the Rails to to throw
+ # a ActiveRecord::ImmutableRelation error
+ def committers
+ object.commits.committers
+ end
end
end
diff --git a/app/views/notify/access_token_created_email.html.haml b/app/views/notify/access_token_created_email.html.haml
index 9eea8f44142..8216994f8fa 100644
--- a/app/views/notify/access_token_created_email.html.haml
+++ b/app/views/notify/access_token_created_email.html.haml
@@ -1,7 +1,7 @@
%p
= _('Hi %{username}!') % { username: sanitize_name(@user.name) }
%p
- = html_escape(_('A new personal access token, named %{token_name}, has been created.')) % { token_name: @token_name }
+ = html_escape(_('A new personal access token, named %{code_start}%{token_name}%{code_end}, has been created.')) % { code_start: '<code>'.html_safe, token_name: @token_name, code_end: '</code>'.html_safe }
%p
- pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url }
= html_escape(_('You can check it in your %{pat_link_start}personal access tokens%{pat_link_end} settings.')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe }
diff --git a/doc/topics/gitlab_flow.md b/doc/topics/gitlab_flow.md
index 5ae6d547639..d03caab19c4 100644
--- a/doc/topics/gitlab_flow.md
+++ b/doc/topics/gitlab_flow.md
@@ -4,9 +4,37 @@ group: Source Code
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
---
-# Introduction to GitLab Flow **(FREE)**
+# Introduction to Git workflows **(FREE)**
With Git, you can use a variety of branching strategies and workflows.
+Having a structured workflow for collaboration in complex projects is
+crucial for several reasons:
+
+- **Code organization**: Keep the codebase organized, prevent
+ overlapping work, and ensure focused efforts towards a common goal.
+
+- **Version control**: Allow simultaneous work on different features
+ without conflicts, maintaining code stability.
+
+- **Code quality**: A code review and approval process helps maintain high
+ code quality and adherence to coding standards.
+
+- **Traceability and accountability**: Enable tracking of changes and their authors,
+ simplifying issue identification and responsibility assignment.
+
+- **Easier onboarding**: Help new team members quickly grasp the
+ development process, and start contributing effectively.
+
+- **Time and resource management**: Enable better planning, resource
+ allocation, and meeting deadlines, ensuring an efficient development
+ process.
+
+- **CI/CD**: Incorporate automated testing and deployment
+ processes, streamlining the release cycle and delivering high-quality
+ software consistently.
+
+A structured workflow promotes organization, efficiency, and code
+quality, leading to a more successful and streamlined development process.
Because the default workflow is not specifically defined, many organizations
end up with workflows that are too complicated, not clearly defined, or
@@ -14,12 +42,51 @@ not integrated with their issue tracking systems.
Your organization can use GitLab with any workflow you choose.
-However, if you are looking for guidance on best practices, you can use
-the GitLab Flow. This workflow combines [feature-driven development](https://en.wikipedia.org/wiki/Feature-driven_development)
-and [feature branches](https://martinfowler.com/bliki/FeatureBranch.html) with issue tracking.
+## Workflow types
-While this workflow used at GitLab, you can choose whichever workflow
-suits your organization best.
+Here are some of the most common Git workflows.
+
+### Centralized workflow
+
+Best suited for small teams transitioning from a centralized version
+control system like SVN. All team members work on a single branch,
+usually `main`, and push their changes directly to the central
+repository.
+
+### Feature branch workflow
+
+Developers create separate branches for each feature or bugfix,
+keeping the 'main' branch stable. When a feature is complete, the
+developer submits a pull request or merge request to integrate the
+changes back into the `main` branch after a code review.
+
+### Forking workflow
+
+Commonly used in open-source projects, this workflow allows external
+contributors to work without direct access to the main repository.
+Developers create a fork (a personal copy) of the main repository,
+make changes in their fork, and then submit a pull request or merge
+request to have their changes integrated into the main repository.
+
+### Git flow workflow
+
+This workflow is best for projects with a structured release cycle.
+It introduces two long-lived branches: `main` for production-ready
+code and `develop` for integrating features. Additional branches like
+`feature`, `release`, and `hotfix` are used for specific purposes,
+ensuring a strict and organized development process.
+
+### GitLab/GitHub flow
+
+A simplified workflow primarily used for web development and
+continuous deployment. It combines aspects of the Feature branch
+workflow and the Git flow workflow. Developers create feature branches
+from `main`, and after the changes are complete, they are merged back
+into the `main` branch, which is then immediately deployed.
+
+Each of these Git workflows has its advantages and is suited to
+different project types and team structures. Below the most popular
+workflows are reviewed in more details.
## Git workflow
@@ -99,6 +166,16 @@ This flow is clean and straightforward, and many organizations have adopted it w
Atlassian recommends [a similar strategy](https://www.atlassian.com/blog/git/simple-git-workflow-is-simple), although they rebase feature branches.
Merging everything into the `main` branch and frequently deploying means you minimize the amount of unreleased code. This approach is in line with lean and continuous delivery best practices.
However, this flow still leaves a lot of questions unanswered regarding deployments, environments, releases, and integrations with issues.
+
+## Introduction to GitLab Flow **(FREE)**
+
+However, if you are looking for guidance on best practices, you can use
+the GitLab Flow. This workflow combines [feature-driven development](https://en.wikipedia.org/wiki/Feature-driven_development)
+and [feature branches](https://martinfowler.com/bliki/FeatureBranch.html) with issue tracking.
+
+While this workflow used at GitLab, you can choose whichever workflow
+suits your organization best.
+
With GitLab flow, we offer additional guidance for these questions.
## Production branch with GitLab flow
@@ -113,7 +190,8 @@ While this is possible in some cases, such as SaaS applications, there are some
In these cases, you can create a production branch that reflects the deployed code.
You can deploy a new version by merging `main` into the `production` branch.
-While not shown in the graph below, the work on the `main` branch works just like in GitHub flow, i.e. with feature-branches being merged into `main`.
+While not shown in the graph below, the work on the `main` branch works just like in GitHub flow:
+with feature branches being merged into `main`.
```mermaid
graph TD
@@ -255,7 +333,8 @@ In GitLab, each change to the codebase starts with an issue in the issue trackin
If there is no issue yet, create the issue if the change requires more than an hour's work.
In many organizations, raising an issue is part of the development process because they are used in sprint planning.
The issue title should describe the desired state of the system.
-For example, the issue title "As an administrator, I want to remove users without receiving an error" is better than "Administrators can't remove users."
+For example, the issue title `As an administrator, I want to remove users without receiving an error`
+is better than "Administrators can't remove users."
When you are ready to code, create a branch for the issue from the `main` branch.
This branch is the place for any work related to this change.
@@ -267,7 +346,7 @@ When you are done or want to discuss the code, open a merge request.
A merge request is an online place to discuss the change and review the code.
If you open the merge request but do not assign it to anyone, it is a [draft merge request](../user/project/merge_requests/drafts.md).
-These are used to discuss the proposed implementation but are not ready for inclusion in the `main` branch yet.
+Drafts are used to discuss the proposed implementation but are not ready for inclusion in the `main` branch yet.
Start the title of the merge request with `[Draft]`, `Draft:` or `(Draft)` to prevent it from being merged before it's ready.
When you think the code is ready, assign the merge request to a reviewer.
@@ -356,7 +435,11 @@ Sometimes you can reuse recorded resolutions (`rerere`), but merging is better,
Atlassian has [a more thorough explanation of the tradeoffs between merging and rebasing](https://www.atlassian.com/blog/git/git-team-workflows-merge-or-rebase) on their blog.
A good way to prevent creating many merge commits is to not frequently merge `main` into the feature branch.
-There are three reasons to merge in `main`: utilizing new code, resolving merge conflicts, and updating long-running branches.
+Three reasons to merge in `main`:
+
+1. Utilizing new code.
+1. Resolving merge conflicts.
+1. Updating long-running branches.
If you need to use some code that was introduced in `main` after you created the feature branch, you can often solve this by just cherry-picking a commit.
diff --git a/doc/user/clusters/agent/index.md b/doc/user/clusters/agent/index.md
index bd62c635be9..07149ccd8fc 100644
--- a/doc/user/clusters/agent/index.md
+++ b/doc/user/clusters/agent/index.md
@@ -57,9 +57,9 @@ This workflow has a weaker security model and is not recommended for production
GitLab supports the following Kubernetes versions. You can upgrade your
Kubernetes version to a supported version at any time:
+- 1.26 (support ends on March 22, 2024 or when 1.29 becomes supported)
- 1.25 (support ends on October 22, 2023 or when 1.28 becomes supported)
- 1.24 (support ends on July 22, 2023 or when 1.27 becomes supported)
-- 1.23 (support ends on February 22, 2023 or when 1.26 becomes supported)
GitLab aims to support a new minor Kubernetes version three months after its initial release. GitLab supports at least three production-ready Kubernetes minor
versions at any given time.
diff --git a/doc/user/clusters/agent/install/index.md b/doc/user/clusters/agent/install/index.md
index 1a86d1c9939..13837d480f3 100644
--- a/doc/user/clusters/agent/install/index.md
+++ b/doc/user/clusters/agent/install/index.md
@@ -138,6 +138,23 @@ By default, the Helm installation command generated by GitLab:
To see the full list of customizations available, see the Helm chart's [default values file](https://gitlab.com/gitlab-org/charts/gitlab-agent/-/blob/main/values.yaml).
+##### Use the agent when KAS is behind a self-signed certificate
+
+When [KAS]((../../../../administration/clusters/kas.md) is behind a self-signed certificate,
+you can set the value of `config.caCert` to the certificate. For example:
+
+```shell
+helm update --install gitlab-agent gitlab/gitlab-agent \
+ --set-file config.caCert=my-custom-ca.pem
+```
+
+In this example, `my-custom-ca.pem` is the path to a local file that contains
+the CA certificate used by KAS. The certificate is automatically stored in a
+config map and mounted in the `agentk` pod.
+
+If KAS is installed with the GitLab chart, and the chart is configured to provide
+an [auto-generated self-signed wildcard certificate](https://docs.gitlab.com/charts/installation/tls.html#option-4-use-auto-generated-self-signed-wildcard-certificate), you can extract the CA certificate from the `RELEASE-wildcard-tls-ca` secret.
+
##### Use the agent behind an HTTP proxy
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/351867) in GitLab 15.0, the GitLab agent Helm chart supports setting environment variables.
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 08a57b9f3b2..028c33b3c2f 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1758,6 +1758,9 @@ msgstr ""
msgid "A new personal access token has been created"
msgstr ""
+msgid "A new personal access token, named %{code_start}%{token_name}%{code_end}, has been created."
+msgstr ""
+
msgid "A new personal access token, named %{token_name}, has been created."
msgstr ""
diff --git a/package.json b/package.json
index 4d6fa5fe6bb..67109380676 100644
--- a/package.json
+++ b/package.json
@@ -170,6 +170,7 @@
"remark-parse": "^10.0.1",
"remark-rehype": "^10.1.0",
"scrollparent": "^2.0.1",
+ "semver": "^7.3.4",
"sentrybrowser5": "npm:@sentry/browser@5.30.0",
"sentrybrowser7": "npm:@sentry/browser@^7.21.1",
"sortablejs": "^1.10.2",
@@ -234,8 +235,8 @@
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-no-jquery": "2.7.0",
"eslint-plugin-no-unsanitized": "^4.0.2",
- "gettext-extractor": "^3.5.3",
- "gettext-extractor-vue": "^5.0.0",
+ "gettext-extractor": "^3.7.0",
+ "gettext-extractor-vue": "^5.1.0",
"glob": "^7.1.6",
"istanbul-lib-coverage": "^3.0.0",
"istanbul-lib-report": "^3.0.0",
diff --git a/scripts/frontend/extract_gettext_all.js b/scripts/frontend/extract_gettext_all.js
index 922aa85241f..0cd6ab99a3a 100644
--- a/scripts/frontend/extract_gettext_all.js
+++ b/scripts/frontend/extract_gettext_all.js
@@ -19,7 +19,7 @@ extractor.addMessageTransformFunction(ensureSingleLine);
const jsParser = extractor.createJsParser([
// Place all the possible expressions to extract here:
- JsExtractors.callExpression('__', {
+ JsExtractors.callExpression(['__', 's__'], {
arguments: {
text: 0,
},
@@ -30,15 +30,13 @@ const jsParser = extractor.createJsParser([
textPlural: 1,
},
}),
- JsExtractors.callExpression('s__', {
- arguments: {
- text: 0,
- },
- }),
]);
const vueParser = decorateJSParserWithVueSupport(jsParser, {
vue2TemplateCompiler,
+ // All of our expressions contain `__`.
+ // So we can safely ignore parsing files _not_ containing it.
+ guard: '__',
});
function printJson() {
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
index e47cf0156ad..356de5b11e9 100644
--- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
@@ -5,16 +5,13 @@ import { stringify } from 'yaml';
import JobAssistantDrawer from '~/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue';
import JobSetupItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue';
import ImageItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue';
-import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql';
+import getRunnerTags from '~/ci/pipeline_editor/graphql/queries/runner_tags.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
-import {
- mockAllRunnersQueryResponse,
- mockLintResponse,
- mockCiYml,
-} from 'jest/ci/pipeline_editor/mock_data';
+
import { mountExtended } from 'helpers/vue_test_utils_helper';
import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
+import { mockRunnersTagsQueryResponse, mockLintResponse, mockCiYml } from '../../mock_data';
Vue.use(VueApollo);
@@ -36,7 +33,7 @@ describe('Job assistant drawer', () => {
const createComponent = () => {
mockApollo = createMockApollo([
- [getAllRunners, jest.fn().mockResolvedValue(mockAllRunnersQueryResponse)],
+ [getRunnerTags, jest.fn().mockResolvedValue(mockRunnersTagsQueryResponse)],
]);
wrapper = mountExtended(JobAssistantDrawer, {
@@ -58,6 +55,15 @@ describe('Job assistant drawer', () => {
expect(findJobSetupItem().exists()).toBe(true);
});
+ it('job setup item should have tag options', () => {
+ expect(findJobSetupItem().props('tagOptions')).toEqual([
+ { id: 'tag1', name: 'tag1' },
+ { id: 'tag2', name: 'tag2' },
+ { id: 'tag3', name: 'tag3' },
+ { id: 'tag4', name: 'tag4' },
+ ]);
+ });
+
it('should contain image accordion', () => {
expect(findImageItem().exists()).toBe(true);
});
diff --git a/spec/frontend/ci/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js
index ecfc477184b..865dd34fbfe 100644
--- a/spec/frontend/ci/pipeline_editor/mock_data.js
+++ b/spec/frontend/ci/pipeline_editor/mock_data.js
@@ -583,86 +583,31 @@ export const mockCommitCreateResponse = {
},
};
-export const mockAllRunnersQueryResponse = {
+export const mockRunnersTagsQueryResponse = {
data: {
runners: {
nodes: [
{
id: 'gid://gitlab/Ci::Runner/1',
- description: 'test',
- runnerType: 'PROJECT_TYPE',
- shortSha: 'DdTYMQGS',
- version: '15.6.1',
- ipAddress: '127.0.0.1',
- active: true,
- locked: true,
- jobCount: 0,
- jobExecutionStatus: 'IDLE',
- tagList: ['tag1', 'tag2', 'tag3'],
- createdAt: '2022-11-29T09:37:43Z',
- contactedAt: null,
- status: 'NEVER_CONTACTED',
- userPermissions: {
- updateRunner: true,
- deleteRunner: true,
- __typename: 'RunnerPermissions',
- },
- groups: null,
- ownerProject: {
- id: 'gid://gitlab/Project/1',
- name: '123',
- nameWithNamespace: 'Administrator / 123',
- webUrl: 'http://127.0.0.1:3000/root/test',
- __typename: 'Project',
- },
+ tagList: ['tag1', 'tag2'],
__typename: 'CiRunner',
- upgradeStatus: 'NOT_AVAILABLE',
- adminUrl: 'http://127.0.0.1:3000/admin/runners/1',
- editAdminUrl: 'http://127.0.0.1:3000/admin/runners/1/edit',
},
{
id: 'gid://gitlab/Ci::Runner/2',
- description: 'test',
- runnerType: 'PROJECT_TYPE',
- shortSha: 'DdTYMQGA',
- version: '15.6.1',
- ipAddress: '127.0.0.1',
- active: true,
- locked: true,
- jobCount: 0,
- jobExecutionStatus: 'IDLE',
- tagList: ['tag3', 'tag4'],
- createdAt: '2022-11-29T09:37:43Z',
- contactedAt: null,
- status: 'NEVER_CONTACTED',
- userPermissions: {
- updateRunner: true,
- deleteRunner: true,
- __typename: 'RunnerPermissions',
- },
- groups: null,
- ownerProject: {
- id: 'gid://gitlab/Project/1',
- name: '123',
- nameWithNamespace: 'Administrator / 123',
- webUrl: 'http://127.0.0.1:3000/root/test',
- __typename: 'Project',
- },
+ tagList: ['tag2', 'tag3'],
+ __typename: 'CiRunner',
+ },
+ {
+ id: 'gid://gitlab/Ci::Runner/3',
+ tagList: ['tag2', 'tag4'],
+ __typename: 'CiRunner',
+ },
+ {
+ id: 'gid://gitlab/Ci::Runner/4',
+ tagList: [],
__typename: 'CiRunner',
- upgradeStatus: 'NOT_AVAILABLE',
- adminUrl: 'http://127.0.0.1:3000/admin/runners/2',
- editAdminUrl: 'http://127.0.0.1:3000/admin/runners/2/edit',
},
],
- pageInfo: {
- hasNextPage: false,
- hasPreviousPage: false,
- startCursor:
- 'eyJjcmVhdGVkX2F0IjoiMjAyMi0xMS0yOSAwOTozNzo0My40OTEwNTEwMDAgKzAwMDAiLCJpZCI6IjIifQ',
- endCursor:
- 'eyJjcmVhdGVkX2F0IjoiMjAyMi0xMS0yOSAwOTozNzo0My40OTEwNTEwMDAgKzAwMDAiLCJpZCI6IjIifQ',
- __typename: 'PageInfo',
- },
__typename: 'CiRunnerConnection',
},
},
diff --git a/spec/frontend/clusters_list/components/agent_table_spec.js b/spec/frontend/clusters_list/components/agent_table_spec.js
index 9cbb83eedd2..5088069d015 100644
--- a/spec/frontend/clusters_list/components/agent_table_spec.js
+++ b/spec/frontend/clusters_list/components/agent_table_spec.js
@@ -13,9 +13,9 @@ const defaultConfigHelpUrl =
const provideData = {
gitlabVersion: '14.8',
- kasVersion: '14.8',
+ kasVersion: '14.8.0',
};
-const propsData = {
+const defaultProps = {
agents: clusterAgents,
};
@@ -26,9 +26,6 @@ const DeleteAgentButtonStub = stubComponent(DeleteAgentButton, {
const outdatedTitle = I18N_AGENT_TABLE.versionOutdatedTitle;
const mismatchTitle = I18N_AGENT_TABLE.versionMismatchTitle;
const mismatchOutdatedTitle = I18N_AGENT_TABLE.versionMismatchOutdatedTitle;
-const outdatedText = sprintf(I18N_AGENT_TABLE.versionOutdatedText, {
- version: provideData.kasVersion,
-});
const mismatchText = I18N_AGENT_TABLE.versionMismatchText;
describe('AgentTable', () => {
@@ -43,123 +40,134 @@ describe('AgentTable', () => {
wrapper.findAllByTestId('cluster-agent-configuration-link').at(at);
const findDeleteAgentButton = () => wrapper.findAllComponents(DeleteAgentButton);
- beforeEach(() => {
+ const createWrapper = ({ provide = provideData, propsData = defaultProps } = {}) => {
wrapper = mountExtended(AgentTable, {
propsData,
- provide: provideData,
+ provide,
stubs: {
DeleteAgentButton: DeleteAgentButtonStub,
},
});
- });
-
- afterEach(() => {
- if (wrapper) {
- wrapper.destroy();
- }
- });
+ };
describe('agent table', () => {
- it.each`
- agentName | link | lineNumber
- ${'agent-1'} | ${'/agent-1'} | ${0}
- ${'agent-2'} | ${'/agent-2'} | ${1}
- `('displays agent link for $agentName', ({ agentName, link, lineNumber }) => {
- expect(findAgentLink(lineNumber).text()).toBe(agentName);
- expect(findAgentLink(lineNumber).attributes('href')).toBe(link);
+ describe('default', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it.each`
+ agentName | link | lineNumber
+ ${'agent-1'} | ${'/agent-1'} | ${0}
+ ${'agent-2'} | ${'/agent-2'} | ${1}
+ `('displays agent link for $agentName', ({ agentName, link, lineNumber }) => {
+ expect(findAgentLink(lineNumber).text()).toBe(agentName);
+ expect(findAgentLink(lineNumber).attributes('href')).toBe(link);
+ });
+
+ it.each`
+ status | iconName | lineNumber
+ ${'Never connected'} | ${'status-neutral'} | ${0}
+ ${'Connected'} | ${'status-success'} | ${1}
+ ${'Not connected'} | ${'status-alert'} | ${2}
+ `(
+ 'displays agent connection status as "$status" at line $lineNumber',
+ ({ status, iconName, lineNumber }) => {
+ expect(findStatusText(lineNumber).text()).toBe(status);
+ expect(findStatusIcon(lineNumber).props('name')).toBe(iconName);
+ },
+ );
+
+ it.each`
+ lastContact | lineNumber
+ ${'Never'} | ${0}
+ ${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1}
+ ${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2}
+ `(
+ 'displays agent last contact time as "$lastContact" at line $lineNumber',
+ ({ lastContact, lineNumber }) => {
+ expect(findLastContactText(lineNumber).text()).toBe(lastContact);
+ },
+ );
+
+ it.each`
+ agentConfig | link | lineNumber
+ ${'.gitlab/agents/agent-1'} | ${'/agent/full/path'} | ${0}
+ ${'Default configuration'} | ${defaultConfigHelpUrl} | ${1}
+ `(
+ 'displays config file path as "$agentPath" at line $lineNumber',
+ ({ agentConfig, link, lineNumber }) => {
+ const findLink = findConfiguration(lineNumber).findComponent(GlLink);
+
+ expect(findLink.attributes('href')).toBe(link);
+ expect(findConfiguration(lineNumber).text()).toBe(agentConfig);
+ },
+ );
+
+ it('displays actions menu for each agent', () => {
+ expect(findDeleteAgentButton()).toHaveLength(clusterAgents.length);
+ });
});
- it.each`
- status | iconName | lineNumber
- ${'Never connected'} | ${'status-neutral'} | ${0}
- ${'Connected'} | ${'status-success'} | ${1}
- ${'Not connected'} | ${'status-alert'} | ${2}
+ describe.each`
+ agentMockIdx | agentVersion | kasVersion | versionMismatch | versionOutdated | title
+ ${0} | ${''} | ${'14.8.0'} | ${false} | ${false} | ${''}
+ ${1} | ${'14.8.0'} | ${'14.8.0'} | ${false} | ${false} | ${''}
+ ${2} | ${'14.6.0'} | ${'14.8.0'} | ${false} | ${true} | ${outdatedTitle}
+ ${3} | ${'14.7.0'} | ${'14.8.0'} | ${true} | ${false} | ${mismatchTitle}
+ ${4} | ${'14.3.0'} | ${'14.8.0'} | ${true} | ${true} | ${mismatchOutdatedTitle}
+ ${5} | ${'14.6.0'} | ${'14.8.0-rc1'} | ${false} | ${false} | ${''}
+ ${6} | ${'14.8.0'} | ${'15.0.0'} | ${false} | ${true} | ${outdatedTitle}
+ ${7} | ${'14.8.0'} | ${'15.0.0-rc1'} | ${false} | ${true} | ${outdatedTitle}
+ ${8} | ${'14.8.0'} | ${'14.8.10'} | ${false} | ${false} | ${''}
`(
- 'displays agent connection status as "$status" at line $lineNumber',
- ({ status, iconName, lineNumber }) => {
- expect(findStatusText(lineNumber).text()).toBe(status);
- expect(findStatusIcon(lineNumber).props('name')).toBe(iconName);
- },
- );
+ 'when agent version is "$agentVersion", KAS version is "$kasVersion" and version mismatch is "$versionMismatch"',
+ ({ agentMockIdx, agentVersion, kasVersion, versionMismatch, versionOutdated, title }) => {
+ const currentAgent = clusterAgents[agentMockIdx];
- it.each`
- lastContact | lineNumber
- ${'Never'} | ${0}
- ${timeagoMixin.methods.timeFormatted(connectedTimeNow)} | ${1}
- ${timeagoMixin.methods.timeFormatted(connectedTimeInactive)} | ${2}
- `(
- 'displays agent last contact time as "$lastContact" at line $lineNumber',
- ({ lastContact, lineNumber }) => {
- expect(findLastContactText(lineNumber).text()).toBe(lastContact);
- },
- );
+ const findIcon = () => findVersionText(0).findComponent(GlIcon);
+ const findPopover = () => wrapper.findByTestId(`popover-${currentAgent.name}`);
- describe.each`
- agent | version | podsNumber | versionMismatch | versionOutdated | title | texts | lineNumber
- ${'agent-1'} | ${''} | ${1} | ${false} | ${false} | ${''} | ${''} | ${0}
- ${'agent-2'} | ${'14.8'} | ${2} | ${false} | ${false} | ${''} | ${''} | ${1}
- ${'agent-3'} | ${'14.5'} | ${1} | ${false} | ${true} | ${outdatedTitle} | ${[outdatedText]} | ${2}
- ${'agent-4'} | ${'14.7'} | ${2} | ${true} | ${false} | ${mismatchTitle} | ${[mismatchText]} | ${3}
- ${'agent-5'} | ${'14.3'} | ${2} | ${true} | ${true} | ${mismatchOutdatedTitle} | ${[mismatchText, outdatedText]} | ${4}
- `(
- 'agent version column at line $lineNumber',
- ({
- agent,
- version,
- podsNumber,
- versionMismatch,
- versionOutdated,
- title,
- texts,
- lineNumber,
- }) => {
- const findIcon = () => findVersionText(lineNumber).findComponent(GlIcon);
- const findPopover = () => wrapper.findByTestId(`popover-${agent}`);
const versionWarning = versionMismatch || versionOutdated;
+ const outdatedText = sprintf(I18N_AGENT_TABLE.versionOutdatedText, {
+ version: kasVersion,
+ });
- it('shows the correct agent version', () => {
- expect(findVersionText(lineNumber).text()).toBe(version);
+ beforeEach(() => {
+ createWrapper({
+ provide: { gitlabVersion: '14.8', kasVersion },
+ propsData: { agents: [currentAgent] },
+ });
+ });
+
+ it('shows the correct agent version text', () => {
+ expect(findVersionText(0).text()).toBe(agentVersion);
});
if (versionWarning) {
- it(`shows a warning icon when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated} and the number of pods is ${podsNumber}`, () => {
+ it('shows a warning icon', () => {
expect(findIcon().props('name')).toBe('warning');
});
-
it(`renders correct title for the popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => {
expect(findPopover().props('title')).toBe(title);
});
-
- it(`renders correct text for the popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => {
- texts.forEach((text) => {
- expect(findPopover().text()).toContain(text);
+ if (versionMismatch) {
+ it(`renders correct text for the popover when agent versions mismatch is ${versionMismatch}`, () => {
+ expect(findPopover().text()).toContain(mismatchText);
});
- });
+ }
+ if (versionOutdated) {
+ it(`renders correct text for the popover when agent versions outdated is ${versionOutdated}`, () => {
+ expect(findPopover().text()).toContain(outdatedText);
+ });
+ }
} else {
- it(`doesn't show a warning icon with a popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated} and the number of pods is ${podsNumber}`, () => {
+ it(`doesn't show a warning icon with a popover when agent versions mismatch is ${versionMismatch} and outdated is ${versionOutdated}`, () => {
expect(findIcon().exists()).toBe(false);
expect(findPopover().exists()).toBe(false);
});
}
},
);
-
- it.each`
- agentConfig | link | lineNumber
- ${'.gitlab/agents/agent-1'} | ${'/agent/full/path'} | ${0}
- ${'Default configuration'} | ${defaultConfigHelpUrl} | ${1}
- `(
- 'displays config file path as "$agentPath" at line $lineNumber',
- ({ agentConfig, link, lineNumber }) => {
- const findLink = findConfiguration(lineNumber).findComponent(GlLink);
-
- expect(findLink.attributes('href')).toBe(link);
- expect(findConfiguration(lineNumber).text()).toBe(agentConfig);
- },
- );
-
- it('displays actions menu for each agent', () => {
- expect(findDeleteAgentButton()).toHaveLength(5);
- });
});
});
diff --git a/spec/frontend/clusters_list/components/mock_data.js b/spec/frontend/clusters_list/components/mock_data.js
index 3d18b22d727..b5d25c780b6 100644
--- a/spec/frontend/clusters_list/components/mock_data.js
+++ b/spec/frontend/clusters_list/components/mock_data.js
@@ -37,10 +37,10 @@ export const clusterAgents = [
connections: {
nodes: [
{
- metadata: { version: 'v14.8' },
+ metadata: { version: 'v14.8.0' },
},
{
- metadata: { version: 'v14.8' },
+ metadata: { version: 'v14.8.0' },
},
],
},
@@ -61,7 +61,7 @@ export const clusterAgents = [
connections: {
nodes: [
{
- metadata: { version: 'v14.5' },
+ metadata: { version: 'v14.6.0' },
},
],
},
@@ -82,10 +82,10 @@ export const clusterAgents = [
connections: {
nodes: [
{
- metadata: { version: 'v14.7' },
+ metadata: { version: 'v14.7.0' },
},
{
- metadata: { version: 'v14.8' },
+ metadata: { version: 'v14.8.0' },
},
],
},
@@ -106,10 +106,94 @@ export const clusterAgents = [
connections: {
nodes: [
{
- metadata: { version: 'v14.5' },
+ metadata: { version: 'v14.5.0' },
},
{
- metadata: { version: 'v14.3' },
+ metadata: { version: 'v14.3.0' },
+ },
+ ],
+ },
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: connectedTimeInactive,
+ },
+ ],
+ },
+ },
+ {
+ name: 'agent-6',
+ id: 'agent-6-id',
+ webPath: '/agent-6',
+ status: 'inactive',
+ lastContact: connectedTimeInactive.getTime(),
+ connections: {
+ nodes: [
+ {
+ metadata: { version: 'v14.6.0' },
+ },
+ ],
+ },
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: connectedTimeInactive,
+ },
+ ],
+ },
+ },
+ {
+ name: 'agent-7',
+ id: 'agent-7-id',
+ webPath: '/agent-7',
+ status: 'inactive',
+ lastContact: connectedTimeInactive.getTime(),
+ connections: {
+ nodes: [
+ {
+ metadata: { version: 'v14.8.0' },
+ },
+ ],
+ },
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: connectedTimeInactive,
+ },
+ ],
+ },
+ },
+ {
+ name: 'agent-8',
+ id: 'agent-8-id',
+ webPath: '/agent-8',
+ status: 'inactive',
+ lastContact: connectedTimeInactive.getTime(),
+ connections: {
+ nodes: [
+ {
+ metadata: { version: 'v14.8.0' },
+ },
+ ],
+ },
+ tokens: {
+ nodes: [
+ {
+ lastUsedAt: connectedTimeInactive,
+ },
+ ],
+ },
+ },
+ {
+ name: 'agent-9',
+ id: 'agent-9-id',
+ webPath: '/agent-9',
+ status: 'inactive',
+ lastContact: connectedTimeInactive.getTime(),
+ connections: {
+ nodes: [
+ {
+ metadata: { version: 'v14.8.0' },
},
],
},
diff --git a/spec/frontend/notebook/cells/output/dataframe_spec.js b/spec/frontend/notebook/cells/output/dataframe_spec.js
new file mode 100644
index 00000000000..abf6631353c
--- /dev/null
+++ b/spec/frontend/notebook/cells/output/dataframe_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import DataframeOutput from '~/notebook/cells/output/dataframe.vue';
+import JSONTable from '~/behaviors/components/json_table.vue';
+import { outputWithDataframe } from '../../mock_data';
+
+describe('~/notebook/cells/output/DataframeOutput', () => {
+ let wrapper;
+
+ function createComponent(rawCode) {
+ wrapper = shallowMount(DataframeOutput, {
+ propsData: {
+ rawCode,
+ count: 0,
+ index: 0,
+ },
+ });
+ }
+
+ const findTable = () => wrapper.findComponent(JSONTable);
+
+ describe('with valid dataframe', () => {
+ beforeEach(() => createComponent(outputWithDataframe.data['text/html'].join('')));
+
+ it('mounts the table', () => {
+ expect(findTable().exists()).toBe(true);
+ });
+
+ it('table caption is empty', () => {
+ expect(findTable().props().caption).toEqual('');
+ });
+
+ it('allows filtering', () => {
+ expect(findTable().props().hasFilter).toBe(true);
+ });
+
+ it('sets the correct fields', () => {
+ expect(findTable().props().fields).toEqual([
+ { key: 'index', label: '', sortable: true },
+ { key: 'column_1', label: 'column_1', sortable: true },
+ { key: 'column_2', label: 'column_2', sortable: true },
+ ]);
+ });
+
+ it('sets the correct items', () => {
+ expect(findTable().props().items).toEqual([
+ { index: 0, column_1: 'abc de f', column_2: 'a' },
+ { index: 1, column_1: 'True', column_2: '0.1' },
+ ]);
+ });
+ });
+
+ describe('invalid dataframe', () => {
+ it('still displays the table', () => {
+ createComponent('dataframe');
+
+ expect(findTable().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/notebook/cells/output/dataframe_util_spec.js b/spec/frontend/notebook/cells/output/dataframe_util_spec.js
new file mode 100644
index 00000000000..ddc1b3cfe26
--- /dev/null
+++ b/spec/frontend/notebook/cells/output/dataframe_util_spec.js
@@ -0,0 +1,113 @@
+import { isDataframe, convertHtmlTableToJson } from '~/notebook/cells/output/dataframe_util';
+import { outputWithDataframeContent } from '../../mock_data';
+import sanitizeTests from './html_sanitize_fixtures';
+
+describe('notebook/cells/output/dataframe_utils', () => {
+ describe('isDataframe', () => {
+ describe('when output data has no text/html', () => {
+ it('is is not a dataframe', () => {
+ const input = { data: { 'image/png': ['blah'] } };
+
+ expect(isDataframe(input)).toBe(false);
+ });
+ });
+
+ describe('when output data has no text/html, but no mention of dataframe', () => {
+ it('is is not a dataframe', () => {
+ const input = { data: { 'text/html': ['blah'] } };
+
+ expect(isDataframe(input)).toBe(false);
+ });
+ });
+
+ describe('when output data has text/html, but no mention of dataframe in the first 20 lines', () => {
+ it('is is not a dataframe', () => {
+ const input = { data: { 'text/html': [...new Array(20).fill('a'), 'dataframe'] } };
+
+ expect(isDataframe(input)).toBe(false);
+ });
+ });
+
+ describe('when output data has text/html, and includes "dataframe" within the first 20 lines', () => {
+ it('is is not a dataframe', () => {
+ const input = { data: { 'text/html': ['dataframe'] } };
+
+ expect(isDataframe(input)).toBe(true);
+ });
+ });
+ });
+
+ describe('convertHtmlTableToJson', () => {
+ it('converts table correctly', () => {
+ const input = outputWithDataframeContent;
+
+ const output = {
+ fields: [
+ { key: 'index', label: '', sortable: true },
+ { key: 'column_1', label: 'column_1', sortable: true },
+ { key: 'column_2', label: 'column_2', sortable: true },
+ ],
+ items: [
+ { index: 0, column_1: 'abc de f', column_2: 'a' },
+ { index: 1, column_1: 'True', column_2: '0.1' },
+ ],
+ };
+
+ expect(convertHtmlTableToJson(input)).toEqual(output);
+ });
+
+ describe('sanitizes input before parsing table', () => {
+ it('sanitizes input html', () => {
+ const parser = new DOMParser();
+ const spy = jest.spyOn(parser, 'parseFromString');
+ const input = 'hello<style>p {width:50%;}</style><script>alert(1)</script>';
+
+ convertHtmlTableToJson(input, parser);
+
+ expect(spy).toHaveBeenCalledWith('hello', 'text/html');
+ });
+ });
+
+ describe('does not include harmful html', () => {
+ const makeDataframeWithHtml = (html) => {
+ return [
+ '<table border="1" class="dataframe">\n',
+ ' <thead>\n',
+ ' <tr style="text-align: right;">\n',
+ ' <th></th>\n',
+ ' <th>column_1</th>\n',
+ ' </tr>\n',
+ ' </thead>\n',
+ ' <tbody>\n',
+ ' <tr>\n',
+ ' <th>0</th>\n',
+ ` <td>${html}</td>\n`,
+ ' </tr>\n',
+ ' </tbody>\n',
+ '</table>\n',
+ '</div>',
+ ];
+ };
+
+ it.each([
+ ['table', 0],
+ ['style', 1],
+ ['iframe', 2],
+ ['svg', 3],
+ ])('sanitizes output for: %p', (tag, index) => {
+ const inputHtml = makeDataframeWithHtml(sanitizeTests[index][1].input);
+ const convertedHtml = convertHtmlTableToJson(inputHtml).items[0].column_1;
+
+ expect(convertedHtml).not.toContain(tag);
+ });
+ });
+
+ describe('when dataframe is invalid', () => {
+ it('returns empty', () => {
+ const input = [' dataframe', ' blah'];
+
+ expect(convertHtmlTableToJson(input)).toEqual({ fields: [], items: [] });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js
index 1241c133b89..efbdfca8d8c 100644
--- a/spec/frontend/notebook/cells/output/index_spec.js
+++ b/spec/frontend/notebook/cells/output/index_spec.js
@@ -2,7 +2,13 @@ import { mount } from '@vue/test-utils';
import json from 'test_fixtures/blob/notebook/basic.json';
import Output from '~/notebook/cells/output/index.vue';
import MarkdownOutput from '~/notebook/cells/output/markdown.vue';
-import { relativeRawPath, markdownCellContent } from '../../mock_data';
+import DataframeOutput from '~/notebook/cells/output/dataframe.vue';
+import {
+ relativeRawPath,
+ markdownCellContent,
+ outputWithDataframe,
+ outputWithDataframeContent,
+} from '../../mock_data';
describe('Output component', () => {
let wrapper;
@@ -105,6 +111,16 @@ describe('Output component', () => {
});
});
+ describe('Dataframe output', () => {
+ it('renders DataframeOutput component', () => {
+ createComponent(outputWithDataframe);
+
+ expect(wrapper.findComponent(DataframeOutput).props('rawCode')).toBe(
+ outputWithDataframeContent.join(''),
+ );
+ });
+ });
+
describe('default to plain text', () => {
beforeEach(() => {
const unknownType = json.cells[6];
diff --git a/spec/frontend/notebook/mock_data.js b/spec/frontend/notebook/mock_data.js
index 5c47cb5aa9b..15db2931b3c 100644
--- a/spec/frontend/notebook/mock_data.js
+++ b/spec/frontend/notebook/mock_data.js
@@ -6,3 +6,47 @@ export const errorOutputContent = [
'\u001b[0;32m/var/folders/cq/l637k4x13gx6y9p_gfs4c_gc0000gn/T/ipykernel_79203/294318627.py\u001b[0m in \u001b[0;36m<module>\u001b[0;34m\u001b[0m\n\u001b[0;32m----> 1\u001b[0;31m \u001b[0mTo\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m',
"\u001b[0;31mNameError\u001b[0m: name 'To' is not defined",
];
+export const outputWithDataframeContent = [
+ '<div>\n',
+ '<style scoped>\n',
+ ' .dataframe tbody tr th:only-of-type {\n',
+ ' vertical-align: middle;\n',
+ ' }\n',
+ '\n',
+ ' .dataframe tbody tr th {\n',
+ ' vertical-align: top;\n',
+ ' }\n',
+ '\n',
+ ' .dataframe thead th {\n',
+ ' text-align: right;\n',
+ ' }\n',
+ '</style>\n',
+ '<table border="1" class="dataframe">\n',
+ ' <thead>\n',
+ ' <tr style="text-align: right;">\n',
+ ' <th></th>\n',
+ ' <th>column_1</th>\n',
+ ' <th>column_2</th>\n',
+ ' </tr>\n',
+ ' </thead>\n',
+ ' <tbody>\n',
+ ' <tr>\n',
+ ' <th>0</th>\n',
+ ' <td>abc de f</td>\n',
+ ' <td>a</td>\n',
+ ' </tr>\n',
+ ' <tr>\n',
+ ' <th>1</th>\n',
+ ' <td>True</td>\n',
+ ' <td>0.1</td>\n',
+ ' </tr>\n',
+ ' </tbody>\n',
+ '</table>\n',
+ '</div>',
+];
+
+export const outputWithDataframe = {
+ data: {
+ 'text/html': outputWithDataframeContent,
+ },
+};
diff --git a/yarn.lock b/yarn.lock
index 14bb1b618ea..21b03e77d0a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6293,17 +6293,17 @@ get-value@^2.0.3, get-value@^2.0.6:
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
-gettext-extractor-vue@^5.0.0:
- version "5.0.0"
- resolved "https://registry.yarnpkg.com/gettext-extractor-vue/-/gettext-extractor-vue-5.0.0.tgz#dc463868d49e14097c4545c8ed4851d8d3edd6dd"
- integrity sha512-OSuEJlOexxkjYQL2SGf385oWIiy4weWMfUp/ZlOWzMSz0a+HB/Hlv0S4KFTz4A4GuOp3gu15qwXl61GQJ/u+1w==
+gettext-extractor-vue@^5.1.0:
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/gettext-extractor-vue/-/gettext-extractor-vue-5.1.0.tgz#faabdc2398751d9ac05bbcaa2e60d679b1634af5"
+ integrity sha512-LFzDyTJGsZO7KPEO7cZKVEMwpLiuC7AEbcZLZt/PEeujE+kRXEFGOQWWSumi1oOtTD2+hdpC6wQG2G8EQybqMg==
dependencies:
glob "^7.1.6"
-gettext-extractor@^3.5.3:
- version "3.5.3"
- resolved "https://registry.yarnpkg.com/gettext-extractor/-/gettext-extractor-3.5.3.tgz#6ed46931c154a7485a80fa8b91b835ff7b8d0411"
- integrity sha512-9EgJ+hmbtAbATdMIvCj4WnrkeDWH6fv1z+IJJ1XCxdcUMGx6JQdVVFTdzJkSyIHh4td53ngoB5EQbavbKJU9Og==
+gettext-extractor@^3.7.0:
+ version "3.7.0"
+ resolved "https://registry.yarnpkg.com/gettext-extractor/-/gettext-extractor-3.7.0.tgz#6dd1742dfd9cfebf5fb6dd02eea65c75cc28df09"
+ integrity sha512-rLXrJIp2XgXzfIVNaGKj9KnJeI/Gn6FfbPurBXZtkRKwd60JCyaa3l/HlMW6SW/lcO9qfRvqD6C1A5C9yoLhUQ==
dependencies:
"@types/glob" "5 - 7"
"@types/parse5" "^5"