feat(platform): persist quality filters in route
This commit is contained in:
@@ -20,6 +20,9 @@ export default function App() {
|
||||
const [analysisVin, setAnalysisVin] = useState(initialVehicleKey);
|
||||
const [activeProtocol, setActiveProtocol] = useState(initialRoute.protocol ?? '');
|
||||
const [vehicleFilters, setVehicleFilters] = useState<Record<string, string>>(initialRoute.filters ?? {});
|
||||
const [qualityFilters, setQualityFilters] = useState<Record<string, string>>(
|
||||
initialRoute.page === 'quality' ? qualityFiltersFromRoute(initialRoute) : {}
|
||||
);
|
||||
const [linkIssueCount, setLinkIssueCount] = useState<number | null>(null);
|
||||
const [currentVehicleStatus, setCurrentVehicleStatus] = useState<VehicleServiceStatus | undefined>();
|
||||
const [currentVehicleLabel, setCurrentVehicleLabel] = useState('');
|
||||
@@ -87,6 +90,9 @@ export default function App() {
|
||||
if (route.page === 'vehicles') {
|
||||
setVehicleFilters(route.filters ?? {});
|
||||
}
|
||||
if (route.page === 'quality') {
|
||||
setQualityFilters(qualityFiltersFromRoute(route));
|
||||
}
|
||||
setActiveProtocol(route.protocol ?? '');
|
||||
};
|
||||
window.addEventListener('hashchange', applyHashRoute);
|
||||
@@ -110,6 +116,10 @@ export default function App() {
|
||||
replaceHash(page, analysisVin, activeProtocol);
|
||||
return;
|
||||
}
|
||||
if (page === 'quality') {
|
||||
replaceQualityHash(qualityFilters);
|
||||
return;
|
||||
}
|
||||
replaceHash(page, undefined, undefined, page === 'vehicles' ? vehicleFilters : undefined);
|
||||
};
|
||||
|
||||
@@ -124,6 +134,21 @@ export default function App() {
|
||||
replaceHash('vehicles', undefined, undefined, filters);
|
||||
};
|
||||
|
||||
const replaceQualityHash = (filters: Record<string, string> = {}) => {
|
||||
const keyword = filters.keyword;
|
||||
const protocol = filters.protocol;
|
||||
const issueFilters: Record<string, string> = {};
|
||||
if (filters.issueType) {
|
||||
issueFilters.issueType = filters.issueType;
|
||||
}
|
||||
replaceHash('quality', keyword, protocol, issueFilters);
|
||||
};
|
||||
|
||||
const updateQualityFilters = (filters: Record<string, string> = {}) => {
|
||||
setQualityFilters(filters);
|
||||
replaceQualityHash(filters);
|
||||
};
|
||||
|
||||
const openVehicle = async (keyword: string, protocol?: string) => {
|
||||
const lookupKey = keyword.trim();
|
||||
const nextProtocol = protocol?.trim() ?? '';
|
||||
@@ -188,7 +213,7 @@ export default function App() {
|
||||
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenMileage={openMileageForVehicle} onQueryChange={updateVehicleDetailQuery} />,
|
||||
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
||||
mileage: <Mileage initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
||||
quality: <Quality onOpenVehicle={openVehicle} onHealthLoaded={(health) => setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} />
|
||||
quality: <Quality onOpenVehicle={openVehicle} onHealthLoaded={(health) => setLinkIssueCount(health.linkHealth.filter((item) => item.status !== 'ok').length)} onFiltersChange={updateQualityFilters} initialFilters={qualityFilters} />
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -198,6 +223,14 @@ export default function App() {
|
||||
);
|
||||
}
|
||||
|
||||
function qualityFiltersFromRoute(route: ReturnType<typeof parseAppHash>): Record<string, string> {
|
||||
return {
|
||||
...(route.keyword ? { keyword: route.keyword } : {}),
|
||||
...(route.protocol ? { protocol: route.protocol } : {}),
|
||||
...(route.filters?.issueType ? { issueType: route.filters.issueType } : {})
|
||||
};
|
||||
}
|
||||
|
||||
function serviceStatusFromOverview(overview: VehicleServiceOverview): VehicleServiceStatus {
|
||||
const sourceCount = overview.sourceCount;
|
||||
const onlineSourceCount = overview.onlineSourceCount;
|
||||
|
||||
@@ -23,6 +23,17 @@ describe('parseAppHash', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('parses quality governance filters from hash query', () => {
|
||||
expect(parseAppHash('#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE')).toEqual({
|
||||
page: 'quality',
|
||||
keyword: '粤A',
|
||||
protocol: 'VEHICLE_SERVICE',
|
||||
filters: {
|
||||
issueType: 'NO_SOURCE'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores unknown pages', () => {
|
||||
expect(parseAppHash('#/unknown?keyword=VIN001')).toEqual({});
|
||||
});
|
||||
@@ -37,6 +48,10 @@ describe('buildAppHash', () => {
|
||||
expect(buildAppHash({ page: 'vehicles', filters: { coverage: 'multi', serviceStatus: 'degraded' } })).toBe('#/vehicles?coverage=multi&serviceStatus=degraded');
|
||||
});
|
||||
|
||||
test('builds shareable quality hash with filters', () => {
|
||||
expect(buildAppHash({ page: 'quality', keyword: '粤A', protocol: 'VEHICLE_SERVICE', filters: { issueType: 'NO_SOURCE' } })).toBe('#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE');
|
||||
});
|
||||
|
||||
test('builds page-only hash when keyword is empty', () => {
|
||||
expect(buildAppHash({ page: 'quality', keyword: '' })).toBe('#/quality');
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ export type AppRoute = {
|
||||
filters?: Record<string, string>;
|
||||
};
|
||||
|
||||
const filterKeys = ['coverage', 'serviceStatus', 'online', 'bindingStatus'] as const;
|
||||
const filterKeys = ['coverage', 'serviceStatus', 'online', 'bindingStatus', 'issueType'] as const;
|
||||
|
||||
export function parseAppHash(hash: string): AppRoute {
|
||||
const normalized = hash.trim().replace(/^#\/?/, '');
|
||||
|
||||
@@ -59,10 +59,14 @@ async function copyText(value: string, label: string) {
|
||||
|
||||
export function Quality({
|
||||
onOpenVehicle,
|
||||
onHealthLoaded
|
||||
onHealthLoaded,
|
||||
onFiltersChange,
|
||||
initialFilters = {}
|
||||
}: {
|
||||
onOpenVehicle: (vin: string) => void;
|
||||
onHealthLoaded?: (health: OpsHealth) => void;
|
||||
onFiltersChange?: (filters: Record<string, string>) => void;
|
||||
initialFilters?: Record<string, string>;
|
||||
}) {
|
||||
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
|
||||
const [summary, setSummary] = useState<QualitySummary>(emptySummary);
|
||||
@@ -70,7 +74,7 @@ export function Quality({
|
||||
const [loadingIssues, setLoadingIssues] = useState(true);
|
||||
const [loadingSummary, setLoadingSummary] = useState(true);
|
||||
const [loadingHealth, setLoadingHealth] = useState(true);
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string>>(initialFilters);
|
||||
const [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
||||
const primaryIssueType = summary.issueTypes[0]?.name;
|
||||
|
||||
@@ -108,13 +112,15 @@ export function Quality({
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSummary({});
|
||||
loadIssues({}, 1, pagination.pageSize);
|
||||
setFilters(initialFilters);
|
||||
loadSummary(initialFilters);
|
||||
loadIssues(initialFilters, 1, pagination.pageSize);
|
||||
loadHealth();
|
||||
}, []);
|
||||
}, [JSON.stringify(initialFilters)]);
|
||||
|
||||
const applyFilters = (nextFilters: Record<string, string>) => {
|
||||
setFilters(nextFilters);
|
||||
onFiltersChange?.(nextFilters);
|
||||
loadSummary(nextFilters);
|
||||
loadIssues(nextFilters, 1, pagination.pageSize);
|
||||
};
|
||||
|
||||
@@ -795,6 +795,65 @@ test('drills into quality issues by issue type', async () => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/summary?issueType=NO_SOURCE'), undefined);
|
||||
});
|
||||
|
||||
test('applies shareable quality filters from hash', async () => {
|
||||
window.history.replaceState(null, '', '/#/quality?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE');
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path.includes('/api/ops/health')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { linkHealth: [], kafkaLag: 0, redisOnlineKeys: 0, tdengineWritable: true, mysqlWritable: true, runtime: { requestTimeoutMs: 5000 } },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/quality/summary')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
issueVehicleCount: 1,
|
||||
issueRecordCount: 1,
|
||||
errorCount: 0,
|
||||
warningCount: 1,
|
||||
protocols: [{ name: 'VEHICLE_SERVICE', count: 1 }],
|
||||
issueTypes: [{ name: 'NO_SOURCE', count: 1 }]
|
||||
},
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
if (path.includes('/api/quality/issues')) {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { items: [], total: 1, limit: 20, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: { items: [], total: 0, limit: 20, offset: 0 },
|
||||
traceId: 'trace-test',
|
||||
timestamp: 1783094400000
|
||||
})
|
||||
} as Response;
|
||||
});
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/issues?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE&limit=20&offset=0'), undefined);
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/quality/summary?keyword=%E7%B2%A4A&protocol=VEHICLE_SERVICE&issueType=NO_SOURCE'), undefined);
|
||||
});
|
||||
|
||||
test('opens vehicle detail from shareable hash', async () => {
|
||||
window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312');
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user