Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/features/table/InteractiveEntityTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,21 @@ interface Props<T> {
entities: T[];
columns: TableColumn<T>[];
shouldFilterUsingSearchBar?: boolean;
/** When false, page language-scope filters do not hide table rows. */
useScope?: boolean;
tableID: TableID;
}

function InteractiveEntityTable<T extends ObjectData>({
entities,
columns,
shouldFilterUsingSearchBar = true,
useScope = true,
tableID,
}: Props<T>) {
const { getCurrentEntities } = usePagination<T>();
const { filteredEntities } = useFilteredEntities({
useScope: true,
useScope,
useSubstring: shouldFilterUsingSearchBar,
useConnections: true,
useVitality: true,
Expand Down
1 change: 1 addition & 0 deletions src/features/table/TableID.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ enum TableID {
PotentialLocales,
LocaleIndigeneity,
VariantAnnotation,
LanguageScopeIssues,
}

export default TableID;
3 changes: 3 additions & 0 deletions src/widgets/reports/Report.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const ReportCensusInputTool = React.lazy(() => import('./ReportCensusInputTool')
const ReportEntitiesMissingFields = React.lazy(() => import('./ReportEntitiesMissingFields'));
const ReportLanguageDescendants = React.lazy(() => import('./ReportLanguageDescendants'));
const ReportLanguagePaths = React.lazy(() => import('./ReportLanguagePaths'));
const ReportLanguageScopeIssues = React.lazy(() => import('./ReportLanguageScopeIssues'));
const ReportLanguagesDubious = React.lazy(() => import('./ReportLanguagesDubious'));
const ReportLanguagesWithAmbiguousNames = React.lazy(
() => import('./ReportLanguagesWithAmbiguousNames'),
Expand Down Expand Up @@ -44,6 +45,8 @@ const SpecificReport: React.FC<{ reportID: ReportID }> = ({ reportID }) => {
return <ReportEntitiesMissingFields />;
case ReportID.LanguagePaths:
return <ReportLanguagePaths />;
case ReportID.LanguageScopeIssues:
return <ReportLanguageScopeIssues />;
case ReportID.LanguageDescendants:
return <ReportLanguageDescendants />;
case ReportID.LanguagesWithAmbiguousNames:
Expand Down
1 change: 1 addition & 0 deletions src/widgets/reports/ReportID.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ enum ReportID {
LanguagesDubious,
LanguageDescendants,
LanguagePaths,
LanguageScopeIssues,
LocaleCitationCompleteness,
LocaleIndigeneity,
LocalesPotential,
Expand Down
1 change: 1 addition & 0 deletions src/widgets/reports/ReportLabels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const ReportLabels: Record<ReportID, string> = {
[ReportID.EntitiesMissingFields]: 'Missing Fields',
[ReportID.LanguageDescendants]: 'Descendants',
[ReportID.LanguagePaths]: 'Paths',
[ReportID.LanguageScopeIssues]: 'Scope Issues',
[ReportID.LanguagesDubious]: 'Dubious Languages',
[ReportID.LanguagesWithAmbiguousNames]: 'Ambiguous Names',
[ReportID.LocaleCitationCompleteness]: 'Citation Completeness',
Expand Down
119 changes: 119 additions & 0 deletions src/widgets/reports/ReportLanguageScopeIssues.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import React, { useMemo } from 'react';

import { useDataContext } from '@features/data/context/useDataContext';
import Hoverable from '@features/layers/hovercard/Hoverable';
import HoverableObjectName from '@features/layers/hovercard/HoverableObjectName';
import InteractiveEntityTable from '@features/table/InteractiveEntityTable';
import TableID from '@features/table/TableID';
import Field from '@features/transforms/fields/Field';

import { LanguageData, LanguageScope } from '@entities/language/LanguageTypes';

import { getLanguageScopeLabel } from '@strings/LanguageScopeStrings';

import { filterLanguagesWithScopeIssues, getLanguagePath } from './getLanguageScopeIssues';

const ReportLanguageScopeIssues: React.FC = () => {
const { languagesInSelectedSource } = useDataContext();

const languagesWithIssues = useMemo(
() => filterLanguagesWithScopeIssues(languagesInSelectedSource),
[languagesInSelectedSource],
Comment on lines +18 to +21

@conradarcturus conradarcturus Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of forcing a sort, let's use the global sort function.
nevermind, the table overrides the sort anyway :)

);

return (
<>
This report flags languages in the current language source whose scope is broader than their
direct parent&apos;s scope, which may indicate a hierarchy inconsistency. Results are shown
regardless of the sidebar Language Scope filter so dialect and family issues remain visible.
<InteractiveEntityTable<LanguageData>
tableID={TableID.LanguageScopeIssues}
shouldFilterUsingSearchBar={false}
useScope={false}
columns={[
{
key: 'Parent Code',
render: (lang) => lang.parentLanguage?.codeDisplay,
},
{
key: 'Parent Name',
render: (lang) =>
lang.parentLanguage != null ? (
<HoverableObjectName object={lang.parentLanguage} />
) : null,
exportValue: (lang) => lang.parentLanguage?.nameDisplay,
},
{
key: 'Parent Scope',
render: (lang) =>
lang.parentLanguage?.scope != null
? getLanguageScopeLabel(lang.parentLanguage.scope)
: null,
},
{
key: 'Child Code',
render: (lang) => lang.codeDisplay,
field: Field.Code,
},
{
key: 'Child Name',
render: (lang) => <HoverableObjectName object={lang} />,
exportValue: (lang) => lang.nameDisplay,
field: Field.Name,
},
{
key: 'Child Scope',
render: (lang) => (lang.scope != null ? getLanguageScopeLabel(lang.scope) : null),
field: Field.LanguageScope,
},
Comment thread
conradarcturus marked this conversation as resolved.
{
key: 'Full Path',
render: (lang) => <LanguagePath path={getLanguagePath(lang)} />,
exportValue: (lang) => formatLanguagePath(getLanguagePath(lang)),
},
]}
entities={languagesWithIssues}
/>
</>
);
};

const LanguagePath: React.FC<{ path: LanguageData[] }> = ({ path }) => {
const compact = path.map((lang) => getScopeChar(lang.scope)).join('/');

return <Hoverable hoverContent={<ExpandedLanguagePath path={path} />}>{compact}</Hoverable>;
};

const ExpandedLanguagePath: React.FC<{ path: LanguageData[] }> = ({ path }) => (
<>
{path.map((lang, index) => (
<React.Fragment key={lang.ID}>
{index > 0 && ' > '}
<HoverableObjectName object={lang} /> [{lang.codeDisplay}]
</React.Fragment>
))}
</>
);

function getScopeChar(scope: LanguageScope | undefined): string {
switch (scope) {
case LanguageScope.Family:
return 'F';
case LanguageScope.Macrolanguage:
return 'M';
case LanguageScope.Language:
return 'I';
case LanguageScope.Dialect:
return 'D';
case LanguageScope.SpecialCode:
return 'S';
default:
return '?';
}
}

function formatLanguagePath(path: LanguageData[]): string {
return path.map((lang) => `${lang.nameDisplay} [${lang.codeDisplay}]`).join(' > ');
}

export default ReportLanguageScopeIssues;
119 changes: 119 additions & 0 deletions src/widgets/reports/__tests__/getLanguageScopeIssues.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest';

import { getBaseLanguageData, LanguageData, LanguageScope } from '@entities/language/LanguageTypes';

import { filterLanguagesWithScopeIssues, getLanguagePath } from '../getLanguageScopeIssues';

function makeLanguage(
id: string,
name: string,
scope: LanguageScope,
parentLanguage?: LanguageData,
): LanguageData {
return {
...getBaseLanguageData(id, name),
scope,
parentLanguage,
childLanguages: [],
};
}

describe('filterLanguagesWithScopeIssues', () => {
it('detects languages whose scope is broader than their parent', () => {
const dialectParent = makeLanguage('dial1', 'Test Dialect Group', LanguageScope.Dialect);
const individualChild = makeLanguage(
'ind1',
'Test Individual',
LanguageScope.Language,
dialectParent,
);
const macroChild = makeLanguage(
'mac1',
'Test Macro',
LanguageScope.Macrolanguage,
dialectParent,
);
const macroParent = makeLanguage('mac2', 'Another Macro', LanguageScope.Macrolanguage);
const familyChild = makeLanguage('fam1', 'Test Family', LanguageScope.Family, macroParent);
const individualParent = makeLanguage('ind2', 'Parent Individual', LanguageScope.Language);
const familyChild2 = makeLanguage(
'fam2',
'Nested Family',
LanguageScope.Family,
individualParent,
);

dialectParent.childLanguages = [individualChild, macroChild];
macroParent.childLanguages = [familyChild];
individualParent.childLanguages = [familyChild2];

const issues = filterLanguagesWithScopeIssues([
dialectParent,
individualChild,
macroChild,
macroParent,
familyChild,
individualParent,
familyChild2,
]);

expect(issues.map((lang) => lang.ID).sort()).toEqual(['fam1', 'fam2', 'ind1', 'mac1']);
});

it('builds the language path from root to child using parentLanguage', () => {
const root = makeLanguage('fam-root', 'Root Family', LanguageScope.Family);
const parent = makeLanguage('mac-root', 'Root Macro', LanguageScope.Macrolanguage, root);
const child = makeLanguage('ind-root', 'Root Individual', LanguageScope.Language, parent);

root.childLanguages = [parent];
parent.childLanguages = [child];

expect(getLanguagePath(child).map((lang) => lang.ID)).toEqual([
'fam-root',
'mac-root',
'ind-root',
]);
});

it('ignores valid parent-child scope pairs', () => {
const family = makeLanguage('fam-valid', 'Valid Family', LanguageScope.Family);
const macro = makeLanguage('mac-valid', 'Valid Macro', LanguageScope.Macrolanguage, family);
family.childLanguages = [macro];

expect(filterLanguagesWithScopeIssues([family, macro])).toEqual([]);
});

it('ignores languages without a parent', () => {
const family = makeLanguage('fam-orphan', 'Root Family', LanguageScope.Family);

expect(filterLanguagesWithScopeIssues([family])).toEqual([]);
});

it('ignores languages with missing scope', () => {
const parent = makeLanguage('par1', 'Parent', LanguageScope.Dialect);
const childWithIssue = makeLanguage('child1', 'Child', LanguageScope.Language, parent);
const childMissingScope: LanguageData = {
...getBaseLanguageData('child2', 'No Scope Child'),
parentLanguage: parent,
childLanguages: [],
};

expect(filterLanguagesWithScopeIssues([parent, childWithIssue, childMissingScope])).toEqual([
childWithIssue,
]);
});

it('uses lang.parentLanguage and lang.scope instead of Combined fields', () => {
const dialectParent = makeLanguage('par1', 'Parent', LanguageScope.Dialect);
const child = makeLanguage('child1', 'Child', LanguageScope.Language, dialectParent);
const validCombinedParent = makeLanguage('valid-par', 'Valid Parent', LanguageScope.Family);

child.Combined.parentLanguage = validCombinedParent;
child.Combined.scope = LanguageScope.Language;
dialectParent.Combined.scope = LanguageScope.Dialect;

expect(filterLanguagesWithScopeIssues([dialectParent, child, validCombinedParent])).toEqual([
child,
]);
});
});
31 changes: 31 additions & 0 deletions src/widgets/reports/getLanguageScopeIssues.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { LanguageData } from '@entities/language/LanguageTypes';

export function getLanguagePath(child: LanguageData): LanguageData[] {
const path: LanguageData[] = [];
const visited = new Set<string>();
let current: LanguageData | undefined = child;

while (current != null) {
if (visited.has(current.ID)) break;
visited.add(current.ID);
path.unshift(current);
current = current.parentLanguage;
}

return path;
}

export function filterLanguagesWithScopeIssues(languages: LanguageData[]): LanguageData[] {
return languages.filter((lang) => {
const parent = lang.parentLanguage;
if (parent == null) return false;

const parentScope = parent.scope;
const childScope = lang.scope;
if (parentScope == null || childScope == null) return false;

// LanguageScope uses higher values for broader scopes (Family=5 > Dialect=2).
// Flag when the child scope is broader than its parent's scope.
return childScope > parentScope;
});
}
1 change: 1 addition & 0 deletions src/widgets/reports/getReportIDsForEntityType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ function getReportIDsForEntityType(entityType: ObjectType): ReportID[] {
ReportID.LanguagesWithAmbiguousNames,
ReportID.LanguagePaths,
ReportID.LanguageDescendants,
ReportID.LanguageScopeIssues,
];
case ObjectType.Locale:
return [
Expand Down
Loading