datajunction-ui 0.0.1-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.env +1 -0
  2. package/.env.local +4 -0
  3. package/.env.production +1 -0
  4. package/.eslintrc.js +20 -0
  5. package/.gitattributes +201 -0
  6. package/.github/pull_request_template.md +11 -0
  7. package/.github/workflows/ci.yml +33 -0
  8. package/.husky/pre-commit +6 -0
  9. package/.nvmrc +1 -0
  10. package/.prettierignore +4 -0
  11. package/.prettierrc +9 -0
  12. package/.stylelintrc +7 -0
  13. package/.vscode/extensions.json +8 -0
  14. package/.vscode/launch.json +15 -0
  15. package/.vscode/settings.json +25 -0
  16. package/Dockerfile +6 -0
  17. package/LICENSE +22 -0
  18. package/README.md +10 -0
  19. package/internals/testing/loadable.mock.tsx +6 -0
  20. package/package.json +150 -0
  21. package/public/favicon.ico +0 -0
  22. package/public/index.html +29 -0
  23. package/public/manifest.json +15 -0
  24. package/public/robots.txt +3 -0
  25. package/src/app/__tests__/__snapshots__/index.test.tsx.snap +45 -0
  26. package/src/app/__tests__/index.test.tsx +14 -0
  27. package/src/app/components/ListGroupItem.jsx +17 -0
  28. package/src/app/components/NamespaceHeader.jsx +40 -0
  29. package/src/app/components/Tab.jsx +26 -0
  30. package/src/app/components/__tests__/ListGroupItem.test.tsx +16 -0
  31. package/src/app/components/__tests__/NamespaceHeader.test.jsx +14 -0
  32. package/src/app/components/__tests__/__snapshots__/ListGroupItem.test.tsx.snap +26 -0
  33. package/src/app/components/__tests__/__snapshots__/NamespaceHeader.test.jsx.snap +63 -0
  34. package/src/app/components/djgraph/DJNode.jsx +111 -0
  35. package/src/app/components/djgraph/__tests__/DJNode.test.tsx +24 -0
  36. package/src/app/components/djgraph/__tests__/__snapshots__/DJNode.test.tsx.snap +73 -0
  37. package/src/app/index.tsx +53 -0
  38. package/src/app/pages/ListNamespacesPage/Loadable.jsx +23 -0
  39. package/src/app/pages/ListNamespacesPage/index.jsx +53 -0
  40. package/src/app/pages/NamespacePage/Loadable.jsx +23 -0
  41. package/src/app/pages/NamespacePage/__tests__/__snapshots__/index.test.tsx.snap +45 -0
  42. package/src/app/pages/NamespacePage/__tests__/index.test.tsx +14 -0
  43. package/src/app/pages/NamespacePage/index.jsx +93 -0
  44. package/src/app/pages/NodePage/Loadable.jsx +23 -0
  45. package/src/app/pages/NodePage/NodeColumnTab.jsx +44 -0
  46. package/src/app/pages/NodePage/NodeGraphTab.jsx +160 -0
  47. package/src/app/pages/NodePage/NodeInfoTab.jsx +87 -0
  48. package/src/app/pages/NodePage/NodeStatus.jsx +34 -0
  49. package/src/app/pages/NodePage/index.jsx +100 -0
  50. package/src/app/pages/NotFoundPage/Loadable.tsx +14 -0
  51. package/src/app/pages/NotFoundPage/P.ts +8 -0
  52. package/src/app/pages/NotFoundPage/__tests__/__snapshots__/index.test.tsx.snap +61 -0
  53. package/src/app/pages/NotFoundPage/__tests__/index.test.tsx +21 -0
  54. package/src/app/pages/NotFoundPage/index.tsx +45 -0
  55. package/src/app/pages/Root/Loadable.tsx +23 -0
  56. package/src/app/pages/Root/assets/dj-logo.png +0 -0
  57. package/src/app/pages/Root/index.tsx +42 -0
  58. package/src/app/services/DJService.js +124 -0
  59. package/src/index.tsx +47 -0
  60. package/src/react-app-env.d.ts +4 -0
  61. package/src/reportWebVitals.ts +15 -0
  62. package/src/setupTests.ts +8 -0
  63. package/src/styles/dag-styles.ts +117 -0
  64. package/src/styles/global-styles.ts +588 -0
  65. package/src/styles/index.css +546 -0
  66. package/src/utils/__tests__/__snapshots__/loadable.test.tsx.snap +17 -0
  67. package/src/utils/__tests__/loadable.test.tsx +53 -0
  68. package/src/utils/__tests__/request.test.ts +82 -0
  69. package/src/utils/loadable.tsx +30 -0
  70. package/src/utils/request.ts +54 -0
  71. package/tsconfig.json +31 -0
@@ -0,0 +1,93 @@
1
+ import * as React from 'react';
2
+ import { useParams } from 'react-router-dom';
3
+ import { useEffect, useState } from 'react';
4
+ import { DataJunctionAPI } from '../../services/DJService';
5
+ import NamespaceHeader from '../../components/NamespaceHeader';
6
+ import NodeStatus from '../NodePage/NodeStatus';
7
+
8
+ export async function loader({ params }) {
9
+ const djNode = await DataJunctionAPI.node(params.name);
10
+ if (djNode.type === 'metric') {
11
+ const metricNode = await DataJunctionAPI.metric(params.name);
12
+ djNode.dimensions = metricNode.dimensions;
13
+ }
14
+ return djNode;
15
+ }
16
+
17
+ export function NamespacePage() {
18
+ const { namespace } = useParams();
19
+
20
+ const [state, setState] = useState({
21
+ namespace: namespace,
22
+ nodes: [],
23
+ });
24
+
25
+ useEffect(() => {
26
+ const fetchData = async () => {
27
+ const djNodes = await DataJunctionAPI.namespace(namespace);
28
+ const nodes = djNodes.map(node => {
29
+ return DataJunctionAPI.node(node);
30
+ });
31
+ const foundNodes = await Promise.all(nodes);
32
+ setState({
33
+ namespace: namespace,
34
+ nodes: foundNodes,
35
+ });
36
+ };
37
+ fetchData().catch(console.error);
38
+ }, [namespace]);
39
+
40
+ const nodesList = state.nodes.map(node => (
41
+ <tr>
42
+ <td>
43
+ <a href={'/namespaces/' + node.namespace}>{node.namespace}</a>
44
+ </td>
45
+ <td>
46
+ <a href={'/nodes/' + node.name} className="link-table">
47
+ {node.display_name}
48
+ </a>
49
+ <span
50
+ className="rounded-pill badge bg-secondary-soft"
51
+ style={{ marginLeft: '0.5rem' }}
52
+ >
53
+ {node.version}
54
+ </span>
55
+ </td>
56
+ <td>
57
+ <span className={'node_type__' + node.type + ' badge node_type'}>
58
+ {node.type}
59
+ </span>
60
+ </td>
61
+ <td>
62
+ <NodeStatus node={node} />
63
+ </td>
64
+ <td>
65
+ <span className="status">{node.mode}</span>
66
+ </td>
67
+ </tr>
68
+ ));
69
+
70
+ // @ts-ignore
71
+ return (
72
+ <div className="mid">
73
+ <NamespaceHeader namespace={namespace} />
74
+ <div className="card">
75
+ <div className="card-header">
76
+ <h2>Nodes</h2>
77
+ <div className="table-responsive">
78
+ <table className="card-table table">
79
+ <thead>
80
+ <th>Namespace</th>
81
+ <th>Name</th>
82
+ <th>Type</th>
83
+ <th>Status</th>
84
+ <th>Mode</th>
85
+ </thead>
86
+ {nodesList}
87
+ </table>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Asynchronously loads the component for the Node page
3
+ */
4
+
5
+ import * as React from 'react';
6
+ import { lazyLoad } from 'utils/loadable';
7
+ import styled from 'styled-components/macro';
8
+
9
+ const LoadingWrapper = styled.div`
10
+ width: 100%;
11
+ height: 100vh;
12
+ display: flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ `;
16
+
17
+ export const NodePage = lazyLoad(
18
+ () => import('./index'),
19
+ module => module.NodePage,
20
+ {
21
+ fallback: <LoadingWrapper></LoadingWrapper>,
22
+ },
23
+ );
@@ -0,0 +1,44 @@
1
+ import { Component } from 'react';
2
+
3
+ export default class NodeColumnTab extends Component {
4
+ columnList = node => {
5
+ return node.columns.map(col => (
6
+ <tr>
7
+ <td className="text-start">{col.name}</td>
8
+ <td>
9
+ <span className="node_type__transform badge node_type">
10
+ {col.type}
11
+ </span>
12
+ </td>
13
+ <td>{col.dimension ? col.dimension.name : ''}</td>
14
+ <td>
15
+ {col.attributes.find(
16
+ attr => attr.attribute_type.name === 'dimension',
17
+ ) ? (
18
+ <span className="node_type__dimension badge node_type">
19
+ dimensional
20
+ </span>
21
+ ) : (
22
+ ''
23
+ )}
24
+ </td>
25
+ </tr>
26
+ ));
27
+ };
28
+
29
+ render() {
30
+ return (
31
+ <div className="table-responsive">
32
+ <table className="card-inner-table table">
33
+ <thead className="fs-7 fw-bold text-gray-400 border-bottom-0">
34
+ <th className="text-start">Column</th>
35
+ <th>Type</th>
36
+ <th>Dimension</th>
37
+ <th>Attributes</th>
38
+ </thead>
39
+ {this.columnList(this.props.node)}
40
+ </table>
41
+ </div>
42
+ );
43
+ }
44
+ }
@@ -0,0 +1,160 @@
1
+ import React, { useCallback, useEffect, useMemo } from 'react';
2
+ import ReactFlow, {
3
+ addEdge,
4
+ MiniMap,
5
+ Controls,
6
+ Background,
7
+ useNodesState,
8
+ useEdgesState,
9
+ MarkerType,
10
+ } from 'reactflow';
11
+ import 'reactflow/dist/style.css';
12
+ import DJNode from '../../components/djgraph/DJNode';
13
+ import { DataJunctionAPI } from '../../services/DJService';
14
+ import dagre from 'dagre';
15
+
16
+ const NodeLineage = djNode => {
17
+ const nodeTypes = useMemo(() => ({ DJNode: DJNode }), []);
18
+
19
+ const [nodes, setNodes, onNodesChange] = useNodesState([]);
20
+ const [edges, setEdges, onEdgesChange] = useEdgesState([]);
21
+
22
+ const minimapStyle = {
23
+ height: 100,
24
+ width: 150,
25
+ };
26
+
27
+ const dagreGraph = useMemo(() => new dagre.graphlib.Graph(), []);
28
+ dagreGraph.setDefaultEdgeLabel(() => ({}));
29
+
30
+ useEffect(() => {
31
+ const setElementsLayout = (
32
+ nodes,
33
+ edges,
34
+ direction = 'LR',
35
+ nodeWidth = 800,
36
+ nodeHeight = 150,
37
+ ) => {
38
+ const isHorizontal = direction === 'TB';
39
+ dagreGraph.setGraph({ rankdir: direction });
40
+
41
+ nodes.forEach(node => {
42
+ dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
43
+ });
44
+
45
+ edges.forEach(edge => {
46
+ dagreGraph.setEdge(edge.source, edge.target);
47
+ });
48
+
49
+ dagre.layout(dagreGraph);
50
+
51
+ nodes.forEach(node => {
52
+ const nodeWithPosition = dagreGraph.node(node.id);
53
+ node.targetPosition = isHorizontal ? 'left' : 'top';
54
+ node.sourcePosition = isHorizontal ? 'right' : 'bottom';
55
+ node.position = {
56
+ x: nodeWithPosition.x - nodeWidth / 2,
57
+ y: nodeWithPosition.y - nodeHeight / 2,
58
+ };
59
+ return node;
60
+ });
61
+
62
+ return { nodes, edges };
63
+ };
64
+
65
+ const dagFetch = async () => {
66
+ let upstreams = await DataJunctionAPI.upstreams(djNode.djNode.name);
67
+ let downstreams = await DataJunctionAPI.downstreams(djNode.djNode.name);
68
+ let djNodes = [...new Set([...upstreams, ...downstreams, djNode.djNode])];
69
+ let edges = [];
70
+ djNodes.forEach(obj => {
71
+ obj.parents.forEach(parent => {
72
+ if (parent.name) {
73
+ edges.push({
74
+ id: obj.name + '-' + parent.name,
75
+ target: obj.name,
76
+ source: parent.name,
77
+ animated: true,
78
+ markerEnd: {
79
+ type: MarkerType.Arrow,
80
+ },
81
+ });
82
+ }
83
+ });
84
+
85
+ obj.columns.forEach(col => {
86
+ if (col.dimension) {
87
+ edges.push({
88
+ id: obj.name + '-' + col.dimension.name,
89
+ target: obj.name,
90
+ source: col.dimension.name,
91
+ draggable: true,
92
+ });
93
+ }
94
+ });
95
+ });
96
+ const nodes = djNodes.map(node => {
97
+ const primary_key = node.columns
98
+ .filter(col =>
99
+ col.attributes.some(
100
+ attr => attr.attribute_type.name === 'primary_key',
101
+ ),
102
+ )
103
+ .map(col => col.name);
104
+ const column_names = node.columns.map(col => {
105
+ return { name: col.name, type: col.type };
106
+ });
107
+ return {
108
+ id: String(node.name),
109
+ type: 'DJNode',
110
+ data: {
111
+ label:
112
+ node.table !== null
113
+ ? String(node.schema_ + '.' + node.table)
114
+ : String(node.name),
115
+ table: node.table,
116
+ name: String(node.name),
117
+ display_name: String(node.display_name),
118
+ type: node.type,
119
+ primary_key: primary_key,
120
+ column_names: column_names,
121
+ // dimensions: dimensions,
122
+ },
123
+ // parentNode: [node.name.split(".").slice(-2, -1)],
124
+ // extent: 'parent',
125
+ };
126
+ });
127
+
128
+ setNodes(nodes);
129
+ setEdges(edges);
130
+ setElementsLayout(nodes, edges);
131
+ };
132
+
133
+ dagFetch();
134
+ }, [dagreGraph, djNode.djNode, setEdges, setNodes]);
135
+
136
+ const onConnect = useCallback(
137
+ params => setEdges(eds => addEdge(params, eds)),
138
+ [setEdges],
139
+ );
140
+
141
+ return (
142
+ <div style={{ height: '600px' }}>
143
+ <ReactFlow
144
+ nodes={nodes}
145
+ edges={edges}
146
+ nodeTypes={nodeTypes}
147
+ onNodesChange={onNodesChange}
148
+ onEdgesChange={onEdgesChange}
149
+ onConnect={onConnect}
150
+ snapToGrid={true}
151
+ fitView
152
+ >
153
+ <MiniMap style={minimapStyle} zoomable pannable />
154
+ <Controls />
155
+ <Background color="#aaa" gap={16} />
156
+ </ReactFlow>
157
+ </div>
158
+ );
159
+ };
160
+ export default NodeLineage;
@@ -0,0 +1,87 @@
1
+ import { Component } from 'react';
2
+ import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
3
+ import { foundation } from 'react-syntax-highlighter/src/styles/hljs';
4
+ import sql from 'react-syntax-highlighter/dist/esm/languages/hljs/sql';
5
+ import { format } from 'sql-formatter';
6
+
7
+ import NodeStatus from './NodeStatus';
8
+ import ListGroupItem from '../../components/ListGroupItem';
9
+
10
+ SyntaxHighlighter.registerLanguage('sql', sql);
11
+ foundation.hljs['padding'] = '2rem';
12
+
13
+ export default class NodeInfoTab extends Component {
14
+ nodeTags = this.props.node?.tags.map(tag => <div>{tag}</div>);
15
+ queryDiv = this.props.node?.query ? (
16
+ <div className="list-group-item d-flex">
17
+ <div className="d-flex gap-2 w-100 justify-content-between py-3">
18
+ <div
19
+ style={{
20
+ width: window.innerWidth * 0.8,
21
+ }}
22
+ >
23
+ <h6 className="mb-0 w-100">Query</h6>
24
+ <SyntaxHighlighter language="sql" style={foundation}>
25
+ {format(this.props.node?.query, {
26
+ language: 'spark',
27
+ tabWidth: 2,
28
+ keywordCase: 'upper',
29
+ denseOperators: true,
30
+ logicalOperatorNewline: 'before',
31
+ expressionWidth: 10,
32
+ })}
33
+ </SyntaxHighlighter>
34
+ </div>
35
+ </div>
36
+ </div>
37
+ ) : (
38
+ <></>
39
+ );
40
+
41
+ render() {
42
+ return (
43
+ <div className="list-group align-items-center justify-content-between flex-md-row gap-2">
44
+ <ListGroupItem
45
+ label="Description"
46
+ value={this.props.node?.description}
47
+ />
48
+ <div className="list-group-item d-flex">
49
+ <div className="d-flex gap-2 w-100 justify-content-between py-3">
50
+ <div>
51
+ <h6 className="mb-0 w-100">Version</h6>
52
+
53
+ <p className="mb-0 opacity-75">
54
+ <span
55
+ className="rounded-pill badge bg-secondary-soft"
56
+ style={{ marginLeft: '0.5rem', fontSize: '100%' }}
57
+ >
58
+ {this.props.node?.version}
59
+ </span>
60
+ </p>
61
+ </div>
62
+ <div>
63
+ <h6 className="mb-0 w-100">Status</h6>
64
+ <p className="mb-0 opacity-75">
65
+ <NodeStatus node={this.props.node} />
66
+ </p>
67
+ </div>
68
+ <div>
69
+ <h6 className="mb-0 w-100">Mode</h6>
70
+ <p className="mb-0 opacity-75">
71
+ <span className="status">{this.props.node?.mode}</span>
72
+ </p>
73
+ </div>
74
+ <div>
75
+ <h6 className="mb-0 w-100">Tags</h6>
76
+ <p className="mb-0 opacity-75">{this.nodeTags}</p>
77
+ </div>
78
+ </div>
79
+ </div>
80
+ {this.queryDiv}
81
+ <div className="list-group-item d-flex">
82
+ {this.props.node?.primary_key}
83
+ </div>
84
+ </div>
85
+ );
86
+ }
87
+ }
@@ -0,0 +1,34 @@
1
+ import { Component } from 'react';
2
+
3
+ export default class NodeStatus extends Component {
4
+ render() {
5
+ const { node } = this.props;
6
+ return (
7
+ <span className="status__valid status" style={{ alignContent: 'center' }}>
8
+ {node?.status === 'valid' ? (
9
+ <svg
10
+ xmlns="http://www.w3.org/2000/svg"
11
+ width="25"
12
+ height="25"
13
+ fill="currentColor"
14
+ className="bi bi-check-circle-fill"
15
+ viewBox="0 0 16 16"
16
+ >
17
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z" />
18
+ </svg>
19
+ ) : (
20
+ <svg
21
+ xmlns="http://www.w3.org/2000/svg"
22
+ width="16"
23
+ height="16"
24
+ fill="currentColor"
25
+ className="bi bi-x-circle-fill"
26
+ viewBox="0 0 16 16"
27
+ >
28
+ <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
29
+ </svg>
30
+ )}
31
+ </span>
32
+ );
33
+ }
34
+ }
@@ -0,0 +1,100 @@
1
+ import * as React from 'react';
2
+ import { useParams } from 'react-router-dom';
3
+ import { useEffect, useState } from 'react';
4
+ import { DataJunctionAPI } from '../../services/DJService';
5
+ import Tab from '../../components/Tab';
6
+ import NamespaceHeader from '../../components/NamespaceHeader';
7
+ import NodeInfoTab from './NodeInfoTab';
8
+ import NodeColumnTab from './NodeColumnTab';
9
+ import NodeLineage from './NodeGraphTab';
10
+
11
+ export function NodePage() {
12
+ const [state, setState] = useState({
13
+ selectedTab: 0,
14
+ });
15
+
16
+ const [node, setNode] = useState();
17
+
18
+ const onClickTab = id => () => {
19
+ setState({ selectedTab: id });
20
+ };
21
+
22
+ const buildTabs = tab => {
23
+ return (
24
+ <Tab
25
+ key={tab.id}
26
+ id={tab.id}
27
+ name={tab.name}
28
+ onClick={onClickTab(tab.id)}
29
+ selectedTab={state.selectedTab}
30
+ />
31
+ );
32
+ };
33
+
34
+ const { name } = useParams();
35
+
36
+ useEffect(() => {
37
+ const fetchData = async () => {
38
+ const data = await DataJunctionAPI.node(name);
39
+ setNode(data);
40
+ };
41
+ fetchData().catch(console.error);
42
+ }, [name]);
43
+
44
+ const TabsJson = [
45
+ {
46
+ id: 0,
47
+ name: 'Info',
48
+ },
49
+ {
50
+ id: 1,
51
+ name: 'Columns',
52
+ },
53
+ {
54
+ id: 2,
55
+ name: 'Graph',
56
+ },
57
+ ];
58
+ //
59
+ //
60
+ let tabToDisplay = null;
61
+ switch (state.selectedTab) {
62
+ case 0:
63
+ tabToDisplay = node ? <NodeInfoTab node={node} /> : '';
64
+ break;
65
+ case 1:
66
+ tabToDisplay = <NodeColumnTab node={node} />;
67
+ break;
68
+ case 2:
69
+ tabToDisplay = <NodeLineage djNode={node} />;
70
+ break;
71
+ default:
72
+ tabToDisplay = <NodeInfoTab node={node} />;
73
+ }
74
+
75
+ // @ts-ignore
76
+ return (
77
+ <div className="node__header">
78
+ <NamespaceHeader namespace={name.split('.').slice(0, -1).join('.')} />
79
+ <div className="card">
80
+ <div className="card-header">
81
+ <h3 className="card-title align-items-start flex-column">
82
+ <span className="card-label fw-bold text-gray-800">
83
+ {node?.display_name}
84
+ </span>
85
+ </h3>
86
+ <span
87
+ className="fs-6 fw-semibold text-gray-400"
88
+ style={{ marginTop: '-4rem' }}
89
+ >
90
+ Updated {new Date(node?.updated_at).toDateString()}
91
+ </span>
92
+ <div className="align-items-center row">
93
+ {TabsJson.map(buildTabs)}
94
+ </div>
95
+ {tabToDisplay}
96
+ </div>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Asynchronously loads the component for NotFoundPage
3
+ */
4
+
5
+ import * as React from 'react';
6
+ import { lazyLoad } from 'utils/loadable';
7
+
8
+ export const NotFoundPage = lazyLoad(
9
+ () => import('./index'),
10
+ module => module.NotFoundPage,
11
+ {
12
+ fallback: <></>,
13
+ },
14
+ );
@@ -0,0 +1,8 @@
1
+ import styled from 'styled-components/macro';
2
+
3
+ export const P = styled.p`
4
+ font-size: 1rem;
5
+ line-height: 1.5;
6
+ color: ${p => p.theme.textSecondary};
7
+ margin: 0.625rem 0 1.5rem 0;
8
+ `;
@@ -0,0 +1,61 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`<NotFoundPage /> should match snapshot 1`] = `
4
+ .c2 {
5
+ font-size: 1rem;
6
+ line-height: 1.5;
7
+ margin: 0.625rem 0 1.5rem 0;
8
+ }
9
+
10
+ .c0 {
11
+ height: 100%;
12
+ display: -webkit-box;
13
+ display: -webkit-flex;
14
+ display: -ms-flexbox;
15
+ display: flex;
16
+ -webkit-align-items: center;
17
+ -webkit-box-align: center;
18
+ -ms-flex-align: center;
19
+ align-items: center;
20
+ -webkit-box-pack: center;
21
+ -webkit-justify-content: center;
22
+ -ms-flex-pack: center;
23
+ justify-content: center;
24
+ -webkit-flex-direction: column;
25
+ -ms-flex-direction: column;
26
+ flex-direction: column;
27
+ min-height: 320px;
28
+ }
29
+
30
+ .c1 {
31
+ margin-top: -8vh;
32
+ font-weight: bold;
33
+ font-size: 3.375rem;
34
+ }
35
+
36
+ .c1 span {
37
+ font-size: 3.125rem;
38
+ }
39
+
40
+ <div
41
+ className="c0"
42
+ >
43
+ <div
44
+ className="c1"
45
+ >
46
+ 4
47
+ <span
48
+ aria-label="Crying Face"
49
+ role="img"
50
+ >
51
+ 😢
52
+ </span>
53
+ 4
54
+ </div>
55
+ <p
56
+ className="c2"
57
+ >
58
+ Page not found.
59
+ </p>
60
+ </div>
61
+ `;
@@ -0,0 +1,21 @@
1
+ import * as React from 'react';
2
+ import { NotFoundPage } from '..';
3
+ import { MemoryRouter } from 'react-router-dom';
4
+ import { HelmetProvider } from 'react-helmet-async';
5
+ import renderer from 'react-test-renderer';
6
+
7
+ const renderPage = () =>
8
+ renderer.create(
9
+ <MemoryRouter>
10
+ <HelmetProvider>
11
+ <NotFoundPage />
12
+ </HelmetProvider>
13
+ </MemoryRouter>,
14
+ );
15
+
16
+ describe('<NotFoundPage />', () => {
17
+ it('should match snapshot', () => {
18
+ const notFoundPage = renderPage();
19
+ expect(notFoundPage.toJSON()).toMatchSnapshot();
20
+ });
21
+ });
@@ -0,0 +1,45 @@
1
+ import * as React from 'react';
2
+ import styled from 'styled-components/macro';
3
+ import { P } from './P';
4
+ import { Helmet } from 'react-helmet-async';
5
+
6
+ export function NotFoundPage() {
7
+ return (
8
+ <>
9
+ <Helmet>
10
+ <title>404 Page Not Found</title>
11
+ <meta name="description" content="Page not found" />
12
+ </Helmet>
13
+ <Wrapper>
14
+ <Title>
15
+ 4
16
+ <span role="img" aria-label="Crying Face">
17
+ 😢
18
+ </span>
19
+ 4
20
+ </Title>
21
+ <P>Page not found.</P>
22
+ </Wrapper>
23
+ </>
24
+ );
25
+ }
26
+
27
+ const Wrapper = styled.div`
28
+ height: 100%;
29
+ display: flex;
30
+ align-items: center;
31
+ justify-content: center;
32
+ flex-direction: column;
33
+ min-height: 320px;
34
+ `;
35
+
36
+ const Title = styled.div`
37
+ margin-top: -8vh;
38
+ font-weight: bold;
39
+ color: ${p => p.theme.text};
40
+ font-size: 3.375rem;
41
+
42
+ span {
43
+ font-size: 3.125rem;
44
+ }
45
+ `;