@gitlab/eslint-plugin 21.4.0 → 21.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @gitlab/eslint-plugin
2
2
 
3
+ ## 21.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8fa643e: Add `vue-no-popover-target-dollar-el` rule to flag and autofix unnecessary `.$el` in `:target` callbacks on `<gl-popover>` and `<gl-tooltip>`.
8
+
9
+ ## 21.4.1
10
+
11
+ ### Patch Changes
12
+
13
+ - cd391b5: No changes.
14
+
3
15
  ## 21.4.0
4
16
 
5
17
  ### Minor Changes
@@ -0,0 +1,69 @@
1
+ # @gitlab/vue-no-popover-target-dollar-el
2
+
3
+ Disallows function-form `:target` bindings on `<gl-popover>` and `<gl-tooltip>` where
4
+ the callback body ends in `.$el`.
5
+
6
+ `GlPopover` and `GlTooltip` accept a `:target` prop that is either a reference or a
7
+ zero-argument function returning one. The reference can be a plain DOM element
8
+ [or a Vue component instance](https://gitlab.com/gitlab-org/gitlab-services/design.gitlab.com/-/merge_requests/6006).
9
+ The components use `.$el` automatically when it exists, so accessing it
10
+ manually is unnecessary. This helps to avoid reference errors during teardown,
11
+ or other cases where the ref does not exist at the time the `target` function
12
+ is called.
13
+
14
+ ## Rule Details
15
+
16
+ ### Examples of **incorrect** code for this rule
17
+
18
+ ```html
19
+ <!-- Arrow function returning .$el -->
20
+ <gl-popover :target="() => $refs.myButton.$el" />
21
+
22
+ <!-- Arrow function with explicit this -->
23
+ <gl-tooltip :target="() => this.$refs.myButton.$el" />
24
+
25
+ <!-- Function expression -->
26
+ <gl-popover :target="function() { return $refs.myButton.$el; }" />
27
+
28
+ <!-- PascalCase component names -->
29
+ <GlPopover :target="() => $refs.myButton.$el" />
30
+ <GlTooltip :target="() => $refs.myButton.$el" />
31
+
32
+ <!-- v-bind: long-form syntax -->
33
+ <gl-popover v-bind:target="() => $refs.myButton.$el" />
34
+
35
+ <!-- Detection is not limited to `$refs`: any callback returning a
36
+ component instance's `.$el` is flagged -->
37
+ <gl-popover :target="() => this.myButton.$el" />
38
+ ```
39
+
40
+ ### Examples of **correct** code for this rule
41
+
42
+ ```html
43
+ <!-- Plain ref (no function) — always fine -->
44
+ <gl-popover :target="$refs.myButton" />
45
+
46
+ <!-- Arrow function without .$el — already correct -->
47
+ <gl-popover :target="() => $refs.myButton" />
48
+
49
+ <!-- DOM query — no .$el needed -->
50
+ <gl-popover :target="() => document.querySelector('#my-button')" />
51
+
52
+ <!-- .:target on an unrelated component — not flagged -->
53
+ <my-component :target="() => $refs.foo.$el" />
54
+ ```
55
+
56
+ ## Options
57
+
58
+ Nothing
59
+
60
+ ## Related rules
61
+
62
+ - [`vue-no-data-toggle`](./vue-no-data-toggle.md)
63
+
64
+ ## When Not To Use It
65
+
66
+ If your project is still using a version of `@gitlab/ui` that has **not** yet received the
67
+ companion fix (design.gitlab.com!6006), enabling this rule as `error` may cause runtime
68
+ issues. In that case, land the rule as `warn` or keep it off until the updated `@gitlab/ui`
69
+ version is deployed.
package/docs/rules.md CHANGED
@@ -16,6 +16,7 @@ Available rules:
16
16
  - [vue-no-data-toggle](./rules/vue-no-data-toggle.md): Restrict the use of `data-toggle` bootstrap behaviors within Vue templates
17
17
  - [vue-no-hardcoded-urls](./rules/vue-no-hardcoded-urls.md): Disallow hardcoded URLs in Vue templates
18
18
  - [vue-no-new-non-primitive-in-template](./rules/vue-no-new-non-primitive-in-template.md): Prevents non-primitive values from being declared in templates
19
+ - [vue-no-popover-target-dollar-el](./rules/vue-no-popover-target-dollar-el.md): Disallow function-form `:target` bindings ending in `.$el` on `<gl-popover>` and `<gl-tooltip>`
19
20
  - [vue-no-undef-apollo-properties](./rules/vue-no-undef-apollo-properties.md): Require Apollo query properties to be initialized in component data.
20
21
  - [vue-prefer-dollar-scopedslots](./rules/vue-prefer-dollar-scopedslots.md): Prefer $scopedSlots over $slots for Vue 2.x.
21
22
  - [vue-require-i18n-attribute-strings](./rules/vue-require-i18n-attribute-strings.md): Detect non externalized strings in vue `<template>` attributes
@@ -45,6 +45,7 @@ module.exports = {
45
45
  },
46
46
  ],
47
47
  '@gitlab/vue-no-data-toggle': 'error',
48
+ '@gitlab/vue-no-popover-target-dollar-el': 'warn',
48
49
  '@gitlab/vue-prefer-dollar-scopedslots': 'error',
49
50
  '@gitlab/vue-slot-name-casing': 'error',
50
51
  '@gitlab/no-runtime-template-compiler': 'error',
package/lib/index.js CHANGED
@@ -15,6 +15,7 @@ module.exports = {
15
15
  'vue-no-data-toggle': require('./rules/vue-no-data-toggle.js'),
16
16
  'vue-no-hardcoded-urls': require('./rules/vue-no-hardcoded-urls.js'),
17
17
  'vue-no-new-non-primitive-in-template': require('./rules/vue-no-new-non-primitive-in-template.js'),
18
+ 'vue-no-popover-target-dollar-el': require('./rules/vue-no-popover-target-dollar-el.js'),
18
19
  'vue-no-undef-apollo-properties': require('./rules/vue-no-undef-apollo-properties.js'),
19
20
  'vue-prefer-dollar-scopedslots': require('./rules/vue-prefer-dollar-scopedslots.js'),
20
21
  'vue-require-i18n-attribute-strings': require('./rules/vue-require-i18n-attribute-strings.js'),
@@ -0,0 +1,156 @@
1
+ // ------------------------------------------------------------------------------
2
+ // Requirements
3
+ // ------------------------------------------------------------------------------
4
+
5
+ const { DOCS_BASE_URL } = require('../constants');
6
+ const { defineTemplateBodyVisitor } = require('../utils/index');
7
+
8
+ // ------------------------------------------------------------------------------
9
+ // Constants
10
+ // ------------------------------------------------------------------------------
11
+
12
+ /**
13
+ * Component names (both kebab-case and PascalCase) that use a `:target` prop
14
+ * and where passing a function returning `.$el` is unnecessary.
15
+ */
16
+ const TARGET_COMPONENT_NAMES = new Set(['gl-popover', 'gl-tooltip', 'GlPopover', 'GlTooltip']);
17
+
18
+ const MESSAGE_ID = 'noPopoverTargetDollarEl';
19
+
20
+ // ------------------------------------------------------------------------------
21
+ // Helpers
22
+ // ------------------------------------------------------------------------------
23
+
24
+ /**
25
+ * Returns true if the given VAttribute node is a `:target` or `v-bind:target`
26
+ * directive binding.
27
+ *
28
+ * @param {import('vue-eslint-parser').AST.VAttribute | import('vue-eslint-parser').AST.VDirective} node
29
+ * @returns {boolean}
30
+ */
31
+ function isTargetBinding(node) {
32
+ if (!node.directive) return false;
33
+ // Must be a v-bind directive
34
+ if (node.key.name.name !== 'bind') return false;
35
+ // Argument must be the identifier "target"
36
+ const arg = node.key.argument;
37
+ return arg != null && arg.type === 'VIdentifier' && arg.name === 'target';
38
+ }
39
+
40
+ /**
41
+ * Returns true if the given VElement node is a gl-popover, gl-tooltip,
42
+ * GlPopover, or GlTooltip component.
43
+ *
44
+ * @param {import('vue-eslint-parser').AST.VElement} element
45
+ * @returns {boolean}
46
+ */
47
+ function isTargetComponent(element) {
48
+ return TARGET_COMPONENT_NAMES.has(element.rawName);
49
+ }
50
+
51
+ /**
52
+ * Given an expression node (the value of the `:target` binding), returns the
53
+ * MemberExpression node for the trailing `.$el` access if one exists, or null.
54
+ *
55
+ * Handles:
56
+ * - `() => $refs.foo.$el`
57
+ * - `() => this.$refs.foo.$el`
58
+ * - `() => { return $refs.foo.$el; }`
59
+ * - `function() { return $refs.foo.$el; }`
60
+ * - `function() { doSomething(); return this.$refs.foo.$el; }`
61
+ *
62
+ * @param {import('eslint').Rule.Node} expr
63
+ * @returns {import('eslint').Rule.Node | null}
64
+ */
65
+ function getDollarElMemberExpression(expr) {
66
+ if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') {
67
+ return null;
68
+ }
69
+
70
+ let body = expr.body;
71
+
72
+ // This doesn't need to catch every case, just the most common ways of
73
+ // writing the target function, which is why this is simple/naive.
74
+ if (body.type === 'BlockStatement') {
75
+ const lastStatement = body.body.at(-1);
76
+ if (!lastStatement || lastStatement.type !== 'ReturnStatement') {
77
+ return null;
78
+ }
79
+ body = lastStatement.argument;
80
+ }
81
+
82
+ if (!body) return null;
83
+
84
+ // The body (or return value) must be a MemberExpression ending in `.$el`
85
+ if (
86
+ body.type === 'MemberExpression' &&
87
+ !body.computed &&
88
+ body.property.type === 'Identifier' &&
89
+ body.property.name === '$el'
90
+ ) {
91
+ return body;
92
+ }
93
+
94
+ return null;
95
+ }
96
+
97
+ // ------------------------------------------------------------------------------
98
+ // Rule Definition
99
+ // ------------------------------------------------------------------------------
100
+
101
+ module.exports = {
102
+ meta: {
103
+ type: 'suggestion',
104
+ docs: {
105
+ description:
106
+ 'Disallow function-form `:target` bindings ending in `.$el` on `<gl-popover>` and `<gl-tooltip>`',
107
+ category: undefined,
108
+ url: DOCS_BASE_URL + '/vue-no-popover-target-dollar-el.md',
109
+ },
110
+ fixable: 'code',
111
+ messages: {
112
+ [MESSAGE_ID]:
113
+ 'Return the component ref from the `:target` callback on {{component}} instead of its .$el.',
114
+ },
115
+ schema: [],
116
+ },
117
+
118
+ create(context) {
119
+ return defineTemplateBodyVisitor(context, {
120
+ VElement(element) {
121
+ // Must be a gl-popover or gl-tooltip component
122
+ if (!isTargetComponent(element)) return;
123
+
124
+ // Find the `:target` / `v-bind:target` binding among its attributes
125
+ const node = element.startTag.attributes.find(isTargetBinding);
126
+ if (!node) return;
127
+
128
+ // The binding value must be an expression container
129
+ const exprContainer = node.value;
130
+ if (!exprContainer || exprContainer.type !== 'VExpressionContainer') return;
131
+
132
+ const expr = exprContainer.expression;
133
+ if (!expr) return;
134
+
135
+ // Check whether the expression is a function/arrow whose body ends in `.$el`
136
+ const dollarElMember = getDollarElMemberExpression(expr);
137
+ if (!dollarElMember) return;
138
+
139
+ // `dollarElMember` is the full `$refs.foo.$el` MemberExpression.
140
+ // The object part (`$refs.foo`) is what we want to keep.
141
+ const objectNode = dollarElMember.object;
142
+
143
+ context.report({
144
+ node,
145
+ loc: node.loc,
146
+ messageId: MESSAGE_ID,
147
+ data: { component: element.rawName },
148
+ fix(fixer) {
149
+ // Remove the `.$el` suffix: from end of objectNode to end of dollarElMember
150
+ return fixer.removeRange([objectNode.range[1], dollarElMember.range[1]]);
151
+ },
152
+ });
153
+ },
154
+ });
155
+ },
156
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gitlab/eslint-plugin",
3
- "version": "21.4.0",
3
+ "version": "21.5.0",
4
4
  "description": "GitLab package for our custom eslint rules",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
@@ -35,7 +35,7 @@ else
35
35
  fi
36
36
 
37
37
  echo "Installing $CI_REPOSITORY_URL#$SHA"
38
- yarn add --dev "${CI_REPOSITORY_URL}#${SHA}"
38
+ yarn add --dev --ignore-workspace-root-check "${CI_REPOSITORY_URL}#${SHA}"
39
39
 
40
40
  echo "Deduplicate dependencies"
41
41
  # Yarn doesn't seem to be smart enough when using URL installs and might