When querying the DOM during testing with jsdom, it is sometimes not obvious what role
is assigned to an element.
Usually we use Chrome Devtools to lookup an element and find its role
.
For the td
element, the role
assigned to it by Chrome was gridcell
.
But in jsdom the role
assigned to the element was cell
.
This might save some time and frustrations in the future.
When for some reason you get CORS during local development, you can bypass this with a proxy.
local-cors-proxy
is a simple tool that let's you do just that. (https://www.npmjs.com/package/local-cors-proxy)
lcp --proxyUrl [API_BASE_URL]
This is however only a temporary solution. You should fix the cors problem... I mean core problem.
TypeScript by itself can detect unused code. But once you export it, TypeScript no longer cares about it.
The tool ts-prune
looks for unused exports. Some of these exports can be removed.
Some can not be removed.
You can list the unused exports by running:
yarn ts-prune
These components sort of do the same thing. But Select is more powerfull.
To do a selection in a dropdown, you need to use event.target.innerText
.
This is not a good idea because innerText
is not implemented in jsdom
.
So code using innerText
will not work in a test.
Never use Dropdown for selection.
Use it for actions that after the click move away from the component.
Use Select for selecting stuff.
See https://www.patternfly.org/v4/components/select/design-guidelines.
- https://stackoverflow.com/questions/52177631/jest-timer-and-promise-dont-work-well-settimeout-and-async-function
- jestjs/jest#2157
Jest uses JSDOM. JSDOM defines the window innerWidth and innerHeight to be 1024 x 768 by default. So during a test, the application is basically rendered in memory in a window with dimensions 1024 x 768. When writing a test for a component that behaves diffently based on screen width, remember the default dimensions. You can change the dimensions by setting the window properties and triggering a resize event.
// Change the viewport to 500px.
window = Object.assign(window, { innerWidth: 500 });
// Trigger the window resize event.
window.dispatchEvent(new Event("resize"));
We decided to use the last 2 versions of the following browsers: Chrome, Firefox, Safari, Edge. The main reasons for this are:
- Patternfly, the component library we use also only supports these browsers
- improving security
- keeping up to date with the new developments of the browser APIs
Since we only support these browser versions, the target of the typescript compiler is also set to ES2020.
library for archive handling: https://stuk.github.io/jszip/ library for saving a file: https://www.npmjs.com/package/file-saver
These are used for downloading the support archive.
You should always try to use the <Link />
component because this actually creates an anchor element in the html.
This allows right clicking the link and other browser behaviour.
You should only use useNavigateTo
when you need to programmatically change the url.
We publish the packages of new development versions to the github package registry.
The jenkins job to release a new dev version of the web-console also cleans up older dev versions, by calling the clean_up_packages
script in this repository.
This will remove dev versions that were published more than 30 days ago, by using the github GraphQL API.
The state of a page may be saved to the url query parameters by using the useUrlState
and related hooks. This usually includes the expansion state, filters, sorting and paging parameters, but other kinds of component state may also be saved to the url if deemed useful. The two main goals of saving the state to the url are:
- Shareability: a full link with all parameters can be bookmarked or sent to others
- Context: when drilling down - navigating to pages that are under the same 'main' page with regards to breadcrumbs - the state of the parent pages, that has been saved to the url, is kept. This makes the navigation clearer - when going back the user can see exactly where they started, and continue from there. When going back to a parent page, the state of the child page is not kept. The url state is fully reset when navigating to another page via the sidebar.
Under the hood, these hooks are built on the useLocation
and useHistory
hooks from react-router, and use qs for the serialization and deserialization of the url parameters.
Note that the values saved to the url are not checked against the contents of the state store (except for the environment id, which uses a different mechanism). This means that the url can contain items that are outdated (for example: row x is extended, the row is removed in the backend and thus not present after the next auto-update. The id of the row may still be saved to the url). These items are disregarded but not removed explicitly (until they are removed by the navigation).
Currently the url is not shortened, the parameters are written to the url as they are present in the components, this can be optimized if the length of the url becomes a problem.
25-10-2022
Currently, the qs library is being used to parse the query into a stateObject. The library allows you to pass in an arrayLimit in the parsing method. When the limit is reached, the query string will be transformed into an object instead of an array. The current limitation is set to 200
. This limit is only applied to a list of queries of the same level.
Example:
http://example.com?fruits=Banana&fruits=Apple
Will be parsed as :
{ fruits: [ 'Banana', 'Apples' ] }
If the arrayLimit is set to 2
, on the third fruit, the parser will change the fruits Array into a fruits Object. This behaviour is built-in the third party we are using, and cannot be overriden.
http://example.com?fruits=Banana&fruits=Apple&fruits=Oranges
Will be parsed as :
{ fruits: { 1:'Banana', 2:'Apples', 3:'Oranges' } }
When this occurs, the app is getting an Object instead of an Array at the place it calls fruits
and will throw an error.
Concretely this means that when a user clicks 201 logs open on the same page, that limit will be reached and result in an error that's being caught by the ErrorBoundary component.
Decission The limit is set to 200, and before the app would even throw, the user would have to click 201 elements of same the state-type (so in the case of the example above, 201 fruits.) The limitation is only applied on lists of the same level. Nested elements aren't affected and each level will have it's own limit of 200 length.
The effort of changing and catching the unwanted Objects and changing them to Arrays is too big for the few occasions this issue might arise. The limit could be set to a higher number, but this isn't something we should do lightly. Very long urls can cause issues such as DOS. 200 is already a very big number.
If the problem is being noticed and the app crashes for this specific reason, we can investigate how to prevent it while preserving performance and security.
Related tickets
- #3869 Browser crashes when url keeps growing
- #2680 Expanding many items in a resource logs page makes the page crash
The CodeHighlighter
component is responsible for the syntax highlighting that we use on several pages. It offloads the highlighting logic to react-syntax-highlighter.
We chose a custom component instead of the Patternfly CodeBlock
component because the Pattenfly one takes up more screen real estate (actions on top in a mostly empty row, instead of on the side), and it's also missing some features that we could include in the custom component (e.g., line numbers, highlighting according to different languages).
27-10-2022 As of this date, we decided to remove storybook and all related dependencies. The main reasons are as follow:
- We don't have any benefit in displaying the list of components that we are using. We are not building a library of components, but an application using third party components. This results in unnecessary maintenance.
- We tested to see for 1-2 months if it has any usecases, asside from maintaining it, we didn't use it either.
- A lot of dependencies are being imported for no major interesting reasons.
04-10-2022 As of this date, the project has been updated to the latest React version. React 18 brings a few breaking changes and improvements. You can find the release note here
They changed the format, you can't pass a string anymore to the fakeTimer method. You need to pass an object instead.
const jestOptions = { legacyFakeTimers: true };
jest.useFakeTimers(jestOptions);
- Resolving mocked calls in tests always needs to be wrapped in an "act". Example:
await act(async () => {
await apiHelper.resolve(204);
});
-
Some Styled components might give you typing errors, especially SVGIcons when trying to déclare them. If you encounter this issue, please remove your node_modules and reinstall the project. You might have some cached typings. The reason why this error occurs is because StyleComponents are using a wildcard in the dependency for React types. We use a resolution to stay compatible with the new typing of React 18 and the ones needed for the StyledComponents.
-
We needed to proxy the globalStyles, this solution is required because of the typing related compatibility. You can find the Github issue here.
const GlobalStyleProxy: any = GlobalStyles;
- From now on, if a component has children props you will need to declare them explicitly. You have two options.
- You define the children in the interface, this is handy when you already defined an interface for it.
- You can type your component as follow :
React.FC<React.PropsWithChildren<unkown>>
orReact.FC<React.PropsWithChildren<Props>>
if you use an interface.
- If you encounter an error telling you the number of ReactNode is multiple instead of 1, this is again either a compatibility issue from Paternfly components OR you defined that the children of a component as follow :
children?: React.ReactNode;
instead ofchildren?: React.ReactNode | React.ReactNode[];
There is a simple way to bypass this issue if it's coming from a third party component. You can wrap the innercontent of the component in a ReactFragment.
<ComponentWithChildren>
<>
// your content
</>
</ComponentWithChildren>
If you get a simmilar error, you will most likely need to cast the variable to a ReactNode element. Example:
{row.version as React.ReactNode}
We agreed to use hyphen-sepraten name covention for custom Events i.e.
document.dispatchEvent(new Event("settings-update"));
We decided to briefly explain flow and the general usage of Command and Query Managers which are the foundation of our communication with the backend. The decision was motivated to ease the onboarding process for future colleagues who happen to work on currently implemented or create new ones and also for us to help when we have to come back to them in future
Communication is based on Command/Query Managers and Resolvers which were implemented to unify how data is fetched from the backend and also to group all these types of calls in one place to be easily accessed. That also put some abstraction on it which can be difficult to decode at first glance.
The difference between Command and Query Managers is pretty straightforward, Command ones are responsible for every request that sends some data to the backend(POST/UPDATE/DELETE)Query ones, on the other hand, for retrieving data from it(GET/HEAD).
There are two main types of query managers each with two subtypes:
- One Time Query Manager(with Env or not)
- Continuous Query Manager(with Env or not)
There is also one query Manager used for notifications:
- useReadOnlyWithEnv to which implementation is simplified to only 3 parameters but has similar behavior to the covered queries below.
One Time Query Managers as its name indicates creates an instance that's meant to be used as a singular, non-repeating request.
Continuous Query Managers on the other hand are used with one additional parameter to help them fulfill their continuous purpose, parameter "scheduler" indicates the interval on which a given request is called.
Query Manager accepts:
- ApiHelper - takes care of API calls, it's instantiated on app start(in the
Injector.tsx
file) before all other Managers/Resolvers which then is passed down to ones that need that helper. - StateHelper - get call status and eventual data assigned1, it's also instantiated on the start, like ApiHelper
- Scheduler - as mentioned above sets the interval on which query is asked2
- getUnique - a callback that returns a unique "id" that is being populated to Scheduler to avoid having duplicates of queries in scheduler2
- getDependencies - a callback that returns dependencies on which that call relies (with env or not)
- kind - defines what kind of call it is from a set of predefined calls1
- getUrl - a callback that returns the correct URL tail for a given call1
- toUsed - a callback that is used to process data after retrieved from a call
- strategy - a string enum that is used as one of the conditions to define whether to set query call status to loading or not3
Each query Manager has:
- internal update function which takes care of appending data to stateHelper
- useOneTime hook which has two useEffect, one listens for query change and update URL, second listens to URL change to perform fire update() function, also returns data and callback that essentially run update function which is used across the app as retry function
- matches function that checks given query.kind with one that is initially sent to query manager(with kind parameter on initialization)
Each initialization of Query Manager is appended to the main Query Resolver which stores every instance of the queries available in the app. Query Resolver on the other hand is stored in DependecyContext with other Managers or Resolvers.
These instances can be used in components as in the example below:
const { queryResolver } = useContext(DependencyContext);
const [statusData, retry] = queryResolver.useOneTime<"GetServerStatus">({
kind: "GetServerStatus",
});
All queryManagers are stored in QueryResolver class which allows us to browse through the ready-to-use implementation of each query only with kind parameters as seen above. After that assignment we get an Array from the hook with RemoteData Object which, in short, has 4 statuses:
- Not Asked
- Loading
- Failed - with the value that stores an error message
- Success- with the value that corresponds to data requested in a query
That object has to be unfolded to access data through RemoteData.fold()
There are two sub-types of command managers:
- CommandManagerWithEnv
- CommandManagerWithoutEnv
Each CommandManager instance is initialized with apiHelper in CommandResolver which has the same purpose as queryResolver. CommandResolver stores all instances of managers, each of them returns either CommandManagerWithEnv or CommandManagerWithoutEnv instance that takes two parameters:
- kind - string Enum that helps navigate through managers in the same way as in Query Resolvers
- customGetTrigger - a callback that accepts:
- command Object which stores kind parameter and occasionally data used to specify the destination of post/update/delete request
- environment string which is populated in the CommandManagerWithEnv/WithoutEnv body That callback with help of apiHelper takes care of requests based on the requirement that function then returns functions:
- matches - which has the same usability as in queryManager
- useGetTrigger which takes the command Object and passes it down with the environment parameter down to customGetTrigger
These instances can be used in components as in the example below:
const { commandResolver } = useContext(DependencyContext);
const trigger = commandResolver.useGetTrigger<"TriggerDryRun">({
kind: "TriggerDryRun",
version,
});
the trigger is returning customGetTrigger with already populated parameters, ready to be used to send requests to the backend.