diff --git a/src/features/table/InteractiveEntityTable.tsx b/src/features/table/InteractiveEntityTable.tsx index 7fbea2ee9..f7a3f17f7 100644 --- a/src/features/table/InteractiveEntityTable.tsx +++ b/src/features/table/InteractiveEntityTable.tsx @@ -19,6 +19,8 @@ interface Props { entities: T[]; columns: TableColumn[]; shouldFilterUsingSearchBar?: boolean; + /** When false, page language-scope filters do not hide table rows. */ + useScope?: boolean; tableID: TableID; } @@ -26,11 +28,12 @@ function InteractiveEntityTable({ entities, columns, shouldFilterUsingSearchBar = true, + useScope = true, tableID, }: Props) { const { getCurrentEntities } = usePagination(); const { filteredEntities } = useFilteredEntities({ - useScope: true, + useScope, useSubstring: shouldFilterUsingSearchBar, useConnections: true, useVitality: true, diff --git a/src/features/table/TableID.tsx b/src/features/table/TableID.tsx index 8c32e30bb..24489302f 100644 --- a/src/features/table/TableID.tsx +++ b/src/features/table/TableID.tsx @@ -18,6 +18,7 @@ enum TableID { PotentialLocales, LocaleIndigeneity, VariantAnnotation, + LanguageScopeIssues, } export default TableID; diff --git a/src/widgets/reports/Report.tsx b/src/widgets/reports/Report.tsx index 515171188..b6dc806d6 100644 --- a/src/widgets/reports/Report.tsx +++ b/src/widgets/reports/Report.tsx @@ -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'), @@ -44,6 +45,8 @@ const SpecificReport: React.FC<{ reportID: ReportID }> = ({ reportID }) => { return ; case ReportID.LanguagePaths: return ; + case ReportID.LanguageScopeIssues: + return ; case ReportID.LanguageDescendants: return ; case ReportID.LanguagesWithAmbiguousNames: diff --git a/src/widgets/reports/ReportID.ts b/src/widgets/reports/ReportID.ts index 04ba715d4..f27def0c4 100644 --- a/src/widgets/reports/ReportID.ts +++ b/src/widgets/reports/ReportID.ts @@ -7,6 +7,7 @@ enum ReportID { LanguagesDubious, LanguageDescendants, LanguagePaths, + LanguageScopeIssues, LocaleCitationCompleteness, LocaleIndigeneity, LocalesPotential, diff --git a/src/widgets/reports/ReportLabels.tsx b/src/widgets/reports/ReportLabels.tsx index 0b4b217c4..0046b25ee 100644 --- a/src/widgets/reports/ReportLabels.tsx +++ b/src/widgets/reports/ReportLabels.tsx @@ -6,6 +6,7 @@ const ReportLabels: Record = { [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', diff --git a/src/widgets/reports/ReportLanguageScopeIssues.tsx b/src/widgets/reports/ReportLanguageScopeIssues.tsx new file mode 100644 index 000000000..3f769cf0c --- /dev/null +++ b/src/widgets/reports/ReportLanguageScopeIssues.tsx @@ -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], + ); + + return ( + <> + This report flags languages in the current language source whose scope is broader than their + direct parent'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. + + tableID={TableID.LanguageScopeIssues} + shouldFilterUsingSearchBar={false} + useScope={false} + columns={[ + { + key: 'Parent Code', + render: (lang) => lang.parentLanguage?.codeDisplay, + }, + { + key: 'Parent Name', + render: (lang) => + lang.parentLanguage != null ? ( + + ) : 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) => , + exportValue: (lang) => lang.nameDisplay, + field: Field.Name, + }, + { + key: 'Child Scope', + render: (lang) => (lang.scope != null ? getLanguageScopeLabel(lang.scope) : null), + field: Field.LanguageScope, + }, + { + key: 'Full Path', + render: (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 }>{compact}; +}; + +const ExpandedLanguagePath: React.FC<{ path: LanguageData[] }> = ({ path }) => ( + <> + {path.map((lang, index) => ( + + {index > 0 && ' > '} + [{lang.codeDisplay}] + + ))} + +); + +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; diff --git a/src/widgets/reports/__tests__/getLanguageScopeIssues.test.ts b/src/widgets/reports/__tests__/getLanguageScopeIssues.test.ts new file mode 100644 index 000000000..3c00934f0 --- /dev/null +++ b/src/widgets/reports/__tests__/getLanguageScopeIssues.test.ts @@ -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, + ]); + }); +}); diff --git a/src/widgets/reports/getLanguageScopeIssues.ts b/src/widgets/reports/getLanguageScopeIssues.ts new file mode 100644 index 000000000..dfea1893b --- /dev/null +++ b/src/widgets/reports/getLanguageScopeIssues.ts @@ -0,0 +1,31 @@ +import { LanguageData } from '@entities/language/LanguageTypes'; + +export function getLanguagePath(child: LanguageData): LanguageData[] { + const path: LanguageData[] = []; + const visited = new Set(); + 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; + }); +} diff --git a/src/widgets/reports/getReportIDsForEntityType.ts b/src/widgets/reports/getReportIDsForEntityType.ts index c76a45c98..25315aaab 100644 --- a/src/widgets/reports/getReportIDsForEntityType.ts +++ b/src/widgets/reports/getReportIDsForEntityType.ts @@ -13,6 +13,7 @@ function getReportIDsForEntityType(entityType: ObjectType): ReportID[] { ReportID.LanguagesWithAmbiguousNames, ReportID.LanguagePaths, ReportID.LanguageDescendants, + ReportID.LanguageScopeIssues, ]; case ObjectType.Locale: return [