@gitlab/eslint-plugin
Version:
GitLab package for our custom eslint rules
157 lines (134 loc) • 5.21 kB
JavaScript
// ------------------------------------------------------------------------------
// Requirements
// ------------------------------------------------------------------------------
const { DOCS_BASE_URL } = require('../constants');
const { defineTemplateBodyVisitor } = require('../utils/index');
// ------------------------------------------------------------------------------
// Constants
// ------------------------------------------------------------------------------
/**
* Component names (both kebab-case and PascalCase) that use a `:target` prop
* and where passing a function returning `.$el` is unnecessary.
*/
const TARGET_COMPONENT_NAMES = new Set(['gl-popover', 'gl-tooltip', 'GlPopover', 'GlTooltip']);
const MESSAGE_ID = 'noPopoverTargetDollarEl';
// ------------------------------------------------------------------------------
// Helpers
// ------------------------------------------------------------------------------
/**
* Returns true if the given VAttribute node is a `:target` or `v-bind:target`
* directive binding.
*
* @param {import('vue-eslint-parser').AST.VAttribute | import('vue-eslint-parser').AST.VDirective} node
* @returns {boolean}
*/
function isTargetBinding(node) {
if (!node.directive) return false;
// Must be a v-bind directive
if (node.key.name.name !== 'bind') return false;
// Argument must be the identifier "target"
const arg = node.key.argument;
return arg != null && arg.type === 'VIdentifier' && arg.name === 'target';
}
/**
* Returns true if the given VElement node is a gl-popover, gl-tooltip,
* GlPopover, or GlTooltip component.
*
* @param {import('vue-eslint-parser').AST.VElement} element
* @returns {boolean}
*/
function isTargetComponent(element) {
return TARGET_COMPONENT_NAMES.has(element.rawName);
}
/**
* Given an expression node (the value of the `:target` binding), returns the
* MemberExpression node for the trailing `.$el` access if one exists, or null.
*
* Handles:
* - `() => $refs.foo.$el`
* - `() => this.$refs.foo.$el`
* - `() => { return $refs.foo.$el; }`
* - `function() { return $refs.foo.$el; }`
* - `function() { doSomething(); return this.$refs.foo.$el; }`
*
* @param {import('eslint').Rule.Node} expr
* @returns {import('eslint').Rule.Node | null}
*/
function getDollarElMemberExpression(expr) {
if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') {
return null;
}
let body = expr.body;
// This doesn't need to catch every case, just the most common ways of
// writing the target function, which is why this is simple/naive.
if (body.type === 'BlockStatement') {
const lastStatement = body.body.at(-1);
if (!lastStatement || lastStatement.type !== 'ReturnStatement') {
return null;
}
body = lastStatement.argument;
}
if (!body) return null;
// The body (or return value) must be a MemberExpression ending in `.$el`
if (
body.type === 'MemberExpression' &&
!body.computed &&
body.property.type === 'Identifier' &&
body.property.name === '$el'
) {
return body;
}
return null;
}
// ------------------------------------------------------------------------------
// Rule Definition
// ------------------------------------------------------------------------------
module.exports = {
meta: {
type: 'suggestion',
docs: {
description:
'Disallow function-form `:target` bindings ending in `.$el` on `<gl-popover>` and `<gl-tooltip>`',
category: undefined,
url: DOCS_BASE_URL + '/vue-no-popover-target-dollar-el.md',
},
fixable: 'code',
messages: {
[MESSAGE_ID]:
'Return the component ref from the `:target` callback on {{component}} instead of its .$el.',
},
schema: [],
},
create(context) {
return defineTemplateBodyVisitor(context, {
VElement(element) {
// Must be a gl-popover or gl-tooltip component
if (!isTargetComponent(element)) return;
// Find the `:target` / `v-bind:target` binding among its attributes
const node = element.startTag.attributes.find(isTargetBinding);
if (!node) return;
// The binding value must be an expression container
const exprContainer = node.value;
if (!exprContainer || exprContainer.type !== 'VExpressionContainer') return;
const expr = exprContainer.expression;
if (!expr) return;
// Check whether the expression is a function/arrow whose body ends in `.$el`
const dollarElMember = getDollarElMemberExpression(expr);
if (!dollarElMember) return;
// `dollarElMember` is the full `$refs.foo.$el` MemberExpression.
// The object part (`$refs.foo`) is what we want to keep.
const objectNode = dollarElMember.object;
context.report({
node,
loc: node.loc,
messageId: MESSAGE_ID,
data: { component: element.rawName },
fix(fixer) {
// Remove the `.$el` suffix: from end of objectNode to end of dollarElMember
return fixer.removeRange([objectNode.range[1], dollarElMember.range[1]]);
},
});
},
});
},
};