UNPKG

@gitlab/eslint-plugin

Version:

GitLab package for our custom eslint rules

157 lines (134 loc) 5.21 kB
// ------------------------------------------------------------------------------ // 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]]); }, }); }, }); }, };