Repository: ripple/explorer (ts)

Issue:
Notify users what ledgers a node contains in some error text
Not all nodes contain every ledger.  When a ledger or a transaction is not found we should tell the user what ledgers the node contains.

This is a particularly common issue for testnet where the nodes have a low amount of history.

Pull request description:
feat: show ledgers when ledger or tx not found
## High Level Overview of Change

- Add a message displaying the ledgers available on the currently connected node.
- Allow `<NoMatch>` i18n values to access any props on ConnectionState`.
- Update `<NoMatch> to typescript.
- Fix truncate i18n formatter to properly show horizontal ellipsis.
- Fix loading indicator on ledger page.

Fixes #620

### Context of Change

Not all nodes contain the same complete ledgers.  This will help users debug if the node they are connected to just doesnt contain the ones they are looking forward to.

### Type of Change

<!--
Please check relevant options, delete irrelevant ones.
-->

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [x] Tests (You added tests for code that already exists, or your new feature included in this PR)
- [x] Documentation Updates

### TypeScript/Hooks Update

- [x] Updated files to TypeScript

## Ledger Not Found

### Before
![Before - Ledger Not Found](https://user-images.githubusercontent.com/464895/225092493-05467638-5b2a-4542-a3c0-61d1a893958f.png)

### After
![After - Ledger Not Found](https://user-images.githubusercontent.com/464895/225092537-9490c538-fdd9-42c4-888c-1a9ca91285b9.png)

## Transaction Not Found

### Before
![Before - Transaction Not Found](https://user-images.githubusercontent.com/464895/225092692-e6e353e6-8abc-42c0-a05e-668219fb6ce5.png)

### After
![After - Transaction Not Found](https://user-images.githubusercontent.com/464895/225092668-df4042be-230c-4458-abbd-c398317e1419.png)

Patch:
diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json
index 44c3eb53..2ec2330a 100644
--- a/public/locales/en-US/translations.json
+++ b/public/locales/en-US/translations.json
@@ -188,6 +188,7 @@
   "invalid_transaction_hash": "The transaction hash is invalid",
   "ledger_not_found": "Ledger not Found",
   "check_ledger_id": "Please check your ledger id",
+  "server_ledgers_hint": "This node ({{connection.server.publicKey, truncate(length: 10)}}) only contains ledgers {{connection.ledger.validated}}",
   "use_search": "Please use our search",
   "ledger_has_no_trans": "This ledger does not have any transactions",
   "less_than": "Less than",
diff --git a/src/containers/Ledger/index.jsx b/src/containers/Ledger/index.jsx
index e322ecf9..738f7112 100644
--- a/src/containers/Ledger/index.jsx
+++ b/src/containers/Ledger/index.jsx
@@ -37,7 +37,7 @@ const DATE_OPTIONS = {
 const ERROR_MESSAGES = {}
 ERROR_MESSAGES[NOT_FOUND] = {
   title: 'ledger_not_found',
-  hints: ['check_ledger_id'],
+  hints: ['server_ledgers_hint'],
 }
 ERROR_MESSAGES[BAD_REQUEST] = {
   title: 'invalid_ledger_id',
diff --git a/src/containers/Ledger/ledger.scss b/src/containers/Ledger/ledger.scss
index 3d74b66c..4e02ba88 100644
--- a/src/containers/Ledger/ledger.scss
+++ b/src/containers/Ledger/ledger.scss
@@ -14,17 +14,9 @@
   }
 
   .loader {
-    position: absolute;
     z-index: 1;
-    top: 0px;
-    opacity: 0;
     pointer-events: none;
     transition: opacity 0.3s ease;
-
-    &.show {
-      opacity: 0.8;
-      transition: opacity 0.15s ease;
-    }
   }
 
   .ledger-nav {
diff --git a/src/containers/NoMatch/index.jsx b/src/containers/NoMatch/index.tsx
similarity index 65%
rename from src/containers/NoMatch/index.jsx
rename to src/containers/NoMatch/index.tsx
index dd004c1f..25b15633 100644
--- a/src/containers/NoMatch/index.jsx
+++ b/src/containers/NoMatch/index.tsx
@@ -1,13 +1,34 @@
-import PropTypes from 'prop-types'
-import { useEffect } from 'react'
+import { useContext, useEffect } from 'react'
 import { useTranslation } from 'react-i18next'
+import SocketContext from '../shared/SocketContext'
 import { analytics, ANALYTIC_TYPES } from '../shared/utils'
 import InfoIcon from '../shared/images/info.svg'
 import './nomatch.scss'
 
-const NoMatch = (props) => {
+export interface NoMatchProps {
+  /** The i18n key to use for the title.  If the key contains "not_found" it is treated as a 404 type page */
+  title?: string
+  /** An array of i18n keys to use for hints */
+  hints?: string[]
+  /** Treat the message as an error. Adds "Uh Oh". */
+  isError?: boolean
+  /** Custom warning message to display next to info icon */
+  warning?: string
+}
+
+/**
+ * Provides messaging for not found. I18n values have access to XrplClient's ConnectionState through the variable `connection`.
+ * @constructor
+ */
+const NoMatch = ({
+  title = 'not_found_default_title',
+  hints = ['not_found_check_url'],
+  isError = true,
+  warning = undefined,
+}: NoMatchProps) => {
   const { t } = useTranslation()
-  const { title, hints, isError, warning } = props
+  const socket = useContext(SocketContext)
+  const values = { connection: socket?.getState() }
 
   useEffect(() => {
     document.title = `${t('xrpl_explorer')} | ${t(title)}`
@@ -20,7 +41,7 @@ const NoMatch = (props) => {
   const notFound = title.includes('not_found')
   const hintMsg = hints.map((hint) => (
     <div className="hint" key={hint}>
-      {t(hint)}
+      {t(hint, values)}
     </div>
   ))
   const derivedWarning = warning ?? (notFound && t('not_found'))
@@ -28,7 +49,7 @@ const NoMatch = (props) => {
   return (
     <div className="no-match">
       {isError && <div className="uh-oh">{t('uh_oh')}</div>}
-      <div className="title">{t(title)}</div>
+      <div className="title">{t(title, values)}</div>
       {hintMsg}
       {(derivedWarning || isError) && (
         <div className="warning">
@@ -41,22 +62,4 @@ const NoMatch = (props) => {
   )
 }
 
-NoMatch.propTypes = {
-  /** The i18n key to use for the title.  If the key contains "not_found" it is treated as a 404 type page */
-  title: PropTypes.string,
-  /** An array of i18n keys to use for hints */
-  hints: PropTypes.arrayOf(PropTypes.string),
-  /** Treat the message as an error. Adds "Uh Oh". */
-  isError: PropTypes.bool,
-  /** Custom warning message to display next to info icon */
-  warning: PropTypes.string,
-}
-
-NoMatch.defaultProps = {
-  title: 'not_found_default_title',
-  hints: ['not_found_check_url'],
-  isError: true,
-  warning: undefined,
-}
-
 export default NoMatch
diff --git a/src/containers/Transactions/index.jsx b/src/containers/Transactions/index.jsx
index cd7407d2..75e72377 100644
--- a/src/containers/Transactions/index.jsx
+++ b/src/containers/Transactions/index.jsx
@@ -24,7 +24,7 @@ import { TxStatus } from '../shared/components/TxStatus'
 const ERROR_MESSAGES = {}
 ERROR_MESSAGES[NOT_FOUND] = {
   title: 'transaction_not_found',
-  hints: ['check_transaction_hash'],
+  hints: ['server_ledgers_hint', 'check_transaction_hash'],
 }
 ERROR_MESSAGES[BAD_REQUEST] = {
   title: 'invalid_transaction_hash',
diff --git a/src/i18n/formatters.ts b/src/i18n/formatters.ts
index ea999adf..1671282f 100644
--- a/src/i18n/formatters.ts
+++ b/src/i18n/formatters.ts
@@ -15,7 +15,7 @@ const truncate = (
   truncateOptions: { length: number },
 ) =>
   value.substring(0, truncateOptions.length) +
-  (value.length > truncateOptions.length ? '&hellip;' : '')
+  (value.length > truncateOptions.length ? '\u2026' : '')
 
 export const configureFormatters = (instance: typeof i18n) => {
   instance.services.formatter?.add('truncate', truncate)


Test patch:
diff --git a/src/containers/NoMatch/test/NoMatch.test.js b/src/containers/NoMatch/test/NoMatch.test.tsx
similarity index 76%
rename from src/containers/NoMatch/test/NoMatch.test.js
rename to src/containers/NoMatch/test/NoMatch.test.tsx
index 701f9f28..9681672a 100644
--- a/src/containers/NoMatch/test/NoMatch.test.js
+++ b/src/containers/NoMatch/test/NoMatch.test.tsx
@@ -1,17 +1,24 @@
 import { mount } from 'enzyme'
 import { I18nextProvider } from 'react-i18next'
+import { XrplClient } from 'xrpl-client'
+import MockWsClient from '../../test/mockWsClient'
+import SocketContext from '../../shared/SocketContext'
 import i18n from '../../../i18n/testConfigEnglish'
 import NoMatch from '../index'
 
 /* eslint-disable react/jsx-props-no-spreading */
 describe('NoMatch container', () => {
-  const createWrapper = (props = {}) =>
-    mount(
+  const createWrapper = (props = {}) => {
+    const client = new MockWsClient() as unknown as XrplClient
+
+    return mount(
       <I18nextProvider i18n={i18n}>
-        <NoMatch {...props} />
+        <SocketContext.Provider value={client}>
+          <NoMatch {...props} />
+        </SocketContext.Provider>
       </I18nextProvider>,
     )
-
+  }
   it('renders without crashing', () => {
     const wrapper = createWrapper()
     wrapper.unmount()
@@ -70,4 +77,19 @@ describe('NoMatch container', () => {
     const wrapper = createWrapper(params)
     expect(wrapper.find('.warning span')).toHaveText('be_warned')
   })
+
+  it('renders connection state', () => {
+    i18n.addResource(
+      'en-US',
+      'test',
+      'hint_test',
+      'Version: {{connection.server.version}}',
+    )
+    const params = {
+      title: 'props_title',
+      hints: ['test:hint_test'],
+    }
+    const wrapper = createWrapper(params)
+    expect(wrapper.find('.hint')).toHaveText('Version: 1.9.4')
+  })
 })
diff --git a/src/containers/test/mockWsClient.js b/src/containers/test/mockWsClient.js
index d3908477..9ed423d1 100644
--- a/src/containers/test/mockWsClient.js
+++ b/src/containers/test/mockWsClient.js
@@ -108,7 +108,12 @@ class MockWsClient extends EventEmitter {
    */
   // eslint-disable-next-line class-methods-use-this -- not needed for a mock
   getState() {
-    return { online: true }
+    return {
+      online: true,
+      server: {
+        version: '1.9.4',
+      },
+    }
   }
 
   /**
diff --git a/src/i18n/test/formatters.test.ts b/src/i18n/test/formatters.test.ts
index 63670d2b..5c460e8e 100644
--- a/src/i18n/test/formatters.test.ts
+++ b/src/i18n/test/formatters.test.ts
@@ -9,9 +9,7 @@ describe('i18n formatters', () => {
         'woo',
         `Hello {{value, truncate(length: 3)}}`,
       )
-      expect(i18n.t('test:woo', { value: 'World' })).toEqual(
-        'Hello Wor&hellip;',
-      )
+      expect(i18n.t('test:woo', { value: 'World' })).toEqual('Hello Wor\u2026')
     })
   })
 })