mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-10-26 10:20:54 +03:00
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:
parent
265746189e
commit
dbe350d087
@ -7,5 +7,7 @@
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": []
|
||||
"plugins": [
|
||||
"@babel/plugin-proposal-export-default-from"
|
||||
]
|
||||
}
|
||||
|
81
frontend/apps/console-pro/custom-webpack.config.js
Normal file
81
frontend/apps/console-pro/custom-webpack.config.js
Normal 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;
|
||||
};
|
15
frontend/apps/console-pro/postcss.config.js
Normal file
15
frontend/apps/console-pro/postcss.config.js
Normal 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: {},
|
||||
},
|
||||
};
|
@ -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": {
|
||||
|
@ -1 +0,0 @@
|
||||
/* Your styles goes here. */
|
@ -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();
|
||||
});
|
||||
});
|
@ -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;
|
@ -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're up and running</span>
|
||||
</h2>
|
||||
<a href="#commands"> What'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'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;
|
7269
frontend/apps/console-pro/src/css/legacy-boostrap.css
Normal file
7269
frontend/apps/console-pro/src/css/legacy-boostrap.css
Normal file
File diff suppressed because it is too large
Load Diff
167
frontend/apps/console-pro/src/css/tailwind.css
Normal file
167
frontend/apps/console-pro/src/css/tailwind.css
Normal 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
@ -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
|
||||
);
|
||||
|
14
frontend/apps/console-pro/tailwind.config.js
Normal file
14
frontend/apps/console-pro/tailwind.config.js
Normal 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,
|
||||
};
|
@ -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",
|
||||
|
@ -8,7 +8,7 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
|
4
frontend/libs/console/legacy-oss/src/exports/app.js
Normal file
4
frontend/libs/console/legacy-oss/src/exports/app.js
Normal 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';
|
1
frontend/libs/console/legacy-oss/src/exports/appState.js
Normal file
1
frontend/libs/console/legacy-oss/src/exports/appState.js
Normal file
@ -0,0 +1 @@
|
||||
export * from '../lib/components/AppState';
|
@ -0,0 +1,2 @@
|
||||
export { default as NotificationSection } from './lib/components/Main/NotificationSection';
|
||||
export { default as Onboarding } from './lib/components/Common/Onboarding';
|
@ -0,0 +1 @@
|
||||
export * from '../lib/constants';
|
2
frontend/libs/console/legacy-oss/src/exports/index.js
Normal file
2
frontend/libs/console/legacy-oss/src/exports/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './routers';
|
||||
export * from './reducers';
|
90
frontend/libs/console/legacy-oss/src/exports/main.js
Normal file
90
frontend/libs/console/legacy-oss/src/exports/main.js
Normal 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';
|
12
frontend/libs/console/legacy-oss/src/exports/reducers.js
Normal file
12
frontend/libs/console/legacy-oss/src/exports/reducers.js
Normal 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';
|
6
frontend/libs/console/legacy-oss/src/exports/routers.js
Normal file
6
frontend/libs/console/legacy-oss/src/exports/routers.js
Normal 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';
|
8
frontend/libs/console/legacy-oss/src/exports/table.js
Normal file
8
frontend/libs/console/legacy-oss/src/exports/table.js
Normal 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 };
|
@ -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',
|
||||
};
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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}
|
||||
|
@ -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>
|
||||
|
@ -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;
|
@ -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');
|
||||
});
|
||||
});
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
|
@ -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'}
|
||||
>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -0,0 +1 @@
|
||||
export * from './AllowListDetail';
|
@ -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}
|
||||
|
@ -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)}
|
||||
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
@ -8,6 +8,7 @@ type TopNavProps = {
|
||||
|
||||
const TopNav: React.FC<TopNavProps> = ({ location }) => {
|
||||
const sectionsData = [
|
||||
[
|
||||
{
|
||||
key: 'graphiql',
|
||||
link: '/api/api-explorer',
|
||||
@ -20,10 +21,19 @@ const TopNav: React.FC<TopNavProps> = ({ location }) => {
|
||||
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,11 +50,14 @@ 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 className="flex px-1 w-full">
|
||||
{sectionsData.map((group, groupIndex) =>
|
||||
group.map((section, sectionIndex) => (
|
||||
<div
|
||||
role="presentation"
|
||||
className={`whitespace-nowrap font-medium pt-2 pb-1 px-2 border-b-4
|
||||
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'
|
||||
@ -60,7 +73,8 @@ const TopNav: React.FC<TopNavProps> = ({ location }) => {
|
||||
{section.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,6 +1,7 @@
|
||||
.notifications-wrapper .notification {
|
||||
height: auto !important;
|
||||
width: auto !important;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.notifications-wrapper .notifications-tr,
|
||||
|
@ -12,6 +12,7 @@ const rqlQueryTypes = [
|
||||
'run_sql',
|
||||
'mssql_run_sql',
|
||||
'citus_run_sql',
|
||||
'cockroach_run_sql',
|
||||
];
|
||||
|
||||
type Query = {
|
||||
|
@ -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', []);
|
||||
}
|
@ -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} />
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <>Please wait...</>;
|
||||
};
|
||||
|
||||
export default Handler;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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...</>;
|
||||
}
|
@ -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 />;
|
@ -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>
|
||||
);
|
||||
}
|
@ -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();
|
||||
};
|
@ -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>
|
||||
);
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 671 KiB |
@ -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} />;
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
@ -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', []);
|
||||
}
|
@ -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');
|
||||
});
|
||||
});
|
@ -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,
|
||||
};
|
||||
}
|
@ -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();
|
||||
}
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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}`}>
|
||||
{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>
|
||||
);
|
||||
|
@ -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);
|
||||
});
|
||||
};
|
@ -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,7 +169,12 @@ const DataSourceFormWrapper: React.FC<DataSourceFormWrapperProps> = props => {
|
||||
disabled={isEditState}
|
||||
data-test="database-type"
|
||||
>
|
||||
{(drivers ?? []).map(driver => (
|
||||
{(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})`}
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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 (
|
||||
|
@ -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;
|
||||
};
|
@ -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 };
|
||||
};
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -0,0 +1,2 @@
|
||||
export { GDCTree } from './GDCTree';
|
||||
export { useGDCTreeItemClick } from './hooks/useGDCTreeItemClick';
|
@ -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
|
||||
|
@ -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', []);
|
||||
}
|
@ -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 => ({
|
||||
options={metadataSources
|
||||
.filter(source => {
|
||||
if (areGDCFeaturesEnabled) return source;
|
||||
return nativeDrivers.includes(source.kind);
|
||||
})
|
||||
.map(source => ({
|
||||
name: source.name,
|
||||
driver: source.driver,
|
||||
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,6 +562,7 @@ const RawSQL = ({
|
||||
|
||||
{getMigrationWarningModal()}
|
||||
|
||||
{nativeDrivers.includes(selectedDriver) ? (
|
||||
<div className={styles.add_mar_bottom}>
|
||||
{resultType &&
|
||||
resultType !== 'command' &&
|
||||
@ -521,6 +571,13 @@ const RawSQL = ({
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@ -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);
|
||||
|
@ -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 };
|
||||
};
|
@ -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
|
||||
|
@ -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,8 +322,15 @@ const ManageDatabase: React.FC<ManageDatabaseProps> = ({
|
||||
</th>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{dataSources.length ? (
|
||||
dataSources.map(data => (
|
||||
{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}
|
||||
@ -317,7 +342,18 @@ const ManageDatabase: React.FC<ManageDatabaseProps> = ({
|
||||
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'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 ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -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: {
|
||||
|
@ -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) => {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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 };
|
||||
};
|
@ -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 };
|
||||
};
|
@ -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 });
|
||||
|
@ -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> =
|
||||
|
@ -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,8 +35,14 @@ export const TableTrackingCustomizationModal: React.FC<TableTrackingCustomizatio
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (data: TrackingTableFormValues) => {
|
||||
onSubmit(data, buildConfigFromFormValues(data));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={methods.handleSubmit(onSubmit)}>
|
||||
<>
|
||||
{show && (
|
||||
<form onSubmit={methods.handleSubmit(handleSubmit)}>
|
||||
<Dialog
|
||||
hasBackdrop
|
||||
title={tableName}
|
||||
@ -54,5 +64,7 @@ export const TableTrackingCustomizationModal: React.FC<TableTrackingCustomizatio
|
||||
</>
|
||||
</Dialog>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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)',
|
||||
|
@ -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);
|
@ -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,
|
||||
|
@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -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;
|
@ -0,0 +1 @@
|
||||
export { useRows } from './useRows';
|
@ -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);
|
||||
});
|
||||
});
|
@ -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;
|
||||
},
|
||||
});
|
||||
};
|
@ -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>
|
||||
|
@ -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));
|
||||
})
|
||||
);
|
@ -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 = {
|
||||
|
@ -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
Loading…
Reference in New Issue
Block a user