mirror of
https://github.com/grafana/grafana.git
synced 2026-03-13 15:29:48 +08:00
Alerting: add a custom "no-invalid-properties" eslint plugin (#118208)
This commit is contained in:
@@ -361,6 +361,16 @@ module.exports = [
|
|||||||
'no-nested-ternary': 'error',
|
'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
|
// Sections of codebase that have all translation markup issues fixed
|
||||||
name: 'grafana/i18n-overrides',
|
name: 'grafana/i18n-overrides',
|
||||||
|
|||||||
@@ -210,6 +210,7 @@
|
|||||||
"github-codeowners": "^0.2.1",
|
"github-codeowners": "^0.2.1",
|
||||||
"glob": "11.1.0",
|
"glob": "11.1.0",
|
||||||
"html-loader": "5.1.0",
|
"html-loader": "5.1.0",
|
||||||
|
"html-tags": "^5.1.0",
|
||||||
"html-webpack-plugin": "5.6.3",
|
"html-webpack-plugin": "5.6.3",
|
||||||
"http-server": "14.1.1",
|
"http-server": "14.1.1",
|
||||||
"i18next-cli": "^1.24.22",
|
"i18next-cli": "^1.24.22",
|
||||||
@@ -225,6 +226,7 @@
|
|||||||
"jest-watch-typeahead": "^2.2.2",
|
"jest-watch-typeahead": "^2.2.2",
|
||||||
"jimp": "^1.6.0",
|
"jimp": "^1.6.0",
|
||||||
"jsdom-testing-mocks": "^1.13.1",
|
"jsdom-testing-mocks": "^1.13.1",
|
||||||
|
"known-css-properties": "^0.37.0",
|
||||||
"lerna": "9.0.3",
|
"lerna": "9.0.3",
|
||||||
"madge": "^8.0.0",
|
"madge": "^8.0.0",
|
||||||
"mini-css-extract-plugin": "2.9.2",
|
"mini-css-extract-plugin": "2.9.2",
|
||||||
|
|||||||
@@ -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`.
|
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`
|
### `no-unreduced-motion`
|
||||||
|
|
||||||
Avoid direct use of `animation*` or `transition*` properties.
|
Avoid direct use of `animation*` or `transition*` properties.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const themeTokenUsage = require('./rules/theme-token-usage.cjs');
|
|||||||
const noRestrictedImgSrcs = require('./rules/no-restricted-img-srcs.cjs');
|
const noRestrictedImgSrcs = require('./rules/no-restricted-img-srcs.cjs');
|
||||||
const consistentStoryTitles = require('./rules/consistent-story-titles.cjs');
|
const consistentStoryTitles = require('./rules/consistent-story-titles.cjs');
|
||||||
const noPluginExternalImportPaths = require('./rules/no-plugin-external-import-paths.cjs');
|
const noPluginExternalImportPaths = require('./rules/no-plugin-external-import-paths.cjs');
|
||||||
|
const noInvalidCssProperties = require('./rules/no-invalid-css-properties.cjs');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
rules: {
|
rules: {
|
||||||
@@ -15,5 +16,6 @@ module.exports = {
|
|||||||
'no-restricted-img-srcs': noRestrictedImgSrcs,
|
'no-restricted-img-srcs': noRestrictedImgSrcs,
|
||||||
'consistent-story-titles': consistentStoryTitles,
|
'consistent-story-titles': consistentStoryTitles,
|
||||||
'no-plugin-external-import-paths': noPluginExternalImportPaths,
|
'no-plugin-external-import-paths': noPluginExternalImportPaths,
|
||||||
|
'no-invalid-css-properties': noInvalidCssProperties,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -106,7 +106,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
backgroundColor: theme.colors.secondary.main,
|
backgroundColor: theme.colors.secondary.main,
|
||||||
}),
|
}),
|
||||||
alertManagerName: css({
|
alertManagerName: css({
|
||||||
with: 'fit-content',
|
width: 'fit-content',
|
||||||
}),
|
}),
|
||||||
secondAlertManagerLine: css({
|
secondAlertManagerLine: css({
|
||||||
height: '1px',
|
height: '1px',
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
css({
|
css({
|
||||||
padding: 5,
|
padding: 5,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
addingLeft: depth ? `calc(${theme.spacing(depth)} + 5px)` : 5,
|
paddingLeft: depth ? `calc(${theme.spacing(depth)} + 5px)` : 5,
|
||||||
}),
|
}),
|
||||||
groupItemWrapper: (width: number) =>
|
groupItemWrapper: (width: number) =>
|
||||||
css({
|
css({
|
||||||
|
|||||||
@@ -20841,6 +20841,7 @@ __metadata:
|
|||||||
glob: "npm:11.1.0"
|
glob: "npm:11.1.0"
|
||||||
history: "npm:4.10.1"
|
history: "npm:4.10.1"
|
||||||
html-loader: "npm:5.1.0"
|
html-loader: "npm:5.1.0"
|
||||||
|
html-tags: "npm:^5.1.0"
|
||||||
html-webpack-plugin: "npm:5.6.3"
|
html-webpack-plugin: "npm:5.6.3"
|
||||||
http-server: "npm:14.1.1"
|
http-server: "npm:14.1.1"
|
||||||
i18next: "npm:^25.0.0"
|
i18next: "npm:^25.0.0"
|
||||||
@@ -20869,6 +20870,7 @@ __metadata:
|
|||||||
json-source-map: "npm:0.6.1"
|
json-source-map: "npm:0.6.1"
|
||||||
jsurl: "npm:^0.1.5"
|
jsurl: "npm:^0.1.5"
|
||||||
kbar: "npm:0.1.0-beta.48"
|
kbar: "npm:0.1.0-beta.48"
|
||||||
|
known-css-properties: "npm:^0.37.0"
|
||||||
lerna: "npm:9.0.3"
|
lerna: "npm:9.0.3"
|
||||||
leven: "npm:^4.0.0"
|
leven: "npm:^4.0.0"
|
||||||
lodash: "npm:^4.17.23"
|
lodash: "npm:^4.17.23"
|
||||||
@@ -21431,6 +21433,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"html-to-text@npm:9.0.5":
|
||||||
version: 9.0.5
|
version: 9.0.5
|
||||||
resolution: "html-to-text@npm:9.0.5"
|
resolution: "html-to-text@npm:9.0.5"
|
||||||
|
|||||||
Reference in New Issue
Block a user