GeoTokenInput.tsx 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. import { useEffect, useState } from 'react';
  2. import type { Ref } from 'react';
  3. import { useTranslation } from 'react-i18next';
  4. import { Button, Input, Space, Tooltip, Typography } from 'antd';
  5. import type { InputRef } from 'antd';
  6. import { DatabaseOutlined } from '@ant-design/icons';
  7. import { useValidateGeoTokens, type GeoTokenKind } from '@/api/queries/useGeodata';
  8. import { parseTokens } from '@/lib/xray/geoTokens';
  9. import type { GeodataTokenIssue, GeoKind } from '@/generated/types';
  10. import GeoBrowserModal from './GeoBrowserModal';
  11. const VALIDATION_DELAY = 600;
  12. // Each reason needs its own wording: a missing database is fixed under Geodata,
  13. // a missing category by picking another one, and a bad token by editing it.
  14. const REASON_KEYS: Record<string, string> = {
  15. fileMissing: 'pages.xray.geoBrowser.missingDatabase',
  16. categoryMissing: 'pages.xray.geoBrowser.unknownCategories',
  17. attributeMissing: 'pages.xray.geoBrowser.unknownAttribute',
  18. syntax: 'pages.xray.geoBrowser.invalidToken',
  19. wrongKind: 'pages.xray.geoBrowser.wrongKind',
  20. };
  21. export interface GeoTokenInputProps {
  22. value?: string;
  23. onChange?: (value: string) => void;
  24. onBlur?: () => void;
  25. kind: GeoTokenKind;
  26. placeholder?: string;
  27. id?: string;
  28. ref?: Ref<InputRef>;
  29. }
  30. export default function GeoTokenInput({
  31. value = '',
  32. onChange,
  33. onBlur,
  34. kind,
  35. placeholder,
  36. id,
  37. ref,
  38. }: GeoTokenInputProps) {
  39. const { t } = useTranslation();
  40. const [browsing, setBrowsing] = useState(false);
  41. const [issues, setIssues] = useState<GeodataTokenIssue[]>([]);
  42. const [checkFailed, setCheckFailed] = useState(false);
  43. const validate = useValidateGeoTokens();
  44. const { mutateAsync } = validate;
  45. // An empty field has nothing to validate, so it clears during render rather
  46. // than waiting a commit for the effect to catch up.
  47. const isEmpty = parseTokens(value).length === 0;
  48. const [wasEmpty, setWasEmpty] = useState(isEmpty);
  49. if (isEmpty !== wasEmpty) {
  50. setWasEmpty(isEmpty);
  51. if (isEmpty) {
  52. setIssues([]);
  53. setCheckFailed(false);
  54. }
  55. }
  56. useEffect(() => {
  57. const tokens = parseTokens(value);
  58. if (tokens.length === 0) return;
  59. let cancelled = false;
  60. const timer = setTimeout(() => {
  61. mutateAsync({ tokens, kind })
  62. .then((found) => {
  63. if (cancelled) return;
  64. setIssues(found);
  65. setCheckFailed(false);
  66. })
  67. // A rejected check says nothing about the tokens, so the warnings are
  68. // dropped but replaced by a notice — silence here reads as "all valid".
  69. .catch(() => {
  70. if (cancelled) return;
  71. setIssues([]);
  72. setCheckFailed(true);
  73. });
  74. }, VALIDATION_DELAY);
  75. return () => {
  76. cancelled = true;
  77. clearTimeout(timer);
  78. };
  79. }, [value, kind, mutateAsync]);
  80. return (
  81. <>
  82. <Space.Compact block>
  83. <Input
  84. ref={ref}
  85. id={id}
  86. value={value}
  87. placeholder={placeholder}
  88. onChange={(event) => onChange?.(event.target.value)}
  89. onBlur={onBlur}
  90. />
  91. <Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
  92. <Button
  93. icon={<DatabaseOutlined />}
  94. aria-label={t('pages.xray.geoBrowser.openTooltip')}
  95. onClick={() => setBrowsing(true)}
  96. />
  97. </Tooltip>
  98. </Space.Compact>
  99. {groupByReason(issues).map(([reason, tokens]) => (
  100. <Typography.Text key={reason} type="warning" className="geo-unknown-hint">
  101. {t(REASON_KEYS[reason] ?? REASON_KEYS.categoryMissing, { tokens: tokens.join(', ') })}
  102. </Typography.Text>
  103. ))}
  104. {checkFailed && (
  105. <Typography.Text type="secondary" className="geo-unknown-hint">
  106. {t('pages.xray.geoBrowser.checkFailed')}
  107. </Typography.Text>
  108. )}
  109. <GeoBrowserModal
  110. open={browsing}
  111. kind={(kind === 'ip' ? 'ip' : 'site') as GeoKind}
  112. value={value}
  113. onApply={(next) => {
  114. onChange?.(next);
  115. setBrowsing(false);
  116. }}
  117. onClose={() => setBrowsing(false)}
  118. />
  119. </>
  120. );
  121. }
  122. function groupByReason(issues: GeodataTokenIssue[]): Array<[string, string[]]> {
  123. const grouped = new Map<string, string[]>();
  124. for (const issue of issues) {
  125. const tokens = grouped.get(issue.reason) ?? [];
  126. tokens.push(issue.token);
  127. grouped.set(issue.reason, tokens);
  128. }
  129. return [...grouped];
  130. }