platform(nx): import pro console into nx

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/6122
GitOrigin-RevId: 7b36dcb51bf2b7c8dd8f514ceba5878f1c2757ca
This commit is contained in:
Nicolas Beaussart 2022-09-30 10:33:34 +02:00 committed by hasura-bot
parent 265746189e
commit dbe350d087
819 changed files with 86666 additions and 19549 deletions

View File

@ -7,5 +7,7 @@
}
]
],
"plugins": []
"plugins": [
"@babel/plugin-proposal-export-default-from"
]
}

View File

@ -0,0 +1,81 @@
const { merge } = require('webpack-merge');
const util = require('util');
const webpack = require('webpack');
const log = (value) =>
console.log(
util.inspect(value, { showHidden: false, depth: null, colors: true })
);
module.exports = (config, context) => {
const output = merge(config, {
output: {
publicPath: '',
},
plugins: [
new webpack.DefinePlugin({
__CLIENT__: 'true',
__SERVER__: false,
__DEVELOPMENT__: true,
__DEVTOOLS__: true, // <-------- DISABLE redux-devtools HERE
CONSOLE_ASSET_VERSION: Date.now().toString(),
'process.hrtime': () => null,
}),
],
module: {
rules: [
/*
Rule taken from the old codebase
=> Do we still need the version naming ?
*/
{
test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
use: [
{
loader: 'url-loader',
options: { limit: 10000, mimetype: 'image/svg+xml' },
},
],
},
],
},
resolve: {
fallback: {
/*
Used by :
@graphql-codegen/typescript and it's deps (@graphql-codegen/visitor-plugin-common && parse-filepath)
=> one usage is found, so we have to check if the usage is still relevant
*/
path: require.resolve('path-browserify'),
/*
Used by :
jsonwebtoken deps (jwa && jws)
=> we already have an equivalent in the codebases that don't depend on it,jwt-decode.
Might be worth using only the latter
*/
crypto: require.resolve('crypto-browserify'),
/*
Used by :
jsonwebtoken deps (jwa && jws)
@graphql-tools/merge => dependanci of graphiql & graphql-codegen/core, a package upgrade might fix it
*/
util: require.resolve('util/'),
/*
Used by :
jsonwebtoken deps (jwa && jws)
*/
stream: require.resolve('stream-browserify'),
},
},
});
// Kept there while we setup everything
console.log('----------------NX-CONTEXT-------------------');
log(context);
console.log('----------------ORIGINAL-WEBPACK-CONFIG------');
log(config);
console.log('----------------MODIFIED-WEBPACK-CONFIG------');
log(output);
console.log('---------------------------------------------');
return output;
};

View File

@ -0,0 +1,15 @@
const { join } = require('path');
// Note: If you use library-specific PostCSS/Tailwind configuration then you should remove the `postcssConfig` build
// option from your application's configuration (i.e. project.json).
//
// See: https://nx.dev/guides/using-tailwind-css-in-react#step-4:-applying-configuration-to-libraries
module.exports = {
plugins: {
tailwindcss: {
config: join(__dirname, 'tailwind.config.js'),
},
autoprefixer: {},
},
};

View File

@ -21,7 +21,8 @@
],
"styles": ["apps/console-pro/src/styles.css"],
"scripts": [],
"webpackConfig": "@nrwl/react/plugins/webpack"
"webpackConfig": "apps/console-ce/custom-webpack.config.js",
"postcssConfig": "apps/console-ce/postcss.config.js"
},
"configurations": {
"development": {

View File

@ -1 +0,0 @@
/* Your styles goes here. */

View File

@ -1,17 +0,0 @@
import { render } from '@testing-library/react';
import App from './App';
describe('App', () => {
it('should render successfully', () => {
const { baseElement } = render(<App />);
expect(baseElement).toBeTruthy();
});
it('should have a greeting as the title', () => {
const { getByText } = render(<App />);
expect(getByText(/Welcome console-pro/gi)).toBeTruthy();
});
});

View File

@ -1,16 +0,0 @@
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import styles from './App.module.css';
import NxWelcome from './nx-welcome';
import { ConsoleLegacyPro } from '@hasura/console/legacy-pro';
export function App() {
return (
<>
<ConsoleLegacyPro />
<NxWelcome title="console-pro" />
<div />
</>
);
}
export default App;

View File

@ -1,820 +0,0 @@
/*
* * * * * * * * * * * * * * * * * * * * * * * * * * * *
This is a starter component and can be deleted.
* * * * * * * * * * * * * * * * * * * * * * * * * * * *
Delete this file and get started with your project!
* * * * * * * * * * * * * * * * * * * * * * * * * * * *
*/
export function NxWelcome({ title }: { title: string }) {
return (
<>
<style
dangerouslySetInnerHTML={{
__html: `
html {
-webkit-text-size-adjust: 100%;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
line-height: 1.5;
tab-size: 4;
scroll-behavior: smooth;
}
body {
font-family: inherit;
line-height: inherit;
margin: 0;
}
h1,
h2,
p,
pre {
margin: 0;
}
*,
::before,
::after {
box-sizing: border-box;
border-width: 0;
border-style: solid;
border-color: currentColor;
}
h1,
h2 {
font-size: inherit;
font-weight: inherit;
}
a {
color: inherit;
text-decoration: inherit;
}
pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
}
svg {
display: block;
vertical-align: middle;
shape-rendering: auto;
text-rendering: optimizeLegibility;
}
pre {
background-color: rgba(55, 65, 81, 1);
border-radius: 0.25rem;
color: rgba(229, 231, 235, 1);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
overflow: scroll;
padding: 0.5rem 0.75rem;
}
.shadow {
box-shadow: 0 0 #0000, 0 0 #0000, 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
.rounded {
border-radius: 1.5rem;
}
.wrapper {
width: 100%;
}
.container {
margin-left: auto;
margin-right: auto;
max-width: 768px;
padding-bottom: 3rem;
padding-left: 1rem;
padding-right: 1rem;
color: rgba(55, 65, 81, 1);
width: 100%;
}
#welcome {
margin-top: 2.5rem;
}
#welcome h1 {
font-size: 3rem;
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1;
}
#welcome span {
display: block;
font-size: 1.875rem;
font-weight: 300;
line-height: 2.25rem;
margin-bottom: 0.5rem;
}
#hero {
align-items: center;
background-color: hsla(214, 62%, 21%, 1);
border: none;
box-sizing: border-box;
color: rgba(55, 65, 81, 1);
display: grid;
grid-template-columns: 1fr;
margin-top: 3.5rem;
}
#hero .text-container {
color: rgba(255, 255, 255, 1);
padding: 3rem 2rem;
}
#hero .text-container h2 {
font-size: 1.5rem;
line-height: 2rem;
position: relative;
}
#hero .text-container h2 svg {
color: hsla(162, 47%, 50%, 1);
height: 2rem;
left: -0.25rem;
position: absolute;
top: 0;
width: 2rem;
}
#hero .text-container h2 span {
margin-left: 2.5rem;
}
#hero .text-container a {
background-color: rgba(255, 255, 255, 1);
border-radius: 0.75rem;
color: rgba(55, 65, 81, 1);
display: inline-block;
margin-top: 1.5rem;
padding: 1rem 2rem;
text-decoration: inherit;
}
#hero .logo-container {
display: none;
justify-content: center;
padding-left: 2rem;
padding-right: 2rem;
}
#hero .logo-container svg {
color: rgba(255, 255, 255, 1);
width: 66.666667%;
}
#middle-content {
align-items: flex-start;
display: grid;
gap: 4rem;
grid-template-columns: 1fr;
margin-top: 3.5rem;
}
#learning-materials {
padding: 2.5rem 2rem;
}
#learning-materials h2 {
font-weight: 500;
font-size: 1.25rem;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
.list-item-link {
align-items: center;
border-radius: 0.75rem;
display: flex;
margin-top: 1rem;
padding: 1rem;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 100%;
}
.list-item-link svg:first-child {
margin-right: 1rem;
height: 1.5rem;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 1.5rem;
}
.list-item-link > span {
flex-grow: 1;
font-weight: 400;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.list-item-link > span > span {
color: rgba(107, 114, 128, 1);
display: block;
flex-grow: 1;
font-size: 0.75rem;
font-weight: 300;
line-height: 1rem;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
.list-item-link svg:last-child {
height: 1rem;
transition-property: all;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
width: 1rem;
}
.list-item-link:hover {
color: rgba(255, 255, 255, 1);
background-color: hsla(162, 47%, 50%, 1);
}
.list-item-link:hover > span {}
.list-item-link:hover > span > span {
color: rgba(243, 244, 246, 1);
}
.list-item-link:hover svg:last-child {
transform: translateX(0.25rem);
}
#other-links {}
.button-pill {
padding: 1.5rem 2rem;
transition-duration: 300ms;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
align-items: center;
display: flex;
}
.button-pill svg {
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
flex-shrink: 0;
width: 3rem;
}
.button-pill > span {
letter-spacing: -0.025em;
font-weight: 400;
font-size: 1.125rem;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
.button-pill span span {
display: block;
font-size: 0.875rem;
font-weight: 300;
line-height: 1.25rem;
}
.button-pill:hover svg,
.button-pill:hover {
color: rgba(255, 255, 255, 1) !important;
}
#nx-console:hover {
background-color: rgba(0, 122, 204, 1);
}
#nx-console svg {
color: rgba(0, 122, 204, 1);
}
#nx-repo:hover {
background-color: rgba(24, 23, 23, 1);
}
#nx-repo svg {
color: rgba(24, 23, 23, 1);
}
#nx-cloud {
margin-bottom: 2rem;
margin-top: 2rem;
padding: 2.5rem 2rem;
}
#nx-cloud > div {
align-items: center;
display: flex;
}
#nx-cloud > div svg {
border-radius: 0.375rem;
flex-shrink: 0;
width: 3rem;
}
#nx-cloud > div h2 {
font-size: 1.125rem;
font-weight: 400;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
#nx-cloud > div h2 span {
display: block;
font-size: 0.875rem;
font-weight: 300;
line-height: 1.25rem;
}
#nx-cloud p {
font-size: 1rem;
line-height: 1.5rem;
margin-top: 1rem;
}
#nx-cloud pre {
margin-top: 1rem;
}
#nx-cloud a {
color: rgba(107, 114, 128, 1);
display: block;
font-size: 0.875rem;
line-height: 1.25rem;
margin-top: 1.5rem;
text-align: right;
}
#nx-cloud a:hover {
text-decoration: underline;
}
#commands {
padding: 2.5rem 2rem;
margin-top: 3.5rem;
}
#commands h2 {
font-size: 1.25rem;
font-weight: 400;
letter-spacing: -0.025em;
line-height: 1.75rem;
padding-left: 1rem;
padding-right: 1rem;
}
#commands p {
font-size: 1rem;
font-weight: 300;
line-height: 1.5rem;
margin-top: 1rem;
padding-left: 1rem;
padding-right: 1rem;
}
details {
align-items: center;
display: flex;
margin-top: 1rem;
padding-left: 1rem;
padding-right: 1rem;
width: 100%;
}
details pre > span {
color: rgba(181, 181, 181, 1);
display: block;
}
summary {
border-radius: 0.5rem;
display: flex;
font-weight: 400;
padding: 0.5rem;
cursor: pointer;
transition-property: background-color, border-color, color, fill, stroke,
opacity, box-shadow, transform, filter, backdrop-filter,
-webkit-backdrop-filter;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 150ms;
}
summary:hover {
background-color: rgba(243, 244, 246, 1);
}
summary svg {
height: 1.5rem;
margin-right: 1rem;
width: 1.5rem;
}
#love {
color: rgba(107, 114, 128, 1);
font-size: 0.875rem;
line-height: 1.25rem;
margin-top: 3.5rem;
opacity: 0.6;
text-align: center;
}
#love svg {
color: rgba(252, 165, 165, 1);
width: 1.25rem;
height: 1.25rem;
display: inline;
margin-top: -0.25rem;
}
@media screen and (min-width: 768px) {
#hero {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
#hero .logo-container {
display: flex;
}
#middle-content {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
`,
}}
/>
<div className="wrapper">
<div className="container">
<div id="welcome">
<h1>
<span> Hello there, </span>
Welcome {title} 👋
</h1>
</div>
<div id="hero" className="rounded">
<div className="text-container">
<h2>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
/>
</svg>
<span>You&apos;re up and running</span>
</h2>
<a href="#commands"> What&apos;s next? </a>
</div>
<div className="logo-container">
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M11.987 14.138l-3.132 4.923-5.193-8.427-.012 8.822H0V4.544h3.691l5.247 8.833.005-3.998 3.044 4.759zm.601-5.761c.024-.048 0-3.784.008-3.833h-3.65c.002.059-.005 3.776-.003 3.833h3.645zm5.634 4.134a2.061 2.061 0 0 0-1.969 1.336 1.963 1.963 0 0 1 2.343-.739c.396.161.917.422 1.33.283a2.1 2.1 0 0 0-1.704-.88zm3.39 1.061c-.375-.13-.8-.277-1.109-.681-.06-.08-.116-.17-.176-.265a2.143 2.143 0 0 0-.533-.642c-.294-.216-.68-.322-1.18-.322a2.482 2.482 0 0 0-2.294 1.536 2.325 2.325 0 0 1 4.002.388.75.75 0 0 0 .836.334c.493-.105.46.36 1.203.518v-.133c-.003-.446-.246-.55-.75-.733zm2.024 1.266a.723.723 0 0 0 .347-.638c-.01-2.957-2.41-5.487-5.37-5.487a5.364 5.364 0 0 0-4.487 2.418c-.01-.026-1.522-2.39-1.538-2.418H8.943l3.463 5.423-3.379 5.32h3.54l1.54-2.366 1.568 2.366h3.541l-3.21-5.052a.7.7 0 0 1-.084-.32 2.69 2.69 0 0 1 2.69-2.691h.001c1.488 0 1.736.89 2.057 1.308.634.826 1.9.464 1.9 1.541a.707.707 0 0 0 1.066.596zm.35.133c-.173.372-.56.338-.755.639-.176.271.114.412.114.412s.337.156.538-.311c.104-.231.14-.488.103-.74z" />
</svg>
</div>
</div>
<div id="middle-content">
<div id="learning-materials" className="rounded shadow">
<h2>Learning materials</h2>
<a
href="https://nx.dev/getting-started/intro?utm_source=nx-project"
target="_blank"
rel="noreferrer"
className="list-item-link"
>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
<span>
Documentation
<span> Everything is in there </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a
href="https://blog.nrwl.io/?utm_source=nx-project"
target="_blank"
rel="noreferrer"
className="list-item-link"
>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"
/>
</svg>
<span>
Blog
<span> Changelog, features & events </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a
href="https://www.youtube.com/c/Nrwl_io/videos?utm_source=nx-project&sub_confirmation=1"
target="_blank"
rel="noreferrer"
className="list-item-link"
>
<svg
role="img"
viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<title>YouTube</title>
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
<span>
YouTube channel
<span> Nx Show, talks & tutorials </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a
href="https://nx.dev/react-tutorial/01-create-application?utm_source=nx-project"
target="_blank"
rel="noreferrer"
className="list-item-link"
>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"
/>
</svg>
<span>
Interactive tutorials
<span> Create an app, step-by-step </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
<a
href="https://nxplaybook.com/?utm_source=nx-project"
target="_blank"
rel="noreferrer"
className="list-item-link"
>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 14l9-5-9-5-9 5 9 5z" />
<path d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222"
/>
</svg>
<span>
Video courses
<span> Nx custom courses </span>
</span>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M9 5l7 7-7 7"
/>
</svg>
</a>
</div>
<div id="other-links">
<a
id="nx-console"
className="button-pill rounded shadow"
href="https://marketplace.visualstudio.com/items?itemName=nrwl.angular-console&utm_source=nx-project"
target="_blank"
rel="noreferrer"
>
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<title>Visual Studio Code</title>
<path d="M23.15 2.587L18.21.21a1.494 1.494 0 0 0-1.705.29l-9.46 8.63-4.12-3.128a.999.999 0 0 0-1.276.057L.327 7.261A1 1 0 0 0 .326 8.74L3.899 12 .326 15.26a1 1 0 0 0 .001 1.479L1.65 17.94a.999.999 0 0 0 1.276.057l4.12-3.128 9.46 8.63a1.492 1.492 0 0 0 1.704.29l4.942-2.377A1.5 1.5 0 0 0 24 20.06V3.939a1.5 1.5 0 0 0-.85-1.352zm-5.146 14.861L10.826 12l7.178-5.448v10.896z" />
</svg>
<span>
Install Nx Console
<span>Plugin for VSCode</span>
</span>
</a>
<div id="nx-cloud" className="rounded shadow">
<div>
<svg
viewBox="0 0 120 120"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M120 15V30C103.44 30 90 43.44 90 60C90 76.56 76.56 90 60 90C43.44 90 30 103.44 30 120H15C6.72 120 0 113.28 0 105V15C0 6.72 6.72 0 15 0H105C113.28 0 120 6.72 120 15Z"
fill="#0E2039"
/>
<path
d="M120 30V105C120 113.28 113.28 120 105 120H30C30 103.44 43.44 90 60 90C76.56 90 90 76.56 90 60C90 43.44 103.44 30 120 30Z"
fill="white"
/>
</svg>
<h2>
NxCloud
<span>Enable faster CI & better DX</span>
</h2>
</div>
<p>
You can activate distributed tasks executions and caching by
running:
</p>
<pre>nx connect-to-nx-cloud</pre>
<a
href="https://nx.app/?utm_source=nx-project"
target="_blank"
rel="noreferrer"
>
{' '}
What is Nx Cloud?{' '}
</a>
</div>
<a
id="nx-repo"
className="button-pill rounded shadow"
href="https://github.com/nrwl/nx?utm_source=nx-project"
target="_blank"
rel="noreferrer"
>
<svg
fill="currentColor"
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
<span>
Nx is open source
<span> Love Nx? Give us a star! </span>
</span>
</a>
</div>
</div>
<div id="commands" className="rounded shadow">
<h2>Next steps</h2>
<p>Here are some things you can do with Nx:</p>
<details>
<summary>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Add UI library
</summary>
<pre>
<span># Generate UI lib</span>
nx g @nrwl/react:lib ui
<span># Add a component</span>
nx g @nrwl/react:component button --project ui
</pre>
</details>
<details>
<summary>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
View interactive project graph
</summary>
<pre>nx graph</pre>
</details>
<details>
<summary>
<svg
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Run affected commands
</summary>
<pre>
<span># see what&apos;s been affected by changes</span>
nx affected:graph
<span># run tests for current changes</span>
nx affected:test
<span># run e2e tests for current changes</span>
nx affected:e2e
</pre>
</details>
</div>
<p id="love">
Carefully crafted with
<svg
fill="currentColor"
stroke="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</p>
</div>
</div>
</>
);
}
export default NxWelcome;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,167 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* This is where we have **TEMPORARY** fixes while we migrate the codebase to tailwind
*/
/* Redefine bootstrap form control to take precedence over tailwind */
.form-control {
display: block;
width: 100%;
height: 34px;
padding: 6px 12px;
font-size: 14px;
line-height: 1.42857;
color: #555555;
background-color: #fff;
background-image: none;
border: 1px solid #ccc;
border-radius: 4px;
box-shadow: inset 0 1px 1px rgba(0 0 0, 0.08);
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s;
}
/* reset some more bootstrap styles */
.radio-inline input[type="checkbox"],
.radio-inline input[type="radio"],
.form-group input[type="checkbox"],
.checkbox input[type="checkbox"],
.form-group input[type="radio"],
.radio input[type="radio"],
/* escape hatch if we need to add a reset on a input */
.legacy-input-fix,
/* .form-group select, */
/* Nullable + checkbox select in create table */
input[data-test^="nullable-"],
input[data-test^="unique-"],
/* Browse row checkbox */
input[type="checkbox"][data-test^="row-checkbox"],
input[type="checkbox"][data-test="select-all-rows"] {
appearance: revert;
height: unset;
width: unset;
}
select[data-test='qb-select'],
select.form-control {
font-size: unset;
line-height: unset;
border-radius: 4px;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
}
/* By default, tailwind reset everything to block, but this breaks most of our SVGs and images, so this fix it */
svg,
img {
display: inline;
}
/* Add a blue color by default to all a without classes or with an empty class */
a:not([class]),
a[class=''] {
color: #337ab7;
}
.dropdown button {
background-color: rgb(239, 239, 239);
}
/* To remove botstrap html font size from 10px */
html {
font-size: 14px;
}
/* React Select - Fixes Inner Outline in Select Box */
input[id*='react-select'][id$='-input']:focus {
box-shadow: none;
}
/* ** GraphiQL CSS tweaks to fix compatibility */
/* Input fixes from Tailwind */
.graphiql-container input,
.graphiql-container select {
min-width: min-content;
padding: 4px 8px;
height: 32px;
border-radius: 4px;
border: 1px solid rgb(203, 213, 225) !important;
box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px,
rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
}
.graphiql-container input:focus,
.graphiql-container select:focus {
border: 1px solid rgb(251, 191, 36) !important;
box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
rgb(253, 230, 138) 0px 0px 0px 2px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
}
.graphiql-explorer-actions > select {
font-family: system, -apple-system, 'San Francisco', '.SFNSDisplay-Regular',
'Helvetica Neue', helvetica, 'Lucida Grande', arial, sans-serif;
font-variant-caps: normal;
font-weight: 400;
border: 1px solid #cbd5e1;
font-size: 14px;
padding: 5px 8px;
border-radius: 4px;
margin-left: 8px;
margin-right: 8px;
height: 32px;
box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px,
rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
}
.graphiql-explorer-actions > select:focus {
border: 1px solid rgb(251, 191, 36) !important;
box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
rgb(253, 230, 138) 0px 0px 0px 2px, rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
}
/* While we're fixing the inputs maybe the Explorer header + footer */
/* Fixes header spacing */
.doc-explorer-title-bar {
height: 46px !important;
}
.docExplorerHide {
padding: 16px !important;
}
/* Fixes result loading spinner position */
.graphiql-container .spinner-container {
top: 48px !important;
}
/* Fixes spacing in operation title namer */
.graphiql-explorer-root {
padding-top: 0 !important;
}
.graphiql-operation-title-bar {
margin-top: 10px;
}
.graphiql-operation-title-bar > span > input {
margin-right: 4px;
}
/* Fixes footer spacing */
.graphiql-explorer-actions > span {
font-family: system, -apple-system, 'San Francisco', '.SFNSDisplay-Regular',
'Helvetica Neue', helvetica, 'Lucida Grande', arial, sans-serif;
font-variant-caps: normal;
font-weight: 500;
font-size: 12px;
letter-spacing: 0px;
text-transform: capitalize;
}
.graphiql-explorer-actions {
margin: 4px 0px 0px -8px !important;
width: calc(100% + 16px) !important;
padding: 10px !important;
}
/* Fix bug due to the removal of a label style in ApiExplorer.scss which was impacting the whole UI */
label {
font-weight: normal;
margin-bottom: 0;
}

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,11 @@
// import './environments/environment';
import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom';
import App from './app/App';
import { Main } from '@hasura/console/legacy-pro';
ReactDOM.render(
<StrictMode>
<App />
<Main />
</StrictMode>,
document.getElementById('root') as HTMLElement
document.getElementById('content') as HTMLElement
);

View File

@ -0,0 +1,14 @@
const { createGlobPatternsForDependencies } = require('@nrwl/react/tailwind');
const { join } = require('path');
const tailwindConfig = require('../../tailwind.config.js');
module.exports = {
content: [
join(
__dirname,
'{src,pages,components}/**/*!(*.stories|*.spec).{ts,tsx,html}'
),
...createGlobPatternsForDependencies(__dirname),
],
...tailwindConfig,
};

View File

@ -6,7 +6,8 @@
},
"files": [
"../../node_modules/@nrwl/react/typings/cssmodule.d.ts",
"../../node_modules/@nrwl/react/typings/image.d.ts"
"../../node_modules/@nrwl/react/typings/image.d.ts",
"../../types/graphiql-code-exporter.d.ts"
],
"exclude": [
"jest.config.ts",

View File

@ -8,7 +8,7 @@
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noPropertyAccessFromIndexSignature": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},

View File

@ -0,0 +1,4 @@
export * from '../lib/components/App/Actions';
export { default as progressBarReducer } from '../lib/components/App/Actions';
export { default as App } from '../lib/components/App/App';

View File

@ -0,0 +1 @@
export * from '../lib/components/AppState';

View File

@ -0,0 +1,2 @@
export { default as NotificationSection } from './lib/components/Main/NotificationSection';
export { default as Onboarding } from './lib/components/Common/Onboarding';

View File

@ -0,0 +1 @@
export * from '../lib/constants';

View File

@ -0,0 +1,2 @@
export * from './routers';
export * from './reducers';

View File

@ -0,0 +1,90 @@
const CommonScss = require('../lib/components/Common/Common.module.scss');
const filterQueryScss = require('../lib/components/Common/FilterQuery/FilterQuery.module.scss');
const tableScss = require('../lib/components/Common/TableCommon/Table.module.scss');
import * as EndpointNamedExps from '../lib/Endpoints';
export {
persistGraphiQLHeaders,
getPersistedGraphiQLHeaders,
} from '../lib/components/Services/ApiExplorer/ApiRequest/utils';
export { fetchConsoleNotifications } from '../lib/components/Main/Actions';
export { default as NotificationSection } from '../lib/components/Main/NotificationSection';
export { default as Onboarding } from '../lib/components/Common/Onboarding';
export { tracingTools } from '../lib/features/TracingTools';
export { OnboardingWizard } from '../lib/features/OnboardingWizard';
export { prefetchSurveysData } from '../lib/features/Surveys';
export { makeGrowthExperimentsClient } from '../lib/features/GrowthExperiments';
export { default as PageNotFound } from '../lib/components/Error/PageNotFound';
export * from '../lib/new-components/Button/';
export * from '../lib/new-components/Tooltip/';
export { CONSOLE_ADMIN_SECRET } from '../lib/components/AppState';
export { default as dataHeaders } from '../lib/components/Services/Data/Common/Headers';
export { handleMigrationErrors } from '../lib/components/Services/Data/TableModify/ModifyActions';
export { loadMigrationStatus } from '../lib/components/Main/Actions';
export {
fetchSchemaList,
updateSchemaInfo,
UPDATE_CURRENT_SCHEMA,
UPDATE_DATA_HEADERS,
ADMIN_SECRET_ERROR,
} from '../lib/components/Services/Data/DataActions';
export { default as generatedVoyagerConnector } from '../lib/components/Services/VoyagerView/VoyagerView';
export { default as Spinner } from '../lib/components/Common/Spinner/Spinner';
export { CommonScss };
export * from '../lib/components/Services/Settings';
export {
loadInconsistentObjects,
exportMetadata,
} from '../lib/metadata/actions';
import { isMetadataStatusPage } from '../lib/components/Error/ErrorBoundary.tsx';
import { redirectToMetadataStatus } from '../lib/components/Common/utils/routesUtils.ts';
import { ApiLimits } from '../lib/components/Services/ApiExplorer/Security';
import { IntrospectionOptions } from '../lib/components/Services/ApiExplorer/Security/Introspection';
export { default as globals } from '../lib/Globals';
export { default as endpoints } from '../lib/Endpoints';
export { default as mainState } from '../lib/components/Main/State';
export {
changeRequestHeader,
removeRequestHeader,
} from '../lib/components/Services/ApiExplorer/Actions';
export { filterQueryScss, tableScss };
export * from '../lib/components/Common';
export { loadConsoleOpts } from '../lib/telemetry/Actions';
export * from '../lib/telemetry';
export { default as Endpoints } from '../lib/Endpoints';
export { EndpointNamedExps };
export { updateRequestHeaders } from '../lib/components/Main/Main';
export {
showErrorNotification,
showSuccessNotification,
} from '../lib/components/Services/Common/Notification';
export { default as CreateRestView } from '../lib/components/Services/ApiExplorer/Rest/Form/';
export { default as RestListView } from '../lib/components/Services/ApiExplorer/Rest/List';
export { default as DetailsView } from '../lib/components/Services/ApiExplorer/Rest/Details';
export { default as ApiContainer } from '../lib/components/Services/ApiExplorer/Container';
export {
redirectToMetadataStatus,
isMetadataStatusPage,
ApiLimits,
IntrospectionOptions,
};
export * from './table';
export { ReactQueryProvider, reactQueryClient } from '../lib/lib/reactQuery';
export { FeatureFlags } from '../lib/features/FeatureFlags';
export { isMonitoringTabSupportedEnvironment } from '../lib/utils/proConsole';
export {
SampleDBBanner,
newSampleDBTrial,
} from '../lib/components/Services/Data/DataSources/SampleDatabase';
export { AllowListDetail } from '../lib/components/Services/AllowList/AllowListDetail';

View File

@ -0,0 +1,12 @@
export { dataReducer } from '../lib/components/Services/Data';
export { default as actionsReducer } from '../lib/components/Services/Actions/reducer';
export { default as typesReducer } from '../lib/components/Services/Types/reducer';
export { eventsReducer } from '../lib/components/Services/Events';
export { default as apiExplorerReducer } from '../lib/components/Services/ApiExplorer/Actions';
export { default as telemetryReducer } from '../lib/telemetry/Actions';
export { default as invokeEventTriggerReducer } from '../lib/components/Services/Events/EventTriggers/InvokeManualTrigger/InvokeManualTriggerAction';
export { remoteSchemaReducer } from '../lib/components/Services/RemoteSchema';
export { modalReducer } from '../lib/store/modal/modal.reducer';
export { metadataReducer } from '../lib/metadata/reducer';
export { default as mainReducer } from '../lib/components/Main/Actions';

View File

@ -0,0 +1,6 @@
export { dataRouterUtils } from '../lib/components/Services/Data/';
export { default as getActionsRouter } from '../lib/components/Services/Actions/Router';
export { eventsRoutes } from '../lib/components/Services/Events';
export { default as generatedApiExplorer } from '../lib/components/Services/ApiExplorer/ApiExplorer';
export { default as generatedVoyagerConnector } from '../lib/components/Services/VoyagerView/VoyagerView';
export { getRemoteSchemaRouter } from '../lib/components/Services/RemoteSchema';

View File

@ -0,0 +1,8 @@
import DragFoldTable from '../lib/components/Common/TableCommon/DragFoldTable';
import Editor from '../lib/components/Common/Layout/ExpandableEditor/Editor';
import SearchableSelectBox from '../lib/components/Common/SearchableSelect/SearchableSelect';
export { DragFoldTable };
export { Editor, SearchableSelectBox };

View File

@ -1,10 +1,24 @@
/* eslint no-underscore-dangle: 0 */
import { SERVER_CONSOLE_MODE } from './constants';
import { getFeaturesCompatibility } from './helpers/versionUtils';
import { stripTrailingSlash } from './components/Common/utils/urlUtils';
import { sentry } from './features/TracingTools/sentry';
import { isEmpty } from './components/Common/utils/jsUtils';
import { stripTrailingSlash } from './components/Common/utils/urlUtils';
import { SERVER_CONSOLE_MODE } from './constants';
type ConsoleType = 'oss' | 'cloud' | 'pro' | 'pro-lite';
export type LuxFeature =
| 'DatadogIntegration'
| 'ProUser'
| 'CloudUser'
| 'V1V2Migration'
| 'GithubIntegration'
| 'CloudDedicatedVPC'
| 'GCPSupport'
| 'Avalara'
| 'NeonDatabaseIntegration'
| string;
type UUID = string;
@ -18,6 +32,7 @@ type OSSServerEnv = {
serverVersion: string; // e.g. "v2.7.0"
urlPrefix: string; // e.g. "/console"
cdnAssets: boolean;
consoleSentryDsn?: string; // Corresponds to the HASURA_CONSOLE_SENTRY_DSN environment variable
};
type ProServerEnv = {
@ -30,6 +45,7 @@ type ProServerEnv = {
isAdminSecretSet: boolean;
serverVersion: string;
urlPrefix: string;
consoleSentryDsn?: string; // Corresponds to the HASURA_CONSOLE_SENTRY_DSN environment variable
};
type ProLiteServerEnv = {
@ -42,6 +58,7 @@ type ProLiteServerEnv = {
isAdminSecretSet: boolean;
serverVersion: string;
urlPrefix: string;
consoleSentryDsn?: string; // Corresponds to the HASURA_CONSOLE_SENTRY_DSN environment variable
};
type CloudUserRole = 'owner' | 'user';
@ -64,6 +81,11 @@ type CloudServerEnv = {
tenantID: UUID;
urlPrefix: string;
userRole: CloudUserRole;
neonOAuthClientId?: string;
neonRootDomain?: string;
allowedLuxFeatures?: LuxFeature[];
userId?: string;
consoleSentryDsn?: string; // Corresponds to the HASURA_CONSOLE_SENTRY_DSN environment variable
};
type OSSCliEnv = {
@ -78,6 +100,7 @@ type OSSCliEnv = {
enableTelemetry: boolean;
serverVersion: string;
urlPrefix: string;
consoleSentryDsn?: string; // Corresponds to the HASURA_CONSOLE_SENTRY_DSN environment variable
};
export type CloudCliEnv = {
@ -100,11 +123,15 @@ export type CloudCliEnv = {
pro: true;
projectId: UUID;
isAdminSecretSet: boolean;
consoleSentryDsn?: string; // Corresponds to the HASURA_CONSOLE_SENTRY_DSN environment variable
};
type ProCliEnv = CloudCliEnv;
type ProLiteCliEnv = CloudCliEnv;
// Until this non-discriminated-union-based `EnvVars` exist, please keep the following spreadsheet
// https://docs.google.com/spreadsheets/d/10feBESWKCfFuh7g9436Orp4i4fNoQxjnt5xxhrrdtJo/edit#gid=0
// updated with all the env vars that the Console receives and their possible values. The spreadsheet acts as the source of truth for the environment variables, at the moment
export type EnvVars = {
nodeEnv?: string;
apiHost?: string;
@ -124,6 +151,12 @@ export type EnvVars = {
eeMode?: string;
consoleId?: string;
userRole?: string;
neonOAuthClientId?: string;
neonRootDomain?: string;
allowedLuxFeatures?: LuxFeature[];
userId?: string;
cdnAssets?: boolean;
consoleSentryDsn?: string; // Corresponds to the HASURA_CONSOLE_SENTRY_DSN environment variable
} & (
| OSSServerEnv
| CloudServerEnv
@ -138,6 +171,11 @@ export type EnvVars = {
declare global {
interface Window {
__env: EnvVars;
/**
* Consuming Heap is allowed only through the TracingTools/heap module, never directly.
* @deprecated (when marked as deprecated, the IDE shows it as strikethrough'ed, helping the
* developers realize that they should not use it)
*/
heap?: {
addUserProperties: (properties: Record<string, string>) => void;
};
@ -154,6 +192,7 @@ const globals = {
apiPort: window.__env?.apiPort,
dataApiUrl: stripTrailingSlash(window.__env?.dataApiUrl || ''), // overridden below if server mode
urlPrefix: stripTrailingSlash(window.__env?.urlPrefix || '/'), // overridden below if server mode in production
consoleSentryDsn: sentry.parseSentryDsn(window.__env?.consoleSentryDsn),
adminSecret: window.__env?.adminSecret || null, // gets updated after login/logout in server mode
isAdminSecretSet:
window.__env?.isAdminSecretSet ||
@ -175,9 +214,13 @@ const globals = {
herokuOAuthClientId: window.__env?.herokuOAuthClientId,
hasuraCloudTenantId: window.__env?.tenantID,
hasuraCloudProjectId: window.__env?.projectID,
neonOAuthClientId: window.__env?.neonOAuthClientId,
neonRootDomain: window.__env?.neonRootDomain,
allowedLuxFeatures: window.__env?.allowedLuxFeatures || [],
cloudDataApiUrl: `${window.location?.protocol}//data.${window.__env?.cloudRootDomain}`,
luxDataHost: window.__env?.luxDataHost,
userRole: window.__env?.userRole || undefined,
userId: window.__env?.userId || undefined,
consoleType: window.__env?.consoleType || '',
eeMode: window.__env?.eeMode === 'true',
};

View File

@ -10,6 +10,7 @@ import { telemetryNotificationShown } from '../../telemetry/Actions';
import { showTelemetryNotification } from '../../telemetry/Notifications';
import globals from '../../Globals';
import styles from './App.module.scss';
import 'react-loading-skeleton/dist/skeleton.css';
import { theme } from '../UIKit/theme';

View File

@ -100,6 +100,11 @@ const ConfigureTransformation: React.FC<ConfigureTransformationProps> = ({
onClick={() => {
toggleContextArea(!isContextAreaActive);
}}
data-trackid={
isContextAreaActive
? 'actions-tab-hide-sample-context-button'
: 'actions-tab-show-sample-context-button'
}
>
{!isContextAreaActive ? <AddIcon /> : null}
{contextAreaText}
@ -132,6 +137,11 @@ const ConfigureTransformation: React.FC<ConfigureTransformationProps> = ({
requestUrlTransformOnChange(!isRequestUrlTransform);
resetSampleInput();
}}
data-trackid={
isRequestUrlTransform
? 'actions-tab-hide-request-transform-button'
: 'actions-tab-show-request-transform-button'
}
>
{requestUrlTransformText}
</Button>
@ -167,6 +177,11 @@ const ConfigureTransformation: React.FC<ConfigureTransformationProps> = ({
requestPayloadTransformOnChange(!isRequestPayloadTransform);
resetSampleInput();
}}
data-trackid={
isRequestPayloadTransform
? 'actions-tab-hide-payload-transform-button'
: 'actions-tab-show-payload-transform-button'
}
>
{!isRequestPayloadTransform ? <AddIcon /> : null}
{requestPayloadTransformText}

View File

@ -11,6 +11,7 @@ interface Props extends React.ComponentProps<'div'> {
heading: string;
addLink: string;
addLabel: string;
addTrackId: string;
addTestString: string;
childListTestString: string;
}
@ -22,6 +23,7 @@ const LeftSubSidebar: React.FC<Props> = props => {
heading,
addLink,
addLabel,
addTrackId,
addTestString,
children,
childListTestString,
@ -36,7 +38,12 @@ const LeftSubSidebar: React.FC<Props> = props => {
className={`col-xs-4 text-center ${styles.padd_left_remove} ${styles.sidebarCreateTable}`}
>
<Link className={styles.padd_remove_full} to={addLink}>
<Button size="sm" mode="default" data-test={addTestString}>
<Button
size="sm"
mode="default"
data-test={addTestString}
data-trackid={addTrackId}
>
{addLabel}
</Button>
</Link>

View File

@ -0,0 +1,12 @@
// a slight artificial delay can improve UX.
// use Promise.all so we don't force the user to wait longer than necessary
async function delayAsyncAction<T>(asyncAction: Promise<T>, delay = 300) {
const [res] = await Promise.all([
asyncAction,
new Promise(resolve => setTimeout(resolve, delay)),
]);
return res;
}
export default delayAsyncAction;

View File

@ -0,0 +1,21 @@
import { encodeFileContent } from './jsUtils';
describe('encodeFileContent', () => {
it('encodes #', () => {
expect(encodeFileContent('a#1')).toBe('a%231');
});
it('encodes reserved characters', () => {
expect(encodeFileContent(';,/?:@&=+$')).toBe(
'%3B%2C%2F%3F%3A%40%26%3D%2B%24'
);
});
it('preserves Unescaped characters', () => {
expect(encodeFileContent("-_.!~*'()")).toBe("-_.!~*'()");
});
it('encodes alphanumeric strings', () => {
expect(encodeFileContent('example è 123')).toBe('example%20%C3%A8%20123');
});
});

View File

@ -350,6 +350,9 @@ export const downloadObjectAsJsonFile = (fileName: string, object: any) => {
downloadFile(fileNameWithSuffix, dataString);
};
export const encodeFileContent = (data: string) => encodeURIComponent(data);
export const downloadObjectAsCsvFile = (
fileName: string,
rows: Record<string, unknown>[] = []
@ -362,20 +365,21 @@ export const downloadObjectAsCsvFile = (
i =>
`"${
typeof i === 'string' && isJsonString(i)
? i.replace(/"/g, `'`) // in csv, a cell with double quotes and comma will result is bad formating
? i.replace(/"/g, `'`) // in csv, a cell with double quotes and comma will result in bad formatting
: JSON.stringify(i, null, 2).replace(/"/g, `'`)
}"`
)
.join(',')
)
.join('\n');
const csvContent = `data:text/csv;charset=utf-8,${titleRowString}\n${rowsString}`;
const csvContent = `${titleRowString}\n${rowsString}`;
const encodedCsvContent = encodeFileContent(csvContent);
const csvDataString = `data:text/csv;charset=utf-8,${encodedCsvContent}`;
const fileNameWithSuffix = `${fileName}.csv`;
const encodedUri = encodeURI(csvContent);
downloadFile(fileNameWithSuffix, encodedUri);
downloadFile(fileNameWithSuffix, csvDataString);
};
export const getFileExtensionFromFilename = (filename: string) => {
const matches = filename.match(/\.[0-9a-z]+$/i);

View File

@ -50,3 +50,5 @@ export function get<T extends Record<string, any>, P extends Path<T>>(
}, obj);
return new_obj as PathValue<T, P>;
}
export const capitalize = (s: string) => s && s[0].toUpperCase() + s.slice(1);

View File

@ -378,6 +378,7 @@ const AddAction: React.FC<AddActionProps> = ({
disabled={!allowSave}
onClick={onSubmit}
data-test="create-action-btn"
data-trackid="actions-tab-create-action-button"
>
Create Action
</Button>

View File

@ -95,6 +95,7 @@ const LeftSidebar = ({
heading={`Actions (${actionsList.length})`}
addLink={`${appPrefix}/manage/add`}
addLabel={'Create'}
addTrackId="action-tab-button-add-actions-sidebar"
addTestString={'actions-sidebar-add-table'}
childListTestString={'actions-table-links'}
>

View File

@ -0,0 +1,122 @@
/* eslint-disable no-underscore-dangle */
import React from 'react';
import { browserHistory } from 'react-router';
import { Tabs } from '@/new-components/Tabs';
import {
useQueryCollections,
QueryCollectionsOperations,
QueryCollectionHeader,
} from '@/features/QueryCollections';
import { AllowListSidebar, AllowListPermissions } from '@/features/AllowLists';
import PageContainer from '@/components/Common/Layout/PageContainer/PageContainer';
import { isProConsole } from '@/utils/proConsole';
interface AllowListDetailProps {
params: {
name: string;
section: string;
};
}
export const buildUrl = (name: string, section: string) =>
`/api/allow-list/detail/${name}/${section}`;
export const pushUrl = (name: string, section: string) => {
browserHistory.push(buildUrl(name, section));
};
export const AllowListDetail: React.FC<AllowListDetailProps> = props => {
const { name, section } = props.params;
const {
data: queryCollections,
isLoading,
isRefetching,
} = useQueryCollections();
const queryCollection = queryCollections?.find(
({ name: collectionName }) => collectionName === name
);
if (
!isLoading &&
!isRefetching &&
queryCollections?.[0] &&
(!name || !queryCollection)
) {
// Redirect to first collection if no collection is selected or if the selected collection is not found
pushUrl(queryCollections[0].name, section ?? 'operations');
}
return (
<div className="flex flex-auto overflow-y-hidden h-[calc(100vh-35.49px-54px)]">
<PageContainer
helmet="Allow List Detail"
leftContainer={
<div className="bg-white border-r border-gray-300 h-full overflow-y-auto p-4">
<AllowListSidebar
onQueryCollectionCreate={newName => {
pushUrl(newName, 'operations');
}}
buildQueryCollectionHref={(collectionName: string) =>
buildUrl(collectionName, 'operations')
}
onQueryCollectionClick={url => browserHistory.push(url)}
selectedCollectionQuery={name}
/>
</div>
}
>
<div className="h-full overflow-y-auto p-4">
{queryCollection && (
<div>
<QueryCollectionHeader
onRename={(_, newName) => {
pushUrl(newName, section);
}}
onDelete={() => {
if (queryCollections?.[0]?.name) {
pushUrl(queryCollections?.[0]?.name, 'operations');
}
}}
queryCollection={queryCollection}
/>
</div>
)}
{isProConsole(window.__env) ? (
<Tabs
value={section}
onValueChange={value => {
pushUrl(name, value);
}}
items={[
{
value: 'operations',
label: 'Operations',
content: (
<div className="p-4">
<QueryCollectionsOperations collectionName={name} />
</div>
),
},
{
value: 'permissions',
label: 'Permissions',
content: (
<div className="p-4">
<AllowListPermissions collectionName={name} />
</div>
),
},
]}
/>
) : (
<QueryCollectionsOperations collectionName={name} />
)}
</div>
</PageContainer>
</div>
);
};

View File

@ -0,0 +1 @@
export * from './AllowListDetail';

View File

@ -592,7 +592,11 @@ class ApiRequest extends Component {
switch (this.props.bodyType) {
case 'graphql':
return (
<div className={'h-[calc(100vh-400px)] min-h-[500px] pb-[50px]'}>
<div
className={
'h-[calc(100vh-450px)] min-h-[500px] mt-[20px] mb-[50px] resize-y overflow-auto'
}
>
<GraphiQLWrapper
mode={mode}
data={this.props}

View File

@ -13,7 +13,7 @@ import {
} from '../OneGraphExplorer/utils';
import { clearCodeMirrorHints, setQueryVariableSectionHeight } from './utils';
import { generateRandomString } from '../../../Services/Data/DataSources/CreateDataSource/Heroku/utils';
import { generateRandomString } from '../../../Services/Data/DataSources/CreateDataSource/utils';
import { analyzeFetcher, graphQLFetcherFinal } from '../Actions';
import { parse as sdlParse, print } from 'graphql';
import deriveAction from '../../../../shared/utils/deriveAction';
@ -33,7 +33,7 @@ import {
} from '../../Actions/Add/reducer';
import { getGraphQLEndpoint } from '../utils';
import snippets from './snippets';
import globals from '../../../../Globals';
import { canAccessCacheButton } from '@/utils/permissions';
import 'graphiql/graphiql.css';
import 'graphiql-code-exporter/CodeExporter.css';
@ -240,7 +240,7 @@ class GraphiQLWrapper extends Component {
label: 'Cache',
title: 'Cache the response of this query',
onClick: _toggleCacheDirective,
hide: globals.consoleType !== 'cloud',
hide: !canAccessCacheButton(),
},
{
label: 'Code Exporter',
@ -303,7 +303,7 @@ class GraphiQLWrapper extends Component {
return (
<GraphiQLErrorBoundary>
<div className="w-full h-full border mt-md overflow-hidden rounded border-gray-300">
<div className="w-full h-full border overflow-hidden rounded border-gray-300">
<OneGraphExplorer
renderGraphiql={renderGraphiql}
endpoint={getGraphQLEndpoint(mode)}

View File

@ -1,8 +1,8 @@
import React from 'react';
import { RouteComponentProps } from 'react-router';
import { connect, ConnectedProps } from 'react-redux';
import { AllowedRESTMethods, RestEndpointEntry } from '@/metadata/types';
import { useIsUnmounted } from '@/components/Services/Data';
import { Dispatch, ReduxState } from '@/types';
import { addRESTEndpoint, editRESTEndpoint } from '@/metadata/actions';
import { allowedQueriesCollection } from '@/metadata/utils';
@ -125,7 +125,7 @@ const FormEndpoint: React.FC<FormEndpointProps> = ({
editEndpoint,
routeParams,
}) => {
let mounted = true;
const isUnMounted = useIsUnmounted();
const [loading, setLoading] = React.useState(false);
const mode = location.pathname === '/api/rest/create' ? 'create' : 'edit';
@ -140,12 +140,6 @@ const FormEndpoint: React.FC<FormEndpointProps> = ({
edit: useRestEndpointFormStateForEdition,
}[mode](createEndpoint, editEndpoint, { metadataObject, routeParams });
React.useEffect(() => {
return () => {
mounted = false;
};
});
const resetPageState = () => {
routeToPage('/api/rest/list');
};
@ -162,7 +156,7 @@ const FormEndpoint: React.FC<FormEndpointProps> = ({
const [restEndpointObj, request] = forgeFormEndpointObject(state);
await formSubmitHandler(restEndpointObj, request, resetPageState);
if (mounted) {
if (!isUnMounted()) {
setLoading(false);
}
};

View File

@ -8,22 +8,32 @@ type TopNavProps = {
const TopNav: React.FC<TopNavProps> = ({ location }) => {
const sectionsData = [
{
key: 'graphiql',
link: '/api/api-explorer',
dataTestVal: 'graphiql-explorer-link',
title: 'GraphiQL',
},
{
key: 'rest',
link: '/api/rest',
dataTestVal: 'rest-explorer-link',
title: 'REST',
},
[
{
key: 'graphiql',
link: '/api/api-explorer',
dataTestVal: 'graphiql-explorer-link',
title: 'GraphiQL',
},
{
key: 'rest',
link: '/api/rest',
dataTestVal: 'rest-explorer-link',
title: 'REST',
},
],
[
{
key: 'allow-list',
link: '/api/allow-list',
dataTestVal: 'allow-list',
title: 'Allow List',
},
],
];
if (canAccessSecuritySettings()) {
sectionsData.push({
sectionsData[1].push({
key: 'security',
link: '/api/security/api_limits',
dataTestVal: 'security-explorer-link',
@ -40,27 +50,31 @@ const TopNav: React.FC<TopNavProps> = ({ location }) => {
return (
<div className="flex justify-between items-center border-b border-gray-300 bg-white px-sm">
<div className="flex space-x-4 px-1">
{sectionsData.map(section => (
<div
role="presentation"
className={`whitespace-nowrap font-medium pt-2 pb-1 px-2 border-b-4
<div className="flex px-1 w-full">
{sectionsData.map((group, groupIndex) =>
group.map((section, sectionIndex) => (
<div
role="presentation"
className={`${
groupIndex === 1 && sectionIndex === 0 ? 'ml-auto' : 'ml-4'
} whitespace-nowrap font-medium pt-2 pb-1 px-2 border-b-4
${
isActive(section.link)
? 'border-gray-300 hover:border-gray-300'
: 'border-white hover:border-gray-100'
}`}
key={section.key}
>
<Link
to={section.link}
data-test={section.dataTestVal}
className="text-gray-600 font-semibold no-underline hover:text-gray-600 hover:no-underline focus:text-gray-600 focus:no-underline"
key={section.key}
>
{section.title}
</Link>
</div>
))}
<Link
to={section.link}
data-test={section.dataTestVal}
className="text-gray-600 font-semibold no-underline hover:text-gray-600 hover:no-underline focus:text-gray-600 focus:no-underline"
>
{section.title}
</Link>
</div>
))
)}
</div>
</div>
);

View File

@ -1,6 +1,7 @@
.notifications-wrapper .notification {
height: auto !important;
width: auto !important;
pointer-events: auto;
}
.notifications-wrapper .notifications-tr,

View File

@ -12,6 +12,7 @@ const rqlQueryTypes = [
'run_sql',
'mssql_run_sql',
'citus_run_sql',
'cockroach_run_sql',
];
type Query = {

View File

@ -0,0 +1,14 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
export function useIsUnmounted() {
const rIsUnmounted = useRef<'mounting' | 'mounted' | 'unmounted'>('mounting');
useLayoutEffect(() => {
rIsUnmounted.current = 'mounted';
return () => {
rIsUnmounted.current = 'unmounted';
};
}, []);
return useCallback(() => rIsUnmounted.current !== 'mounted', []);
}

View File

@ -34,6 +34,7 @@ import { setDriver } from '../../../dataSources';
import { UPDATE_CURRENT_DATA_SOURCE } from './DataActions';
import { getSourcesFromMetadata } from '../../../metadata/selector';
import { ManageContainer } from '@/features/Data';
import { Connect } from '@/features/ConnectDB';
const makeDataRouter = (
connect,
@ -54,6 +55,7 @@ const makeDataRouter = (
<Route path="v2">
<Route path="manage" component={ManageContainer} />
<Route path="edit" component={Connect.EditConnection} />
</Route>
<Route path="manage" component={ConnectedDatabaseManagePage} />

View File

@ -1,5 +1,5 @@
import React, { Dispatch, useState } from 'react';
import { Collapse } from '@/new-components/Collapse';
import { Collapse } from '@/new-components/deprecated';
import { IconTooltip } from '@/new-components/Tooltip';
import { FaCaretDown, FaCaretRight } from 'react-icons/fa';

View File

@ -6,10 +6,10 @@ import Globals from '../../../../../../Globals';
import { showErrorNotification } from '../../../../Common/Notification';
import {
exchangeHerokuCode,
generateRandomString,
getPersistedHerokuCallbackSearch,
clearPersistedHerokuCallbackSearch,
} from './utils';
import { generateRandomString } from '../utils';
const HEROKU_OAUTH_CLIENT_ID = Globals.herokuOAuthClientId;

View File

@ -1,16 +1,17 @@
import * as React from 'react';
import { persistHerokuCallbackSearch } from './utils';
const Handler: React.FC = () => {
/*
* This component is only used for local development
* It's used for listening to Heroku's OAuth callback (/heroku-callback) in localdev.
* In production, Heroku's OAuth callback is handled by cloud dashboard
*/
export function HerokuCallbackHandler() {
React.useEffect(() => {
if (typeof window !== undefined) {
// set the value for heroku callback search in local storage
persistHerokuCallbackSearch(window.location.search);
window.close();
}
// set the value for heroku callback search in local storage
persistHerokuCallbackSearch(window.location.search);
window.close();
}, []);
return <>Please wait...</>;
};
export default Handler;
}

View File

@ -14,6 +14,14 @@ import { showErrorNotification } from '../../../../Common/Notification';
import Endpoints from '../../../../../../Endpoints';
import Globals from '../../../../../../Globals';
export {
getEnvVars,
updateEnvVars,
getAvailableEnvVar,
setDBURLInEnvVars,
verifyProjectHealthAndConnectDataSource,
} from '../utils';
export const getHerokuHeaders = (session: HerokuSession) => ({
authorization: `${session.token_type} ${session.access_token}`,
'content-type': 'application/json',
@ -234,18 +242,6 @@ export const exchangeHerokuCode = (code: string) => {
});
};
export const generateRandomString = (stringLength = 16) => {
const allChars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let str = '';
for (let i = 0; i < stringLength; i++) {
const randomNum = Math.floor(Math.random() * allChars.length);
str += allChars.charAt(randomNum);
}
return str;
};
const HEROKU_CALLBACK_SEARCH = 'HEROKU_CALLBACK_SEARCH';
export const clearPersistedHerokuCallbackSearch = () => {
window.localStorage.removeItem(HEROKU_CALLBACK_SEARCH);
@ -259,166 +255,6 @@ export const getPersistedHerokuCallbackSearch = () => {
return window.localStorage.getItem(HEROKU_CALLBACK_SEARCH);
};
export const getEnvVars = () => {
const tenantId = Globals.hasuraCloudTenantId;
const query = `
query getTenantEnv($tenantId: uuid!) {
getTenantEnv: getTenantEnv(tenantId: $tenantId) {
hash
envVars
}
}
`;
const variables = {
tenantId,
};
return fetch(Endpoints.luxDataGraphql, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
query,
variables,
}),
})
.then(r => {
return r.json().then(response => {
return response;
});
})
.catch(e => {
throw e;
});
};
export const updateEnvVars = (
currentHash: string,
envs: { key: any; value: any }[]
) => {
const tenantId = Globals.hasuraCloudTenantId;
const query = `
mutation updateTenant(
$tenantId: uuid!
$currentHash: String!
$envs: [UpdateEnvObject!]!
) {
updateTenantEnv(
currentHash: $currentHash
tenantId: $tenantId
envs: $envs
) {
hash
envVars
}
}
`;
const variables = {
tenantId,
currentHash,
envs,
};
return fetch(Endpoints.luxDataGraphql, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
query,
variables,
}),
})
.then(r => {
return r.json().then(response => {
if (response.errors) {
throw new Error(response.errors[0]?.message);
} else {
return response.updateTenantEnv;
}
});
})
.catch(e => {
throw e;
});
};
export const getEmptyEnvVar = (envVars: Record<string, any>) => {
const newEnvVarName = 'PG_DATABASE_URL';
let suffix = 0;
while (
Object.keys(envVars).some(e => e === `${newEnvVarName}${suffix || ''}`) ||
(envVars.environment &&
Object.keys(envVars.environment).some(
e => e === `${newEnvVarName}${suffix || ''}`
))
) {
suffix++;
}
return `${newEnvVarName}${suffix || ''}`;
};
export const setDBURLInEnvVars = (dbURL: string) => {
return getEnvVars()
.then(res => {
const { hash, envVars } = res.data.getTenantEnv;
const emptyEnvVar = getEmptyEnvVar(envVars);
return updateEnvVars(hash, [
{
key: emptyEnvVar,
value: dbURL,
},
]).then(() => {
return emptyEnvVar;
});
})
.catch(e => {
throw e;
});
};
const getProjectHealth = () => {
const healthEndpoint = `${Globals.dataApiUrl}/healthz`;
return fetch(healthEndpoint, {
method: 'GET',
headers: {
'content-type': 'application/json',
},
credentials: 'include',
})
.then(health => {
return health.ok;
})
.catch(e => {
throw e;
});
};
export const verifyProjectHealthAndConnectDataSource = (
successCallback: VoidFunction,
errorCallback: VoidFunction,
retryCount = 0
) => {
if (retryCount === 10) {
errorCallback();
return;
}
getProjectHealth()
.then(() => {
successCallback();
})
.catch(() => {
setTimeout(() => {
verifyProjectHealthAndConnectDataSource(
successCallback,
errorCallback,
retryCount + 1
);
}, 1500);
});
};
export const startHerokuDBURLSync = (
envVar: string,
appName: string,

View File

@ -0,0 +1,17 @@
import * as React from 'react';
import { persistNeonCallbackSearch } from './utils';
/*
* This component is only used for local development
* It's used for listening to Neon's OAuth callback (/neon-integration/callback) in localdev.
* In production, Neon's OAuth callback is handled by cloud dashboard
*/
export function NeonCallbackHandler() {
React.useEffect(() => {
// set the value for heroku callback search in local storage
persistNeonCallbackSearch(window.location.search);
window.close();
}, []);
return <>Please wait...</>;
}

View File

@ -0,0 +1,10 @@
import React from 'react';
import { ComponentMeta, Story } from '@storybook/react';
import { HerokuBanner } from './Banner';
export default {
title: 'features/Neon Integration/Heroku Banner',
component: HerokuBanner,
} as ComponentMeta<typeof HerokuBanner>;
export const Base: Story = () => <HerokuBanner />;

View File

@ -0,0 +1,27 @@
import React from 'react';
import { Button } from '@/new-components/Button';
import { GrHeroku } from 'react-icons/gr';
export function HerokuBanner() {
return (
<div className="flex items-center justify-between border border-gray-300 border-l-4 border-l-[#430098] shadow-md rounded bg-white p-md">
<div className="flex items-center">
<div className="ml-sm">
<GrHeroku size={24} color="#430098" />
</div>
<div className="text-lg text-gray-700 ml-sm">
Starting <b>November 28th, 2022,</b> free Heroku Dynos, free Heroku
Postgres, and free Heroku Data for Redis will no longer be available.
</div>
</div>
<a
href="https://help.heroku.com/RSBRUH58/removal-of-heroku-free-product-plans-faq"
className="no-underline"
target="_blank"
rel="noopener noreferrer"
>
<Button className="ml-auto">Know more</Button>
</a>
</div>
);
}

View File

@ -0,0 +1,97 @@
import React from 'react';
import { ComponentMeta, Story } from '@storybook/react';
import { within } from '@storybook/testing-library';
import { expect } from '@storybook/jest';
import { NeonBanner } from './NeonBanner';
export default {
title: 'features/Neon Integration/NeonBanner',
component: NeonBanner,
} as ComponentMeta<typeof NeonBanner>;
export const Base: Story = () => (
<NeonBanner
onClickConnect={() => window.alert('clicked connect button')}
status={{
status: 'default',
}}
buttonText="Create Neon Database for free"
/>
);
Base.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Expect element renders successfully
expect(
await canvas.findByText('Create Neon Database for free')
).toBeVisible();
expect(await canvas.getByTestId('neon-connect-db-button')).toBeVisible();
expect(await canvas.getByTestId('neon-connect-db-button')).not.toBeDisabled();
};
export const Loading: Story = () => (
<NeonBanner
status={{
status: 'loading',
}}
buttonText="Authenticating"
onClickConnect={() => null}
/>
);
Loading.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Expect element renders successfully
expect(await canvas.findByText('Authenticating')).toBeVisible();
// Expect button disabled state to be as expected
expect(await canvas.getByTestId('neon-connect-db-button')).toBeVisible();
expect(await canvas.getByTestId('neon-connect-db-button')).toBeDisabled();
};
export const Creating: Story = () => (
<NeonBanner
status={{
status: 'loading',
}}
buttonText="Creating Database"
onClickConnect={() => null}
/>
);
Creating.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Expect element renders successfully
expect(await canvas.findByText('Creating Database')).toBeVisible();
// Expect element renders successfully
expect(await canvas.getByTestId('neon-connect-db-button')).toBeVisible();
expect(await canvas.getByTestId('neon-connect-db-button')).toBeDisabled();
};
export const Error: Story = () => (
<NeonBanner
onClickConnect={() => window.alert('clicked connect button')}
status={{
status: 'error',
errorTitle: 'Error creating database',
errorDescription: 'You have exceeded the free project limit on Neon.',
}}
buttonText="Try Again"
icon="refresh"
/>
);
Error.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
// Expect element renders successfully
expect(await canvas.findByText('Try Again')).toBeVisible();
expect(await canvas.findByText('Try Again')).not.toBeDisabled();
// Expect element rend
expect(await canvas.findByText('Error creating database')).toBeVisible();
expect(
await canvas.findByText('You have exceeded the free project limit on Neon.')
).toBeVisible();
expect(await canvas.getByTestId('neon-connect-db-button')).toBeVisible();
expect(await canvas.getByTestId('neon-connect-db-button')).not.toBeDisabled();
};

View File

@ -0,0 +1,86 @@
import React from 'react';
import { MdRefresh } from 'react-icons/md';
import { Button } from '@/new-components/Button';
import { IndicatorCard } from '@/new-components/IndicatorCard';
const iconMap = {
refresh: <MdRefresh />,
};
type Status =
| {
status: 'loading';
}
| {
status: 'error';
errorTitle: string;
errorDescription: string;
}
| {
status: 'default';
};
export type Props = {
status: Status;
onClickConnect: VoidFunction;
buttonText: string;
icon?: keyof typeof iconMap;
};
export function NeonBanner(props: Props) {
const { status, onClickConnect, buttonText, icon } = props;
const isButtonDisabled = status.status === 'loading';
return (
<div className="border border-gray-300 shadow-md rounded bg-white p-md">
<div className="flex items-center mb-xs">
<span className="font-semibold flex items-center text-sm py-0.5 px-1.5 text-indigo-600 bg-indigo-100 rounded">
New
</span>
<span className="ml-xs font-semibold flex items-center text-sm py-0.5 px-1.5 text-indigo-600 bg-indigo-100 rounded">
Free
</span>
</div>
<img
src="https://storage.googleapis.com/graphql-engine-cdn.hasura.io/cloud-console/assets/common/img/neon_banner.png"
alt="neon_banner"
/>
<div className="mt-sm mb-sm text-gray-700 text-lg">
<b>Hasura</b> + <b>Neon</b> are partners now!
</div>
<div className="flex justify-between items-center mb-sm">
<div className="w-3/4 text-md text-gray-700">
<p>
The multi-cloud fully managed Postgres with a generous free tier. We
separated storage and compute to offer autoscaling, branching, and
bottomless storage.
</p>
</div>
<div className="flex w-1/4 justify-end">
<Button
data-trackid="neon-connect-db-button"
data-testid="neon-connect-db-button"
mode={status.status === 'loading' ? 'default' : 'primary'}
isLoading={status.status === 'loading'}
loadingText={buttonText}
size="md"
icon={icon ? iconMap[icon] : undefined}
onClick={() => {
if (!isButtonDisabled) {
onClickConnect();
}
}}
disabled={isButtonDisabled}
>
{props.buttonText}
</Button>
</div>
</div>
{status.status === 'error' && (
<IndicatorCard status="negative" headline={status.errorTitle} showIcon>
{status.errorDescription}
</IndicatorCard>
)}
</div>
);
}

View File

@ -0,0 +1,110 @@
import * as React from 'react';
import { Dispatch } from '@/types';
import {
NeonBanner,
Props as NeonBannerProps,
} from './components/Neon/NeonBanner';
import { getNeonDBName } from './utils';
import { useNeonIntegration } from './useNeonIntegration';
import _push from '../../../push';
// This component deals with Neon DB creation on connect DB page
export function Neon(props: { allDatabases: string[]; dispatch: Dispatch }) {
const { dispatch, allDatabases } = props;
const pushToDatasource = (dataSourceName: string) => {
dispatch(_push(`/data/${dataSourceName}`));
};
const pushToConnectDBPage = () => {
dispatch(_push(`/data/manage/connect`));
};
const neonIntegrationStatus = useNeonIntegration(
getNeonDBName(allDatabases),
pushToDatasource,
pushToConnectDBPage,
dispatch
);
let neonBannerProps: NeonBannerProps;
switch (neonIntegrationStatus.status) {
case 'idle':
neonBannerProps = {
status: {
status: 'default',
},
buttonText: 'Connect Neon Database',
onClickConnect: neonIntegrationStatus.action,
};
break;
case 'authentication-loading':
neonBannerProps = {
status: {
status: 'loading',
},
buttonText: 'Authenticating with Neon',
onClickConnect: () => null,
};
break;
case 'authentication-error':
neonBannerProps = {
status: {
status: 'error',
errorTitle: neonIntegrationStatus.title,
errorDescription: neonIntegrationStatus.description,
},
buttonText: 'Try again',
onClickConnect: neonIntegrationStatus.action,
icon: 'refresh',
};
break;
case 'authentication-success':
case 'neon-database-creation-loading':
neonBannerProps = {
status: {
status: 'loading',
},
buttonText: 'Creating Database',
onClickConnect: () => null,
};
break;
case 'neon-database-creation-error':
neonBannerProps = {
status: {
status: 'error',
errorTitle: neonIntegrationStatus.title,
errorDescription: neonIntegrationStatus.description,
},
buttonText: 'Try again',
onClickConnect: neonIntegrationStatus.action,
icon: 'refresh',
};
break;
case 'neon-database-creation-success':
case 'env-var-creation-loading':
case 'env-var-creation-success':
case 'env-var-creation-error':
case 'hasura-source-creation-loading':
case 'hasura-source-creation-error':
case 'hasura-source-creation-success':
neonBannerProps = {
status: {
status: 'loading',
},
buttonText: 'Connecting to Hasura',
onClickConnect: () => null,
};
break;
default:
neonBannerProps = {
status: {
status: 'default',
},
buttonText: 'Connect Neon Database',
onClickConnect: () => null,
};
break;
}
return <NeonBanner {...neonBannerProps} />;
}

View File

@ -0,0 +1,201 @@
import { useState, useCallback } from 'react';
import { tracingTools } from '@/features/TracingTools';
import { Dispatch } from '@/types';
import {
setDBURLInEnvVars,
verifyProjectHealthAndConnectDataSource,
} from '../utils';
import { setDBConnectionDetails } from '../../../DataActions';
import {
connectDataSource,
connectionTypes,
getDefaultState,
} from '../../../DataSources/state';
type HasuraDBCreationPayload = {
envVar: string;
dataSourceName: string;
};
// this hook must never go into an error
type HasuraDatasourceStatus =
| {
status: 'idle';
}
| {
status: 'adding-env-var';
}
| {
status: 'adding-data-source';
payload: HasuraDBCreationPayload;
}
| {
status: 'success';
payload: HasuraDBCreationPayload;
}
| {
status: 'adding-env-var-failed';
payload: {
dbUrl: string;
};
}
| {
status: 'adding-data-source-failed';
payload: HasuraDBCreationPayload;
};
export function useCreateHasuraCloudDatasource(
dbUrl: string,
dataSourceName = 'default',
dispatch: Dispatch
) {
const [state, setState] = useState<HasuraDatasourceStatus>({
status: 'idle',
});
// this function adds an ENV var database to Hasura
const executeConnect = useCallback(
(
envVar: string,
successCallback: VoidFunction,
errorCallback: VoidFunction
) => {
setState({
status: 'adding-data-source',
payload: {
envVar,
dataSourceName,
},
});
const connectionConfig = {
envVar,
dbName: dataSourceName,
};
dispatch(setDBConnectionDetails(connectionConfig));
try {
connectDataSource(
dispatch,
connectionTypes.ENV_VAR,
getDefaultState({
dbConnection: connectionConfig,
}),
successCallback
);
} catch (e) {
errorCallback();
}
},
[dataSourceName, dbUrl]
);
const start = useCallback(() => {
if (dbUrl) {
setState({
status: 'adding-env-var',
});
// This sets the database URL of the given Hasura project as an env var in Hasura Cloud project
setDBURLInEnvVars(dbUrl)
.then(envVar => {
setState({
status: 'adding-data-source',
payload: {
envVar,
dataSourceName,
},
});
/*
There's a downtime after env var updation.
So we verify the project health and attempt connecting datasource only after project is up
We start verifying the project health after a timeout of 5000 seconds,
because it could 2-3 seconds for the project to go down after the environment variable update
*/
setTimeout(() => {
verifyProjectHealthAndConnectDataSource(
() => {
executeConnect(
envVar,
// set success status when the data source gets added successfully
() => {
setState({
status: 'success',
payload: {
envVar,
dataSourceName,
},
});
},
// set error status when the data source gets added successfully
() => {
setState({
status: 'adding-data-source-failed',
payload: {
envVar,
dataSourceName,
},
});
}
);
},
// set error status if creating env var failed
() => {
setState({
status: 'adding-data-source-failed',
payload: {
envVar,
dataSourceName,
},
});
}
);
}, 5000);
})
.catch(e => {
// if adding env var fails unexpectedly, set the error state
setState(prevState => {
if (prevState.status === 'adding-env-var') {
// this is an unexpected error; so we need alerts about this
tracingTools.sentry.captureException(
new Error('failed creating env vars in Hasura'),
{
debug: {
error: 'message' in e ? e.message : e,
trace: 'useCreateHasuraDatasource',
},
}
);
return {
status: 'adding-env-var-failed',
payload: { dbUrl },
};
// if adding data-source fails unexpectedly, set the error state
} else if (prevState.status === 'adding-data-source') {
// this is an unexpected error; so we need alerts about this
tracingTools.sentry.captureException(
new Error('failed adding created data source in Hasura'),
{
debug: {
error: 'message' in e ? e.message : e,
trace: 'useCreateHasuraDatasource',
},
}
);
return {
status: 'adding-data-source-failed',
payload: prevState.payload,
};
}
return prevState;
});
});
}
}, [dbUrl, dataSourceName, state]);
return {
state,
addHasuraDatasource: start,
};
}

View File

@ -0,0 +1,14 @@
import { useCallback, useLayoutEffect, useRef } from 'react';
export function useIsUnmounted() {
const rIsUnmounted = useRef<'mounting' | 'mounted' | 'unmounted'>('mounting');
useLayoutEffect(() => {
rIsUnmounted.current = 'mounted';
return () => {
rIsUnmounted.current = 'unmounted';
};
}, []);
return useCallback(() => rIsUnmounted.current !== 'mounted', []);
}

View File

@ -0,0 +1,105 @@
import * as React from 'react';
import { GraphQLError } from 'graphql';
import {
act,
render,
screen,
cleanup,
waitFor,
fireEvent,
} from '@testing-library/react';
import { useNeonDatabase } from './useNeonDatabase';
const TestComponent = () => {
const { create, state } = useNeonDatabase();
return (
<div>
<p data-testid="status">{state.status}</p>
{state.status === 'error' && <p data-testid="error">{state.error}</p>}
{state.status === 'success' && (
<p data-testid="payload">{JSON.stringify(state.payload)}</p>
)}
<button data-testid="start" onClick={create}>
start
</button>
</div>
);
};
// --------------------------------------------------
// NETWORK MOCK
// --------------------------------------------------
const mockHTTPResponse = (status = 200, returnBody: any) => {
global.fetch = jest.fn().mockImplementationOnce(() => {
return new Promise(resolve => {
setTimeout(() => {
resolve({
ok: true,
status,
json: () => {
return returnBody || {};
},
headers: {
get: (key: string) =>
key === 'Content-Type' ? 'application/json' : '',
},
});
}, 2000);
});
});
};
jest.useFakeTimers();
beforeEach(() => {
jest.clearAllMocks();
jest.clearAllTimers();
cleanup();
});
describe('useNeonDatabase', () => {
it('Renders idle state correctly', () => {
render(<TestComponent />);
expect(screen.getByTestId('status')).toHaveTextContent('idle');
});
it('Creates DB correctly', async () => {
const creationResponse = {
data: {
neonCreateDatabase: {
isAuthenticated: true,
databaseUrl: 'db-url',
email: 'email@test.com',
envVar: 'TEST',
},
},
};
mockHTTPResponse(200, creationResponse);
await render(<TestComponent />);
act(() => {
fireEvent.click(screen.getByTestId('start'));
});
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('loading');
});
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('success');
});
expect(screen.getByTestId('payload')).toHaveTextContent(
JSON.stringify(creationResponse.data.neonCreateDatabase)
);
});
it('Propogates error state correctly', async () => {
const creationResponse = { errors: [new GraphQLError('api error')] };
mockHTTPResponse(200, creationResponse);
await render(<TestComponent />);
act(() => {
fireEvent.click(screen.getByTestId('start'));
});
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('error');
});
expect(screen.getByTestId('error')).toHaveTextContent('api error');
});
});

View File

@ -0,0 +1,102 @@
import React, { useMemo, useCallback } from 'react';
import { createFetchControlPlaneData } from '@/hooks/createFetchControlPlaneData';
import globals from '@/Globals';
type CreateDatabaseResponse = {
data: {
neonCreateDatabase: {
isAuthenticated: boolean;
databaseUrl?: string;
email?: string;
};
};
};
const NEON_CREATE_DATABASE_QUERY = `
mutation neonCreateDatabase ($projectId:uuid!) {
neonCreateDatabase (projectId: $projectId) {
databaseUrl
email
envVar
isAuthenticated
}
}
`;
// this stores the state of the
type NeonDBStatus =
| {
status: 'idle';
}
| {
status: 'success';
payload: CreateDatabaseResponse['data']['neonCreateDatabase'];
}
| {
status: 'error';
error: 'unauthorized' | string;
}
| {
status: 'loading';
};
function getHumanReadableAPIError(err: string) {
if (err.includes('limit exceeded')) {
return 'You have reached the free tier limit on Neon. Please delete a free tier project from Neon dashboard and try again.';
}
return err;
}
export function useNeonDatabase() {
const [state, setState] = React.useState<NeonDBStatus>({ status: 'idle' });
// initialise the GraphQL query to create Neon database
const createNeonDatabase = useMemo(() => {
return createFetchControlPlaneData<CreateDatabaseResponse>({
query: NEON_CREATE_DATABASE_QUERY,
variables: {
projectId: globals.hasuraCloudProjectId || '',
},
});
}, []);
// a function to create neon database and set the appropriate state
const startCreation = useCallback(async () => {
setState({
status: 'loading',
});
const responseOrError = await createNeonDatabase();
if (typeof responseOrError === 'string') {
setState({
status: 'error',
error: getHumanReadableAPIError(responseOrError),
});
} else {
const payload = responseOrError.data.neonCreateDatabase;
if (!payload.isAuthenticated) {
setState({
status: 'error',
error: 'unauthorized',
});
} else {
setState({
status: 'success',
payload,
});
}
}
}, [createNeonDatabase]);
const reset = useCallback(() => {
setState({
status: 'idle',
});
}, []);
return {
create: startCreation,
state,
reset,
};
}

View File

@ -0,0 +1,307 @@
import { useEffect } from 'react';
import { Dispatch } from '@/types';
import { useNeonOAuth } from './useNeonOAuth';
import { useNeonDatabase } from './useNeonDatabase';
import { useCreateHasuraCloudDatasource } from './useCreateHasuraCloudDatasource';
import { setDBConnectionDetails } from '../../../DataActions';
type EmptyPayload = Record<string, never>;
type NeonDBCreationSuccessPayload = {
databaseUrl: string;
email: string;
};
type EnvVarCreationPayload = {
databaseUrl: string;
dataSourceName: string;
};
type DatasourceCreationPayload = {
envVar: string;
databaseUrl: string;
dataSourceName: string;
};
type Idle<Status, Payload> = {
status: Status;
payload?: Payload;
action: VoidFunction;
};
type Loading<Status, Payload> = {
status: Status;
payload: Payload;
};
type Error<Status, Payload> = {
status: Status;
payload: Payload;
title: string;
description: string;
action: VoidFunction;
};
type Success<Status, Payload> = {
status: Status;
payload: Payload;
};
type NeonIntegrationStatus =
| Idle<'idle', EmptyPayload>
| Loading<'authentication-loading', EmptyPayload>
| Success<'authentication-success', EmptyPayload>
| Error<'authentication-error', EmptyPayload>
| Loading<'neon-database-creation-loading', EmptyPayload>
| Success<'neon-database-creation-success', NeonDBCreationSuccessPayload>
| Error<'neon-database-creation-error', EmptyPayload>
| Loading<'env-var-creation-loading', EnvVarCreationPayload>
| Success<'env-var-creation-success', DatasourceCreationPayload>
| Error<'env-var-creation-error', EnvVarCreationPayload>
| Loading<'hasura-source-creation-loading', DatasourceCreationPayload>
| Success<'hasura-source-creation-success', DatasourceCreationPayload>
| Error<'hasura-source-creation-error', DatasourceCreationPayload>;
export function useNeonIntegration(
dataSourceName: string,
dbCreationCallback: (dataSourceName: string) => void, // TODO use NeonIntegrationStatus as a parameter
failureCallback: VoidFunction, // TODO use NeonIntegrationStatus as a parameter
dispatch: Dispatch
): NeonIntegrationStatus {
const { startNeonOAuth, neonOauthStatus } = useNeonOAuth();
const {
create: createNeonDatabase,
state: neonDBCreationStatus,
reset: resetNeonDBCreationState,
} = useNeonDatabase();
const { state: hasuraCloudDataSourceConnectionStatus, addHasuraDatasource } =
useCreateHasuraCloudDatasource(
neonDBCreationStatus.status === 'success'
? neonDBCreationStatus.payload.databaseUrl || ''
: '',
dataSourceName,
dispatch
);
useEffect(() => {
// automatically login if creating database fails with 401 unauthorized
if (
neonDBCreationStatus.status === 'error' &&
neonDBCreationStatus.error === 'unauthorized' &&
neonOauthStatus.status === 'idle'
) {
startNeonOAuth();
resetNeonDBCreationState();
}
// automatically create database after authentication completion
if (
neonOauthStatus.status === 'authenticated' &&
(neonDBCreationStatus.status === 'idle' ||
(neonDBCreationStatus.status === 'error' &&
neonDBCreationStatus.error === 'unauthorized'))
) {
createNeonDatabase();
}
}, [neonDBCreationStatus, neonOauthStatus]);
useEffect(() => {
if (neonDBCreationStatus.status === 'success') {
switch (hasuraCloudDataSourceConnectionStatus.status) {
case 'idle':
addHasuraDatasource();
break;
case 'adding-env-var-failed':
dispatch(
setDBConnectionDetails({
dbURL: hasuraCloudDataSourceConnectionStatus.payload.dbUrl,
dbName: 'default',
})
);
failureCallback();
break;
case 'adding-data-source-failed':
dispatch(
setDBConnectionDetails({
envVar: hasuraCloudDataSourceConnectionStatus.payload.envVar,
dbName: 'default',
})
);
failureCallback();
break;
case 'success':
dbCreationCallback(
hasuraCloudDataSourceConnectionStatus.payload.dataSourceName
);
break;
default:
break;
}
}
}, [neonDBCreationStatus, hasuraCloudDataSourceConnectionStatus]);
const getNeonDBCreationStatus = (): NeonIntegrationStatus => {
switch (neonDBCreationStatus.status) {
case 'idle':
return {
status: 'idle',
action: createNeonDatabase,
};
case 'error': {
switch (neonOauthStatus.status) {
case 'idle':
if (neonDBCreationStatus.error === 'unauthorized') {
return {
status: 'idle',
action: startNeonOAuth,
};
}
return {
status: 'neon-database-creation-error',
action: createNeonDatabase,
payload: {},
title: 'Error creating Neon database',
description: neonDBCreationStatus.error,
};
case 'error':
return {
status: 'authentication-error',
payload: {},
action: startNeonOAuth,
title: 'Error authenticating with Neon',
description: neonOauthStatus.error.message,
};
case 'authenticating':
return {
status: 'authentication-loading',
payload: {},
};
case 'authenticated':
return {
status: 'neon-database-creation-error',
action: createNeonDatabase,
payload: {},
title: 'Error creating Neon database',
description: neonDBCreationStatus.error,
};
default:
return {
status: 'idle',
action: startNeonOAuth,
};
}
break;
}
case 'loading':
return {
status: 'neon-database-creation-loading',
payload: {},
};
case 'success':
{
const { databaseUrl: dbUrl } = neonDBCreationStatus.payload;
switch (hasuraCloudDataSourceConnectionStatus.status) {
case 'idle':
return {
status: 'neon-database-creation-success',
payload: {
databaseUrl: neonDBCreationStatus.payload.databaseUrl || '',
email: neonDBCreationStatus.payload.email || '',
},
};
case 'adding-env-var':
return {
status: 'env-var-creation-loading',
payload: {
databaseUrl: neonDBCreationStatus.payload.databaseUrl || '',
dataSourceName,
},
};
case 'adding-env-var-failed':
return {
status: 'env-var-creation-error',
payload: {
databaseUrl: dbUrl || '',
dataSourceName,
},
action: () => null,
title: 'Error creating env var',
description:
'Unexpected error adding env vars to the Hasura Cloud project',
};
case 'adding-data-source':
return {
status: 'hasura-source-creation-loading',
payload: {
dataSourceName,
envVar: hasuraCloudDataSourceConnectionStatus.payload.envVar,
databaseUrl: dbUrl || '',
},
};
case 'success':
return {
status: 'hasura-source-creation-success',
payload: {
databaseUrl: dbUrl || '',
dataSourceName,
envVar: hasuraCloudDataSourceConnectionStatus.payload.envVar,
},
};
case 'adding-data-source-failed':
default:
return {
status: 'hasura-source-creation-error',
payload: {
databaseUrl: dbUrl || '',
dataSourceName,
envVar: hasuraCloudDataSourceConnectionStatus.payload.envVar,
},
action: createNeonDatabase,
title: 'Error creating Hasura datasource',
description: 'Unexpected error creating Hasura datasource',
};
}
}
break;
default: {
return {
status: 'idle',
payload: {},
action: createNeonDatabase,
};
}
}
};
const getNeonIntegrationStatus = (): NeonIntegrationStatus => {
switch (neonOauthStatus.status) {
case 'idle':
return getNeonDBCreationStatus();
case 'authenticating':
return {
status: 'authentication-loading',
payload: {},
};
case 'error':
return {
status: 'authentication-error',
payload: {},
title: 'Error authenticating with Neon',
description: neonOauthStatus.error.message,
action: startNeonOAuth,
};
case 'authenticated':
return getNeonDBCreationStatus();
default:
return {
status: 'idle',
payload: {},
action: startNeonOAuth,
};
}
};
return getNeonIntegrationStatus();
}

View File

@ -0,0 +1,281 @@
import * as React from 'react';
import {
act,
render,
screen,
cleanup,
waitFor,
fireEvent,
} from '@testing-library/react';
import type { GraphQLError } from 'graphql';
import { ExchangeTokenResponse, useNeonOAuth } from './useNeonOAuth';
import { NEON_CALLBACK_SEARCH } from './utils';
function TestComponent() {
const { startNeonOAuth, neonOauthStatus, oauth2State } = useNeonOAuth();
return (
<div>
<p data-testid="status">{neonOauthStatus.status}</p>
<p data-testid="error">
{neonOauthStatus.status === 'error'
? neonOauthStatus.error.message
: ''}
</p>
<p data-testid="email">
{neonOauthStatus.status === 'authenticated'
? neonOauthStatus.email
: ''}
</p>
<p data-testid="oauth2-state">{oauth2State}</p>
<button data-testid="start" onClick={startNeonOAuth}>
start
</button>
</div>
);
}
// --------------------------------------------------
// LOCALSTORAGE MOCK
// --------------------------------------------------
const getMockLocalStorage = () => {
let store: Record<string, string> = {};
return {
getItem(key: string): string | undefined {
return store[key];
},
setItem(key: string, value: string) {
store[key] = value.toString();
},
clear() {
store = {};
},
removeItem(key: string) {
delete store[key];
},
};
};
const mockLocalStorage = getMockLocalStorage();
Object.defineProperty(window, 'localStorage', { value: mockLocalStorage });
// --------------------------------------------------
// POPUP MOCK
// --------------------------------------------------
const getMockPopupImpl = () => {
const popup = {
closed: false,
};
const openPopup = () => {
popup.closed = false;
return popup;
};
const closePopup = () => {
popup.closed = true;
return popup;
};
return {
openPopup,
closePopup,
};
};
const mockPopupImpl = getMockPopupImpl();
Object.defineProperty(window, 'open', { value: mockPopupImpl.openPopup });
// --------------------------------------------------
// NETWORK MOCK
// --------------------------------------------------
const mockHTTPResponse = (status = 200, returnBody: any) => {
global.fetch = jest.fn().mockImplementationOnce(() => {
return new Promise(resolve => {
setTimeout(() => {
resolve({
ok: true,
status,
json: () => {
return returnBody || {};
},
headers: {
get: (key: string) =>
key === 'Content-Type' ? 'application/json' : '',
},
});
}, 1000);
});
});
};
// --------------------------------------------------
// TEST UTILS
// --------------------------------------------------
// use fake timers because the hook uses setTimeout and setInterval
jest.useFakeTimers();
function closeFakePopup() {
act(() => {
mockPopupImpl.closePopup();
jest.advanceTimersByTime(2000);
});
}
// --------------------------------------------------
// TESTS
// --------------------------------------------------
describe('Neon', () => {
// reset test state after each test
beforeEach(() => {
cleanup();
mockLocalStorage.clear();
mockPopupImpl.closePopup();
jest.clearAllTimers();
jest.clearAllMocks();
});
it('Happy path', async () => {
// Arrange
render(<TestComponent />);
// Act
fireEvent.click(screen.getByTestId('start'));
// Wait for the loading state to be triggered
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('authenticating');
});
// --------------------------------------------------
// CONTROLLING TEST MOCKS
// set search params in local storage and close popup
// mock success exchange of token
const oauth2State = screen.getByTestId('oauth2-state').textContent;
mockLocalStorage.setItem(
NEON_CALLBACK_SEARCH,
`code=test_code&state=${oauth2State}`
);
const response: ExchangeTokenResponse = {
data: {
neonExchangeOAuthToken: {
accessToken: 'test_token',
email: 'test@email.com',
},
},
};
mockHTTPResponse(200, response);
closeFakePopup();
// --------------------------------------------------
// Assert
await waitFor(() => {
expect(screen.getByTestId('status')).toHaveTextContent('authenticated');
});
expect(screen.getByTestId('email')).toHaveTextContent(
response.data.neonExchangeOAuthToken.email
);
});
it('Renders idle state correctly', () => {
// Arrange
render(<TestComponent />);
// Assert
expect(screen.getByTestId('status')).toHaveTextContent('idle');
});
it('throws unexpected error when the popup is closed before the parameters are stored in localstorage', () => {
// Arrange
render(<TestComponent />);
// Act
fireEvent.click(screen.getByTestId('start'));
// --------------------------------------------------
// CONTROLLING TEST MOCKS
closeFakePopup();
// --------------------------------------------------
// Assert
expect(screen.getByTestId('error')).toHaveTextContent(
'Neon login closed unexpectedly. Please try again.'
);
});
it('throws OAuth error when the OAuth code does not exist in search params in local storage', () => {
// Arrange
render(<TestComponent />);
// Act
fireEvent.click(screen.getByTestId('start'));
// --------------------------------------------------
// CONTROLLING TEST MOCKS
// set search params in localstorage and close popup
// code is not set in search params
// results in authentication error
mockLocalStorage.setItem(NEON_CALLBACK_SEARCH, 'state=test_state');
closeFakePopup();
// --------------------------------------------------
// Assert
expect(screen.getByTestId('error')).toHaveTextContent(
'Error authenticating with Neon. Please try again.'
);
});
it('Throw forgery error when the OAuth state mismatch', () => {
// Arrange
render(<TestComponent />);
// Act
fireEvent.click(screen.getByTestId('start'));
// --------------------------------------------------
// CONTROLLING TEST MOCKS
// set search params localstorage and close popup
// results in state mismatch error
mockLocalStorage.setItem(
NEON_CALLBACK_SEARCH,
'code=test_code&state=test_state'
);
closeFakePopup();
// --------------------------------------------------
// Assert
expect(screen.getByTestId('error')).toHaveTextContent(
'Invalid OAuth session state. Please try again.'
);
});
it('Renders oauth error when there is an error exchanging the token', async () => {
// Arrange
render(<TestComponent />);
// Act
fireEvent.click(screen.getByTestId('start'));
// --------------------------------------------------
// CONTROLLING TEST MOCKS
// set the right in local storage along with a code
const oauth2State = screen.getByTestId('oauth2-state').textContent;
mockLocalStorage.setItem(
NEON_CALLBACK_SEARCH,
`code=test_code&state=${oauth2State}`
);
const response: { errors: GraphQLError[] } = {
// @ts-expect-error The error format seems not to match the declared type returned by
// controlPlaneDataApiClient, we should fix it
errors: [{ message: 'oauth api error' }],
};
// mock HTTP response for exchanging token to return an exchange error
mockHTTPResponse(200, response);
closeFakePopup();
// --------------------------------------------------
// Assert
await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent('oauth api error');
});
});
});

View File

@ -0,0 +1,225 @@
import * as React from 'react';
import { useMemo, useState, useCallback } from 'react';
import globals from '@/Globals';
import { createFetchControlPlaneData } from '@/hooks/createFetchControlPlaneData';
import { generateRandomString } from '../utils';
import {
getPersistedNeonCallbackSearch,
clearPersistedNeonCallbackSearch,
} from './utils';
import { useIsUnmounted } from './useIsUnmounted';
export type ExchangeTokenResponse = {
data: {
neonExchangeOAuthToken: {
accessToken: string;
email: string;
};
};
};
const NEON_TOKEN_EXCHANGE_QUERY = `
mutation neonTokenExchange (
$code: String!
$state: String!
$projectId: uuid!
) {
neonExchangeOAuthToken (
code: $code
state: $state
projectId: $projectId
) {
accessToken
email
}
}
`;
type NeonOauthStatus =
| {
status: 'idle';
}
| {
status: 'error';
error: Error;
}
| {
status: 'authenticating';
}
| {
status: 'authenticated';
email: string;
};
export const useNeonOAuth = (oauthString?: string) => {
const isUnmounted = useIsUnmounted();
const [status, setStatus] = useState<NeonOauthStatus>({
status: 'idle',
});
// initialise the GraphQL query to exchange oauth token
const fetchControlPlaneData = useMemo(() => {
return createFetchControlPlaneData<ExchangeTokenResponse>({
query: NEON_TOKEN_EXCHANGE_QUERY,
});
}, []);
// generates a local oauth state to avoid forged callback
const oauth2State = oauthString ?? useMemo(generateRandomString, []);
/*
* Handle the redirect params to get the oauth session:
* */
const startFetchingControlPlaneData = useCallback(
async (callbackSearch: string) => {
const params = new URLSearchParams(callbackSearch);
// if grant code is absent, the redirect was unsuccessful
if (!params.get('code')) {
setStatus({
status: 'error',
error: new Error('Error authenticating with Neon. Please try again.'),
});
return;
}
/* if the state in params does not match the local state,
* the request was probably tampered with
* */
if (params.get('state') !== oauth2State) {
setStatus({
status: 'error',
error: new Error('Invalid OAuth session state. Please try again.'),
});
return;
}
try {
const responseOrError = await fetchControlPlaneData({
variables: {
code: params.get('code') || '',
state: params.get('state') || '',
projectId: globals.hasuraCloudProjectId || '',
},
});
if (isUnmounted()) return;
if (typeof responseOrError === 'string') {
setStatus({
status: 'error',
error: new Error(responseOrError),
});
return;
}
const response = responseOrError;
setStatus({
status: 'authenticated',
email: response.data.neonExchangeOAuthToken.email,
});
return;
} catch (error) {
console.error(error);
setStatus({
status: 'error',
error: error instanceof Error ? error : new Error('Unknown error'),
});
}
},
[oauth2State, fetchControlPlaneData]
);
// function to start the oauth process
const startNeonOAuth = React.useCallback(() => {
setStatus({ status: 'authenticating' });
const searchParams = generateUrlSearchParams(
globals.neonOAuthClientId ?? '',
`${window.location.origin}/neon-integration/callback`,
oauth2State
);
// open Neon auth page in a popup
const popup = window.open(
`https://oauth2.${
globals.neonRootDomain
}/oauth2/auth?${searchParams.toString()}`,
'neon-oauth2',
'menubar=no,toolbar=no,location=no,width=800,height=600'
);
// Usually means that a popup blocked blocked the popup
if (!popup) {
setStatus({
status: 'error',
error: new Error(
'Could not open popup for logging in with Neon. Please disable your popup blocker and try again.'
),
});
return;
}
/*
* After OAuth success, neon redirects the user to
* /neon-integration/callback path of Hasura Cloud. Check ./TempNeonCallback.
* The Callback component passes the search params of the redirect
* to the current context through localstorage.
* We read the the localstorage and clear the redirect params.
* */
const intervalId = setInterval(() => {
if (isUnmounted()) return;
if (!popup.closed) return;
clearInterval(intervalId);
/*
* Once the popup is closed,
* the redirect params are expected to be available in localstorage.
* Here we read the params from localstorage and store them in local state.
* These params are handled in another effect.
* */
setTimeout(() => {
if (isUnmounted()) return;
const search = getPersistedNeonCallbackSearch();
if (!search) {
setStatus({
status: 'error',
error: new Error(
'Neon login closed unexpectedly. Please try again.'
),
});
return;
}
startFetchingControlPlaneData(search);
clearPersistedNeonCallbackSearch();
}, 500);
}, 500);
}, [oauth2State, isUnmounted]);
return {
oauth2State,
startNeonOAuth,
neonOauthStatus: status,
};
};
function generateUrlSearchParams(
neonOAuthClientId: string,
redirectURI: string,
oauth2State: string
) {
const searchParams = new URLSearchParams();
searchParams.set('client_id', neonOAuthClientId);
searchParams.set('redirect_uri', redirectURI);
searchParams.set('response_type', 'code');
searchParams.set('scope', 'openid offline urn:neoncloud:projects:create');
searchParams.set('state', oauth2State);
return searchParams;
}

View File

@ -0,0 +1,30 @@
import { LS_KEYS } from '@/utils/localStorage';
export const NEON_CALLBACK_SEARCH = LS_KEYS.neonCallbackSearch;
export const clearPersistedNeonCallbackSearch = () => {
window.localStorage.removeItem(NEON_CALLBACK_SEARCH);
};
export const persistNeonCallbackSearch = (value: string) => {
window.localStorage.setItem(NEON_CALLBACK_SEARCH, value);
};
export const getPersistedNeonCallbackSearch = () => {
return window.localStorage.getItem(NEON_CALLBACK_SEARCH);
};
export function getNeonDBName(allDatabases: string[]) {
if (!allDatabases.includes('default')) {
return 'default';
}
const prefix = 'neon-db';
let suffix = 0;
let dbName = prefix;
while (allDatabases.includes(dbName)) {
dbName = `${prefix}-${++suffix}`;
}
return dbName;
}

View File

@ -1,5 +1,6 @@
import * as React from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { isCloudConsole, hasLuxFeatureAccess } from '@/utils/cloudConsole';
import Globals from '../../../../../Globals';
import Heroku from './Heroku';
import { HerokuSession } from './Heroku/types';
@ -9,6 +10,8 @@ import { mapDispatchToPropsEmpty } from '../../../../Common/utils/reactUtils';
import Tabbed from '../TabbedDataSourceConnection';
import { NotFoundError } from '../../../../Error/PageNotFound';
import { getDataSources } from '../../../../../metadata/selector';
import { HerokuBanner } from './Neon/components/HerokuBanner/Banner';
import { Neon } from './Neon';
interface Props extends InjectedProps {}
@ -17,20 +20,38 @@ const CreateDataSource: React.FC<Props> = ({
dispatch,
allDataSources,
}) => {
if (!Globals.herokuOAuthClientId || !Globals.hasuraCloudTenantId) {
// this condition fails for everything other than a Hasura Cloud project
if (!isCloudConsole(Globals)) {
throw new NotFoundError();
}
const showNeonIntegration =
hasLuxFeatureAccess(Globals, 'NeonDatabaseIntegration') &&
Globals.neonOAuthClientId &&
Globals.neonRootDomain;
return (
<Tabbed tabName="create">
<div className={styles.connect_db_content}>
<div className={`${styles.container}`}>
<Heroku
session={herokuSession}
dispatch={dispatch}
allDataSources={allDataSources}
/>
</div>
{showNeonIntegration ? (
<div className={`${styles.container} mb-md`}>
<div className="w-full mb-md">
<Neon
allDatabases={allDataSources.map(d => d.name)}
dispatch={dispatch}
/>
</div>
<HerokuBanner />
</div>
) : (
<div className={`${styles.container} mb-md`}>
<Heroku
session={herokuSession}
dispatch={dispatch}
allDataSources={allDataSources}
/>
</div>
)}
</div>
</Tabbed>
);

View File

@ -0,0 +1,179 @@
/* eslint no-loop-func: 0 */ // --> OFF
import Globals from '@/Globals';
import Endpoints from '@/Endpoints';
export const generateRandomString = (stringLength = 16) => {
const allChars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let str = '';
for (let i = 0; i < stringLength; i++) {
const randomNum = Math.floor(Math.random() * allChars.length);
str += allChars.charAt(randomNum);
}
return str;
};
export const getEnvVars = () => {
const tenantId = Globals.hasuraCloudTenantId;
const query = `
query getTenantEnv($tenantId: uuid!) {
getTenantEnv: getTenantEnv(tenantId: $tenantId) {
hash
envVars
}
}
`;
const variables = {
tenantId,
};
return fetch(Endpoints.luxDataGraphql, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
query,
variables,
}),
})
.then(r => {
return r.json().then(response => {
if (response.errors) {
throw new Error(response.errors[0]?.message);
}
return response;
});
})
.catch(e => {
throw e;
});
};
export const updateEnvVars = (
currentHash: string,
envs: { key: any; value: any }[]
) => {
const tenantId = Globals.hasuraCloudTenantId;
const query = `
mutation updateTenant(
$tenantId: uuid!
$currentHash: String!
$envs: [UpdateEnvObject!]!
) {
updateTenantEnv(
currentHash: $currentHash
tenantId: $tenantId
envs: $envs
) {
hash
envVars
}
}
`;
const variables = {
tenantId,
currentHash,
envs,
};
return fetch(Endpoints.luxDataGraphql, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({
query,
variables,
}),
})
.then(r => {
return r.json().then(response => {
if (response.errors) {
throw new Error(response.errors[0]?.message);
} else {
return response.updateTenantEnv;
}
});
})
.catch(e => {
throw e;
});
};
export const getAvailableEnvVar = (envVars: Record<string, any>) => {
const newEnvVarName = 'PG_DATABASE_URL';
let suffix = 0;
while (
Object.keys(envVars).some(e => e === `${newEnvVarName}${suffix || ''}`) ||
(envVars.environment &&
Object.keys(envVars.environment).some(
e => e === `${newEnvVarName}${suffix || ''}`
))
) {
suffix++;
}
return `${newEnvVarName}${suffix || ''}`;
};
export const setDBURLInEnvVars = (dbURL: string) => {
return getEnvVars()
.then(res => {
const { hash, envVars } = res.data.getTenantEnv;
const emptyEnvVar = getAvailableEnvVar(envVars);
return updateEnvVars(hash, [
{
key: emptyEnvVar,
value: dbURL,
},
]).then(() => {
return emptyEnvVar;
});
})
.catch(e => {
throw e;
});
};
const getProjectHealth = () => {
const healthEndpoint = `${Globals.dataApiUrl}/healthz`;
return fetch(healthEndpoint, {
method: 'GET',
headers: {
'content-type': 'application/json',
},
credentials: 'include',
})
.then(health => {
return health.ok;
})
.catch(e => {
throw e;
});
};
export const verifyProjectHealthAndConnectDataSource = (
successCallback: VoidFunction,
errorCallback: VoidFunction,
retryCount = 0
) => {
if (retryCount === 10) {
errorCallback();
return;
}
getProjectHealth()
.then(() => {
successCallback();
})
.catch(() => {
setTimeout(() => {
verifyProjectHealthAndConnectDataSource(
successCallback,
errorCallback,
retryCount + 1
);
}, 1500);
});
};

View File

@ -1,7 +1,10 @@
import React, { FormEvent } from 'react';
import { LabeledInput } from '@/components/Common/LabeledInput';
import { Connect, useAvailableDrivers } from '@/features/ConnectDB';
import { GDC_DB_CONNECTOR_DEV } from '@/utils/featureFlags';
import {
availableFeatureFlagIds,
useIsFeatureFlagEnabled,
} from '@/features/FeatureFlags';
import { Button } from '@/new-components/Button';
import ConnectDatabaseForm, { ConnectDatabaseFormProps } from './ConnectDBForm';
import styles from './DataSources.module.scss';
@ -40,8 +43,6 @@ const driverToLabel: Record<
},
};
// const supportedDrivers = getSupportedDrivers('connectDbForm.enabled');
const DataSourceFormWrapper: React.FC<DataSourceFormWrapperProps> = props => {
const {
onSubmit,
@ -58,6 +59,10 @@ const DataSourceFormWrapper: React.FC<DataSourceFormWrapperProps> = props => {
const { isLoading, data: drivers } = useAvailableDrivers();
const { enabled: isGDCFeatureFlagEnabled } = useIsFeatureFlagEnabled(
availableFeatureFlagIds.gdcId
);
const onSampleDBTry = () => {
if (!sampleDBTrial || !sampleDBTrial.isActive()) return;
@ -86,6 +91,13 @@ const DataSourceFormWrapper: React.FC<DataSourceFormWrapperProps> = props => {
type: 'UPDATE_DB_DRIVER',
data: value,
});
/**
* Early return for gdc drivers when feature flag is enabled
*/
const driver = drivers?.find(d => d.name === value);
if (isGDCFeatureFlagEnabled && !driver?.native) return;
if (!isSupported && changeConnectionType) {
changeConnectionType(driverToLabel[value].defaultConnection);
}
@ -98,13 +110,12 @@ const DataSourceFormWrapper: React.FC<DataSourceFormWrapperProps> = props => {
const nativeDrivers = drivers
.filter(driver => driver.native)
.map(driver => driver.name);
return (
<>
{GDC_DB_CONNECTOR_DEV === 'enabled' &&
{isGDCFeatureFlagEnabled &&
!nativeDrivers.includes(connectionDBState.dbType) ? (
<div className="max-w-xl">
<Connect
<Connect.CreateConnection
name={connectionDBState.displayName}
driver={connectionDBState.dbType}
onDriverChange={(driver, name) => {
@ -158,12 +169,17 @@ const DataSourceFormWrapper: React.FC<DataSourceFormWrapperProps> = props => {
disabled={isEditState}
data-test="database-type"
>
{(drivers ?? []).map(driver => (
<option key={driver.name} value={driver.name}>
{driver.displayName}{' '}
{driver.release === 'GA' ? null : `(${driver.release})`}
</option>
))}
{(drivers ?? [])
/**
* Why this filter? if GDC feature flag is not enabled, then I want to see only native sources
*/
.filter(driver => driver.native || isGDCFeatureFlagEnabled)
.map(driver => (
<option key={driver.name} value={driver.name}>
{driver.displayName}{' '}
{driver.release === 'GA' ? null : `(${driver.release})`}
</option>
))}
</select>
</>
)}

View File

@ -1,4 +1,4 @@
import { Collapse } from '@/new-components/Collapse';
import { Collapse } from '@/new-components/deprecated';
import { IconTooltip } from '@/new-components/Tooltip';
import React from 'react';
import { FormRow } from './FormRow';

View File

@ -1,6 +1,7 @@
import React from 'react';
import { hasLuxFeatureAccess } from '@/utils/cloudConsole';
import Globals from '@/Globals';
import { Tabs } from '../../../Common/Layout/ReusableTabs/ReusableTabs';
import Globals from '../../../../Globals';
import styles from './DataSources.module.scss';
const tabs: Tabs = {
@ -8,15 +9,21 @@ const tabs: Tabs = {
display_text: 'Connect Existing Database',
},
};
if (Globals.hasuraCloudTenantId && Globals.herokuOAuthClientId) {
// this condition is true only for Hasura Cloud projects
if (Globals.consoleType === 'cloud' && Globals.hasuraCloudTenantId) {
const tabTitle = hasLuxFeatureAccess(Globals, 'NeonDatabaseIntegration')
? 'Create New Database'
: 'Create Heroku Database';
tabs.create = {
display: (
<div className={styles.display_flex}>
<div className={styles.add_mar_right_mid}>Create Heroku Database</div>
<div className={styles.add_mar_right_mid}>{tabTitle}</div>
<div className={styles.free_badge}>Free</div>
</div>
),
display_text: 'Create Heroku Database',
display_text: tabTitle,
};
}

View File

@ -22,8 +22,7 @@ import _push from './push';
import { Button } from '@/new-components/Button';
import styles from '../../Common/Layout/LeftSubSidebar/LeftSubSidebar.module.scss';
import Spinner from '../../Common/Spinner/Spinner';
// import { useGDCTreeClick } from './GDCTree/hooks/useGDCTreeClick';
import { GDC_TREE_VIEW_DEV } from '@/utils/featureFlags';
import { useGDCTreeItemClick } from './GDCTree/hooks/useGDCTreeItemClick';
const DATA_SIDEBAR_SET_LOADING = 'dataSidebar/DATA_SIDEBAR_SET_LOADING';
@ -203,34 +202,7 @@ const DataSubSidebar = props => {
const [treeViewItems, setTreeViewItems] = useState([]);
const handleGDCTreeClick = value => {
if (GDC_TREE_VIEW_DEV === 'disabled') return;
const { database, ...table } = JSON.parse(value[0]);
const metadataSource = sources.find(source => source.name === database);
if (!metadataSource)
throw Error('useGDCTreeClick: source was not found in metadata');
/**
* Handling click for GDC DBs
*/
const isTableClicked = Object.keys(table).length !== 0;
if (isTableClicked) {
dispatch(
_push(
encodeURI(
`/data/v2/manage?database=${database}&table=${JSON.stringify(
table
)}`
)
)
);
} else {
dispatch(_push(encodeURI(`/data/v2/manage?database=${database}`)));
}
};
const { handleClick } = useGDCTreeItemClick(dispatch);
useEffect(() => {
// skip api call, if the data is there in store
@ -321,7 +293,7 @@ const DataSubSidebar = props => {
databaseLoading={databaseLoading}
schemaLoading={schemaLoading}
preLoadState={preLoadState}
gdcItemClick={handleGDCTreeClick}
gdcItemClick={handleClick}
/>
</div>
</ul>

View File

@ -31,7 +31,7 @@ type Props = {
/*
This component is still very much in development and will be changed once we have an API that tells us about the hierarchy of a GDC source
Until then, this component is more or less a POC/experminatal in nature and tests for its accompaniying story have not been included for this reason.
If you wish to test out this component, head over to src/utils/featureFlags.ts and edit the GDC_TREE_VIEW_DEV to enabled to view it the console with mock data
If you wish to test out this component, go to the settings > feature flag and enable "Experimental features for GDC"
*/
export const GDCTree = (props: Props) => {
@ -39,7 +39,6 @@ export const GDCTree = (props: Props) => {
const activeKey = isGDCRouteActive ? getCurrentActiveKeys() : [];
const { data: gdcDatabases } = useTreeData();
if (!gdcDatabases || gdcDatabases.length === 0) return null;
return (

View File

@ -1,63 +0,0 @@
import { exportMetadata } from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { useFireNotification } from '@/new-components/Notifications';
import { Dispatch } from '@/types';
import { GDC_TREE_VIEW_DEV } from '@/utils/featureFlags';
import { useCallback } from 'react';
import _push from '../../push';
import { useIsUnmounted } from '../utils';
export const useGDCTreeClick = (dispatch: Dispatch) => {
const isUnmounted = useIsUnmounted();
const httpClient = useHttpClient();
const { fireNotification } = useFireNotification();
const handleClick = useCallback(
async (value: string[]) => {
try {
if (isUnmounted()) return;
if (GDC_TREE_VIEW_DEV === 'disabled') return;
const { metadata } = await exportMetadata({ httpClient });
const { database, ...table } = JSON.parse(value[0]);
const metadataSource = metadata.sources.find(
source => source.name === database
);
if (!metadataSource)
throw Error('useGDCTreeClick: source was not found in metadata');
/**
* Handling click for GDC DBs
*/
const isTableClicked = Object.keys(table).length !== 0;
if (isTableClicked) {
dispatch(
_push(
encodeURI(
`/data/v2/manage?database=${database}&table=${JSON.stringify(
table
)}`
)
)
);
} else {
dispatch(_push(encodeURI(`/data/v2/manage?database=${database}`)));
}
} catch (err) {
fireNotification({
type: 'error',
title: 'Could handle database selection',
message: JSON.stringify(err),
});
}
},
[dispatch, httpClient, isUnmounted]
);
return handleClick;
};

View File

@ -0,0 +1,47 @@
import { exportMetadata } from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { Dispatch } from '@/types';
import { useCallback } from 'react';
import { useIsUnmounted } from '../../Common/tsUtils';
import _push from '../../push';
export const useGDCTreeItemClick = (dispatch: Dispatch) => {
const httpClient = useHttpClient();
const isUnmounted = useIsUnmounted();
const handleClick = useCallback(
async value => {
if (isUnmounted()) return;
const { metadata } = await exportMetadata({ httpClient });
const { database, ...rest } = JSON.parse(value[0]);
const metadataSource = metadata.sources.find(
source => source.name === database
);
if (!metadataSource)
throw Error('useGDCTreeClick: source was not found in metadata');
/**
* Handling click for GDC DBs
*/
const isTableClicked = Object.keys(rest?.table || {}).length !== 0;
if (isTableClicked) {
dispatch(
_push(
encodeURI(
`/data/v2/manage?database=${database}&table=${JSON.stringify(
rest.table
)}`
)
)
);
} else {
dispatch(_push(encodeURI(`/data/v2/manage?database=${database}`)));
}
},
[dispatch, httpClient, isUnmounted]
);
return { handleClick };
};

View File

@ -1,14 +1,42 @@
import {
DataSource,
exportMetadata,
nativeDrivers,
} from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { DataNode } from 'antd/lib/tree';
import { useQuery } from 'react-query';
import { getTreeData } from '../utils';
const isValueDataNode = (value: DataNode | null): value is DataNode =>
value !== null;
export const useTreeData = () => {
const httpClient = useHttpClient();
return useQuery({
queryKey: 'treeview',
queryKey: ['treeview'],
queryFn: async () => {
return getTreeData({ httpClient });
const { metadata } = await exportMetadata({ httpClient });
if (!metadata) throw Error('Unable to fetch metadata');
const treeData = metadata.sources
/**
* NOTE: this filter prevents native drivers from being part of the new tree
*/
.filter(source => !nativeDrivers.includes(source.kind))
.map(async source => {
const tablesAsTree = await DataSource(
httpClient
).getTablesWithHierarchy({ dataSourceName: source.name });
return tablesAsTree;
});
const promisesResult = await Promise.all(treeData);
const filteredResult = promisesResult.filter<DataNode>(isValueDataNode);
return filteredResult;
},
});
};

View File

@ -0,0 +1,2 @@
export { GDCTree } from './GDCTree';
export { useGDCTreeItemClick } from './hooks/useGDCTreeItemClick';

View File

@ -1,4 +1,4 @@
import { Table } from '@/features/DataSource';
import { Table } from '@/features/MetadataAPI';
/*
A GDC Source can be any user defined DB that can be added during run-time. We can only know a few properties during build time, such as name and kind

View File

@ -1,116 +0,0 @@
import React, { useCallback, useLayoutEffect, useRef } from 'react';
import { FaTable, FaDatabase, FaFolder } from 'react-icons/fa';
import { DataSource, exportMetadata, NetworkArgs } from '@/features/DataSource';
import { DataNode } from 'antd/lib/tree';
import { GDC_TREE_VIEW_DEV } from '@/utils/featureFlags';
import { GDCSource } from './types';
const getSources = async ({ httpClient }: NetworkArgs) => {
const { metadata } = await exportMetadata({ httpClient });
const nativeDrivers = await DataSource(httpClient).getNativeDrivers();
return metadata.sources
.filter(source => !nativeDrivers.includes(source.kind))
.map<GDCSource>(source => ({
name: source.name,
kind: source.kind,
tables: source.tables.map(({ table }) => ({ table })),
}));
};
const nest = (
tables: GDCSource['tables'],
hierarchy: string[],
name: string
): any => {
if (!hierarchy.length) return;
const key = hierarchy[0];
function onlyUnique(value: any, index: any, self: string | any[]) {
return self.indexOf(value) === index;
}
const levelValues: string[] = tables
.map((t: any) => t.table[key])
.filter(Boolean);
const uniqueLevelValues = levelValues.filter(onlyUnique);
return [
...uniqueLevelValues.map(levelValue => {
// eslint-disable-next-line no-underscore-dangle
const _key = JSON.stringify({ ...JSON.parse(name), [key]: levelValue });
const children = nest(
tables.filter((t: any) => t.table[key] === levelValue),
hierarchy.slice(1),
_key
);
if (!children)
return {
icon: <FaTable />,
title: levelValue,
key: _key,
};
return {
icon: <FaFolder />,
title: levelValue,
selectable: false,
children,
key: _key,
};
}),
];
};
export const getTreeData = async ({
httpClient,
}: NetworkArgs): Promise<DataNode[]> => {
const sources = await getSources({ httpClient });
const tree = sources.map(async source => {
const tables = source.tables;
const hierarchy = await DataSource(httpClient).getDatabaseHierarchy({
dataSourceName: source.name,
});
// return a node of the tree
return {
title: (
<div className="inline-block">
{source.name}
<span className="items-center ml-sm px-sm py-0.5 rounded-full text-sm tracking-wide font-semibold bg-indigo-100 text-indigo-800">
Experimental
</span>
</div>
),
key: JSON.stringify({ database: source.name }),
icon: <FaDatabase />,
children: nest(
tables,
hierarchy,
JSON.stringify({ database: source.name })
),
};
});
// feature flag to enable tree view
if (GDC_TREE_VIEW_DEV === 'enabled') return Promise.all(tree);
return [];
};
export function useIsUnmounted() {
const rIsUnmounted = useRef<'mounting' | 'mounted' | 'unmounted'>('mounting');
useLayoutEffect(() => {
rIsUnmounted.current = 'mounted';
return () => {
rIsUnmounted.current = 'unmounted';
};
}, []);
return useCallback(() => rIsUnmounted.current !== 'mounted', []);
}

View File

@ -36,6 +36,13 @@ import { services } from '../../../../dataSources/services';
import { isFeatureSupported, setDriver } from '../../../../dataSources';
import { fetchDataInit, UPDATE_CURRENT_DATA_SOURCE } from '../DataActions';
import { unsupportedRawSQLDrivers } from './utils';
import { nativeDrivers } from '@/features/DataSource';
import { useRunSQL } from './hooks/useRunSQL';
import { useFireNotification } from '@/new-components/Notifications';
import {
availableFeatureFlagIds,
useIsFeatureFlagEnabled,
} from '@/features/FeatureFlags';
const checkChangeLang = (sql, selectedDriver) => {
return (
@ -80,9 +87,24 @@ const RawSQL = ({
isTableTrackChecked,
migrationMode,
allSchemas,
sources,
// sources,
currentDataSource,
metadataSources,
}) => {
const { enabled: areGDCFeaturesEnabled } = useIsFeatureFlagEnabled(
availableFeatureFlagIds.gdcId
);
const { fireNotification } = useFireNotification();
const { fetchRunSQLResult, data, isLoading } = useRunSQL({
onError: err => {
fireNotification({
type: 'error',
title: 'failed to run SQL statement',
message: err?.message,
});
},
});
const [statementTimeout, setStatementTimeout] = useState(
Number(getLSItem(LS_KEYS.rawSqlStatementTimeout)) || 10
);
@ -94,14 +116,25 @@ const RawSQL = ({
const [suggestLangChange, setSuggestLangChange] = useState(false);
useEffect(() => {
const driver = getSourceDriver(sources, selectedDatabase);
const driver = getSourceDriver(metadataSources, selectedDatabase);
setSelectedDriver(driver);
if (!nativeDrivers.includes(driver)) {
setStatementTimeout(null);
return;
}
if (!isFeatureSupported('rawSQL.statementTimeout'))
setStatementTimeout(null);
}, [selectedDatabase, sources]);
}, [selectedDatabase, metadataSources]);
const dropDownSelectorValueChange = value => {
const driver = getSourceDriver(sources, value);
const driver = getSourceDriver(metadataSources, value);
if (!nativeDrivers.includes(driver)) {
setSelectedDatabase(value);
return;
}
dispatch({
type: UPDATE_CURRENT_DATA_SOURCE,
source: value,
@ -134,6 +167,15 @@ const RawSQL = ({
}, [sql, selectedDriver]);
const submitSQL = () => {
if (!nativeDrivers.includes(selectedDriver)) {
fetchRunSQLResult({
driver: selectedDriver,
dataSourceName: selectedDatabase,
sql: sqlText,
});
return;
}
if (!sqlText) {
setLSItem(LS_KEYS.rawSQLKey, '');
return;
@ -451,16 +493,22 @@ const RawSQL = ({
<b>Database</b>
</label>{' '}
<DropDownSelector
options={sources.map(source => ({
name: source.name,
driver: source.driver,
}))}
options={metadataSources
.filter(source => {
if (areGDCFeaturesEnabled) return source;
return nativeDrivers.includes(source.kind);
})
.map(source => ({
name: source.name,
driver: source.kind,
}))}
defaultValue={currentDataSource}
onChange={dropDownSelectorValueChange}
/>
</div>
<div className={`${styles.padd_left_remove} col-xs-10`}>
{unsupportedRawSQLDrivers.includes(selectedDriver) && getSQLSection()}
{!unsupportedRawSQLDrivers.includes(selectedDriver) &&
getSQLSection()}
</div>
<div
@ -492,6 +540,7 @@ const RawSQL = ({
mode="primary"
data-test="run-sql"
disabled={!sqlText.length}
isLoading={isLoading}
>
Run!
</Button>
@ -513,14 +562,22 @@ const RawSQL = ({
{getMigrationWarningModal()}
<div className={styles.add_mar_bottom}>
{resultType &&
resultType !== 'command' &&
result &&
result?.length > 0 && (
<ResultTable rows={result} headers={resultHeaders} />
{nativeDrivers.includes(selectedDriver) ? (
<div className={styles.add_mar_bottom}>
{resultType &&
resultType !== 'command' &&
result &&
result?.length > 0 && (
<ResultTable rows={result} headers={resultHeaders} />
)}
</div>
) : (
<div className={styles.add_mar_bottom}>
{data && data.result.length > 0 && (
<ResultTable rows={data.result.slice(1)} headers={data.result[0]} />
)}
</div>
</div>
)}
</div>
);
};
@ -551,6 +608,7 @@ const mapStateToProps = state => ({
serverVersion: state.main.serverVersion ? state.main.serverVersion : '',
sources: getDataSources(state),
currentDataSource: state.tables.currentDataSource,
metadataSources: state.metadata.metadataObject.sources,
});
const rawSQLConnector = connect => connect(mapStateToProps)(RawSQL);

View File

@ -0,0 +1,56 @@
// eslint-disable-next-line no-restricted-imports
import { runSQL, RunSQLResponse } from '@/features/DataSource/api';
import { SupportedDrivers } from '@/features/MetadataAPI';
import { useHttpClient } from '@/features/Network';
import { useCallback, useState } from 'react';
/**
* This run SQL hook is the new implementation of the run sql api using react hooks. Right now, it's used only
* for gdc based sources since the old rawSQL.js UI needs a rewrite and decoupling from redux
*/
export const useRunSQL = (props: { onError?: (err: unknown) => void }) => {
const httpClient = useHttpClient();
const [data, setData] = useState<RunSQLResponse | undefined>();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | undefined>();
const fetchRunSQLResult = useCallback(
async ({
driver,
dataSourceName,
sql,
}: {
dataSourceName: string;
driver: SupportedDrivers;
sql: string;
}) => {
setData(undefined);
setIsLoading(true);
try {
const result = await runSQL({
httpClient,
source: {
kind: driver,
name: dataSourceName,
},
sql,
});
setData(result);
setIsLoading(false);
} catch (err) {
setError(err as Error);
if (props.onError) {
props.onError(err);
}
} finally {
setIsLoading(false);
}
},
[httpClient]
);
return { fetchRunSQLResult, data, isLoading, error };
};

View File

@ -22,10 +22,12 @@ const CollapsibleToggle: React.FC<CollapsibleToggleProps> = ({
<div onClick={toggleHandler} role="button" tabIndex={0}>
<div className="flex items-center">
<div className="text-gray-600 font-semibold mr-xs break-all">
{dataSource.name}
{dataSource.name}{' '}
<span className="font-normal">
({driverToLabel[dataSource.driver]})
</span>
</div>
<div className="flex ml-auto w-full sm:w-6/12">
<span className="mr-xs">({driverToLabel[dataSource.driver]})</span>
{!!dataSource?.read_replicas?.length && (
<span className="mr-xs inline-flex items-center px-2.5 py-0.5 rounded-full text-sm font-medium bg-blue-100 text-gray-800">
{dataSource.read_replicas.length} Replicas

View File

@ -2,8 +2,13 @@ import React, { useState, useEffect } from 'react';
import Helmet from 'react-helmet';
import { connect, ConnectedProps } from 'react-redux';
import { FaExclamationTriangle, FaEye, FaTimes } from 'react-icons/fa';
import { ManageAgents } from '@/features/ManageAgents';
import { Button } from '@/new-components/Button';
import {
availableFeatureFlagIds,
useIsFeatureFlagEnabled,
} from '@/features/FeatureFlags';
import { nativeDrivers } from '@/features/DataSource';
import styles from './styles.module.scss';
import { Dispatch, ReduxState } from '../../../../types';
import BreadCrumb from '../../../Common/Layout/BreadCrumb/BreadCrumb';
@ -13,6 +18,7 @@ import {
removeDataSource,
reloadDataSource,
} from '../../../../metadata/actions';
import { GDCDatabaseListItem } from './components/GDCDatabaseListItem';
import { RightContainer } from '../../../Common/Layout/RightContainer';
import { getDataSources } from '../../../../metadata/selector';
import ToolTip from '../../../Common/Tooltip/Tooltip';
@ -204,9 +210,17 @@ const ManageDatabase: React.FC<ManageDatabaseProps> = ({
inconsistentObjects,
location,
dataHeaders,
sourcesFromMetadata,
}) => {
useEffect(() => {
if (dataSources.length === 0 && !autoRedirectedToConnectPage) {
if (sourcesFromMetadata.length === 0 && !autoRedirectedToConnectPage) {
/**
* Because the getDataSources() doesn't list the GDC sources, the Data tab will redirect to the /connect page
* thinking that are no sources available in Hasura, even if there are GDC sources connected to it. Modifying getDataSources()
* to list gdc sources is a huge task that involves modifying redux state variables.
* So a quick workaround is to check from the actual metadata if any sources are present -
* Combined with checks between getDataSources() and metadata -> we know the remaining sources are GDC sources. In such a case redirect to the manage db route
*/
dispatch(_push('/data/manage/connect'));
autoRedirectedToConnectPage = true;
}
@ -215,6 +229,10 @@ const ManageDatabase: React.FC<ManageDatabaseProps> = ({
const { show: shouldShowVPCBanner, dismiss: dismissVPCBanner } =
useVPCBannerVisibility();
const { enabled: isDCAgentsManageUIEnabled } = useIsFeatureFlagEnabled(
availableFeatureFlagIds.gdcId
);
const crumbs = [
{
title: 'Data',
@ -304,20 +322,38 @@ const ManageDatabase: React.FC<ManageDatabaseProps> = ({
</th>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{dataSources.length ? (
dataSources.map(data => (
<DatabaseListItem
key={data.name}
dataSource={data}
inconsistentObjects={inconsistentObjects}
pushRoute={pushRoute}
onEdit={onEdit}
onReload={onReload}
onRemove={onRemove}
dispatch={dispatch}
dataHeaders={dataHeaders}
/>
))
{sourcesFromMetadata.length ? (
sourcesFromMetadata.map(source => {
if (nativeDrivers.includes(source.kind)) {
const data = dataSources.find(
s => s.name === source.name
);
if (!data) return null;
return (
<DatabaseListItem
key={data.name}
dataSource={data}
inconsistentObjects={inconsistentObjects}
pushRoute={pushRoute}
onEdit={onEdit}
onReload={onReload}
onRemove={onRemove}
dispatch={dispatch}
dataHeaders={dataHeaders}
/>
);
}
if (isDCAgentsManageUIEnabled)
return (
<GDCDatabaseListItem
dataSource={{ name: source.name, kind: source.kind }}
inconsistentObjects={inconsistentObjects}
dispatch={dispatch}
/>
);
return null;
})
) : (
<td colSpan={3} className="text-center px-sm py-xs">
You don&apos;t have any data sources connected, please
@ -328,6 +364,12 @@ const ManageDatabase: React.FC<ManageDatabaseProps> = ({
</table>
</div>
</div>
{isDCAgentsManageUIEnabled ? (
<div className="mt-lg">
<ManageAgents />
</div>
) : null}
</div>
</RightContainer>
);
@ -342,6 +384,7 @@ const mapStateToProps = (state: ReduxState) => {
currentSchema: state.tables.currentSchema,
inconsistentObjects: state.metadata.inconsistentObjects,
location: state?.routing?.locationBeforeTransitions,
sourcesFromMetadata: state?.metadata?.metadataObject?.sources ?? [],
};
};

View File

@ -7,7 +7,7 @@ import {
import { setupServer } from 'msw/node';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import { RequestHandler } from 'msw/lib/types/handlers/RequestHandler';
import { RestHandler } from 'msw/lib';
import {
templateGalleryReducer,
fetchGlobalSchemaSharingConfiguration,
@ -21,7 +21,7 @@ beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const schemaGalleryModalBodyRender = async (...handlers: RequestHandler[]) => {
const schemaGalleryModalBodyRender = async (...handlers: RestHandler[]) => {
server.use(networkStubs.rootJson, ...handlers);
const store = configureStore<any>({
reducer: {

View File

@ -165,7 +165,7 @@ export const MOCK_ONE_TO_ONE_METADATA = {
],
},
};
const BASE_PS_URL = `${BASE_URL_TEMPLATE}/./postgres-template-1`;
const BASE_PS_URL = `${BASE_URL_TEMPLATE}/postgres-template-1`;
export const networkStubs = {
rootJson: rest.get(ROOT_CONFIG_PATH, (req, res, context) => {

View File

@ -0,0 +1,107 @@
import { Source } from '@/features/MetadataAPI';
import { exportMetadata } from '@/metadata/actions';
import { Button } from '@/new-components/Button';
import { Tooltip } from '@/new-components/Tooltip';
import { Dispatch } from '@/types';
import React from 'react';
import { FaExclamationTriangle } from 'react-icons/fa';
import { useQueryClient } from 'react-query';
import _push from '../../push';
import { isInconsistentSource } from '../../utils';
import { useDropSource } from '../hooks/useDropSource';
import { useReloadSource } from '../hooks/useReloadSource';
type GDCDatabaseListItemItemProps = {
dataSource: Pick<Source, 'name' | 'kind'>;
inconsistentObjects: Record<string, any>;
dispatch: Dispatch;
};
export const GDCDatabaseListItem: React.FC<GDCDatabaseListItemItemProps> = ({
dataSource,
inconsistentObjects,
dispatch,
}) => {
const queryClient = useQueryClient();
const { dropSource, isLoading: isDropSourceInProgress } = useDropSource({
customOnSuccess: () => {
dispatch(exportMetadata());
queryClient.invalidateQueries('treeview');
},
});
const { reloadSource, isLoading: isReloadSourceInProgress } =
useReloadSource();
const isInconsistentDataSource = isInconsistentSource(
dataSource.name,
inconsistentObjects
);
return (
<tr data-test={dataSource.name}>
<td className="px-sm py-xs align-top w-0 whitespace-nowrap">
<Button
size="sm"
className="mr-xs"
isLoading={isDropSourceInProgress}
onClick={() => {
dispatch(_push(`/data/v2/manage?database=${dataSource.name}`));
}}
disabled={isInconsistentDataSource}
>
View Database
</Button>
<Button
size="sm"
className="mr-xs"
loadingText="Reloading..."
isLoading={isReloadSourceInProgress}
onClick={() => {
reloadSource(dataSource.name);
}}
>
Reload
</Button>
<Button
size="sm"
className="mr-xs"
onClick={() => {
dispatch(_push(`/data/v2/edit?database=${dataSource.name}`));
}}
>
Edit
</Button>
<Button
size="sm"
loadingText="Removing..."
className="text-red-600"
onClick={() => {
dropSource(dataSource.kind, dataSource.name);
}}
>
Remove
</Button>
</td>
<td className="px-sm py-xs max-w-xs align-top break-all">
<div className="font-bold">
{dataSource.name}{' '}
<span className="font-normal">({dataSource.kind})</span>
</div>
</td>
<td className="px-sm py-xs max-w-xs align-top">
{isInconsistentDataSource && (
<Tooltip tooltipContentChildren="Source is inconsistent">
<FaExclamationTriangle
className="ml-xs text-red-800"
aria-hidden="true"
/>
</Tooltip>
)}
</td>
{/* <td className="px-sm py-xs max-w-xs align-top break-all">
</td> */}
</tr>
);
};

View File

@ -0,0 +1,54 @@
import { Source, useMetadataMigration } from '@/features/MetadataAPI';
import { useFireNotification } from '@/new-components/Notifications';
import { useCallback } from 'react';
interface UseDropSourceProps {
customOnSuccess?: () => void;
customOnError?: (err: Error) => void;
}
export const useDropSource = (props?: UseDropSourceProps) => {
const mutation = useMetadataMigration();
const { customOnSuccess, customOnError } = props ?? {};
const { fireNotification } = useFireNotification();
const dropSource = useCallback(
async (driver: Source['kind'], name: Source['name']) => {
mutation.mutate(
{
query: {
type: `${driver}_drop_source`,
args: {
name,
cascade: true,
},
},
},
{
onSuccess: () => {
fireNotification({
title: 'Success!',
message: 'Successfully removed source from Hasura',
type: 'success',
});
if (customOnSuccess) {
customOnSuccess();
}
},
onError: err => {
fireNotification({
title: 'Error!',
message: JSON.stringify(err),
type: 'error',
});
if (customOnError) {
customOnError(err);
}
},
}
);
},
[customOnError, customOnSuccess, fireNotification, mutation]
);
return { dropSource, isLoading: mutation.isLoading };
};

View File

@ -0,0 +1,51 @@
import { Source, useMetadataMigration } from '@/features/MetadataAPI';
import { useFireNotification } from '@/new-components/Notifications';
import { useCallback } from 'react';
type UseReloadSource = {
customOnSuccess?: () => void;
customOnError?: (err: Error) => void;
};
export const useReloadSource = (props?: UseReloadSource) => {
const mutation = useMetadataMigration();
const { customOnSuccess, customOnError } = props ?? {};
const { fireNotification } = useFireNotification();
const reloadSource = useCallback(
async (name: Source['name']) => {
mutation.mutate(
{
query: {
type: 'reload_metadata',
args: { reload_sources: [name] },
},
},
{
onSuccess: () => {
fireNotification({
title: 'Success!',
message: 'Successfully reloaded source!',
type: 'success',
});
if (customOnSuccess) {
customOnSuccess();
}
},
onError: err => {
fireNotification({
title: 'Error!',
message: JSON.stringify(err),
type: 'error',
});
if (customOnError) {
customOnError(err);
}
},
}
);
},
[customOnError, customOnSuccess, fireNotification, mutation]
);
return { reloadSource, isLoading: mutation.isLoading };
};

View File

@ -2,6 +2,7 @@ import {
getTableBrowseRoute,
getTableModifyRoute,
} from '@/components/Common/utils/routesUtils';
import { TrackingTableFormValues as FormValues } from '@/components/Services/Data/Schema/tableTrackCustomization/types';
import { Driver } from '@/dataSources';
import {
allowedMetadataTypes,
@ -16,7 +17,6 @@ import { ThunkDispatch } from 'redux-thunk';
import { REQUEST_SUCCESS, updateSchemaInfo } from '../../DataActions';
import { setSidebarLoading } from '../../DataSubSidebar';
import _push from '../../push';
import { FormValues } from './TableTrackingCustomizationForm';
import {
TableTrackingCustomizationModal,
TableTrackingCustomizationModalProps,
@ -33,7 +33,6 @@ export const TableTrackingCustomizationModalContainer: React.FC<TableTrackingCus
const { fireNotification } = useFireNotification();
const dispatch: ThunkDispatch<ReduxState, unknown, AnyAction> =
useDispatch();
const mutation = useMetadataMigration({
onSuccess: () => {
dispatch({ type: REQUEST_SUCCESS });

View File

@ -1,4 +1,5 @@
import { Collapse } from '@/new-components/Collapse';
import { TrackingTableFormValues } from '@/components/Services/Data/Schema/tableTrackCustomization/types';
import { Collapse } from '@/new-components/deprecated';
import React, { useState } from 'react';
import { UseFormRegisterReturn, UseFormReturn } from 'react-hook-form';
import { FaExclamationCircle } from 'react-icons/fa';
@ -41,23 +42,9 @@ const InputField: React.FC<InputFieldProps> = ({
);
};
export type FormValues = {
custom_name: string;
select: string;
select_by_pk: string;
select_aggregate: string;
select_stream: string;
insert: string;
insert_one: string;
update: string;
update_by_pk: string;
delete: string;
delete_by_pk: string;
};
type TableTrackingCustomizationFormProps = {
initialTableName: string;
formMethods: UseFormReturn<FormValues>;
formMethods: UseFormReturn<TrackingTableFormValues>;
};
export const TableTrackingCustomizationForm: React.FC<TableTrackingCustomizationFormProps> =

View File

@ -1,21 +1,25 @@
import { TrackingTableFormValues } from '@/components/Services/Data/Schema/tableTrackCustomization/types';
import { buildConfigFromFormValues } from '@/components/Services/Data/Schema/tableTrackCustomization/utils';
import { MetadataTableConfig } from '@/features/MetadataAPI';
import { Dialog } from '@/new-components/Dialog';
import React from 'react';
import { useForm } from 'react-hook-form';
import {
FormValues,
TableTrackingCustomizationForm,
} from './TableTrackingCustomizationForm';
import { TableTrackingCustomizationForm } from './TableTrackingCustomizationForm';
export type TableTrackingCustomizationModalProps = {
tableName: string;
onSubmit: (data: FormValues) => void;
onSubmit: (
data: TrackingTableFormValues,
configuration: MetadataTableConfig
) => void;
onClose: () => void;
isLoading: boolean;
show?: boolean;
};
export const TableTrackingCustomizationModal: React.FC<TableTrackingCustomizationModalProps> =
({ tableName, onSubmit, onClose, isLoading }) => {
const methods = useForm<FormValues>({
({ tableName, onSubmit, onClose, isLoading, show = true }) => {
const methods = useForm<TrackingTableFormValues>({
defaultValues: {
custom_name: '',
select: '',
@ -31,28 +35,36 @@ export const TableTrackingCustomizationModal: React.FC<TableTrackingCustomizatio
},
});
const handleSubmit = (data: TrackingTableFormValues) => {
onSubmit(data, buildConfigFromFormValues(data));
};
return (
<form onSubmit={methods.handleSubmit(onSubmit)}>
<Dialog
hasBackdrop
title={tableName}
description="Rename your table to resolve a conflicting with an existing GraphQL node."
onClose={onClose}
footer={{
callToAction: 'Customize and Track',
callToActionLoadingText: 'Sending...',
callToDeny: 'Cancel',
onClose,
isLoading,
}}
>
<>
<TableTrackingCustomizationForm
initialTableName={tableName}
formMethods={methods}
/>
</>
</Dialog>
</form>
<>
{show && (
<form onSubmit={methods.handleSubmit(handleSubmit)}>
<Dialog
hasBackdrop
title={tableName}
description="Rename your table to resolve a conflicting with an existing GraphQL node."
onClose={onClose}
footer={{
callToAction: 'Customize and Track',
callToActionLoadingText: 'Sending...',
callToDeny: 'Cancel',
onClose,
isLoading,
}}
>
<>
<TableTrackingCustomizationForm
initialTableName={tableName}
formMethods={methods}
/>
</>
</Dialog>
</form>
)}
</>
);
};

View File

@ -1,5 +1,3 @@
import type { TrackingTableFormPlaceholders } from '../utils';
import {
getDriverPrefix,
getQualifiedTable,
@ -7,6 +5,8 @@ import {
getTrackingTableFormPlaceholders,
} from '../utils';
import { TrackingTableFormValues } from '../types';
describe('getDriverPrefix', () => {
it.each`
driver | expected
@ -63,7 +63,7 @@ describe('getTableObjectType', () => {
describe('getTrackingTableFormPlaceholders', () => {
it('returns the placeholder', () => {
const expected: TrackingTableFormPlaceholders = {
const expected: TrackingTableFormValues = {
custom_name: 'customizeTableName (default)',
select: 'customizeTableName (default)',

View File

@ -0,0 +1,24 @@
import { MetadataTableConfig } from '@/features/MetadataAPI';
import { Driver } from '@/dataSources';
export type TrackingTableFormValues = {
custom_name: string;
} & Required<MetadataTableConfig['custom_root_fields']>;
type GetTablePayloadArgs = {
driver: Driver;
schema: string;
tableName: string;
};
type BigQueryQualifiedTable = {
dataset: string;
};
type SchemaQualifiedTable = {
schema: string;
};
export type QualifiedTable = {
name: string;
} & (BigQueryQualifiedTable | SchemaQualifiedTable);

View File

@ -1,22 +1,14 @@
import {
GetTablePayloadArgs,
QualifiedTable,
TrackingTableFormValues,
} from '@/components/Services/Data/Schema/tableTrackCustomization/types';
import { Driver } from '@/dataSources';
export type TrackingTableFormPlaceholders = {
custom_name: string;
select: string;
select_by_pk: string;
select_aggregate: string;
select_stream: string;
insert: string;
insert_one: string;
update: string;
update_by_pk: string;
delete: string;
delete_by_pk: string;
};
import { MetadataTableConfig } from '@/features/MetadataAPI';
export const getTrackingTableFormPlaceholders = (
tableName: string
): TrackingTableFormPlaceholders => {
): TrackingTableFormValues => {
return {
custom_name: `${tableName} (default)`,
select: `${tableName} (default)`,
@ -32,6 +24,32 @@ export const getTrackingTableFormPlaceholders = (
};
};
export const buildConfigFromFormValues = (
values: TrackingTableFormValues
): MetadataTableConfig => {
// we want to only add properties if a value is "truthy"/not empty
const config: MetadataTableConfig = {};
// the shape of the form type almost matches the config type
const { custom_name, ...remainingValues } = values;
if (custom_name) config.custom_name = custom_name;
let prop: keyof typeof remainingValues;
for (prop in remainingValues) {
if (remainingValues[prop]) {
if (!config.custom_root_fields) {
// initialize obj if not yet created
config.custom_root_fields = {};
}
config.custom_root_fields[prop] = remainingValues[prop];
}
}
return config;
};
export const getDriverPrefix = (driver: Driver) =>
driver === 'postgres' ? 'pg' : driver;
@ -40,24 +58,6 @@ export const getTrackTableType = (driver: Driver) => {
return `${prefix}_track_table`;
};
type GetTablePayloadArgs = {
driver: Driver;
schema: string;
tableName: string;
};
type BigQueryQualifiedTable = {
dataset: string;
};
type SchemaQualifiedTable = {
schema: string;
};
export type QualifiedTable = {
name: string;
} & (BigQueryQualifiedTable | SchemaQualifiedTable);
export const getQualifiedTable = ({
driver,
schema,

View File

@ -6,7 +6,6 @@ import {
downloadObjectAsJsonFile,
downloadObjectAsCsvFile,
getCurrTimeForFileName,
getConfirmation,
} from '../../../Common/utils/jsUtils';
const LOADING = 'ViewTable/FilterQuery/LOADING';
@ -121,59 +120,48 @@ const runQuery = tableSchema => {
const exportDataQuery = (tableSchema, type) => {
return (dispatch, getState) => {
const count = getState().tables.view.count;
const confirmed = getConfirmation(
`There ${
count === 1 ? 'is 1 row' : `are ${count} rows`
} selected for export.`
);
if (!confirmed) {
return;
}
const state = getState().tables.view.curFilter;
let finalWhereClauses = state.where.$and.filter(w => {
const colName = Object.keys(w)[0].trim();
const filteredWhereClauses = state.where.$and.filter(whereClause => {
const colName = Object.keys(whereClause)[0].trim();
if (colName === '') {
return false;
}
const opName = Object.keys(w[colName])[0].trim();
const opName = Object.keys(whereClause[colName])[0].trim();
if (opName === '') {
return false;
}
return true;
});
finalWhereClauses = finalWhereClauses.map(w => {
const colName = Object.keys(w)[0];
const opName = Object.keys(w[colName])[0];
const val = w[colName][opName];
const finalWhereClauses = filteredWhereClauses.map(whereClause => {
const colName = Object.keys(whereClause)[0];
const opName = Object.keys(whereClause[colName])[0];
const val = whereClause[colName][opName];
if (['$in', '$nin'].includes(opName)) {
w[colName][opName] = parseArray(val);
return w;
whereClause[colName][opName] = parseArray(val);
return whereClause;
}
const colType = tableSchema.columns.find(
c => c.column_name === colName
).data_type;
if (Integers.indexOf(colType) > 0) {
w[colName][opName] = parseInt(val, 10);
return w;
whereClause[colName][opName] = parseInt(val, 10);
return whereClause;
}
if (Reals.indexOf(colType) > 0) {
w[colName][opName] = parseFloat(val);
return w;
whereClause[colName][opName] = parseFloat(val);
return whereClause;
}
if (colType === 'boolean') {
if (val === 'true') {
w[colName][opName] = true;
whereClause[colName][opName] = true;
} else if (val === 'false') {
w[colName][opName] = false;
whereClause[colName][opName] = false;
}
}
return w;
return whereClause;
});
const newQuery = {
where: { $and: finalWhereClauses },
@ -190,10 +178,18 @@ const exportDataQuery = (tableSchema, type) => {
const fileName = `export_${table_schema}_${table_name}_${getCurrTimeForFileName()}`;
dispatch({ type: 'ViewTable/V_SET_QUERY_OPTS', queryStuff: newQuery });
dispatch(vMakeExportRequest()).then(d => {
if (d) {
if (type === 'JSON') downloadObjectAsJsonFile(fileName, d);
else if (type === 'CSV') downloadObjectAsCsvFile(fileName, d);
dispatch(vMakeExportRequest()).then(rows => {
if (!rows) {
return;
}
if (type === 'JSON') {
downloadObjectAsJsonFile(fileName, rows);
return;
}
if (type === 'CSV') {
downloadObjectAsCsvFile(fileName, rows);
}
});
};

View File

@ -1,347 +0,0 @@
/*
Use state exactly the way columns in create table do.
dispatch actions using a given function,
but don't listen to state.
derive everything through viewtable as much as possible.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { createHistory } from 'history';
import { FaTimes } from 'react-icons/fa';
import {
setFilterCol,
setFilterOp,
setFilterVal,
addFilter,
removeFilter,
} from './FilterActions.js';
import {
setOrderCol,
setOrderType,
addOrder,
removeOrder,
} from './FilterActions.js';
import {
setDefaultQuery,
runQuery,
exportDataQuery,
setOffset,
} from './FilterActions';
import { Button } from '@/new-components/Button';
import ReloadEnumValuesButton from '../Common/Components/ReloadEnumValuesButton';
import { getPersistedPageSize } from './tableUtils';
import { isEmpty } from '../../../Common/utils/jsUtils';
import ExportData from './ExportData';
import { dataSource, getTableCustomColumnName } from '../../../../dataSources';
import { inputStyles } from '../../Actions/constants.js';
const history = createHistory();
const renderCols = (
colName,
tableSchema,
onChange,
usage,
key,
skipColumns
) => {
let columns = tableSchema.columns.map(c => c.column_name);
if (skipColumns) {
columns = columns.filter(n => !skipColumns.includes(n) || n === colName);
}
return (
<select
className={inputStyles}
onChange={onChange}
value={colName.trim()}
data-test={
usage === 'sort' ? `sort-column-${key}` : `filter-column-${key}`
}
>
{colName.trim() === '' ? (
<option disabled value="">
-- column --
</option>
) : null}
{columns.map((c, i) => {
const col_name = getTableCustomColumnName(tableSchema, c) ?? c;
return (
<option key={i} value={c}>
{col_name}
</option>
);
})}
</select>
);
};
const renderOps = (opName, onChange, key) => (
<select
className={inputStyles}
onChange={onChange}
value={opName.trim()}
data-test={`filter-op-${key}`}
>
{opName.trim() === '' ? (
<option disabled value="">
-- op --
</option>
) : null}
{dataSource.operators.map((o, i) => (
<option key={i} value={o.value}>
{`[${o.graphqlOp}] ${o.name}`}
</option>
))}
</select>
);
const getDefaultValue = (possibleValue, opName) => {
if (possibleValue) {
if (Array.isArray(possibleValue)) return JSON.stringify(possibleValue);
return possibleValue;
}
const operator = dataSource.operators.find(op => op.value === opName);
return operator && operator.defaultValue ? operator.defaultValue : '';
};
const renderWheres = (whereAnd, tableSchema, dispatch) => {
return whereAnd.map((clause, i) => {
const colName = Object.keys(clause)[0];
const opName = Object.keys(clause[colName])[0];
const dSetFilterCol = e => {
dispatch(setFilterCol(e.target.value, i));
};
const dSetFilterOp = e => {
dispatch(setFilterOp(e.target.value, i));
};
let removeIcon = null;
if (i + 1 < whereAnd.length) {
removeIcon = (
<FaTimes
onClick={() => {
dispatch(removeFilter(i));
}}
data-test={`clear-filter-${i}`}
/>
);
}
return (
<div key={i} className="flex mb-xs">
<div className="w-4/12">
{renderCols(colName, tableSchema, dSetFilterCol, 'filter', i, [])}
</div>
<div className="w-3/12 ml-xs">{renderOps(opName, dSetFilterOp, i)}</div>
<div className="w-3/12 ml-xs">
<input
type="text"
placeholder="-- value --"
value={getDefaultValue(clause[colName][opName], opName)}
onChange={e => {
dispatch(setFilterVal(e.target.value, i));
if (i + 1 === whereAnd.length) {
dispatch(addFilter());
}
}}
data-test={`filter-value-${i}`}
className={inputStyles}
/>
</div>
<div className="w-1/12">{removeIcon}</div>
</div>
);
});
};
const renderSorts = (orderBy, tableSchema, dispatch) => {
const currentOrderBy = orderBy.map(o => o.column);
return orderBy.map((c, i) => {
const dSetOrderCol = e => {
dispatch(setOrderCol(e.target.value, i));
if (i + 1 === orderBy.length) {
dispatch(addOrder());
}
};
let removeIcon = null;
if (i + 1 < orderBy.length) {
removeIcon = (
<FaTimes
onClick={() => {
dispatch(removeOrder(i));
}}
data-test={`clear-sorts-${i}`}
/>
);
}
return (
<div key={i} className="flex mb-xs">
<div className="w-6/12 mr-xs">
{renderCols(
c.column,
tableSchema,
dSetOrderCol,
'sort',
i,
currentOrderBy
)}
</div>
<div className="w-6/12">
<select
value={c.column ? c.type : ''}
className={inputStyles}
onChange={e => {
dispatch(setOrderType(e.target.value, i));
}}
data-test={`sort-order-${i}`}
>
<option disabled value="">
--
</option>
<option value="asc">Asc</option>
<option value="desc">Desc</option>
</select>
</div>
<div className="">{removeIcon}</div>
</div>
);
});
};
class FilterQuery extends Component {
componentDidMount() {
const { dispatch, tableSchema, curQuery } = this.props;
const limit = getPersistedPageSize();
if (isEmpty(this.props.urlQuery)) {
dispatch(setDefaultQuery({ ...curQuery, limit }));
return;
}
let urlFilters = [];
if (typeof this.props.urlQuery.filter === 'string') {
urlFilters = [this.props.urlQuery.filter];
} else if (Array.isArray(this.props.urlQuery.filter)) {
urlFilters = this.props.urlQuery.filter;
}
const where = {
$and: urlFilters.map(filter => {
const parts = filter.split(';');
const col = parts[0];
const op = parts[1];
const value = parts[2];
return { [col]: { [op]: value } };
}),
};
let urlSorts = [];
if (typeof this.props.urlQuery.sort === 'string') {
urlSorts = [this.props.urlQuery.sort];
} else if (Array.isArray(this.props.urlQuery.sort)) {
urlSorts = this.props.urlQuery.sort;
}
const order_by = urlSorts.map(sort => {
const parts = sort.split(';');
const column = parts[0];
const type = parts[1];
const nulls = 'last';
return { column, type, nulls };
});
dispatch(setDefaultQuery({ where, order_by, limit }));
dispatch(runQuery(tableSchema));
}
setParams(query = { filters: [], sorts: [] }) {
const searchParams = new URLSearchParams();
query.filters.forEach(filter => searchParams.append('filter', filter));
query.sorts.forEach(sort => searchParams.append('sort', sort));
return searchParams.toString();
}
setUrlParams(whereAnd, orderBy) {
const sorts = orderBy
.filter(order => order.column)
.map(order => `${order.column};${order.type}`);
const filters = whereAnd
.filter(
where => Object.keys(where).length === 1 && Object.keys(where)[0] !== ''
)
.map(where => {
const col = Object.keys(where)[0];
const op = Object.keys(where[col])[0];
const value = where[col][op];
return `${col};${op};${value}`;
});
const url = this.setParams({ filters, sorts });
history.push({
pathname: history.getCurrentLocation().pathname,
search: `?${url}`,
});
}
render() {
const { dispatch, whereAnd, tableSchema, orderBy } = this.props; // eslint-disable-line no-unused-vars
const exportData = type => {
dispatch(exportDataQuery(tableSchema, type));
};
return (
<div className="mt-sm">
<form
onSubmit={e => {
e.preventDefault();
dispatch(setOffset(0));
this.setUrlParams(whereAnd, orderBy);
dispatch(runQuery(tableSchema));
}}
>
<div className="flex">
<div className="w-1/2 pl-0">
<div className="text-lg font-bold pb-md">Filter</div>
{renderWheres(whereAnd, tableSchema, dispatch)}
</div>
<div className="w-1/2 pl-0">
<div className="text-lg font-bold pb-md">Sort</div>
{renderSorts(orderBy, tableSchema, dispatch)}
</div>
</div>
<div className="pr-sm clear-both mt-sm">
<Button
type="submit"
mode="primary"
data-test="run-query"
className="mr-sm"
>
Run query
</Button>
<ExportData onExport={exportData} />
{tableSchema.is_enum ? (
<ReloadEnumValuesButton
dispatch={dispatch}
tooltipStyle="ml-sm"
/>
) : null}
</div>
</form>
</div>
);
}
}
FilterQuery.propTypes = {
curQuery: PropTypes.object.isRequired,
tableSchema: PropTypes.object.isRequired,
whereAnd: PropTypes.array.isRequired,
orderBy: PropTypes.array.isRequired,
limit: PropTypes.number.isRequired,
count: PropTypes.number,
tableName: PropTypes.string,
offset: PropTypes.number.isRequired,
dispatch: PropTypes.func.isRequired,
};
export default FilterQuery;

View File

@ -0,0 +1 @@
export { useRows } from './useRows';

View File

@ -0,0 +1,30 @@
import { renderHook } from '@testing-library/react-hooks';
import { wrapper } from '../../../../../hooks/__tests__/common/decorator';
import { useRows } from '.';
import { server, postgresTableMockData } from '../mocks/handlers.mock';
import { UseRowsPropType } from './useRows';
describe('useRemoveAgent tests: ', () => {
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
it('returns table data for a postgres table', async () => {
const props: UseRowsPropType = {
dataSourceName: 'chinook',
table: { name: 'Album', schema: 'public' },
options: {
limit: 10,
where: { $and: [{ AlbumId: { $gt: 4 } }] },
order_by: [{ column: 'Title', type: 'desc' }],
offset: 15,
},
};
const { result, waitFor } = renderHook(() => useRows(props), { wrapper });
await waitFor(() => result.current.isSuccess);
expect(result.current.data).toEqual(postgresTableMockData);
});
});

View File

@ -0,0 +1,43 @@
import { DataSource, OrderBy, WhereClause } from '@/features/DataSource';
import { Table } from '@/features/MetadataAPI';
import { useHttpClient } from '@/features/Network';
import { useQuery } from 'react-query';
export type UseRowsPropType = {
dataSourceName: string;
table: Table;
columns?: string[];
options?: {
where?: WhereClause;
offset?: number;
limit?: number;
order_by?: OrderBy[];
};
};
export const useRows = ({
dataSourceName,
table,
columns,
options,
}: UseRowsPropType) => {
const httpClient = useHttpClient();
return useQuery({
queryKey: ['browse-rows', dataSourceName, table, columns],
queryFn: async () => {
const tableColumns = await DataSource(httpClient).getTableColumns({
dataSourceName,
table,
});
const result = await DataSource(httpClient).getTableRows({
dataSourceName,
table,
columns: columns ?? tableColumns.map(column => column.name),
options,
});
return result;
},
});
};

View File

@ -32,19 +32,20 @@ import {
import { Button } from '@/new-components/Button';
import { FilterSectionContainer } from '@/features/BrowseRows/FiltersSection/FiltersSectionContainer';
import { PaginationWithOnlyNavContainer } from '@/new-components/PaginationWithOnlyNav/PaginationWithOnlyNavContainer';
import {
setOrderCol,
setOrderType,
removeOrder,
runQuery,
setOffset,
setLimit,
addOrder,
} from './FilterActions';
import _push from '../push';
import { ordinalColSort } from '../utils';
import FilterQuery from './FilterQuery';
import Spinner from '../../../Common/Spinner/Spinner';
import { E_SET_EDITITEM } from './EditActions';
@ -66,10 +67,8 @@ import {
getPersistedCollapsedColumns,
persistColumnOrderChange,
getPersistedColumnsOrder,
persistPageSizeChange,
} from './tableUtils';
import { compareRows, isTableWithPK } from './utils';
import { inputStyles } from '../constants';
const ViewRows = props => {
const {
@ -92,7 +91,6 @@ const ViewRows = props => {
count,
expandedRow,
manualTriggers = [],
location,
readOnlyMode,
shouldHidePagination,
currentSource,
@ -721,44 +719,6 @@ const ViewRows = props => {
disableBulkSelect
);
const getFilterQuery = () => {
let _filterQuery = null;
if (!isSingleRow) {
if (curRelName === activePath[curDepth] || curDepth === 0) {
// Rendering only if this is the activePath or this is the root
let wheres = [{ '': { '': '' } }];
if ('where' in curFilter && '$and' in curFilter.where) {
wheres = [...curFilter.where.$and];
}
let orderBy = [{ column: '', type: 'asc', nulls: 'last' }];
if ('order_by' in curFilter) {
orderBy = [...curFilter.order_by];
}
const offset = 'offset' in curFilter ? curFilter.offset : 0;
_filterQuery = (
<FilterQuery
curQuery={curQuery}
whereAnd={wheres}
tableSchema={tableSchema}
orderBy={orderBy}
dispatch={dispatch}
count={count}
tableName={curTableName}
offset={offset}
urlQuery={location && location.query}
/>
);
}
}
return _filterQuery;
};
const getSelectedRowsSection = () => {
const handleDeleteItems = () => {
const pkClauses = selectedRows.map(row =>
@ -873,6 +833,11 @@ const ViewRows = props => {
return _childComponent;
};
const [userQuery, setUserQuery] = useState({
where: { $and: [] },
order_by: [],
});
const renderTableBody = () => {
if (isProgressing) {
return (
@ -976,72 +941,30 @@ const ViewRows = props => {
const handlePageChange = page => {
if (curFilter.offset !== page * curFilter.limit) {
dispatch(setOffset(page * curFilter.limit));
dispatch(runQuery(tableSchema));
setSelectedRows([]);
}
};
const handlePageSizeChange = size => {
if (curFilter.size !== size) {
dispatch(setLimit(size));
dispatch(setOffset(0));
dispatch(runQuery(tableSchema));
setSelectedRows([]);
persistPageSizeChange(size);
}
};
const PaginationWithOnlyNav = () => {
const newPage = curFilter.offset / curFilter.limit;
return (
<div className="flex ml-sm mr-sm justify-around">
<div>
<Button
onClick={() => handlePageChange(newPage - 1)}
disabled={curFilter.offset === 0}
data-test="custom-pagination-prev"
>
Prev
</Button>
</div>
<div className="w-1/3">
<select
className={inputStyles}
value={curFilter.limit}
onChange={e => {
e.persist();
handlePageSizeChange(parseInt(e.target.value, 10) || 10);
}}
data-test="pagination-select"
>
<option disabled value="">
--
</option>
<option value={5}>5 rows</option>
<option value={10}>10 rows</option>
<option value={20}>20 rows</option>
<option value={25}>25 rows</option>
<option value={50}>50 rows</option>
<option value={100}>100 rows</option>
</select>
</div>
<div>
<Button
onClick={() => handlePageChange(newPage + 1)}
disabled={curRows.length === 0}
data-test="custom-pagination-next"
>
Next
</Button>
</div>
</div>
);
};
const paginationProps = {};
if (useCustomPagination) {
paginationProps.PaginationComponent = PaginationWithOnlyNav;
paginationProps.PaginationComponent = () => (
<PaginationWithOnlyNavContainer
limit={curFilter.limit}
offset={curFilter.offset}
onChangePage={handlePageChange}
onChangePageSize={handlePageSizeChange}
pageSize={curFilter.size}
rows={curRows}
tableSchema={tableSchema}
userQuery={userQuery}
/>
);
}
return (
@ -1084,9 +1007,18 @@ const ViewRows = props => {
isVisible = true;
}
const isFilterSectionVisible =
!isSingleRow && (curRelName === activePath[curDepth] || curDepth === 0);
return (
<div className={isVisible ? '' : 'hide '}>
{getFilterQuery()}
{isFilterSectionVisible && (
<FilterSectionContainer
dataSourceName={currentSource}
table={{ schema: tableSchema.table_schema, name: curTableName }}
onRunQuery={newUserQuery => setUserQuery(newUserQuery)}
/>
)}
<div className="w-fit ml-0 mt-md">
{getSelectedRowsSection()}
<div>

View File

@ -0,0 +1,95 @@
import { TableRow } from '@/features/DataSource';
import { Metadata } from '@/features/MetadataAPI';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
export const mockMetadata: Metadata = {
resource_version: 54,
metadata: {
version: 3,
sources: [
{
name: 'chinook',
kind: 'postgres',
tables: [
{
table: {
name: 'Album',
schema: 'public',
},
},
],
configuration: {
connection_info: {
database_url:
'postgres://postgres:test@host.docker.internal:6001/chinook',
isolation_level: 'read-committed',
use_prepared_statements: false,
},
},
},
],
},
};
export const postgresTableMockData: TableRow[] = [
{
AlbumId: 225,
Title: 'Volume Dois',
ArtistId: 146,
},
{
AlbumId: 275,
Title: 'Vivaldi: The Four Seasons',
ArtistId: 209,
},
{
AlbumId: 114,
Title: 'Virtual XI',
ArtistId: 90,
},
{
AlbumId: 52,
Title: 'Vinícius De Moraes - Sem Limite',
ArtistId: 70,
},
{
AlbumId: 247,
Title: 'Vinicius De Moraes',
ArtistId: 72,
},
{
AlbumId: 67,
Title: "Vault: Def Leppard's Greatest Hits",
ArtistId: 78,
},
{
AlbumId: 245,
Title: 'Van Halen III',
ArtistId: 152,
},
{
AlbumId: 244,
Title: 'Van Halen',
ArtistId: 152,
},
{
AlbumId: 92,
Title: 'Use Your Illusion II',
ArtistId: 88,
},
{
AlbumId: 91,
Title: 'Use Your Illusion I',
ArtistId: 88,
},
];
export const server = setupServer(
rest.post('http://localhost/v1/metadata', (req, res, ctx) => {
return res(ctx.status(200), ctx.json(mockMetadata));
}),
rest.post('http://localhost/v2/query', (req, res, ctx) => {
return res(ctx.status(200), ctx.json(postgresTableMockData));
})
);

View File

@ -1,9 +1,9 @@
import { MetadataDataSource } from '../../../../metadata/types';
import { ReduxState } from './../../../../types';
type TableSchema = {
export type TableSchema = {
primary_key?: { columns: string[] };
columns: Array<{ column_name: string }>;
columns: Array<{ column_name: string; data_type: string }>;
};
type TableSchemaWithPK = {

View File

@ -55,6 +55,7 @@ import {
PERM_SET_FILTER,
PERM_SET_FILTER_SAME_AS,
PERM_TOGGLE_FIELD,
PERM_TOGGLE_SELECT_FIELD,
PERM_TOGGLE_ALL_FIELDS,
PERM_ALLOW_ALL,
PERM_TOGGLE_MODIFY_LIMIT,
@ -417,7 +418,7 @@ const modifyReducer = (tableName, schemas, modifyStateOrig, action) => {
return returnState;
case PERM_TOGGLE_FIELD:
case PERM_TOGGLE_SELECT_FIELD:
const tablePrimaryKeys = getPrimaryKeysFromTable(schemas, modifyState);
return produce(modifyState, draft => {
const newPermissionsState = updatePermissionsState(
@ -441,6 +442,22 @@ const modifyReducer = (tableName, schemas, modifyStateOrig, action) => {
);
});
case PERM_TOGGLE_FIELD:
return {
...modifyState,
permissionsState: {
...updatePermissionsState(
modifyState.permissionsState,
action.fieldType,
toggleField(
modifyState.permissionsState[modifyState.permissionsState.query],
action.fieldName,
action.fieldType
)
),
},
};
case PERM_REMOVE_ACCESS:
return {
...modifyState,

Some files were not shown because too many files have changed in this diff Show More