diff --git a/eslint.config.js b/eslint.config.js index e073f46c3d7..8168702a8ee 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', diff --git a/package.json b/package.json index 756c996742b..f4ed86268b4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/grafana-eslint-rules/README.md b/packages/grafana-eslint-rules/README.md index aa6d4e880ff..7c36931ca03 100644 --- a/packages/grafana-eslint-rules/README.md +++ b/packages/grafana-eslint-rules/README.md @@ -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. diff --git a/packages/grafana-eslint-rules/index.cjs b/packages/grafana-eslint-rules/index.cjs index 89a76ee60a1..0028e58894d 100644 --- a/packages/grafana-eslint-rules/index.cjs +++ b/packages/grafana-eslint-rules/index.cjs @@ -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, }, }; diff --git a/packages/grafana-eslint-rules/rules/no-invalid-css-properties.cjs b/packages/grafana-eslint-rules/rules/no-invalid-css-properties.cjs new file mode 100644 index 00000000000..41b627fc658 --- /dev/null +++ b/packages/grafana-eslint-rules/rules/no-invalid-css-properties.cjs @@ -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; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx index 88db4e44596..a03a92c2e47 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/AlertManagerRouting.tsx @@ -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', diff --git a/public/app/features/alerting/unified/triage/rows/GenericRow.tsx b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx index 1588520fb48..bb1a81f2698 100644 --- a/public/app/features/alerting/unified/triage/rows/GenericRow.tsx +++ b/public/app/features/alerting/unified/triage/rows/GenericRow.tsx @@ -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({ diff --git a/yarn.lock b/yarn.lock index df6f25da132..bcfe6566f51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"