This is an automated email from the ASF dual-hosted git repository.
maximebeauchemin pushed a commit to branch eslit-no-title-case
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/eslit-no-title-case by this
push:
new 16b4ec347d feat(i18n): Add ESLint rule to enforce sentence case in
translations
16b4ec347d is described below
commit 16b4ec347d8c98a80275f1cbbc0b8755f2fc9378
Author: Maxime Beauchemin <[email protected]>
AuthorDate: Thu Jul 31 13:36:41 2025 -0700
feat(i18n): Add ESLint rule to enforce sentence case in translations
This commit introduces a new ESLint rule 'i18n-strings/no-title-case' that
enforces
sentence case instead of title case in translation strings, aligning with
the recent
UI modernization effort to use sentence case throughout the application.
Key changes:
- Add 'no-title-case' rule to detect title case patterns in t() and tn()
functions
- Rule intelligently skips: single words, acronyms, placeholders, and
multi-sentence strings
- Enhanced error messages to show the actual violating string for easier
identification
- Enable the rule as an error in .eslintrc.js
- Add comprehensive test coverage for the new rule
Fix title case violations across the codebase:
- Convert "Yes"/"No" to "yes"/"no" in list filters
- Fix "Virtual"/"Physical" to lowercase in DatasetList
- Update various UI labels: "Create Chart", "Dataset Name", "Chart Source",
etc.
- Fix security page labels: "Row Level Security", "Filter Type", "Group Key"
- Update time-related labels: "Time Range", "Time Column", "Time Grain"
- Fix SqlLab keyboard shortcuts: "Previous Line" to "Previous line"
- Fix additional violations: "Untitled Dataset", "Include Template
Parameters",
"Affected Dashboards/Charts", "Delete Dataset?", "0 Selected", "List
Users",
"Open Datasource tab"
This change helps maintain consistency with the new sentence case standard
and
prevents future title case violations from being introduced.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <[email protected]>
---
superset-frontend/.eslintrc.js | 1 +
.../eslint-plugin-i18n-strings/index.js | 131 +++++++++++++++++-
.../no-title-case.test.js | 152 +++++++++++++++++++++
.../superset-ui-chart-controls/src/constants.ts | 8 +-
.../components/ExploreResultsButton/index.tsx | 2 +-
.../components/KeyboardShortcutButton/index.tsx | 2 +-
.../SqlLab/components/SaveDatasetModal/index.tsx | 8 +-
.../components/ExploreViewContainer/index.jsx | 4 +-
.../src/explore/components/SaveModal.tsx | 2 +-
superset-frontend/src/pages/ChartList/index.tsx | 4 +-
.../src/pages/DashboardList/index.tsx | 8 +-
superset-frontend/src/pages/DatasetList/index.tsx | 16 +--
.../src/pages/RowLevelSecurityList/index.tsx | 14 +-
superset-frontend/src/pages/UsersList/index.tsx | 4 +-
14 files changed, 319 insertions(+), 37 deletions(-)
diff --git a/superset-frontend/.eslintrc.js b/superset-frontend/.eslintrc.js
index bd87c8c6a4..acab08bd72 100644
--- a/superset-frontend/.eslintrc.js
+++ b/superset-frontend/.eslintrc.js
@@ -403,6 +403,7 @@ module.exports = {
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': ['error', true],
+ 'i18n-strings/no-title-case': 'error',
camelcase: [
'error',
{
diff --git a/superset-frontend/eslint-rules/eslint-plugin-i18n-strings/index.js
b/superset-frontend/eslint-rules/eslint-plugin-i18n-strings/index.js
index 9f3a54c42a..d59a2a44bf 100644
--- a/superset-frontend/eslint-rules/eslint-plugin-i18n-strings/index.js
+++ b/superset-frontend/eslint-rules/eslint-plugin-i18n-strings/index.js
@@ -41,7 +41,7 @@ module.exports = {
context.report({
node,
message:
- "Don't use variables in translation string templates.
Flask-babel is a static translation service, so it can’t handle strings that
include variables",
+ "Don't use variables in translation string templates.
Flask-babel is a static translation service, so it can't handle strings that
include variables",
});
}
}
@@ -52,5 +52,134 @@ module.exports = {
};
},
},
+ 'no-title-case': {
+ create(context) {
+ function checkTitleCase(str) {
+ // Skip strings with placeholders like %s, %d, %(name)s, etc.
+ if (/%[sdf]|%\([^)]+\)[sdf]/.test(str)) {
+ return false;
+ }
+
+ // Skip strings that are all uppercase (likely acronyms)
+ if (str === str.toUpperCase()) {
+ return false;
+ }
+
+ // Skip strings with periods (likely multiple sentences)
+ if (str.includes('.')) {
+ return false;
+ }
+
+ // Skip single words
+ const words = str.trim().split(/\s+/);
+ if (words.length <= 1) {
+ return false;
+ }
+
+ // Whitelist of words that are commonly capitalized in product names
+ // but should not trigger title case warnings
+ const productWords = [
+ 'Lab',
+ 'Server',
+ 'Studio',
+ 'Pro',
+ 'Plus',
+ 'Max',
+ 'Mini',
+ ];
+
+ // Common prepositions and articles that should be lowercase (unless
at start)
+ const lowercaseWords = [
+ 'a',
+ 'an',
+ 'the',
+ 'and',
+ 'or',
+ 'but',
+ 'for',
+ 'with',
+ 'to',
+ 'from',
+ 'in',
+ 'on',
+ 'at',
+ 'by',
+ 'of',
+ ];
+
+ // Check if the string uses title case (multiple words with first
letter capitalized)
+ const hasTitleCase = words.some((word, index) => {
+ // Skip first word
+ if (index === 0) {
+ return false;
+ }
+
+ // Skip acronyms (all uppercase)
+ if (word === word.toUpperCase()) {
+ return false;
+ }
+
+ // Skip whitelisted product words when preceded by an uppercase
word
+ if (
+ productWords.includes(word) &&
+ index > 0 &&
+ words[index - 1] === words[index - 1].toUpperCase()
+ ) {
+ return false;
+ }
+
+ // Check if it's a lowercase word that's incorrectly capitalized
+ if (
+ lowercaseWords.includes(word.toLowerCase()) &&
+ /^[A-Z]/.test(word)
+ ) {
+ return true;
+ }
+
+ // For other words, check if they start with capital letter
+ return (
+ word.length > 1 &&
+ /^[A-Z]/.test(word) &&
+ !productWords.includes(word)
+ );
+ });
+
+ return hasTitleCase;
+ }
+
+ function handler(node) {
+ if (node.arguments.length) {
+ const firstArg = node.arguments[0];
+ let stringValue = null;
+
+ // Extract string value based on node type
+ if (
+ firstArg.type === 'Literal' &&
+ typeof firstArg.value === 'string'
+ ) {
+ stringValue = firstArg.value;
+ } else if (
+ firstArg.type === 'TemplateLiteral' &&
+ firstArg.quasis.length === 1
+ ) {
+ // Handle template literals without expressions
+ stringValue = firstArg.quasis[0].value.raw;
+ }
+
+ if (stringValue && checkTitleCase(stringValue)) {
+ context.report({
+ node: firstArg,
+ message: `Avoid title case in i18n strings: "${stringValue}".
Use sentence case instead.`,
+ });
+ }
+ }
+ }
+
+ return {
+ "CallExpression[callee.name='t']": handler,
+ "CallExpression[callee.name='tn']": handler,
+ };
+ },
+ },
},
};
diff --git
a/superset-frontend/eslint-rules/eslint-plugin-i18n-strings/no-title-case.test.js
b/superset-frontend/eslint-rules/eslint-plugin-i18n-strings/no-title-case.test.js
new file mode 100644
index 0000000000..3e2ba0d150
--- /dev/null
+++
b/superset-frontend/eslint-rules/eslint-plugin-i18n-strings/no-title-case.test.js
@@ -0,0 +1,152 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+const { RuleTester } = require('eslint');
+const plugin = require('./index');
+
+const ruleTester = new RuleTester({
+ parserOptions: {
+ ecmaVersion: 6,
+ },
+});
+
+const rule = plugin.rules['no-title-case'];
+
+ruleTester.run('no-title-case', rule, {
+ valid: [
+ // Sentence case (correct)
+ {
+ code: "t('Add a divider')",
+ },
+ {
+ code: "t('Create new dashboard')",
+ },
+ {
+ code: "t('Save and continue')",
+ },
+ // Single words
+ {
+ code: "t('Save')",
+ },
+ {
+ code: "t('Delete')",
+ },
+ // All uppercase (acronyms)
+ {
+ code: "t('SQL')",
+ },
+ {
+ code: "t('API KEY')",
+ },
+ // With placeholders
+ {
+ code: "t('Deleted: %s', name)",
+ },
+ {
+ code: "t('User %(username)s added', { username })",
+ },
+ // Template literals without expressions
+ {
+ code: 't(`Add a new filter`)',
+ },
+ // Mixed case but not title case
+ {
+ code: "t('Use SQL Lab')",
+ },
+ // tn function
+ {
+ code: "tn('Add a filter', 'Add filters', count)",
+ },
+ // Multiple sentences with period
+ {
+ code: "t('Welcome Back. Please Login.')",
+ },
+ {
+ code: "t('Save Changes. This Will Update All Records.')",
+ },
+ ],
+ invalid: [
+ // Title case (incorrect)
+ {
+ code: "t('Add Divider')",
+ errors: [
+ {
+ message:
+ 'Avoid title case in i18n strings: "Add Divider". Use sentence
case instead.',
+ },
+ ],
+ },
+ {
+ code: "t('Create New Dashboard')",
+ errors: [
+ {
+ message:
+ 'Avoid title case in i18n strings: "Create New Dashboard". Use
sentence case instead.',
+ },
+ ],
+ },
+ {
+ code: "t('Save And Continue')",
+ errors: [
+ {
+ message:
+ 'Avoid title case in i18n strings: "Save And Continue". Use
sentence case instead.',
+ },
+ ],
+ },
+ {
+ code: "t('Add Filter')",
+ errors: [
+ {
+ message:
+ 'Avoid title case in i18n strings: "Add Filter". Use sentence case
instead.',
+ },
+ ],
+ },
+ {
+ code: "t('Edit User')",
+ errors: [
+ {
+ message:
+ 'Avoid title case in i18n strings: "Edit User". Use sentence case
instead.',
+ },
+ ],
+ },
+ // Template literals
+ {
+ code: 't(`Add Layer`)',
+ errors: [
+ {
+ message:
+ 'Avoid title case in i18n strings: "Add Layer". Use sentence case
instead.',
+ },
+ ],
+ },
+ // tn function
+ {
+ code: "tn('Delete Item', 'Delete Items', count)",
+ errors: [
+ {
+ message:
+ 'Avoid title case in i18n strings: "Delete Item". Use sentence
case instead.',
+ },
+ ],
+ },
+ ],
+});
diff --git
a/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts
b/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts
index 394c2c7f30..2f7532b389 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts
+++ b/superset-frontend/packages/superset-ui-chart-controls/src/constants.ts
@@ -30,10 +30,10 @@ export const DEFAULT_MAX_ROW_TABLE_SERVER = 500000;
// eslint-disable-next-line import/prefer-default-export
export const TIME_FILTER_LABELS = {
- time_range: t('Time Range'),
- granularity_sqla: t('Time Column'),
- time_grain_sqla: t('Time Grain'),
- granularity: t('Time Granularity'),
+ time_range: t('Time range'),
+ granularity_sqla: t('Time column'),
+ time_grain_sqla: t('Time grain'),
+ granularity: t('Time granularity'),
};
export const COLUMN_NAME_ALIASES: Record<string, string> = {
diff --git
a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx
b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx
index 53f9cdc2b0..415b5beb3e 100644
--- a/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx
+++ b/superset-frontend/src/SqlLab/components/ExploreResultsButton/index.tsx
@@ -46,7 +46,7 @@ const ExploreResultsButton = ({
tooltip={t('Explore the result set in the data exploration view')}
data-test="explore-results-button"
>
- {t('Create Chart')}
+ {t('Create chart')}
</Button>
);
};
diff --git
a/superset-frontend/src/SqlLab/components/KeyboardShortcutButton/index.tsx
b/superset-frontend/src/SqlLab/components/KeyboardShortcutButton/index.tsx
index e9749a5618..217e724f42 100644
--- a/superset-frontend/src/SqlLab/components/KeyboardShortcutButton/index.tsx
+++ b/superset-frontend/src/SqlLab/components/KeyboardShortcutButton/index.tsx
@@ -51,7 +51,7 @@ export const KEY_MAP: Record<KeyboardShortcut, string |
undefined> = {
[KeyboardShortcut.CtrlE]: userOS !== 'MacOS' ? t('Stop query') : undefined,
[KeyboardShortcut.CtrlQ]: userOS === 'Windows' ? t('New tab') : undefined,
[KeyboardShortcut.CtrlT]: userOS !== 'Windows' ? t('New tab') : undefined,
- [KeyboardShortcut.CtrlP]: t('Previous Line'),
+ [KeyboardShortcut.CtrlP]: t('Previous line'),
[KeyboardShortcut.CtrlShiftF]: t('Format SQL'),
[KeyboardShortcut.CtrlLeft]: t('Switch to the previous tab'),
[KeyboardShortcut.CtrlRight]: t('Switch to the next tab'),
diff --git a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx
b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx
index 6186a1e900..16ad5ad6c6 100644
--- a/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx
+++ b/superset-frontend/src/SqlLab/components/SaveDatasetModal/index.tsx
@@ -158,7 +158,7 @@ const updateDataset = async (
return data.json.result;
};
-const UNTITLED = t('Untitled Dataset');
+const UNTITLED = t('Untitled dataset');
export const SaveDatasetModal = ({
visible,
@@ -374,10 +374,10 @@ export const SaveDatasetModal = ({
return (
<Modal
show={visible}
- name={t('Save or Overwrite Dataset')}
+ name={t('Save or overwrite dataset')}
title={
<ModalTitleWithIcon
- title={t('Save or Overwrite Dataset')}
+ title={t('Save or overwrite dataset')}
icon={<Icons.SaveOutlined />}
data-test="save-or-overwrite-dataset-title"
/>
@@ -394,7 +394,7 @@ export const SaveDatasetModal = ({
}
/>
<span style={{ marginLeft: '5px' }}>
- {t('Include Template Parameters')}
+ {t('Include template parameters')}
</span>
</div>
)}
diff --git
a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
index 13b67c5b99..9d64e3e713 100644
--- a/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
+++ b/superset-frontend/src/explore/components/ExploreViewContainer/index.jsx
@@ -637,7 +637,7 @@ function ExploreViewContainer(props) {
}
>
<div className="title-container">
- <span className="horizontal-text">{t('Chart Source')}</span>
+ <span className="horizontal-text">{t('Chart source')}</span>
<span
role="button"
tabIndex={0}
@@ -672,7 +672,7 @@ function ExploreViewContainer(props) {
tabIndex={0}
>
<span role="button" tabIndex={0} className="action-button">
- <Tooltip title={t('Open Datasource tab')}>
+ <Tooltip title={t('Open datasource tab')}>
<Icons.VerticalAlignTopOutlined
iconSize="xl"
css={css`
diff --git a/superset-frontend/src/explore/components/SaveModal.tsx
b/superset-frontend/src/explore/components/SaveModal.tsx
index 6b737e0c22..c79c209e6e 100644
--- a/superset-frontend/src/explore/components/SaveModal.tsx
+++ b/superset-frontend/src/explore/components/SaveModal.tsx
@@ -370,7 +370,7 @@ class SaveModal extends Component<SaveModalProps,
SaveModalState> {
/>
</FormItem>
{this.props.datasource?.type === 'query' && (
- <FormItem label={t('Dataset Name')} required>
+ <FormItem label={t('Dataset name')} required>
<InfoTooltip
tooltip={t('A reusable dataset will be saved with your chart.')}
placement="right"
diff --git a/superset-frontend/src/pages/ChartList/index.tsx
b/superset-frontend/src/pages/ChartList/index.tsx
index 5696438eab..b79d25a774 100644
--- a/superset-frontend/src/pages/ChartList/index.tsx
+++ b/superset-frontend/src/pages/ChartList/index.tsx
@@ -575,8 +575,8 @@ function ChartList(props: ChartListProps) {
operator: FilterOperator.ChartIsFav,
unfilteredLabel: t('Any'),
selects: [
- { label: t('Yes'), value: true },
- { label: t('No'), value: false },
+ { label: t('yes'), value: true },
+ { label: t('no'), value: false },
],
}),
[],
diff --git a/superset-frontend/src/pages/DashboardList/index.tsx
b/superset-frontend/src/pages/DashboardList/index.tsx
index 25856e7179..b7c4f1308e 100644
--- a/superset-frontend/src/pages/DashboardList/index.tsx
+++ b/superset-frontend/src/pages/DashboardList/index.tsx
@@ -530,8 +530,8 @@ function DashboardList(props: DashboardListProps) {
operator: FilterOperator.DashboardIsFav,
unfilteredLabel: t('Any'),
selects: [
- { label: t('Yes'), value: true },
- { label: t('No'), value: false },
+ { label: t('yes'), value: true },
+ { label: t('no'), value: false },
],
}),
[],
@@ -604,8 +604,8 @@ function DashboardList(props: DashboardListProps) {
operator: FilterOperator.DashboardIsCertified,
unfilteredLabel: t('Any'),
selects: [
- { label: t('Yes'), value: true },
- { label: t('No'), value: false },
+ { label: t('yes'), value: true },
+ { label: t('no'), value: false },
],
},
{
diff --git a/superset-frontend/src/pages/DatasetList/index.tsx
b/superset-frontend/src/pages/DatasetList/index.tsx
index 25e655c8ea..1e98b91b32 100644
--- a/superset-frontend/src/pages/DatasetList/index.tsx
+++ b/superset-frontend/src/pages/DatasetList/index.tsx
@@ -530,8 +530,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
operator: FilterOperator.DatasetIsNullOrEmpty,
unfilteredLabel: 'All',
selects: [
- { label: t('Virtual'), value: false },
- { label: t('Physical'), value: true },
+ { label: t('virtual'), value: false },
+ { label: t('physical'), value: true },
],
},
{
@@ -598,8 +598,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
operator: FilterOperator.DatasetIsCertified,
unfilteredLabel: t('Any'),
selects: [
- { label: t('Yes'), value: true },
- { label: t('No'), value: false },
+ { label: t('yes'), value: true },
+ { label: t('no'), value: false },
],
},
{
@@ -764,7 +764,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
</p>
{datasetCurrentlyDeleting.dashboards.count >= 1 && (
<>
- <h4>{t('Affected Dashboards')}</h4>
+ <h4>{t('Affected dashboards')}</h4>
<List
split={false}
size="small"
@@ -807,7 +807,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
)}
{datasetCurrentlyDeleting.charts.count >= 1 && (
<>
- <h4>{t('Affected Charts')}</h4>
+ <h4>{t('Affected charts')}</h4>
<List
split={false}
size="small"
@@ -860,7 +860,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
}}
onHide={closeDatasetDeleteModal}
open
- title={t('Delete Dataset?')}
+ title={t('Delete dataset?')}
/>
)}
{datasetCurrentlyEditing && (
@@ -931,7 +931,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
);
if (!selected.length) {
- return t('0 Selected');
+ return t('0 selected');
}
if (virtualCount && !physicalCount) {
return t(
diff --git a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx
b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx
index fa0f3085f5..3626b91930 100644
--- a/superset-frontend/src/pages/RowLevelSecurityList/index.tsx
+++ b/superset-frontend/src/pages/RowLevelSecurityList/index.tsx
@@ -65,7 +65,7 @@ function RowLevelSecurityList(props: RLSProps) {
toggleBulkSelect,
} = useListViewResource<RLSObject>(
'rowlevelsecurity',
- t('Row Level Security'),
+ t('Row level security'),
addDangerToast,
true,
undefined,
@@ -130,13 +130,13 @@ function RowLevelSecurityList(props: RLSProps) {
},
{
accessor: 'filter_type',
- Header: t('Filter Type'),
+ Header: t('Filter type'),
size: 'xl',
id: 'filter_type',
},
{
accessor: 'group_key',
- Header: t('Group Key'),
+ Header: t('Group key'),
size: 'xl',
id: 'group_key',
},
@@ -246,7 +246,7 @@ function RowLevelSecurityList(props: RLSProps) {
);
const emptyState = {
- title: t('No Rules yet'),
+ title: t('No rules yet'),
image: 'filter-results.svg',
buttonAction: () => handleRuleEdit(null),
buttonIcon: canEdit ? (
@@ -265,7 +265,7 @@ function RowLevelSecurityList(props: RLSProps) {
operator: FilterOperator.StartsWith,
},
{
- Header: t('Filter Type'),
+ Header: t('Filter type'),
key: 'filter_type',
id: 'filter_type',
input: 'select',
@@ -277,7 +277,7 @@ function RowLevelSecurityList(props: RLSProps) {
],
},
{
- Header: t('Group Key'),
+ Header: t('Group key'),
key: 'search',
id: 'group_key',
input: 'search',
@@ -329,7 +329,7 @@ function RowLevelSecurityList(props: RLSProps) {
return (
<>
- <SubMenu name={t('Row Level Security')} buttons={subMenuButtons} />
+ <SubMenu name={t('Row level security')} buttons={subMenuButtons} />
<ConfirmStatusChange
title={t('Please confirm')}
description={t('Are you sure you want to delete the selected rules?')}
diff --git a/superset-frontend/src/pages/UsersList/index.tsx
b/superset-frontend/src/pages/UsersList/index.tsx
index bd1d65ad93..6a4b81c768 100644
--- a/superset-frontend/src/pages/UsersList/index.tsx
+++ b/superset-frontend/src/pages/UsersList/index.tsx
@@ -519,7 +519,7 @@ function UsersList({ user }: UsersListProps) {
return (
<>
- <SubMenu name={t('List Users')} buttons={subMenuButtons} />
+ <SubMenu name={t('List users')} buttons={subMenuButtons} />
<UserListAddModal
onHide={() => closeModal(ModalType.ADD)}
show={modalState.add}
@@ -554,7 +554,7 @@ function UsersList({ user }: UsersListProps) {
}}
onHide={() => setUserCurrentlyDeleting(null)}
open
- title={t('Delete User?')}
+ title={t('Delete user?')}
/>
)}
<ConfirmStatusChange