Alerting: add a custom "no-invalid-properties" eslint plugin (#118208)

This commit is contained in:
Gilles De Mey
2026-02-16 16:25:57 +01:00
committed by GitHub
parent cd376cfc0e
commit ea7f1b5ec5
8 changed files with 230 additions and 2 deletions

View File

@@ -361,6 +361,16 @@ module.exports = [
'no-nested-ternary': 'error',
},
},
{
name: 'grafana/css-in-js-validation-unified',
plugins: {
'@grafana': grafanaPlugin,
},
files: ['public/app/features/alerting/unified/**/*.{ts,tsx}'],
rules: {
'@grafana/no-invalid-css-properties': 'error',
},
},
{
// Sections of codebase that have all translation markup issues fixed
name: 'grafana/i18n-overrides',

View File

@@ -210,6 +210,7 @@
"github-codeowners": "^0.2.1",
"glob": "11.1.0",
"html-loader": "5.1.0",
"html-tags": "^5.1.0",
"html-webpack-plugin": "5.6.3",
"http-server": "14.1.1",
"i18next-cli": "^1.24.22",
@@ -225,6 +226,7 @@
"jest-watch-typeahead": "^2.2.2",
"jimp": "^1.6.0",
"jsdom-testing-mocks": "^1.13.1",
"known-css-properties": "^0.37.0",
"lerna": "9.0.3",
"madge": "^8.0.0",
"mini-css-extract-plugin": "2.9.2",

View File

@@ -20,6 +20,58 @@ To improve the consistency across Grafana we encourage devs to use tokens instea
Instead of using `0` to remove a previously set border-radius, use `unset`.
### `no-invalid-css-properties`
Disallow invalid CSS property names in Emotion `css()` calls.
This rule catches typos and invalid CSS properties in Emotion's `css()` function calls, helping prevent bugs where styles are silently ignored by browsers. It uses the [`known-css-properties`](https://www.npmjs.com/package/known-css-properties) package (the same one used by Stylelint) to validate property names against all standard CSS properties.
The rule automatically converts camelCase property names to kebab-case for validation and allows:
- Valid CSS properties (e.g., `paddingLeft`, `backgroundColor`)
- CSS custom properties/variables (e.g., `--my-custom-property`)
- Nested selectors and pseudo-classes (e.g., `&:hover`, `& > div`)
- At-rules (e.g., `@media`, `@supports`)
- HTML tag selectors (e.g., `button`, `span`)
#### Examples
```tsx
// Bad ❌ - Typo in property name
const styles = css({
addingLeft: 10, // Should be "paddingLeft"
backgroudColor: 'red', // Should be "backgroundColor"
});
// Good ✅ - Valid CSS properties
const styles = css({
paddingLeft: 10,
backgroundColor: 'red',
});
// Good ✅ - CSS custom properties
const styles = css({
'--my-custom-property': '10px',
});
// Good ✅ - Nested selectors and pseudo-classes
const styles = css({
'&:hover': {
backgroundColor: 'blue',
},
'& > span': {
color: 'red',
},
});
// Good ✅ - Media queries
const styles = css({
'@media (max-width: 768px)': {
display: 'none',
},
});
```
### `no-unreduced-motion`
Avoid direct use of `animation*` or `transition*` properties.

View File

@@ -5,6 +5,7 @@ const themeTokenUsage = require('./rules/theme-token-usage.cjs');
const noRestrictedImgSrcs = require('./rules/no-restricted-img-srcs.cjs');
const consistentStoryTitles = require('./rules/consistent-story-titles.cjs');
const noPluginExternalImportPaths = require('./rules/no-plugin-external-import-paths.cjs');
const noInvalidCssProperties = require('./rules/no-invalid-css-properties.cjs');
module.exports = {
rules: {
@@ -15,5 +16,6 @@ module.exports = {
'no-restricted-img-srcs': noRestrictedImgSrcs,
'consistent-story-titles': consistentStoryTitles,
'no-plugin-external-import-paths': noPluginExternalImportPaths,
'no-invalid-css-properties': noInvalidCssProperties,
},
};

View File

@@ -0,0 +1,153 @@
const { ESLintUtils, AST_NODE_TYPES } = require('@typescript-eslint/utils');
const { all: allCssProperties } = require('known-css-properties');
const htmlTagsModule = require('html-tags');
const createRule = ESLintUtils.RuleCreator(
(name) => `https://github.com/grafana/grafana/blob/main/packages/grafana-eslint-rules/README.md#${name}`
);
// Get valid CSS properties from the known-css-properties package
// This package maintains an up-to-date list of all standard CSS properties
// It's the same package used by Stylelint for property validation
const VALID_CSS_PROPERTIES = new Set(allCssProperties);
// HTML tags used as nested selectors in CSS-in-JS
// Using the html-tags package (same as Stylelint uses)
const htmlTags = htmlTagsModule.default || htmlTagsModule;
const HTML_TAGS = new Set(htmlTags);
// Regex to match CSS selector characters (nested selectors, pseudo-classes, etc.)
const SELECTOR_PATTERN = /[&:[\]>+~@]/;
// Valid keyframe selectors
const KEYFRAME_SELECTORS = new Set(['from', 'to']);
function isValidProperty(propertyName) {
// Allow CSS custom properties (variables)
if (propertyName.startsWith('--')) {
return true;
}
// Allow nested selectors and at-rules (CSS-in-JS feature)
// Examples: '&:hover', '& > div', '[disabled]', '@media', etc.
if (SELECTOR_PATTERN.test(propertyName)) {
return true;
}
// Allow valid HTML tags used as nested selectors
if (HTML_TAGS.has(propertyName.toLowerCase())) {
return true;
}
// Allow keyframe selectors (from, to)
if (KEYFRAME_SELECTORS.has(propertyName)) {
return true;
}
// Check against valid CSS properties from known-css-properties
// Convert camelCase to kebab-case (also handles vendor prefixes)
const kebabCase = propertyName.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
return VALID_CSS_PROPERTIES.has(kebabCase);
}
function isInsideFunctionCall(node) {
// Check if this property is an argument to a function call (not a CSS property)
// by looking at the immediate context
let parent = node.parent;
// Walk up to find the ObjectExpression that contains this property
while (parent && parent.type !== AST_NODE_TYPES.ObjectExpression) {
parent = parent.parent;
}
if (!parent) {
return false;
}
// Check if the ObjectExpression is an argument to a CallExpression
const objectParent = parent.parent;
if (objectParent && objectParent.type === AST_NODE_TYPES.CallExpression && objectParent.arguments.includes(parent)) {
// Make sure it's not the css() call itself - that's the top level
if (objectParent.callee.type === AST_NODE_TYPES.Identifier && objectParent.callee.name === 'css') {
return false;
}
// This object is a function argument, not a CSS object
return true;
}
return false;
}
function isInsideKeyframes(node) {
// Check if this property is inside a @keyframes rule
let parent = node.parent;
while (parent) {
// If parent is a Property with a key that starts with '@keyframes'
if (parent.type === AST_NODE_TYPES.Property) {
const key = parent.key;
if (key.type === AST_NODE_TYPES.Literal && typeof key.value === 'string') {
if (key.value.startsWith('@keyframes')) {
return true;
}
}
if (key.type === AST_NODE_TYPES.Identifier && key.name.startsWith('@keyframes')) {
return true;
}
}
parent = parent.parent;
}
return false;
}
const invalidCssPropertiesRule = createRule({
create(context) {
return {
[`${AST_NODE_TYPES.CallExpression}[callee.name="css"] ${AST_NODE_TYPES.Property}`]: function (node) {
if (
node.type === AST_NODE_TYPES.Property &&
node.key.type === AST_NODE_TYPES.Identifier &&
!node.computed // Skip computed properties like [narrowScreenQuery]: {...}
) {
const propertyName = node.key.name;
// Skip properties that are inside function calls (not direct CSS properties)
if (isInsideFunctionCall(node)) {
return;
}
// Skip properties that are inside @keyframes rules
if (isInsideKeyframes(node)) {
return;
}
if (!isValidProperty(propertyName)) {
context.report({
node: node.key,
messageId: 'invalidProperty',
data: {
propertyName,
},
});
}
}
},
};
},
name: 'no-invalid-css-properties',
meta: {
type: 'problem',
docs: {
description: 'Disallow invalid CSS property names in Emotion css() calls',
},
messages: {
invalidProperty:
'Invalid CSS property "{{propertyName}}" in css() call. This property will be ignored by browsers.',
},
schema: [],
},
defaultOptions: [],
});
module.exports = invalidCssPropertiesRule;

View File

@@ -106,7 +106,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
backgroundColor: theme.colors.secondary.main,
}),
alertManagerName: css({
with: 'fit-content',
width: 'fit-content',
}),
secondAlertManagerLine: css({
height: '1px',

View File

@@ -121,7 +121,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
css({
padding: 5,
width: '100%',
addingLeft: depth ? `calc(${theme.spacing(depth)} + 5px)` : 5,
paddingLeft: depth ? `calc(${theme.spacing(depth)} + 5px)` : 5,
}),
groupItemWrapper: (width: number) =>
css({

View File

@@ -20841,6 +20841,7 @@ __metadata:
glob: "npm:11.1.0"
history: "npm:4.10.1"
html-loader: "npm:5.1.0"
html-tags: "npm:^5.1.0"
html-webpack-plugin: "npm:5.6.3"
http-server: "npm:14.1.1"
i18next: "npm:^25.0.0"
@@ -20869,6 +20870,7 @@ __metadata:
json-source-map: "npm:0.6.1"
jsurl: "npm:^0.1.5"
kbar: "npm:0.1.0-beta.48"
known-css-properties: "npm:^0.37.0"
lerna: "npm:9.0.3"
leven: "npm:^4.0.0"
lodash: "npm:^4.17.23"
@@ -21431,6 +21433,13 @@ __metadata:
languageName: node
linkType: hard
"html-tags@npm:^5.1.0":
version: 5.1.0
resolution: "html-tags@npm:5.1.0"
checksum: 10/73d0448496ad9ac4a905427717908a3e72b8676d514e077437aaa9310deb43fd9f3eda58ac6b01b651db7431f3ddcc914e1f8e24052375dfe11d3ddfe4a20de7
languageName: node
linkType: hard
"html-to-text@npm:9.0.5":
version: 9.0.5
resolution: "html-to-text@npm:9.0.5"