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 [analysisVin, setAnalysisVin] = useState(initialVehicleKey);
|
||||||
const [activeProtocol, setActiveProtocol] = useState(initialRoute.protocol ?? '');
|
const [activeProtocol, setActiveProtocol] = useState(initialRoute.protocol ?? '');
|
||||||
const [vehicleFilters, setVehicleFilters] = useState<Record<string, string>>(initialRoute.filters ?? {});
|
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 [linkIssueCount, setLinkIssueCount] = useState<number | null>(null);
|
||||||
const [currentVehicleStatus, setCurrentVehicleStatus] = useState<VehicleServiceStatus | undefined>();
|
const [currentVehicleStatus, setCurrentVehicleStatus] = useState<VehicleServiceStatus | undefined>();
|
||||||
const [currentVehicleLabel, setCurrentVehicleLabel] = useState('');
|
const [currentVehicleLabel, setCurrentVehicleLabel] = useState('');
|
||||||
@@ -87,6 +90,9 @@ export default function App() {
|
|||||||
if (route.page === 'vehicles') {
|
if (route.page === 'vehicles') {
|
||||||
setVehicleFilters(route.filters ?? {});
|
setVehicleFilters(route.filters ?? {});
|
||||||
}
|
}
|
||||||
|
if (route.page === 'quality') {
|
||||||
|
setQualityFilters(qualityFiltersFromRoute(route));
|
||||||
|
}
|
||||||
setActiveProtocol(route.protocol ?? '');
|
setActiveProtocol(route.protocol ?? '');
|
||||||
};
|
};
|
||||||
window.addEventListener('hashchange', applyHashRoute);
|
window.addEventListener('hashchange', applyHashRoute);
|
||||||
@@ -110,6 +116,10 @@ export default function App() {
|
|||||||
replaceHash(page, analysisVin, activeProtocol);
|
replaceHash(page, analysisVin, activeProtocol);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (page === 'quality') {
|
||||||
|
replaceQualityHash(qualityFilters);
|
||||||
|
return;
|
||||||
|
}
|
||||||
replaceHash(page, undefined, undefined, page === 'vehicles' ? vehicleFilters : undefined);
|
replaceHash(page, undefined, undefined, page === 'vehicles' ? vehicleFilters : undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,6 +134,21 @@ export default function App() {
|
|||||||
replaceHash('vehicles', undefined, undefined, filters);
|
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 openVehicle = async (keyword: string, protocol?: string) => {
|
||||||
const lookupKey = keyword.trim();
|
const lookupKey = keyword.trim();
|
||||||
const nextProtocol = protocol?.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} />,
|
detail: <VehicleDetail vin={activeVin} protocol={activeProtocol} onOpenHistory={openHistoryForVehicle} onOpenMileage={openMileageForVehicle} onQueryChange={updateVehicleDetailQuery} />,
|
||||||
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
history: <History initialVin={analysisVin} initialProtocol={activeProtocol} onOpenVehicle={openVehicle} />,
|
||||||
mileage: <Mileage 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 (
|
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 {
|
function serviceStatusFromOverview(overview: VehicleServiceOverview): VehicleServiceStatus {
|
||||||
const sourceCount = overview.sourceCount;
|
const sourceCount = overview.sourceCount;
|
||||||
const onlineSourceCount = overview.onlineSourceCount;
|
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', () => {
|
test('ignores unknown pages', () => {
|
||||||
expect(parseAppHash('#/unknown?keyword=VIN001')).toEqual({});
|
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');
|
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', () => {
|
test('builds page-only hash when keyword is empty', () => {
|
||||||
expect(buildAppHash({ page: 'quality', keyword: '' })).toBe('#/quality');
|
expect(buildAppHash({ page: 'quality', keyword: '' })).toBe('#/quality');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export type AppRoute = {
|
|||||||
filters?: Record<string, string>;
|
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 {
|
export function parseAppHash(hash: string): AppRoute {
|
||||||
const normalized = hash.trim().replace(/^#\/?/, '');
|
const normalized = hash.trim().replace(/^#\/?/, '');
|
||||||
|
|||||||
@@ -59,10 +59,14 @@ async function copyText(value: string, label: string) {
|
|||||||
|
|
||||||
export function Quality({
|
export function Quality({
|
||||||
onOpenVehicle,
|
onOpenVehicle,
|
||||||
onHealthLoaded
|
onHealthLoaded,
|
||||||
|
onFiltersChange,
|
||||||
|
initialFilters = {}
|
||||||
}: {
|
}: {
|
||||||
onOpenVehicle: (vin: string) => void;
|
onOpenVehicle: (vin: string) => void;
|
||||||
onHealthLoaded?: (health: OpsHealth) => void;
|
onHealthLoaded?: (health: OpsHealth) => void;
|
||||||
|
onFiltersChange?: (filters: Record<string, string>) => void;
|
||||||
|
initialFilters?: Record<string, string>;
|
||||||
}) {
|
}) {
|
||||||
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
|
const [issues, setIssues] = useState<QualityIssueRow[]>([]);
|
||||||
const [summary, setSummary] = useState<QualitySummary>(emptySummary);
|
const [summary, setSummary] = useState<QualitySummary>(emptySummary);
|
||||||
@@ -70,7 +74,7 @@ export function Quality({
|
|||||||
const [loadingIssues, setLoadingIssues] = useState(true);
|
const [loadingIssues, setLoadingIssues] = useState(true);
|
||||||
const [loadingSummary, setLoadingSummary] = useState(true);
|
const [loadingSummary, setLoadingSummary] = useState(true);
|
||||||
const [loadingHealth, setLoadingHealth] = 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 [pagination, setPagination] = useState({ currentPage: 1, pageSize: 20, total: 0 });
|
||||||
const primaryIssueType = summary.issueTypes[0]?.name;
|
const primaryIssueType = summary.issueTypes[0]?.name;
|
||||||
|
|
||||||
@@ -108,13 +112,15 @@ export function Quality({
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSummary({});
|
setFilters(initialFilters);
|
||||||
loadIssues({}, 1, pagination.pageSize);
|
loadSummary(initialFilters);
|
||||||
|
loadIssues(initialFilters, 1, pagination.pageSize);
|
||||||
loadHealth();
|
loadHealth();
|
||||||
}, []);
|
}, [JSON.stringify(initialFilters)]);
|
||||||
|
|
||||||
const applyFilters = (nextFilters: Record<string, string>) => {
|
const applyFilters = (nextFilters: Record<string, string>) => {
|
||||||
setFilters(nextFilters);
|
setFilters(nextFilters);
|
||||||
|
onFiltersChange?.(nextFilters);
|
||||||
loadSummary(nextFilters);
|
loadSummary(nextFilters);
|
||||||
loadIssues(nextFilters, 1, pagination.pageSize);
|
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);
|
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 () => {
|
test('opens vehicle detail from shareable hash', async () => {
|
||||||
window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312');
|
window.history.replaceState(null, '', '/#/detail?keyword=%E7%B2%A4AG18312');
|
||||||
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||||
|
|||||||
Reference in New Issue
Block a user