Compare commits

...

1 Commits

Author SHA1 Message Date
Maxime Beauchemin
16b4ec347d 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 <noreply@anthropic.com>
2025-07-31 13:36:41 -07:00
14 changed files with 319 additions and 37 deletions

View File

@@ -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',
{

View File

@@ -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 cant 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,
};
},
},
},
};

View File

@@ -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.',
},
],
},
],
});

View File

@@ -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> = {

View File

@@ -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>
);
};

View File

@@ -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'),

View File

@@ -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>
)}

View File

@@ -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`

View File

@@ -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"

View File

@@ -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 },
],
}),
[],

View File

@@ -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 },
],
},
{

View File

@@ -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(

View File

@@ -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?')}

View File

@@ -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