diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..6e87a003d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# Editor configuration, see http://editorconfig.org +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +max_line_length = off +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore index f2b0565ec..5b8cb9fbc 100644 --- a/.gitignore +++ b/.gitignore @@ -11,7 +11,7 @@ wren-ai-service/qdrant_storage/ src/eval/vulcansql-core-server/config.properties outputs/ spider/ -data/ +wren-ai-service/**/data/ !wren-ai-service/tests/data !src/eval/data poetry.lock @@ -29,4 +29,40 @@ local_cache # os .DS_Store -__MACOSX/ \ No newline at end of file +__MACOSX/ +*.pem + +# sqlite +*.sqlite +*.sqlite3 + +# ui +## dependencies +node_modules +.pnp +.pnp.js + +## testing +wren-ui/coverage + +## next.js +wren-ui/.next +wren-ui/out + +## production +wren-ui/build + +## debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +## local env files +.env*.local + +## vercel +.vercel + +## typescript +*.tsbuildinfo +next-env.d.ts \ No newline at end of file diff --git a/wren-ui/.eslintignore b/wren-ui/.eslintignore new file mode 100644 index 000000000..600e365ec --- /dev/null +++ b/wren-ui/.eslintignore @@ -0,0 +1 @@ +**/node_modules \ No newline at end of file diff --git a/wren-ui/.eslintrc.json b/wren-ui/.eslintrc.json new file mode 100644 index 000000000..90cbdef03 --- /dev/null +++ b/wren-ui/.eslintrc.json @@ -0,0 +1,56 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "extends": ["next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"], + "plugins": ["@typescript-eslint", "eslint-plugin-prettier"], + "ignorePatterns": [ + "!**/*", + ".next/**/*", + "src/apollo/client/graphql/__types__.ts", + "src/apollo/client/graphql/*.generated.ts" + ], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": { + "@next/next/no-html-link-for-pages": [ + "error", + "src/pages" + ], + "@next/next/no-img-element": "off", + "react-hooks/exhaustive-deps": "off" + } + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ], + "rules": { + "prettier/prettier": "error", + "@next/next/no-html-link-for-pages": "off", + "react/display-name": 0, + "react/no-unescaped-entities": 0, + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "args": "all", + "argsIgnorePattern": "^_", + "caughtErrors": "all", + "caughtErrorsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "ignoreRestSiblings": true + } + ] + }, + "env": { + "jest": true + } +} diff --git a/wren-ui/.prettierrc b/wren-ui/.prettierrc new file mode 100644 index 000000000..544138be4 --- /dev/null +++ b/wren-ui/.prettierrc @@ -0,0 +1,3 @@ +{ + "singleQuote": true +} diff --git a/wren-ui/README.md b/wren-ui/README.md new file mode 100644 index 000000000..c4033664f --- /dev/null +++ b/wren-ui/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/wren-ui/codegen.yaml b/wren-ui/codegen.yaml new file mode 100644 index 000000000..640471fcb --- /dev/null +++ b/wren-ui/codegen.yaml @@ -0,0 +1,20 @@ +overwrite: true +schema: + [ + 'http://localhost:3000/api/graphql' + ] +generates: + ./src/apollo/client/graphql/__types__.ts: + plugins: + - typescript + - typescript-operations + - typescript-react-apollo + ./: + preset: near-operation-file + presetConfig: + extension: .generated.ts + baseTypesPath: ./src/apollo/client/graphql/__types__.ts + documents: ./src/apollo/client/graphql/!(*.generated).{ts,tsx} + plugins: + - typescript-operations + - typescript-react-apollo diff --git a/wren-ui/jest.config.js b/wren-ui/jest.config.js new file mode 100644 index 000000000..b413e106d --- /dev/null +++ b/wren-ui/jest.config.js @@ -0,0 +1,5 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', +}; \ No newline at end of file diff --git a/wren-ui/knexfile.js b/wren-ui/knexfile.js new file mode 100644 index 000000000..a6df4efe7 --- /dev/null +++ b/wren-ui/knexfile.js @@ -0,0 +1,19 @@ +// Update with your config settings. + +/** + * @type { Object. } + */ +if (process.env.DB_TYPE === 'pg') { + console.log('Using Postgres'); + module.exports = { + client: 'pg', + connection: process.env.PG_URL, + }; + } else { + console.log('Using SQLite'); + module.exports = { + client: 'better-sqlite3', + connection: process.env.SQLITE_FILE, + }; + } + \ No newline at end of file diff --git a/wren-ui/migrations/20240125070643_create_project_table.js b/wren-ui/migrations/20240125070643_create_project_table.js new file mode 100644 index 000000000..61c40ae5a --- /dev/null +++ b/wren-ui/migrations/20240125070643_create_project_table.js @@ -0,0 +1,39 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('project', (table) => { + table.increments('id').comment('ID'); + table + .string('type') + .comment( + 'project datasource type. ex: bigquery, mysql, postgresql, mongodb, etc' + ); + table.string('display_name').comment('project display name'); + + // bq + table.string('project_id').comment('gcp project id, big query specific'); + table + .text('credentials') + .comment('project credentials, big query specific'); + table + .string('location') + .comment('where the dataset been stored, big query specific'); + table.string('dataset').comment('big query dataset name'); + + // not sure to store or not, the catalog & schema in the manifest + table.string('catalog').comment('catalog name'); + table.string('schema').comment(''); + + table.timestamps(true, true); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTable('project'); +}; diff --git a/wren-ui/migrations/20240125071855_create_model_table.js b/wren-ui/migrations/20240125071855_create_model_table.js new file mode 100644 index 000000000..a1e190882 --- /dev/null +++ b/wren-ui/migrations/20240125071855_create_model_table.js @@ -0,0 +1,44 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('model', (table) => { + table.increments('id').comment('ID'); + table.integer('project_id').comment('Reference to project.id'); + + // basic info + table.string('name').comment('the model display name'); + table + .string('table_name') + .comment('referenced table name in the datasource'); + table.text('ref_sql').comment('Reference SQL'); + + // cache setting + table.boolean('cached').comment('model is cached or not'); + table + .string('refresh_time') + .comment( + 'contain a number followed by a time unit (ns, us, ms, s, m, h, d). For example, "2h"' + ) + .nullable(); + + // model properties + table + .text('properties') + .comment( + 'model properties, a json string, the description and displayName should be stored here' + ) + .nullable(); + + table.timestamps(true, true); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTable('model'); +}; diff --git a/wren-ui/migrations/20240125081244_create_model_column_table.js b/wren-ui/migrations/20240125081244_create_model_column_table.js new file mode 100644 index 000000000..cd033343f --- /dev/null +++ b/wren-ui/migrations/20240125081244_create_model_column_table.js @@ -0,0 +1,59 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('model_column', (table) => { + table.increments('id').comment('ID'); + table.integer('model_id').comment('Reference to model ID'); + // column name + table.boolean('is_calculated').comment('Is calculated field'); + table.string('name').comment('Column name'); + + // aggregation + table + .text('aggregation') + .comment( + 'Expression for the column, could be custom field or calculated field expression, eg: sum, aggregate' + ) + .nullable(); + table + .text('lineage') + .comment( + 'the selected field in calculated field, array of ids, [relationId 1, relationId 2, columnId], last one should be columnId, while others are relationId' + ) + .nullable(); + table + .text('diagram') + .comment('for FE to store the calculated field diagram') + .nullable(); + + table + .text('custom_expression') + .comment('for custom field or custom expression of calculated field.') + .nullable(); + + table + .string('type') + .comment('Data type, refer to the column type in the datasource'); + table.boolean('not_null').comment('Is not null'); + // is primary key + table.boolean('is_pk').comment('Is primary key of the table'); + table + .text('properties') + .comment( + 'column properties, a json string, the description and displayName should be stored here' + ) + .nullable(); + + table.timestamps(true, true); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTable('model_column'); +}; diff --git a/wren-ui/migrations/20240125083821_create_relation_table.js b/wren-ui/migrations/20240125083821_create_relation_table.js new file mode 100644 index 000000000..113b8ea6a --- /dev/null +++ b/wren-ui/migrations/20240125083821_create_relation_table.js @@ -0,0 +1,33 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('relation', (table) => { + table.increments('id').comment('ID'); + table.integer('project_id').comment('Reference to project.id'); + table.string('name').comment('relation name').unique(); + table + .string('join_type') + .comment('join type, eg:"ONE_TO_ONE", "ONE_TO_MANY", "MANY_TO_ONE"'); + table + .integer('left_column_id') + .comment( + 'left column id, "{leftSideColumn} {joinType} {rightSideColumn}"' + ); + table + .integer('right_column_id') + .comment( + 'right column id, "{leftSideColumn} {joinType} {rightSideColumn}"' + ); + table.timestamps(true, true); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTable('relation'); +}; diff --git a/wren-ui/migrations/20240125085655_create_metrics_table.js b/wren-ui/migrations/20240125085655_create_metrics_table.js new file mode 100644 index 000000000..1ef6a0ba2 --- /dev/null +++ b/wren-ui/migrations/20240125085655_create_metrics_table.js @@ -0,0 +1,40 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('metric', (table) => { + table.increments('id').comment('ID'); + table.integer('project_id').comment('Reference to project.id'); + table.string('name').comment('metric name'); + table.string('type').comment('metric type, ex: "simple" or "cumulative"'); + + // cache setting + table.boolean('cached').comment('model is cached or not'); + table + .string('refresh_time') + .comment( + 'contain a number followed by a time unit (ns, us, ms, s, m, h, d). For example, "2h"' + ) + .nullable(); + + // metric can based on model or another metric + table.integer('model_id').comment('Reference to model.id').nullable(); + table.integer('metric_id').comment('Reference to metric.id').nullable(); + table + .text('properties') + .comment( + 'metric properties, a json string, the description and displayName should be stored here' + ) + .nullable(); + table.timestamps(true, true); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTable('metric'); +}; diff --git a/wren-ui/migrations/20240126100753_create_metrics_measure_table.js b/wren-ui/migrations/20240126100753_create_metrics_measure_table.js new file mode 100644 index 000000000..086b23f62 --- /dev/null +++ b/wren-ui/migrations/20240126100753_create_metrics_measure_table.js @@ -0,0 +1,36 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + // name string + // expression string + // granularity string, nullable + + return knex.schema.createTable('metric_measure', (table) => { + table.increments('id').comment('ID'); + table.integer('metric_id').comment('Reference to metric ID'); + table.string('name').comment('Measure name'); + table + .text('expression') + .comment('Expression for the measure') + .comment( + 'the expression of measure, eg: "Sum", "Everage", or customize expression' + ); + table + .string('granularity') + .comment( + 'Granularity for the measure, eg: "day", "hour", "minute", "year"' + ) + .nullable(); + table.timestamps(true, true); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTable('metric_measure'); +}; diff --git a/wren-ui/migrations/20240129021453_create_view_table.js b/wren-ui/migrations/20240129021453_create_view_table.js new file mode 100644 index 000000000..7d1cbc01c --- /dev/null +++ b/wren-ui/migrations/20240129021453_create_view_table.js @@ -0,0 +1,40 @@ +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = function (knex) { + return knex.schema.createTable('view', (table) => { + table.increments('id').comment('ID'); + table.integer('project_id').comment('Reference to project.id'); + + // basic info + table.string('name').comment('the view name'); + table.text('statement').comment('the sql statement of this view'); + + // cache setting + table.boolean('cached').comment('view is cached or not'); + table + .string('refresh_time') + .comment( + 'contain a number followed by a time unit (ns, us, ms, s, m, h, d). For example, "2h"' + ) + .nullable(); + + // view properties + table + .text('properties') + .comment( + 'view properties, a json string, the description and displayName should be stored here' + ) + .nullable(); + table.timestamps(true, true); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = function (knex) { + return knex.schema.dropTable('view'); +}; diff --git a/wren-ui/next.config.js b/wren-ui/next.config.js new file mode 100644 index 000000000..665563485 --- /dev/null +++ b/wren-ui/next.config.js @@ -0,0 +1,26 @@ +const path = require('path'); +const withAntdLess = require('next-plugin-antd-less'); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + compiler: { + // Enables the styled-components SWC transform + styledComponents: { + displayName: true, + ssr: true, + }, + }, + serverRuntimeConfig: { + PG_DATABASE: process.env.PG_DATABASE, + PG_PORT: process.env.PG_PORT, + PG_USERNAME: process.env.PG_USERNAME, + PG_PASSWORD: process.env.PG_PASSWORD, + }, + ...withAntdLess({ + // next-plugin-antd-less options + lessVarsFilePath: path.resolve(__dirname, 'src/styles/antd-variables.less'), + lessVarsFilePathAppendToEndOfContent: false, + }), +}; + +module.exports = nextConfig diff --git a/wren-ui/package.json b/wren-ui/package.json new file mode 100644 index 000000000..e0a6f4fa6 --- /dev/null +++ b/wren-ui/package.json @@ -0,0 +1,79 @@ +{ + "name": "wren-ui", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint", + "test": "jest", + "knex-migrate": "yarn knex migrate:latest", + "rollback": "yarn knex migrate:rollback", + "generate-gql": "yarn graphql-codegen --config codegen.yaml" + }, + "resolutions": { + "@types/react": "18.2.0", + "@types/react-dom": "18.2.0" + }, + "dependencies": { + "@google-cloud/bigquery": "^6.0.3", + "@google-cloud/storage": "^6.10.1", + "apollo-server-micro": "^3.10.2", + "axios": "^0.27.2", + "bcryptjs": "^2.4.3", + "better-sqlite3": "^9.4.3", + "graphql": "^16.6.0", + "graphql-type-json": "^0.3.2", + "knex": "^3.1.0", + "lodash": "^4.17.21", + "log4js": "^6.9.1", + "micro": "^9.4.1", + "micro-cors": "^0.1.1", + "next": "13.5.6", + "pg": "^8.8.0", + "pg-cursor": "^2.7.4", + "uuid": "^9.0.0" + }, + "devDependencies": { + "@apollo/client": "^3.6.9", + "@graphql-codegen/cli": "2.12.0", + "@graphql-codegen/introspection": "2.2.1", + "@graphql-codegen/near-operation-file-preset": "^2.4.1", + "@graphql-codegen/typescript": "2.7.3", + "@graphql-codegen/typescript-operations": "^2.5.3", + "@graphql-codegen/typescript-react-apollo": "3.3.3", + "@testing-library/react": "14.0.0", + "@types/jest": "29.4.4", + "@types/lodash": "^4.14.202", + "@types/micro-cors": "^0.1.5", + "@types/node": "18.16.9", + "@types/pg": "^8.6.5", + "@types/pg-cursor": "^2.7.0", + "@types/react": "18.2.0", + "@types/react-dom": "18.2.0", + "@types/styled-components": "5.1.26", + "@typescript-eslint/eslint-plugin": "6.18.0", + "@typescript-eslint/parser": "6.18.0", + "ace-builds": "^1.32.3", + "antd": "4.20.4", + "eslint": "^8", + "eslint-config-next": "13.5.6", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "jest": "29.4.3", + "next-plugin-antd-less": "^1.8.0", + "prettier": "^3.2.5", + "react": "18.2.0", + "react-ace": "^10.1.0", + "react-dom": "18.2.0", + "reactflow": "11.10.3", + "styled-components": "5.3.6", + "styled-icons": "^10.47.0", + "ts-essentials": "^9.1.2", + "ts-jest": "29.1.1", + "ts-node": "9.1.1", + "ts-sinon": "^2.0.2", + "typescript": "~4.6.2" + } +} diff --git a/wren-ui/public/images/dataSource/bigQuery.svg b/wren-ui/public/images/dataSource/bigQuery.svg new file mode 100644 index 000000000..2b9492fdf --- /dev/null +++ b/wren-ui/public/images/dataSource/bigQuery.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/wren-ui/public/images/dataSource/dataBricks.svg b/wren-ui/public/images/dataSource/dataBricks.svg new file mode 100644 index 000000000..0cf3365aa --- /dev/null +++ b/wren-ui/public/images/dataSource/dataBricks.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/wren-ui/public/images/dataSource/snowflake.svg b/wren-ui/public/images/dataSource/snowflake.svg new file mode 100644 index 000000000..1aacc1c25 --- /dev/null +++ b/wren-ui/public/images/dataSource/snowflake.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/wren-ui/public/images/logo.svg b/wren-ui/public/images/logo.svg new file mode 100644 index 000000000..80373ea88 --- /dev/null +++ b/wren-ui/public/images/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/wren-ui/public/next.svg b/wren-ui/public/next.svg new file mode 100644 index 000000000..5174b28c5 --- /dev/null +++ b/wren-ui/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wren-ui/public/vercel.svg b/wren-ui/public/vercel.svg new file mode 100644 index 000000000..d2f842227 --- /dev/null +++ b/wren-ui/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/wren-ui/src/apollo/client/graphql/__types__.ts b/wren-ui/src/apollo/client/graphql/__types__.ts new file mode 100644 index 000000000..d007db7e1 --- /dev/null +++ b/wren-ui/src/apollo/client/graphql/__types__.ts @@ -0,0 +1,257 @@ +import { gql } from '@apollo/client'; +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; + JSON: any; +}; + +export type AutoGenerateInput = { + tables: Array; +}; + +export type CalculatedFieldInput = { + expression: Scalars['String']; + name: Scalars['String']; +}; + +export type CompactColumn = { + __typename?: 'CompactColumn'; + name: Scalars['String']; + type: Scalars['String']; +}; + +export type CompactModel = { + __typename?: 'CompactModel'; + cached: Scalars['Boolean']; + description?: Maybe; + name: Scalars['String']; + primaryKey?: Maybe; + refSql: Scalars['String']; + refreshTime: Scalars['String']; +}; + +export type CompactTable = { + __typename?: 'CompactTable'; + columns: Array; + name: Scalars['String']; +}; + +export type CreateModelInput = { + cached: Scalars['Boolean']; + calculatedFields?: InputMaybe>; + customFields?: InputMaybe>; + description?: InputMaybe; + displayName: Scalars['String']; + fields: Array; + refreshTime?: InputMaybe; + tableName: Scalars['String']; + type: ModelType; +}; + +export type CreateSimpleMetricInput = { + cached: Scalars['Boolean']; + description?: InputMaybe; + dimension: Array; + displayName: Scalars['String']; + measure: Array; + model: Scalars['String']; + modelType: ModelType; + name: Scalars['String']; + properties: Scalars['JSON']; + refreshTime?: InputMaybe; + timeGrain: Array; +}; + +export type CustomFieldInput = { + expression: Scalars['String']; + name: Scalars['String']; +}; + +export type DataSource = { + __typename?: 'DataSource'; + properties: Scalars['JSON']; + type: DataSourceName; +}; + +export type DataSourceInput = { + properties: Scalars['JSON']; + type: DataSourceName; +}; + +export enum DataSourceName { + BigQuery = 'BIG_QUERY' +} + +export type DetailedColumn = { + __typename?: 'DetailedColumn'; + isCalculated: Scalars['Boolean']; + name: Scalars['String']; + notNull: Scalars['Boolean']; + properties: Scalars['JSON']; + type: Scalars['String']; +}; + +export type DetailedModel = { + __typename?: 'DetailedModel'; + cached: Scalars['Boolean']; + columns: Array; + description?: Maybe; + name: Scalars['String']; + primaryKey?: Maybe; + properties: Scalars['JSON']; + refSql: Scalars['String']; + refreshTime: Scalars['String']; +}; + +export type DimensionInput = { + isCalculated: Scalars['Boolean']; + name: Scalars['String']; + notNull: Scalars['Boolean']; + properties: Scalars['JSON']; + type: Scalars['String']; +}; + +export type MdlInput = { + models: Array; + relations: Array; +}; + +export type MdlModelSubmitInput = { + columns: Array; + name: Scalars['String']; +}; + +export enum ModelType { + Custom = 'CUSTOM', + Table = 'TABLE' +} + +export type ModelWhereInput = { + name: Scalars['String']; +}; + +export type Mutation = { + __typename?: 'Mutation'; + createModel: Scalars['JSON']; + deleteModel: Scalars['Boolean']; + saveDataSource: DataSource; + saveMDL: Scalars['JSON']; + updateModel: Scalars['JSON']; +}; + + +export type MutationCreateModelArgs = { + data: CreateModelInput; +}; + + +export type MutationDeleteModelArgs = { + where: ModelWhereInput; +}; + + +export type MutationSaveDataSourceArgs = { + data: DataSourceInput; +}; + + +export type MutationSaveMdlArgs = { + data: MdlInput; +}; + + +export type MutationUpdateModelArgs = { + data: UpdateModelInput; + where: ModelWhereInput; +}; + +export type Query = { + __typename?: 'Query'; + autoGenerateRelation: Array; + getModel: DetailedModel; + listDataSourceTables: Array; + listModels: Array; + manifest: Scalars['JSON']; + usableDataSource: Array; +}; + + +export type QueryAutoGenerateRelationArgs = { + where?: InputMaybe; +}; + + +export type QueryGetModelArgs = { + where: ModelWhereInput; +}; + +export type Relation = { + __typename?: 'Relation'; + from: RelationColumnInformation; + to: RelationColumnInformation; + type: RelationType; +}; + +export type RelationColumnInformation = { + __typename?: 'RelationColumnInformation'; + columnName: Scalars['String']; + tableName: Scalars['String']; +}; + +export type RelationColumnInformationInput = { + columnName: Scalars['String']; + tableName: Scalars['String']; +}; + +export type RelationInput = { + from: RelationColumnInformationInput; + to: RelationColumnInformationInput; + type: RelationType; +}; + +export enum RelationType { + ManyToMany = 'MANY_TO_MANY', + ManyToOne = 'MANY_TO_ONE', + OneToMany = 'ONE_TO_MANY', + OneToOne = 'ONE_TO_ONE' +} + +export type SimpleMeasureInput = { + isCalculated: Scalars['Boolean']; + name: Scalars['String']; + notNull: Scalars['Boolean']; + properties: Scalars['JSON']; + type: Scalars['String']; +}; + +export type TimeGrainInput = { + dateParts: Array; + name: Scalars['String']; + refColumn: Scalars['String']; +}; + +export type UpdateModelInput = { + cached: Scalars['Boolean']; + calculatedFields?: InputMaybe>; + customFields?: InputMaybe>; + description?: InputMaybe; + displayName: Scalars['String']; + fields: Array; + refreshTime?: InputMaybe; + type: ModelType; +}; + +export type UsableDataSource = { + __typename?: 'UsableDataSource'; + requiredProperties: Array; + type: DataSourceName; +}; diff --git a/wren-ui/src/apollo/client/graphql/dataSource.generated.ts b/wren-ui/src/apollo/client/graphql/dataSource.generated.ts new file mode 100644 index 000000000..d18aab654 --- /dev/null +++ b/wren-ui/src/apollo/client/graphql/dataSource.generated.ts @@ -0,0 +1,180 @@ +import * as Types from './__types__'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type UsableDataSourcesQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type UsableDataSourcesQuery = { __typename?: 'Query', usableDataSource: Array<{ __typename?: 'UsableDataSource', type: Types.DataSourceName, requiredProperties: Array }> }; + +export type ListDataSourceTablesQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type ListDataSourceTablesQuery = { __typename?: 'Query', listDataSourceTables: Array<{ __typename?: 'CompactTable', name: string, columns: Array<{ __typename?: 'CompactColumn', name: string, type: string }> }> }; + +export type AutoGeneratedRelationsQueryVariables = Types.Exact<{ + where?: Types.InputMaybe; +}>; + + +export type AutoGeneratedRelationsQuery = { __typename?: 'Query', autoGenerateRelation: Array<{ __typename?: 'Relation', type: Types.RelationType, from: { __typename?: 'RelationColumnInformation', tableName: string, columnName: string }, to: { __typename?: 'RelationColumnInformation', tableName: string, columnName: string } }> }; + +export type SaveDataSourceMutationVariables = Types.Exact<{ + data: Types.DataSourceInput; +}>; + + +export type SaveDataSourceMutation = { __typename?: 'Mutation', saveDataSource: { __typename?: 'DataSource', type: Types.DataSourceName, properties: any } }; + + +export const UsableDataSourcesDocument = gql` + query UsableDataSources { + usableDataSource { + type + requiredProperties + } +} + `; + +/** + * __useUsableDataSourcesQuery__ + * + * To run a query within a React component, call `useUsableDataSourcesQuery` and pass it any options that fit your needs. + * When your component renders, `useUsableDataSourcesQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useUsableDataSourcesQuery({ + * variables: { + * }, + * }); + */ +export function useUsableDataSourcesQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(UsableDataSourcesDocument, options); + } +export function useUsableDataSourcesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(UsableDataSourcesDocument, options); + } +export type UsableDataSourcesQueryHookResult = ReturnType; +export type UsableDataSourcesLazyQueryHookResult = ReturnType; +export type UsableDataSourcesQueryResult = Apollo.QueryResult; +export const ListDataSourceTablesDocument = gql` + query ListDataSourceTables { + listDataSourceTables { + name + columns { + name + type + } + } +} + `; + +/** + * __useListDataSourceTablesQuery__ + * + * To run a query within a React component, call `useListDataSourceTablesQuery` and pass it any options that fit your needs. + * When your component renders, `useListDataSourceTablesQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useListDataSourceTablesQuery({ + * variables: { + * }, + * }); + */ +export function useListDataSourceTablesQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ListDataSourceTablesDocument, options); + } +export function useListDataSourceTablesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ListDataSourceTablesDocument, options); + } +export type ListDataSourceTablesQueryHookResult = ReturnType; +export type ListDataSourceTablesLazyQueryHookResult = ReturnType; +export type ListDataSourceTablesQueryResult = Apollo.QueryResult; +export const AutoGeneratedRelationsDocument = gql` + query AutoGeneratedRelations($where: AutoGenerateInput) { + autoGenerateRelation(where: $where) { + from { + tableName + columnName + } + to { + tableName + columnName + } + type + } +} + `; + +/** + * __useAutoGeneratedRelationsQuery__ + * + * To run a query within a React component, call `useAutoGeneratedRelationsQuery` and pass it any options that fit your needs. + * When your component renders, `useAutoGeneratedRelationsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useAutoGeneratedRelationsQuery({ + * variables: { + * where: // value for 'where' + * }, + * }); + */ +export function useAutoGeneratedRelationsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(AutoGeneratedRelationsDocument, options); + } +export function useAutoGeneratedRelationsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(AutoGeneratedRelationsDocument, options); + } +export type AutoGeneratedRelationsQueryHookResult = ReturnType; +export type AutoGeneratedRelationsLazyQueryHookResult = ReturnType; +export type AutoGeneratedRelationsQueryResult = Apollo.QueryResult; +export const SaveDataSourceDocument = gql` + mutation SaveDataSource($data: DataSourceInput!) { + saveDataSource(data: $data) { + type + properties + } +} + `; +export type SaveDataSourceMutationFn = Apollo.MutationFunction; + +/** + * __useSaveDataSourceMutation__ + * + * To run a mutation, you first call `useSaveDataSourceMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSaveDataSourceMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [saveDataSourceMutation, { data, loading, error }] = useSaveDataSourceMutation({ + * variables: { + * data: // value for 'data' + * }, + * }); + */ +export function useSaveDataSourceMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SaveDataSourceDocument, options); + } +export type SaveDataSourceMutationHookResult = ReturnType; +export type SaveDataSourceMutationResult = Apollo.MutationResult; +export type SaveDataSourceMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file diff --git a/wren-ui/src/apollo/client/graphql/dataSource.ts b/wren-ui/src/apollo/client/graphql/dataSource.ts new file mode 100644 index 000000000..3a90d88a9 --- /dev/null +++ b/wren-ui/src/apollo/client/graphql/dataSource.ts @@ -0,0 +1,48 @@ +import { gql } from '@apollo/client'; + +export const USABLE_DATA_SOURCES = gql` + query UsableDataSources { + usableDataSource { + type + requiredProperties + } + } +`; + +export const LIST_DATA_SOURCE_TABLES = gql` + query ListDataSourceTables { + listDataSourceTables { + name + columns { + name + type + } + } + } +`; + +export const AUTO_GENERATED_RELATIONS = gql` + query AutoGeneratedRelations($where: AutoGenerateInput) { + autoGenerateRelation(where: $where) { + name + id + relations { + fromColumn + fromModel + toColumn + toModel + type + name + } + } + } +`; + +export const SAVE_DATA_SOURCE = gql` + mutation SaveDataSource($data: DataSourceInput!) { + saveDataSource(data: $data) { + type + properties + } + } +`; diff --git a/wren-ui/src/apollo/client/graphql/manifest.generated.ts b/wren-ui/src/apollo/client/graphql/manifest.generated.ts new file mode 100644 index 000000000..04db0a2bf --- /dev/null +++ b/wren-ui/src/apollo/client/graphql/manifest.generated.ts @@ -0,0 +1,81 @@ +import * as Types from './__types__'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type ManifestQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type ManifestQuery = { __typename?: 'Query', manifest: any }; + +export type SaveMdlMutationVariables = Types.Exact<{ + data: Types.MdlInput; +}>; + + +export type SaveMdlMutation = { __typename?: 'Mutation', saveMDL: any }; + + +export const ManifestDocument = gql` + query Manifest { + manifest +} + `; + +/** + * __useManifestQuery__ + * + * To run a query within a React component, call `useManifestQuery` and pass it any options that fit your needs. + * When your component renders, `useManifestQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useManifestQuery({ + * variables: { + * }, + * }); + */ +export function useManifestQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ManifestDocument, options); + } +export function useManifestLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ManifestDocument, options); + } +export type ManifestQueryHookResult = ReturnType; +export type ManifestLazyQueryHookResult = ReturnType; +export type ManifestQueryResult = Apollo.QueryResult; +export const SaveMdlDocument = gql` + mutation SaveMDL($data: MDLInput!) { + saveMDL(data: $data) +} + `; +export type SaveMdlMutationFn = Apollo.MutationFunction; + +/** + * __useSaveMdlMutation__ + * + * To run a mutation, you first call `useSaveMdlMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSaveMdlMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [saveMdlMutation, { data, loading, error }] = useSaveMdlMutation({ + * variables: { + * data: // value for 'data' + * }, + * }); + */ +export function useSaveMdlMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SaveMdlDocument, options); + } +export type SaveMdlMutationHookResult = ReturnType; +export type SaveMdlMutationResult = Apollo.MutationResult; +export type SaveMdlMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file diff --git a/wren-ui/src/apollo/client/graphql/manifest.ts b/wren-ui/src/apollo/client/graphql/manifest.ts new file mode 100644 index 000000000..4aa3ed34b --- /dev/null +++ b/wren-ui/src/apollo/client/graphql/manifest.ts @@ -0,0 +1,7 @@ +import { gql } from '@apollo/client'; + +export const MANIFEST = gql` + query Manifest { + manifest + } +`; diff --git a/wren-ui/src/apollo/client/graphql/model.generated.ts b/wren-ui/src/apollo/client/graphql/model.generated.ts new file mode 100644 index 000000000..1d96b2c80 --- /dev/null +++ b/wren-ui/src/apollo/client/graphql/model.generated.ts @@ -0,0 +1,221 @@ +import * as Types from './__types__'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type ListModelsQueryVariables = Types.Exact<{ [key: string]: never; }>; + + +export type ListModelsQuery = { __typename?: 'Query', listModels: Array<{ __typename?: 'CompactModel', cached: boolean, description?: string | null, name: string, primaryKey?: string | null, refreshTime: string, refSql: string }> }; + +export type GetModelQueryVariables = Types.Exact<{ + where: Types.ModelWhereInput; +}>; + + +export type GetModelQuery = { __typename?: 'Query', getModel: { __typename?: 'DetailedModel', name: string, refSql: string, primaryKey?: string | null, cached: boolean, refreshTime: string, description?: string | null, properties: any, columns: Array<{ __typename?: 'DetailedColumn', name: string, type: string, isCalculated: boolean, notNull: boolean, properties: any }> } }; + +export type CreateModelMutationVariables = Types.Exact<{ + data: Types.CreateModelInput; +}>; + + +export type CreateModelMutation = { __typename?: 'Mutation', createModel: any }; + +export type UpdateModelMutationVariables = Types.Exact<{ + where: Types.ModelWhereInput; + data: Types.UpdateModelInput; +}>; + + +export type UpdateModelMutation = { __typename?: 'Mutation', updateModel: any }; + +export type DeleteModelMutationVariables = Types.Exact<{ + where: Types.ModelWhereInput; +}>; + + +export type DeleteModelMutation = { __typename?: 'Mutation', deleteModel: boolean }; + + +export const ListModelsDocument = gql` + query ListModels { + listModels { + cached + description + name + primaryKey + refreshTime + refSql + } +} + `; + +/** + * __useListModelsQuery__ + * + * To run a query within a React component, call `useListModelsQuery` and pass it any options that fit your needs. + * When your component renders, `useListModelsQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useListModelsQuery({ + * variables: { + * }, + * }); + */ +export function useListModelsQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(ListModelsDocument, options); + } +export function useListModelsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(ListModelsDocument, options); + } +export type ListModelsQueryHookResult = ReturnType; +export type ListModelsLazyQueryHookResult = ReturnType; +export type ListModelsQueryResult = Apollo.QueryResult; +export const GetModelDocument = gql` + query GetModel($where: ModelWhereInput!) { + getModel(where: $where) { + name + refSql + primaryKey + cached + refreshTime + description + columns { + name + type + isCalculated + notNull + properties + } + properties + } +} + `; + +/** + * __useGetModelQuery__ + * + * To run a query within a React component, call `useGetModelQuery` and pass it any options that fit your needs. + * When your component renders, `useGetModelQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetModelQuery({ + * variables: { + * where: // value for 'where' + * }, + * }); + */ +export function useGetModelQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetModelDocument, options); + } +export function useGetModelLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetModelDocument, options); + } +export type GetModelQueryHookResult = ReturnType; +export type GetModelLazyQueryHookResult = ReturnType; +export type GetModelQueryResult = Apollo.QueryResult; +export const CreateModelDocument = gql` + mutation CreateModel($data: CreateModelInput!) { + createModel(data: $data) +} + `; +export type CreateModelMutationFn = Apollo.MutationFunction; + +/** + * __useCreateModelMutation__ + * + * To run a mutation, you first call `useCreateModelMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateModelMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createModelMutation, { data, loading, error }] = useCreateModelMutation({ + * variables: { + * data: // value for 'data' + * }, + * }); + */ +export function useCreateModelMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateModelDocument, options); + } +export type CreateModelMutationHookResult = ReturnType; +export type CreateModelMutationResult = Apollo.MutationResult; +export type CreateModelMutationOptions = Apollo.BaseMutationOptions; +export const UpdateModelDocument = gql` + mutation UpdateModel($where: ModelWhereInput!, $data: UpdateModelInput!) { + updateModel(where: $where, data: $data) +} + `; +export type UpdateModelMutationFn = Apollo.MutationFunction; + +/** + * __useUpdateModelMutation__ + * + * To run a mutation, you first call `useUpdateModelMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useUpdateModelMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [updateModelMutation, { data, loading, error }] = useUpdateModelMutation({ + * variables: { + * where: // value for 'where' + * data: // value for 'data' + * }, + * }); + */ +export function useUpdateModelMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(UpdateModelDocument, options); + } +export type UpdateModelMutationHookResult = ReturnType; +export type UpdateModelMutationResult = Apollo.MutationResult; +export type UpdateModelMutationOptions = Apollo.BaseMutationOptions; +export const DeleteModelDocument = gql` + mutation DeleteModel($where: ModelWhereInput!) { + deleteModel(where: $where) +} + `; +export type DeleteModelMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteModelMutation__ + * + * To run a mutation, you first call `useDeleteModelMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteModelMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteModelMutation, { data, loading, error }] = useDeleteModelMutation({ + * variables: { + * where: // value for 'where' + * }, + * }); + */ +export function useDeleteModelMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteModelDocument, options); + } +export type DeleteModelMutationHookResult = ReturnType; +export type DeleteModelMutationResult = Apollo.MutationResult; +export type DeleteModelMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file diff --git a/wren-ui/src/apollo/client/graphql/model.ts b/wren-ui/src/apollo/client/graphql/model.ts new file mode 100644 index 000000000..42eb5a173 --- /dev/null +++ b/wren-ui/src/apollo/client/graphql/model.ts @@ -0,0 +1,53 @@ +import { gql } from '@apollo/client'; + +export const LIST_MODELS = gql` + query ListModels { + listModels { + cached + description + name + primaryKey + refreshTime + refSql + } + } +`; + +export const GET_MODEL = gql` + query GetModel($where: ModelWhereInput!) { + getModel(where: $where) { + name + refSql + primaryKey + cached + refreshTime + description + columns { + name + type + isCalculated + notNull + properties + } + properties + } + } +`; + +export const CREATE_MODEL = gql` + mutation CreateModel($data: CreateModelInput!) { + createModel(data: $data) + } +`; + +export const UPDATE_MODEL = gql` + mutation UpdateModel($where: ModelWhereInput!, $data: UpdateModelInput!) { + updateModel(where: $where, data: $data) + } +`; + +export const DELETE_MODEL = gql` + mutation DeleteModel($where: ModelWhereInput!) { + deleteModel(where: $where) + } +`; diff --git a/wren-ui/src/apollo/client/index.ts b/wren-ui/src/apollo/client/index.ts new file mode 100644 index 000000000..f127c3a6f --- /dev/null +++ b/wren-ui/src/apollo/client/index.ts @@ -0,0 +1,12 @@ +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; + +const link = new HttpLink({ + uri: '/api/graphql', +}); + +const client = new ApolloClient({ + link, + cache: new InMemoryCache(), +}); + +export default client; diff --git a/wren-ui/src/apollo/server/config.ts b/wren-ui/src/apollo/server/config.ts new file mode 100644 index 000000000..bdd2bcd43 --- /dev/null +++ b/wren-ui/src/apollo/server/config.ts @@ -0,0 +1,63 @@ +import { pickBy } from 'lodash'; + +export interface IConfig { + // database + dbType: string; + // pg + pgUrl?: string; + debug?: boolean; + // sqlite + sqliteFile?: string; + + persistCredentialDir?: string; + + // encryption + encryptionPassword: string; + encryptionSalt: string; +} + +const defaultConfig = { + // database + dbType: 'pg', + + // pg + pgUrl: 'postgres://postgres:postgres@localhost:5432/admin_ui', + debug: false, + + // sqlite + sqliteFile: './db.sqlite', + + persistCredentialDir: process.cwd(), + + // encryption + encryptionPassword: 'sementic', + encryptionSalt: 'layer', +}; + +const config = { + // database + dbType: process.env.DB_TYPE, + // pg + pgUrl: process.env.PG_URL, + debug: process.env.DEBUG === 'true', + // sqlite + sqliteFile: process.env.SQLITE_FILE, + + persistCredentialDir: (() => { + if ( + process.env.PERSIST_CREDENTIAL_DIR && + process.env.PERSIST_CREDENTIAL_DIR.length > 0 + ) { + return process.env.PERSIST_CREDENTIAL_DIR; + } + return undefined; + })(), + + // encryption + encryptionPassword: process.env.ENCRYPTION_PASSWORD, + encryptionSalt: process.env.ENCRYPTION_SALT, +}; + +export function getConfig(): IConfig { + return { ...defaultConfig, ...pickBy(config) }; +} diff --git a/wren-ui/src/apollo/server/connectors/bqConnector.ts b/wren-ui/src/apollo/server/connectors/bqConnector.ts new file mode 100644 index 000000000..937ace7ce --- /dev/null +++ b/wren-ui/src/apollo/server/connectors/bqConnector.ts @@ -0,0 +1,135 @@ +import { CompactTable } from '../types'; +import { IConnector } from './connector'; +import { BigQuery, BigQueryOptions } from '@google-cloud/bigquery'; + +// column type ref: https://cloud.google.com/bigquery/docs/information-schema-columns#schema +export interface BQColumnResponse { + table_catalog: string; + table_schema: string; + table_name: string; + column_name: string; + ordinal_position: number; + is_nullable: string; + data_type: string; + is_generated: string; + generation_expression: any; + is_stored: string; + is_hidden: string; + is_updatable: string; + is_system_defined: string; + is_partitioning_column: string; + clustering_ordinal_position: number; + collation_name: string; + column_default: string; + rounding_mode: string; + description: string; +} + +export interface BQConstraintResponse { + constraintName: string; + constraintType: string; + constraintTable: string; + constraintColumn: string; + constraintedTable: string; + constraintedColumn: string; +} + +export interface BQListTableOptions { + dataset: string; + format?: boolean; +} +export class BQConnector + implements IConnector +{ + private bq: BigQuery; + + // Not storing the bq client instance because we rarely need to use it + constructor(bqOptions: BigQueryOptions) { + this.bq = new BigQuery(bqOptions); + } + + public async connect() { + try { + await this.bq.query('SELECT 1;'); + return true; + } catch (_err) { + return false; + } + } + + public async listTables(listTableOptions: BQListTableOptions) { + const { dataset, format } = listTableOptions; + // list columns from INFORMATION_SCHEMA ref: https://cloud.google.com/bigquery/docs/information-schema-columns + const columns = await new Promise((resolve, reject) => { + this.bq.query( + `SELECT + c.*, cf.description + FROM ${dataset}.INFORMATION_SCHEMA.COLUMNS c + JOIN ${dataset}.INFORMATION_SCHEMA.COLUMN_FIELD_PATHS cf + ON cf.table_name = c.table_name + AND cf.column_name = c.column_name + ORDER BY + c.table_name, c.ordinal_position;`, + (err, rows) => { + if (err) { + reject(err); + } else { + resolve(rows); + } + }, + ); + }); + + if (!format) { + return columns as BQColumnResponse[]; + } + return this.formatToCompactTable(columns); + } + + public async listConstraints(options) { + const { dataset } = options; + // ref: information schema link: https://cloud.google.com/bigquery/docs/information-schema-intro + const constraints = await new Promise((resolve, reject) => { + this.bq.query( + ` + SELECT + ccu.table_name as constraintTable, ccu.column_name constraintColumn, + kcu.table_name as constraintedTable, kcu.column_name as constraintedColumn, + tc.constraint_type as constraintType + FROM ${dataset}.INFORMATION_SCHEMA.CONSTRAINT_COLUMN_USAGE ccu + JOIN ${dataset}.INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu + ON ccu.constraint_name = kcu.constraint_name + JOIN ${dataset}.INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc + ON ccu.constraint_name = tc.constraint_name + WHERE tc.constraint_type = 'FOREIGN KEY' + `, + (err, rows) => { + if (err) { + reject(err); + } else { + resolve(rows); + } + }, + ); + }); + return constraints as BQConstraintResponse[]; + } + + private formatToCompactTable(columns: any): CompactTable[] { + return columns.reduce((acc: CompactTable[], row: any) => { + let table = acc.find((t) => t.name === row.table_name); + if (!table) { + table = { + name: row.table_name, + columns: [], + }; + acc.push(table); + } + table.columns.push({ + name: row.column_name, + type: row.data_type, + }); + return acc; + }, []); + } +} diff --git a/wren-ui/src/apollo/server/connectors/connector.ts b/wren-ui/src/apollo/server/connectors/connector.ts new file mode 100644 index 000000000..d17031913 --- /dev/null +++ b/wren-ui/src/apollo/server/connectors/connector.ts @@ -0,0 +1,7 @@ +import { CompactTable } from '../types'; + +export interface IConnector { + connect(): Promise; + listTables(listTableOptions: any): Promise; + listConstraints(listConstraintOptions: any): Promise<[] | C[]>; +} diff --git a/wren-ui/src/apollo/server/index.ts b/wren-ui/src/apollo/server/index.ts new file mode 100644 index 000000000..f0bd409f1 --- /dev/null +++ b/wren-ui/src/apollo/server/index.ts @@ -0,0 +1,2 @@ +export * from './schema'; +export * from './resolvers'; diff --git a/wren-ui/src/apollo/server/manifest.json b/wren-ui/src/apollo/server/manifest.json new file mode 100644 index 000000000..951947676 --- /dev/null +++ b/wren-ui/src/apollo/server/manifest.json @@ -0,0 +1,376 @@ +{ + "catalog": "test-catalog", + "schema": "test-schema", + "models": [ + { + "name": "OrdersModel", + "refSql": "select * from orders", + "columns": [ + { + "name": "orderkey", + "type": "integer", + "isCalculated": false, + "notNull": true, + "description": "the key of each order", + "properties": { + "description": "the key of each order" + } + }, + { + "name": "custkey", + "type": "integer", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "orderstatus", + "type": "string", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "totalprice", + "type": "double", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "orderdate", + "type": "date", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "orderpriority", + "type": "string", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "clerk", + "type": "string", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "shippriority", + "type": "integer", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "comment", + "type": "string", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "customer", + "type": "CustomerModel", + "relationship": "OrdersCustomer", + "isCalculated": false, + "notNull": true, + "properties": {} + } + ], + "primaryKey": "orderkey", + "cached": false, + "refreshTime": "30.00m", + "description": "tpch tiny orders table", + "properties": { + "description": "tpch tiny orders table" + } + }, + { + "name": "LineitemModel", + "refSql": "select * from lineitem", + "columns": [ + { + "name": "orderkey", + "type": "integer", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "linenumber", + "type": "integer", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "extendedprice", + "type": "integer", + "isCalculated": false, + "notNull": true, + "properties": {} + } + ], + "cached": false, + "refreshTime": "30.00m", + "properties": {} + }, + { + "name": "CustomerModel", + "refSql": "select * from customer", + "columns": [ + { + "name": "custkey", + "type": "integer", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "name", + "type": "string", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "address", + "type": "string", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "nationkey", + "type": "integer", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "phone", + "type": "string", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "acctbal", + "type": "double", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "mktsegment", + "type": "string", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "comment", + "type": "string", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "orders", + "type": "OrdersModel", + "relationship": "OrdersCustomer", + "isCalculated": false, + "notNull": true, + "properties": {} + }, + { + "name": "orders_totalprice", + "type": "varchar", + "isCalculated": true, + "notNull": false, + "expression": "SUM(orders.totalprice)", + "properties": {} + } + ], + "primaryKey": "custkey", + "cached": false, + "refreshTime": "30.00m", + "properties": {} + } + ], + "relationships": [ + { + "name": "OrdersCustomer", + "models": [ + "OrdersModel", + "CustomerModel" + ], + "joinType": "MANY_TO_ONE", + "condition": "OrdersModel.custkey = CustomerModel.custkey", + "manySideSortKeys": [ + { + "name": "orderkey", + "descending": false + } + ], + "description": "the relationship between orders and customers", + "properties": { + "description": "the relationship between orders and customers" + } + } + ], + "enumDefinitions": [ + { + "name": "OrderStatus", + "values": [ + { + "name": "PENDING", + "value": "pending", + "properties": { + "description": "pending" + } + }, + { + "name": "PROCESSING", + "value": "processing", + "properties": {} + }, + { + "name": "SHIPPED", + "value": "shipped", + "properties": {} + }, + { + "name": "COMPLETE", + "value": "complete", + "properties": {} + } + ], + "description": "the status of an order", + "properties": { + "description": "the status of an order" + } + } + ], + "metrics": [ + { + "name": "Revenue", + "baseObject": "OrdersModel", + "dimension": [ + { + "name": "orderkey", + "type": "string", + "isCalculated": false, + "notNull": true, + "properties": {} + } + ], + "measure": [ + { + "name": "total", + "type": "integer", + "isCalculated": false, + "notNull": true, + "properties": {} + } + ], + "timeGrain": [ + { + "name": "orderdate", + "refColumn": "orderdate", + "dateParts": [ + "DAY", + "MONTH" + ] + } + ], + "cached": true, + "refreshTime": "30.00m", + "description": "the revenue of an order", + "properties": { + "description": "the revenue of an order" + } + } + ], + "cumulativeMetrics": [ + { + "name": "DailyRevenue", + "baseObject": "Orders", + "measure": { + "name": "totalprice", + "type": "integer", + "operator": "sum", + "refColumn": "totalprice", + "properties": { + "description": "totalprice" + } + }, + "window": { + "name": "orderdate", + "refColumn": "orderdate", + "timeUnit": "DAY", + "start": "1994-01-01", + "end": "1994-12-31", + "properties": { + "description": "orderdate" + } + }, + "cached": false, + "description": "daily revenue", + "properties": { + "description": "daily revenue" + } + }, + { + "name": "WeeklyRevenue", + "baseObject": "Orders", + "measure": { + "name": "totalprice", + "type": "integer", + "operator": "sum", + "refColumn": "totalprice" + }, + "window": { + "name": "orderdate", + "refColumn": "orderdate", + "timeUnit": "WEEK", + "start": "1994-01-01", + "end": "1994-12-31", + "properties": {} + }, + "cached": false, + "properties": {} + } + ], + "views": [ + { + "name": "useMetric", + "statement": "select * from Revenue", + "description": "the view for the revenue metric", + "properties": { + "description": "the view for the revenue metric" + } + } + ], + "macros": [ + { + "name": "test", + "definition": "(a: Expression) => a + 1", + "properties": { + "description": "a macro" + } + } + ], + "dateSpine": { + "unit": "DAY", + "start": "1970-01-01", + "end": "2077-12-31", + "properties": { + "description": "a date spine" + } + } +} diff --git a/wren-ui/src/apollo/server/models/index.ts b/wren-ui/src/apollo/server/models/index.ts new file mode 100644 index 000000000..9f8ccaddf --- /dev/null +++ b/wren-ui/src/apollo/server/models/index.ts @@ -0,0 +1 @@ +export * from './model'; diff --git a/wren-ui/src/apollo/server/models/model.ts b/wren-ui/src/apollo/server/models/model.ts new file mode 100644 index 000000000..11b4ccf59 --- /dev/null +++ b/wren-ui/src/apollo/server/models/model.ts @@ -0,0 +1,4 @@ +export interface CreateModelsInput { + name: string; + columns: string[]; +} diff --git a/wren-ui/src/apollo/server/repositories/baseRepository.ts b/wren-ui/src/apollo/server/repositories/baseRepository.ts new file mode 100644 index 000000000..0123ce5d0 --- /dev/null +++ b/wren-ui/src/apollo/server/repositories/baseRepository.ts @@ -0,0 +1,130 @@ +import { Knex } from 'knex'; +import { camelCase, isPlainObject, mapKeys, snakeCase } from 'lodash'; + +export interface IQueryOptions { + tx?: Knex.Transaction; + order?: string; + limit?: number; +} + +export interface IBasicRepository { + transaction: () => Promise; + commit: (tx: Knex.Transaction) => Promise; + rollback: (tx: Knex.Transaction) => Promise; + findOneBy: (filter: Partial, queryOptions?: IQueryOptions) => Promise; + findAllBy: (filter: Partial, queryOptions?: IQueryOptions) => Promise; + findAll: (queryOptions?: IQueryOptions) => Promise; + createOne: (data: Partial, queryOptions?: IQueryOptions) => Promise; + updateOne: ( + id: string, + data: Partial, + queryOptions?: IQueryOptions, + ) => Promise; + deleteOne: (id: string, queryOptions?: IQueryOptions) => Promise; +} + +export class BaseRepository implements IBasicRepository { + protected knex: Knex; + protected tableName: string; + + constructor({ knexPg, tableName }: { knexPg: Knex; tableName: string }) { + this.knex = knexPg; + this.tableName = tableName; + } + + public async transaction() { + return await this.knex.transaction(); + } + + public async commit(tx: Knex.Transaction) { + await tx.commit(); + } + + public async rollback(tx: Knex.Transaction) { + await tx.rollback(); + } + + public async findOneBy(filter: Partial, queryOptions?: IQueryOptions) { + const executer = queryOptions?.tx ? queryOptions.tx : this.knex; + const query = executer(this.tableName).where( + this.transformToDBData(filter), + ); + if (queryOptions?.limit) { + query.limit(queryOptions.limit); + } + const [result] = await query; + return this.transformFromDBData(result); + } + + public async findAllBy(filter: Partial, queryOptions?: IQueryOptions) { + const executer = queryOptions?.tx ? queryOptions.tx : this.knex; + // format filter keys to snake_case + + const query = executer(this.tableName).where( + this.transformToDBData(filter), + ); + if (queryOptions?.order) { + query.orderBy(queryOptions.order); + } + const result = await query; + return result.map(this.transformFromDBData); + } + + public async findAll(queryOptions?: IQueryOptions) { + const executer = queryOptions?.tx ? queryOptions.tx : this.knex; + const query = executer(this.tableName); + if (queryOptions?.order) { + query.orderBy(queryOptions.order); + } + if (queryOptions.limit) { + query.limit(queryOptions.limit); + } + const result = await query; + return result.map(this.transformFromDBData); + } + + public async createOne(data: Partial, queryOptions?: IQueryOptions) { + const executer = queryOptions?.tx ? queryOptions.tx : this.knex; + const [result] = await executer(this.tableName) + .insert(this.transformToDBData(data)) + .returning('*'); + return this.transformFromDBData(result); + } + + public async updateOne( + id: string, + data: Partial, + queryOptions?: IQueryOptions, + ) { + const executer = queryOptions?.tx ? queryOptions.tx : this.knex; + const [result] = await executer(this.tableName) + .where({ id }) + .update(this.transformToDBData(data)) + .returning('*'); + return this.transformFromDBData(result); + } + + public async deleteOne(id: string, queryOptions?: IQueryOptions) { + const executer = queryOptions?.tx ? queryOptions.tx : this.knex; + const [result] = await executer(this.tableName) + .where({ id }) + .del() + .returning('*'); + return this.transformFromDBData(result); + } + + protected transformToDBData = (data: Partial) => { + if (!isPlainObject(data)) { + throw new Error('Unexpected dbdata'); + } + return mapKeys(data, (_value, key) => snakeCase(key)); + }; + + protected transformFromDBData = (data: any): T => { + if (!isPlainObject(data)) { + throw new Error('Unexpected dbdata'); + } + const camelCaseData = mapKeys(data, (_value, key) => camelCase(key)); + return camelCaseData as T; + }; +} diff --git a/wren-ui/src/apollo/server/repositories/index.ts b/wren-ui/src/apollo/server/repositories/index.ts new file mode 100644 index 000000000..92576d4c9 --- /dev/null +++ b/wren-ui/src/apollo/server/repositories/index.ts @@ -0,0 +1,8 @@ +export * from './modelRepository'; +export * from './projectRepository'; +export * from './modelColumnRepository'; +export * from './relationshipRepository'; +export * from './metricsRepository'; +export * from './metricsMeasureRepository'; +export * from './viewRepository'; +export * from './baseRepository'; diff --git a/wren-ui/src/apollo/server/repositories/metricsMeasureRepository.ts b/wren-ui/src/apollo/server/repositories/metricsMeasureRepository.ts new file mode 100644 index 000000000..5e26cb75a --- /dev/null +++ b/wren-ui/src/apollo/server/repositories/metricsMeasureRepository.ts @@ -0,0 +1,19 @@ +import { Knex } from 'knex'; +import { BaseRepository, IBasicRepository } from './baseRepository'; + +export interface MetricMeasure { + id: number; // ID + metricId: number; // Reference to metric ID + name: string; // Measure name + expression: string; // Expression for the measure + granularity?: string; // Granularity for the measure, eg: "day", "hour", "minute", "year" +} + +export interface IMetricMeasureRepository + extends IBasicRepository {} + +export class MetricMeasureRepository extends BaseRepository { + constructor(knexPg: Knex) { + super({ knexPg, tableName: 'metric_measure' }); + } +} diff --git a/wren-ui/src/apollo/server/repositories/metricsRepository.ts b/wren-ui/src/apollo/server/repositories/metricsRepository.ts new file mode 100644 index 000000000..a69aba144 --- /dev/null +++ b/wren-ui/src/apollo/server/repositories/metricsRepository.ts @@ -0,0 +1,24 @@ +import { Knex } from 'knex'; +import { BaseRepository, IBasicRepository } from './baseRepository'; + +export interface Metric { + id: number; // ID + projectId: number; // Reference to project.id + name: string; // Metric name + type: string; // Metric type, ex: "simple" or "cumulative" + cached: boolean; // Model is cached or not + refreshTime?: string; // Contain a number followed by a time unit (ns, us, ms, s, m, h, d). For example, "2h" + + // metric can based on model or another metric + modelId?: number; // Reference to model.id + metricId?: number; // Reference to metric.id + properties?: string; // Metric properties, a json string, the description and displayName should be stored here +} + +export interface IMetricRepository extends IBasicRepository {} + +export class MetricRepository extends BaseRepository { + constructor(knexPg: Knex) { + super({ knexPg, tableName: 'metric' }); + } +} diff --git a/wren-ui/src/apollo/server/repositories/modelColumnRepository.ts b/wren-ui/src/apollo/server/repositories/modelColumnRepository.ts new file mode 100644 index 000000000..cef90527b --- /dev/null +++ b/wren-ui/src/apollo/server/repositories/modelColumnRepository.ts @@ -0,0 +1,64 @@ +import { Knex } from 'knex'; +import { + BaseRepository, + IBasicRepository, + IQueryOptions, +} from './baseRepository'; + +export interface ModelColumn { + id: number; // ID + modelId: number; // Reference to model ID + isCalculated: boolean; // Is calculated field + name: string; // Column name + aggregation?: string; // Expression for the column, could be custom field or calculated field expression + lineage?: string; // The selected field in calculated field, array of ids + diagram?: string; // For FE to store the calculated field diagram + customExpression?: string; // For custom field or custom expression of calculated field + type: string; // Data type, refer to the column type in the datasource + notNull: boolean; // Is not null + isPk: boolean; // Is primary key of the table + properties?: string; // Column properties, a json string, the description and displayName should be stored here +} + +export interface IModelColumnRepository extends IBasicRepository { + findColumnsByModelIds( + modelIds: number[], + queryOptions?: IQueryOptions, + ): Promise; + findColumnsByIds( + ids: number[], + queryOptions?: IQueryOptions, + ): Promise; +} + +export class ModelColumnRepository extends BaseRepository { + constructor(knexPg: Knex) { + super({ knexPg, tableName: 'model_column' }); + } + + public async findColumnsByModelIds(modelIds, queryOptions?: IQueryOptions) { + if (queryOptions && queryOptions.tx) { + const { tx } = queryOptions; + const result = await tx(this.tableName) + .whereIn('model_id', modelIds) + .select('*'); + return result.map((r) => this.transformFromDBData(r)); + } + const result = await this.knex('model_column') + .whereIn('model_id', modelIds) + .select('*'); + return result.map((r) => this.transformFromDBData(r)); + } + + public async findColumnsByIds(ids: number[], queryOptions?: IQueryOptions) { + if (queryOptions && queryOptions.tx) { + const { tx } = queryOptions; + const result = await tx(this.tableName).whereIn('id', ids).select('*'); + return result.map((r) => this.transformFromDBData(r)); + } + const result = await this.knex('model_column') + .whereIn('id', ids) + .select('*'); + return result.map((r) => this.transformFromDBData(r)); + } +} diff --git a/wren-ui/src/apollo/server/repositories/modelRepository.ts b/wren-ui/src/apollo/server/repositories/modelRepository.ts new file mode 100644 index 000000000..08876b997 --- /dev/null +++ b/wren-ui/src/apollo/server/repositories/modelRepository.ts @@ -0,0 +1,21 @@ +import { Knex } from 'knex'; +import { BaseRepository, IBasicRepository } from './baseRepository'; + +export interface Model { + id: number; // ID + name: string; // Model name + projectId: number; // Reference to project.id + tableName: string; // Referenced table name in the datasource + refSql: string; // Reference SQL + cached: boolean; // Model is cached or not + refreshTime: string | null; // Contain a number followed by a time unit (ns, us, ms, s, m, h, d). For example, "2h" + properties: string | null; // Model properties, a json string, the description and displayName should be stored here +} + +export interface IModelRepository extends IBasicRepository {} + +export class ModelRepository extends BaseRepository { + constructor(knexPg: Knex) { + super({ knexPg, tableName: 'model' }); + } +} diff --git a/wren-ui/src/apollo/server/repositories/projectRepository.ts b/wren-ui/src/apollo/server/repositories/projectRepository.ts new file mode 100644 index 000000000..bb83703f8 --- /dev/null +++ b/wren-ui/src/apollo/server/repositories/projectRepository.ts @@ -0,0 +1,22 @@ +import { Knex } from 'knex'; +import { BaseRepository, IBasicRepository } from './baseRepository'; + +export interface Project { + id: number; // ID + type: string; // Project datasource type. ex: bigquery, mysql, postgresql, mongodb, etc + displayName: string; // Project display name + projectId: string; // GCP project id, big query specific + location: string; // GCP location, big query specific + dataset: string; // GCP location, big query specific + credentials: string; // Project credentials, big query specific + catalog: string; // Catalog name + schema: string; // Schema name +} + +export interface IProjectRepository extends IBasicRepository {} + +export class ProjectRepository extends BaseRepository { + constructor(knexPg: Knex) { + super({ knexPg, tableName: 'project' }); + } +} diff --git a/wren-ui/src/apollo/server/repositories/relationshipRepository.ts b/wren-ui/src/apollo/server/repositories/relationshipRepository.ts new file mode 100644 index 000000000..58052e8f4 --- /dev/null +++ b/wren-ui/src/apollo/server/repositories/relationshipRepository.ts @@ -0,0 +1,20 @@ +import { Knex } from 'knex'; +import { BaseRepository, IBasicRepository } from './baseRepository'; + +export interface Relation { + id: number; // ID + projectId: number; // Reference to project.id + name: string; // Relation name + joinType: string; // Join type, eg:"MANY_TO_ONE", "ONE_TO_MANY", "MANY_TO_MANY" + condition: string; // Join condition, ex: "OrdersModel.custkey = CustomerModel.custkey" + leftColumnId: number; // Left column id, "{leftSideColumn} {joinType} {rightSideColumn}" + rightColumnId: number; // Right column id, "{leftSideColumn} {joinType} {rightSideColumn}" +} + +export interface IRelationRepository extends IBasicRepository {} + +export class RelationRepository extends BaseRepository { + constructor(knexPg: Knex) { + super({ knexPg, tableName: 'relation' }); + } +} diff --git a/wren-ui/src/apollo/server/repositories/viewRepository.ts b/wren-ui/src/apollo/server/repositories/viewRepository.ts new file mode 100644 index 000000000..bb56af853 --- /dev/null +++ b/wren-ui/src/apollo/server/repositories/viewRepository.ts @@ -0,0 +1,20 @@ +import { Knex } from 'knex'; +import { BaseRepository, IBasicRepository } from './baseRepository'; + +export interface View { + id: number; // ID + projectId: number; // Reference to project.id + name: string; // The view name + statement: string; // The SQL statement of this view + cached: boolean; // View is cached or not + refreshTime?: string; // Contain a number followed by a time unit (ns, us, ms, s, m, h, d). For example, "2h" + properties?: string; // View properties, a json string, the description and displayName should be stored here +} + +export interface IViewRepository extends IBasicRepository {} + +export class ViewRepository extends BaseRepository { + constructor(knexPg: Knex) { + super({ knexPg, tableName: 'view' }); + } +} diff --git a/wren-ui/src/apollo/server/resolvers.ts b/wren-ui/src/apollo/server/resolvers.ts new file mode 100644 index 000000000..dea9241c9 --- /dev/null +++ b/wren-ui/src/apollo/server/resolvers.ts @@ -0,0 +1,224 @@ +import GraphQLJSON from 'graphql-type-json'; +import { + UsableDataSource, + DataSourceName, + DataSource, + CreateModelPayload, + UpdateModelPayload, + UpdateModelWhere, + DeleteModelWhere, + GetModelWhere, + CompactTable, +} from './types'; +import * as demoManifest from './manifest.json'; +import { pick } from 'lodash'; +import { ProjectResolver } from './resolvers/projectResolver'; +import { ModelResolver } from './resolvers/modelResolver'; + +const mockResolvers = { + JSON: GraphQLJSON, + Query: { + usableDataSource: () => + [ + { + type: DataSourceName.BIG_QUERY, + requiredProperties: ['displayName', 'projectId', 'credentials'], + }, + ] as UsableDataSource[], + listDataSourceTables: () => + [ + { + name: 'orders', + columns: [ + { + name: 'id', + type: 'string', + }, + { + name: 'customerId', + type: 'string', + }, + { + name: 'productId', + type: 'string', + }, + ], + }, + { + name: 'customers', + columns: [ + { + name: 'id', + type: 'string', + }, + { + name: 'name', + type: 'string', + }, + ], + }, + { + name: 'products', + columns: [ + { + name: 'id', + type: 'string', + }, + { + name: 'name', + type: 'string', + }, + ], + }, + ] as CompactTable[], + autoGenerateRelation: () => [], + manifest: () => demoManifest, + listModels: () => { + const { models } = demoManifest; + return models.map((model) => ({ + ...pick(model, [ + 'name', + 'refSql', + 'primaryKey', + 'cached', + 'refreshTime', + 'description', + ]), + })); + }, + getModel: (_, args: { where: GetModelWhere }) => { + const { where } = args; + const { models } = demoManifest; + const model = models.find((model) => model.name === where.name); + return { + ...pick(model, [ + 'name', + 'refSql', + 'primaryKey', + 'cached', + 'refreshTime', + 'description', + ]), + columns: model.columns.map((column) => ({ + ...pick(column, [ + 'name', + 'type', + 'isCalculated', + 'notNull', + 'properties', + ]), + })), + properties: model.properties, + }; + }, + }, + Mutation: { + saveDataSource: (_, args: { data: DataSource }) => { + return args.data; + }, + saveTables: ( + _, + _args: { + data: [tables: { name: string; columns: string[] }]; + }, + ) => { + return demoManifest; + }, + createModel: (_, args: { data: CreateModelPayload }) => { + const { data } = args; + const { fields = [], customFields = [], calculatedFields = [] } = data; + return { + name: data.tableName, + refSql: `SELECT * FROM ${data.tableName}`, + columns: [ + ...fields.map((field) => ({ + name: field, + type: 'string', + isCalculated: false, + notNull: false, + properties: {}, + })), + ...customFields.map((field) => ({ + name: field.name, + type: 'string', + isCalculated: false, + notNull: false, + properties: {}, + })), + ...calculatedFields.map((field) => ({ + name: field.name, + type: 'string', + isCalculated: true, + notNull: false, + properties: {}, + })), + ], + properties: { + displayName: data.displayName, + description: data.description, + }, + }; + }, + updateModel: ( + _, + args: { where: UpdateModelWhere; data: UpdateModelPayload }, + ) => { + const { where, data } = args; + const { models } = demoManifest; + const model = + models.find((model) => model.name === where.name) || models[0]; + return { + ...pick(model, [ + 'name', + 'refSql', + 'primaryKey', + 'cached', + 'refreshTime', + 'description', + ]), + columns: model.columns.map((column) => ({ + ...pick(column, [ + 'name', + 'type', + 'isCalculated', + 'notNull', + 'properties', + ]), + })), + properties: { + ...model.properties, + displayName: data.displayName, + description: data.description, + }, + }; + }, + deleteModel: (_, _args: { where: DeleteModelWhere }) => { + return true; + }, + }, +}; + +const projectResolver = new ProjectResolver(); +const modelResolver = new ModelResolver(); + +const resolvers = { + JSON: GraphQLJSON, + Query: { + listDataSourceTables: projectResolver.listDataSourceTables, + autoGenerateRelation: projectResolver.autoGenerateRelation, + listModels: modelResolver.listModels, + }, + Mutation: { + saveDataSource: projectResolver.saveDataSource, + saveTables: projectResolver.saveTables, + saveRelations: projectResolver.saveRelations, + }, +}; + +const useMockResolvers = process.env.APOLLO_RESOLVER === 'mock'; +useMockResolvers + ? console.log('Using mock resolvers') + : console.log('Using real resolvers'); +export default process.env.APOLLO_RESOLVER === 'mock' + ? mockResolvers + : resolvers; diff --git a/wren-ui/src/apollo/server/resolvers/modelResolver.ts b/wren-ui/src/apollo/server/resolvers/modelResolver.ts new file mode 100644 index 000000000..69d95894d --- /dev/null +++ b/wren-ui/src/apollo/server/resolvers/modelResolver.ts @@ -0,0 +1,48 @@ +import { IContext } from '../types'; +import { getLogger } from '@/apollo/server/utils'; + +const logger = getLogger('ModelResolver'); +logger.level = 'debug'; + +export class ModelResolver { + constructor() { + this.listModels = this.listModels.bind(this); + } + + public async listModels(_root: any, _args: any, ctx: IContext) { + const project = await this.getCurrentProject(ctx); + const projectId = project.id; + const models = await ctx.modelRepository.findAllBy({ projectId }); + const modelIds = models.map((m) => m.id); + const modelColumns = + await ctx.modelColumnRepository.findColumnsByModelIds(modelIds); + const result = []; + for (const model of models) { + result.push({ + ...model, + columns: modelColumns + .filter((c) => c.modelId === model.id) + .map((c) => { + c.properties = JSON.parse(c.properties); + return c; + }), + properties: { + ...JSON.parse(model.properties), + displayName: model.name, + }, + }); + } + return result; + } + + private async getCurrentProject(ctx: IContext) { + const projects = await ctx.projectRepository.findAll({ + order: 'id', + limit: 1, + }); + if (!projects.length) { + throw new Error('No project found'); + } + return projects[0]; + } +} diff --git a/wren-ui/src/apollo/server/resolvers/projectResolver.ts b/wren-ui/src/apollo/server/resolvers/projectResolver.ts new file mode 100644 index 000000000..bcf0c8e35 --- /dev/null +++ b/wren-ui/src/apollo/server/resolvers/projectResolver.ts @@ -0,0 +1,384 @@ +import { BigQueryOptions } from '@google-cloud/bigquery'; +import { + BQColumnResponse, + BQConnector, + BQConstraintResponse, + BQListTableOptions, +} from '../connectors/bqConnector'; +import { + DataSource, + DataSourceName, + IContext, + RelationData, + RelationType, +} from '../types'; +import crypto from 'crypto'; +import * as fs from 'fs'; +import path from 'path'; +import { getLogger, Encryptor } from '@/apollo/server/utils'; +import { Model, ModelColumn, Project } from '../repositories'; +import { CreateModelsInput } from '../models'; +import { IConfig } from '../config'; + +const logger = getLogger('DataSourceResolver'); +logger.level = 'debug'; + +export class ProjectResolver { + constructor() { + this.saveDataSource = this.saveDataSource.bind(this); + this.listDataSourceTables = this.listDataSourceTables.bind(this); + this.saveTables = this.saveTables.bind(this); + this.autoGenerateRelation = this.autoGenerateRelation.bind(this); + this.saveRelations = this.saveRelations.bind(this); + } + + public async saveDataSource( + _root: any, + args: { + data: DataSource; + }, + ctx: IContext, + ) { + const { type, properties } = args.data; + if (type === DataSourceName.BIG_QUERY) { + await this.saveBigQueryDataSource(properties, ctx); + return args.data; + } + } + + public async listDataSourceTables(_root: any, _arg, ctx: IContext) { + const project = await this.getCurrentProject(ctx); + const filePath = await this.getCredentialFilePath(project, ctx.config); + const connector = await this.getBQConnector(project, filePath); + const listTableOptions: BQListTableOptions = { + dataset: project.dataset, + format: true, + }; + return await connector.listTables(listTableOptions); + } + + public async saveTables( + _root: any, + arg: { + data: { tables: CreateModelsInput[] }; + }, + ctx: IContext, + ) { + const tables = arg.data.tables; + + // get current project + const project = await this.getCurrentProject(ctx); + const filePath = await this.getCredentialFilePath(project, ctx.config); + + // get columns with descriptions + const transformToCompactTable = false; + const connector = await this.getBQConnector(project, filePath); + const listTableOptions: BQListTableOptions = { + dataset: project.dataset, + format: transformToCompactTable, + }; + const dataSourceColumns = await connector.listTables(listTableOptions); + // create models + const id = project.id; + const models = await this.createModels(tables, id, ctx); + + // create columns + const columns = await this.createModelColumns( + tables, + models, + dataSourceColumns as BQColumnResponse[], + ctx, + ); + + return { models, columns }; + } + + public async autoGenerateRelation(_root: any, _arg: any, ctx: IContext) { + const project = await this.getCurrentProject(ctx); + const filePath = await this.getCredentialFilePath(project, ctx.config); + const models = await ctx.modelRepository.findAllBy({ + projectId: project.id, + }); + + const connector = await this.getBQConnector(project, filePath); + const listConstraintOptions = { + dataset: project.dataset, + }; + const constraints = await connector.listConstraints(listConstraintOptions); + const modelIds = models.map((m) => m.id); + const columns = + await ctx.modelColumnRepository.findColumnsByModelIds(modelIds); + const relations = this.analysisRelation(constraints, models, columns); + return models.map(({ id, tableName }) => ({ + id, + name: tableName, + relations: relations.filter((relation) => relation.fromModel === id), + })); + } + + public async saveRelations( + _root: any, + arg: { data: { relations: RelationData[] } }, + ctx: IContext, + ) { + const { relations } = arg.data; + const project = await this.getCurrentProject(ctx); + + // throw error if the relation name is duplicated + const relationNames = relations.map((relation) => relation.name); + if (new Set(relationNames).size !== relationNames.length) { + throw new Error('Duplicated relation name'); + } + + const columnIds = relations + .map(({ fromColumn, toColumn }) => [fromColumn, toColumn]) + .flat(); + const columns = await ctx.modelColumnRepository.findColumnsByIds(columnIds); + const relationValues = relations.map((relation) => { + const fromColumn = columns.find( + (column) => column.id === relation.fromColumn, + ); + if (!fromColumn) { + throw new Error(`Column not found, column Id ${relation.fromColumn}`); + } + const toColumn = columns.find( + (column) => column.id === relation.toColumn, + ); + if (!toColumn) { + throw new Error(`Column not found, column Id ${relation.toColumn}`); + } + return { + projectId: project.id, + name: relation.name, + leftColumnId: relation.fromColumn, + rightColumnId: relation.toColumn, + joinType: relation.type, + }; + }); + + const savedRelations = await Promise.all( + relationValues.map((relation) => + ctx.relationRepository.createOne(relation), + ), + ); + return savedRelations; + } + + private analysisRelation( + constraints: BQConstraintResponse[], + models: Model[], + columns: ModelColumn[], + ): RelationData[] { + const relations = []; + for (const constraint of constraints) { + const { + constraintTable, + constraintColumn, + constraintedTable, + constraintedColumn, + } = constraint; + // validate tables and columns exists in our models and model columns + const fromModel = models.find((m) => m.tableName === constraintTable); + const toModel = models.find((m) => m.tableName === constraintedTable); + if (!fromModel || !toModel) { + continue; + } + const fromColumn = columns.find( + (c) => c.modelId === fromModel.id && c.name === constraintColumn, + ); + const toColumn = columns.find( + (c) => c.modelId === toModel.id && c.name === constraintedColumn, + ); + if (!fromColumn || !toColumn) { + continue; + } + // create relation + const relation = { + // upper case the first letter of the tableName + name: + fromModel.tableName.charAt(0).toUpperCase() + + fromModel.tableName.slice(1) + + toModel.tableName.charAt(0).toUpperCase() + + toModel.tableName.slice(1), + fromModel: fromModel.id, + fromColumn: fromColumn.id, + toModel: toModel.id, + toColumn: toColumn.id, + // TODO: add join type + type: RelationType.ONE_TO_MANY, + }; + relations.push(relation); + } + return relations; + } + + private async getBQConnector(project: Project, filePath: string) { + // fetch tables + const { location, projectId } = project; + const connectionOption: BigQueryOptions = { + location, + projectId, + keyFilename: filePath, + }; + return new BQConnector(connectionOption); + } + + private async getCredentialFilePath(project: Project, config: IConfig) { + const { credentials: encryptedCredentials } = project; + const encryptor = new Encryptor(config); + const credentials = encryptor.decrypt(encryptedCredentials); + const filePath = this.writeCredentialsFile( + JSON.parse(credentials), + config.persistCredentialDir, + ); + return filePath; + } + + private async createModelColumns( + tables: CreateModelsInput[], + models: Model[], + dataSourceColumns: BQColumnResponse[], + ctx: IContext, + ) { + const columnValues = tables.reduce((acc, table) => { + const modelId = models.find((m) => m.tableName === table.name)?.id; + for (const columnName of table.columns) { + const dataSourceColumn = dataSourceColumns.find( + (c) => c.table_name === table.name && c.column_name === columnName, + ); + if (!dataSourceColumn) { + throw new Error( + `Column ${columnName} not found in the DataSource ${table.name}`, + ); + } + const columnValue = { + modelId, + isCalculated: false, + name: columnName, + type: dataSourceColumn?.data_type || 'string', + notNull: dataSourceColumn.is_nullable.toLocaleLowerCase() !== 'yes', + isPk: false, + properties: JSON.stringify({ + description: dataSourceColumn.description, + }), + } as Partial; + acc.push(columnValue); + } + return acc; + }, []); + const columns = await Promise.all( + columnValues.map( + async (column) => await ctx.modelColumnRepository.createOne(column), + ), + ); + return columns; + } + + private async createModels( + tables: CreateModelsInput[], + id: number, + ctx: IContext, + ) { + const modelValues = tables.map(({ name }) => { + const model = { + projectId: id, + name, //use table name as model name + tableName: name, + refSql: `select * from ${name}`, + cached: false, + refreshTime: null, + properties: JSON.stringify({ description: '' }), + } as Partial; + return model; + }); + + const models = await Promise.all( + modelValues.map( + async (model) => await ctx.modelRepository.createOne(model), + ), + ); + return models; + } + + private async getCurrentProject(ctx: IContext) { + const projects = await ctx.projectRepository.findAll({ + order: 'id', + limit: 1, + }); + if (!projects.length) { + throw new Error('No project found'); + } + return projects[0]; + } + + private async saveBigQueryDataSource(properties: any, ctx: IContext) { + const { displayName, location, projectId, dataset, credentials } = + properties; + const { config } = ctx; + let filePath = ''; + // check DataSource is valid and can connect to it + filePath = await this.writeCredentialsFile( + credentials, + config.persistCredentialDir, + ); + const connectionOption: BigQueryOptions = { + location, + projectId, + keyFilename: filePath, + }; + const connector = new BQConnector(connectionOption); + const connected = await connector.connect(); + if (!connected) { + throw new Error('Cannot connect to DataSource'); + } + // check can list dataset table + try { + await connector.listTables({ dataset }); + } catch (_e) { + throw new Error('Cannot list tables in dataset'); + } + // save DataSource to database + const encryptor = new Encryptor(config); + const encryptedCredentials = encryptor.encrypt(credentials); + + // TODO: add displayName, schema, catalog to the DataSource, depends on the MDL structure + const project = await ctx.projectRepository.createOne({ + displayName, + schema: 'tbd', + catalog: 'tbd', + type: DataSourceName.BIG_QUERY, + projectId, + location, + dataset, + credentials: encryptedCredentials, + }); + return project; + } + + private writeCredentialsFile( + credentials: JSON, + persistCredentialDir: string, + ) { + // create persist_credential_dir if not exists + if (!fs.existsSync(persistCredentialDir)) { + fs.mkdirSync(persistCredentialDir, { recursive: true }); + } + // file name will be the hash of the credentials, file path is current working directory + // convert credentials from base64 to string and replace all the matched "\n" with "\\n", there are many \n in the "private_key" property + const credentialString = JSON.stringify(credentials); + const fileName = crypto + .createHash('md5') + .update(credentialString) + .digest('hex'); + + const filePath = path.join(persistCredentialDir, `${fileName}.json`); + // check if file exists + if (fs.existsSync(filePath)) { + logger.debug(`File ${filePath} already exists`); + return filePath; + } + logger.debug(`Writing credentials to file ${filePath}`); + fs.writeFileSync(filePath, credentialString); + return filePath; + } +} diff --git a/wren-ui/src/apollo/server/schema.ts b/wren-ui/src/apollo/server/schema.ts new file mode 100644 index 000000000..b8ffe808e --- /dev/null +++ b/wren-ui/src/apollo/server/schema.ts @@ -0,0 +1,226 @@ +import { gql } from 'apollo-server-micro'; + +export const typeDefs = gql` + scalar JSON + + enum DataSourceName { + BIG_QUERY + } + + type UsableDataSource { + type: DataSourceName! + requiredProperties: [String!]! + } + + type DataSource { + type: DataSourceName! + properties: JSON! + } + + input DataSourceInput { + type: DataSourceName! + properties: JSON! + } + + type CompactTable { + name: String! + columns: [CompactColumn!]! + } + + input MDLModelSubmitInput { + name: String! + columns: [String!]! + } + + enum RelationType { + ONE_TO_ONE + ONE_TO_MANY + MANY_TO_ONE + MANY_TO_MANY + } + + type Relation { + fromModel: Int! + fromColumn: Int! + toModel: Int! + toColumn: Int! + type: RelationType! + name: String! + } + + type RecommandRelations { + name: String! + id: Int! + relations: [Relation]! + } + + input RelationInput { + name: String! + fromModel: Int! + fromColumn: Int! + toModel: Int! + toColumn: Int! + type: RelationType! + } + + input SaveRelationInput { + relations: [RelationInput]! + } + + input SaveTablesInput { + tables: [ModelsInput!]! + } + + input ModelsInput { + name: String! + columns: [String!]! + } + + type CompactColumn { + name: String! + type: String! + } + + enum ModelType { + TABLE + CUSTOM + } + + input CustomFieldInput { + name: String! + expression: String! + } + + input CalculatedFieldInput { + name: String! + expression: String! + } + + input CreateModelInput { + type: ModelType! + tableName: String! + displayName: String! + description: String + cached: Boolean! + refreshTime: String + fields: [String!]! + customFields: [CustomFieldInput!] + calculatedFields: [CalculatedFieldInput!] + } + + input ModelWhereInput { + name: String! + } + + input UpdateModelInput { + type: ModelType! + displayName: String! + description: String + cached: Boolean! + refreshTime: String + fields: [String!]! + customFields: [CustomFieldInput!] + calculatedFields: [CalculatedFieldInput!] + } + + type ColumnInfo { + id: Int! + name: String! + type: String! + isCalculated: Boolean! + notNull: Boolean! + expression: String + properties: JSON + } + + type ModelInfo { + id: Int! + name: String! + refSql: String + primaryKey: String + cached: Boolean! + refreshTime: String + description: String + columns: [ColumnInfo]! + properties: JSON + } + + type DetailedColumn { + name: String! + type: String! + isCalculated: Boolean! + notNull: Boolean! + properties: JSON! + } + + type DetailedModel { + name: String! + refSql: String! + primaryKey: String + cached: Boolean! + refreshTime: String! + description: String + columns: [DetailedColumn!]! + properties: JSON! + } + + input SimpleMeasureInput { + name: String! + type: String! + isCalculated: Boolean! + notNull: Boolean! + properties: JSON! + } + + input DimensionInput { + name: String! + type: String! + isCalculated: Boolean! + notNull: Boolean! + properties: JSON! + } + + input TimeGrainInput { + name: String! + refColumn: String! + dateParts: [String!]! + } + + input CreateSimpleMetricInput { + name: String! + displayName: String! + description: String + cached: Boolean! + refreshTime: String + model: String! + modelType: ModelType! + properties: JSON! + measure: [SimpleMeasureInput!]! + dimension: [DimensionInput!]! + timeGrain: [TimeGrainInput!]! + } + + type Query { + # On Boarding Steps + usableDataSource: [UsableDataSource!]! + listDataSourceTables: [CompactTable!]! + autoGenerateRelation: [RecommandRelations!] + manifest: JSON! + + # Modeling Page + listModels: [ModelInfo!]! + getModel(where: ModelWhereInput!): DetailedModel! + } + + type Mutation { + # On Boarding Steps + saveDataSource(data: DataSourceInput!): DataSource! + saveTables(data: SaveTablesInput!): JSON! + saveRelations(data: SaveRelationInput!): JSON! + + # Modeling Page + createModel(data: CreateModelInput!): JSON! + updateModel(where: ModelWhereInput!, data: UpdateModelInput!): JSON! + deleteModel(where: ModelWhereInput!): Boolean! + } +`; diff --git a/wren-ui/src/apollo/server/types/context.ts b/wren-ui/src/apollo/server/types/context.ts new file mode 100644 index 000000000..d0fc9fe14 --- /dev/null +++ b/wren-ui/src/apollo/server/types/context.ts @@ -0,0 +1,17 @@ +import { IConfig } from '../config'; +import { + IModelColumnRepository, + IModelRepository, + IProjectRepository, + IRelationRepository, +} from '../repositories'; + +export interface IContext { + config: IConfig; + + // repository + projectRepository: IProjectRepository; + modelRepository: IModelRepository; + modelColumnRepository: IModelColumnRepository; + relationRepository: IRelationRepository; +} diff --git a/wren-ui/src/apollo/server/types/dataSource.ts b/wren-ui/src/apollo/server/types/dataSource.ts new file mode 100644 index 000000000..67a12b066 --- /dev/null +++ b/wren-ui/src/apollo/server/types/dataSource.ts @@ -0,0 +1,23 @@ +export enum DataSourceName { + BIG_QUERY = 'BIG_QUERY', +} + +interface BaseDataSource { + type: DataSourceName; +} + +export interface UsableDataSource extends BaseDataSource { + requiredProperties: string[]; +} + +export interface DataSource { + type: DataSourceName; + properties: any; +} + +export interface BigQueryDataSourceOptions { + displayName: string; + location: string; + projectId: string; + credentials: string; +} diff --git a/wren-ui/src/apollo/server/types/index.ts b/wren-ui/src/apollo/server/types/index.ts new file mode 100644 index 000000000..e64f0ee90 --- /dev/null +++ b/wren-ui/src/apollo/server/types/index.ts @@ -0,0 +1,6 @@ +export * from './dataSource'; +export * from './relationship'; +export * from './manifest'; +export * from './model'; +export * from './metric'; +export * from './context'; diff --git a/wren-ui/src/apollo/server/types/manifest.ts b/wren-ui/src/apollo/server/types/manifest.ts new file mode 100644 index 000000000..e96a55d60 --- /dev/null +++ b/wren-ui/src/apollo/server/types/manifest.ts @@ -0,0 +1,139 @@ +export interface Manifest { + catalog: string; + schema: string; + models: Model[]; + relationships: Relationship[]; + enumDefinitions: EnumDefinition[]; + metrics: Metric[]; + cumulativeMetrics: CumulativeMetric[]; + views: EnumDefinition[]; + macros: Macro[]; + dateSpine: DateSpine; +} + +export interface CumulativeMetric { + name: string; + baseObject: string; + measure: Measure; + window: Window; + cached: boolean; + description?: string; + properties: CumulativeMetricProperties; +} + +export interface Measure { + name: string; + type: string; + operator: string; + refColumn: string; + properties?: CumulativeMetricProperties; +} + +export interface CumulativeMetricProperties { + description?: string; +} + +export interface Window { + name: string; + refColumn: string; + timeUnit: string; + start: Date; + end: Date; + properties: CumulativeMetricProperties; +} + +export interface DateSpine { + unit: string; + start: Date; + end: Date; + properties: CumulativeMetricProperties; +} + +export interface EnumDefinition { + name: string; + values?: Value[]; + description: string; + properties: CumulativeMetricProperties; + statement?: string; +} + +export interface Value { + name: string; + value: string; + properties: CumulativeMetricProperties; +} + +export interface Macro { + name: string; + definition: string; + properties: CumulativeMetricProperties; +} + +export interface Metric { + name: string; + baseObject: string; + dimension: Dimension[]; + measure: Dimension[]; + timeGrain: TimeGrain[]; + cached: boolean; + refreshTime: string; + description: string; + properties: CumulativeMetricProperties; +} + +export interface Dimension { + name: string; + type: string; + isCalculated: boolean; + notNull: boolean; + properties: DimensionProperties; +} + +export interface DimensionProperties {} + +export interface TimeGrain { + name: string; + refColumn: string; + dateParts: string[]; +} + +export interface Model { + name: string; + refSql: string; + columns: Column[]; + primaryKey?: string; + cached: boolean; + refreshTime: string; + description?: string; + properties: CumulativeMetricProperties; +} + +export interface createColumnInput { + name: string; +} + +export interface Column { + name: string; + type: string; + isCalculated: boolean; + notNull: boolean; + description?: string; + properties: CumulativeMetricProperties; + relationship?: string; + expression?: string; +} + +export interface Relationship { + name: string; + models: string[]; + joinType: string; + condition: string; + manySideSortKeys: ManySideSortKey[]; + description: string; + properties: CumulativeMetricProperties; +} + +export interface ManySideSortKey { + name: string; + descending: boolean; +} diff --git a/wren-ui/src/apollo/server/types/metric.ts b/wren-ui/src/apollo/server/types/metric.ts new file mode 100644 index 000000000..4a8785fa2 --- /dev/null +++ b/wren-ui/src/apollo/server/types/metric.ts @@ -0,0 +1,67 @@ +enum ModelType { + TABLE = 'TABLE', + METRIC = 'METRIC', +} + +export type CreateSimpleMetricPayload = BaseMetricPaylod & { + measure: SimpleMeasure[]; + dimension: Dimension[]; + timeGrain: TimeGrain[]; +}; + +export type CreateCumulativeMetricPayload = BaseMetricPaylod & { + measure: CumulativeMeasure[]; + window: Window; +}; + +interface BaseMetricPaylod { + name: string; + displayName: string; + description: string; + cached: boolean; + refreshTime?: string; + model: string; + modelType: ModelType; + properties: Properties; +} + +interface SimpleMeasure { + name: string; + type: string; + isCalculated: boolean; + notNull: boolean; + properties: Properties; +} + +interface CumulativeMeasure { + name: string; + type: string; + operator: string; + refColumn: string; + properties: Properties; +} + +interface Dimension { + name: string; + type: string; + isCalculated: boolean; + notNull: boolean; + properties: Properties; +} + +interface TimeGrain { + name: string; + refColumn: string; + dateParts: string[]; +} + +interface Window { + name: string; + refColumn: string; + timeUnit: string; + start: string; + end: string; + properties: Properties; +} + +export interface Properties {} diff --git a/wren-ui/src/apollo/server/types/model.ts b/wren-ui/src/apollo/server/types/model.ts new file mode 100644 index 000000000..71de2fad5 --- /dev/null +++ b/wren-ui/src/apollo/server/types/model.ts @@ -0,0 +1,44 @@ +enum ModelType { + TABLE = 'TABLE', + CUSTOM = 'CUSTOM', +} + +export interface CreateModelPayload { + type: ModelType; + tableName: string; + displayName: string; + description?: string; + cached: boolean; + // 30m, 1h, 1d, 1w, 1m, 1y + refreshTime?: string; + fields?: string[]; + customFields?: { + name: string; + expression: string; + }[]; + calculatedFields?: { + name: string; + expression: string; + }[]; +} + +export interface ModelWhere { + name: string; +} + +export type UpdateModelWhere = ModelWhere; +export type UpdateModelPayload = Partial>; + +export type DeleteModelWhere = ModelWhere; + +export type GetModelWhere = ModelWhere; + +export interface CompactColumn { + name: string; + type: string; +} + +export interface CompactTable { + name: string; + columns: CompactColumn[]; +} diff --git a/wren-ui/src/apollo/server/types/relationship.ts b/wren-ui/src/apollo/server/types/relationship.ts new file mode 100644 index 000000000..0caf2a420 --- /dev/null +++ b/wren-ui/src/apollo/server/types/relationship.ts @@ -0,0 +1,15 @@ +export interface RelationData { + name: string; + fromModel: number; + fromColumn: number; + toModel: number; + toColumn: number; + type: RelationType; +} + +export enum RelationType { + ONE_TO_ONE = 'ONE_TO_ONE', + ONE_TO_MANY = 'ONE_TO_MANY', + MANY_TO_ONE = 'MANY_TO_ONE', + MANY_TO_MANY = 'MANY_TO_MANY', +} diff --git a/wren-ui/src/apollo/server/utils/encryptor.ts b/wren-ui/src/apollo/server/utils/encryptor.ts new file mode 100644 index 000000000..263325518 --- /dev/null +++ b/wren-ui/src/apollo/server/utils/encryptor.ts @@ -0,0 +1,70 @@ +import crypto from 'crypto'; +import { IConfig } from '../config'; + +export interface encryptOptions { + password: string; + salt: string; + iteration?: number; + keyLength?: number; + algorithm?: string; + separator?: string; +} + +export class Encryptor { + private ENCRYPTION_PASSWORD: string; + private ENCRYPTION_SALT: string; + private ENCRYPTION_ITERATION = 1000; + private ENCRYPTION_KEY_LENGTH = 256 / 8; // in bytes + private ENCRYPTION_ALGORITHM = 'aes-256-cbc'; + private ENCRYPTION_SEPARATOR = ':'; + + constructor(config: IConfig) { + this.ENCRYPTION_PASSWORD = config.encryptionPassword; + this.ENCRYPTION_SALT = config.encryptionSalt; + } + + public encrypt(credentials: JSON) { + const credentialsString = JSON.stringify(credentials); + const key = this.createSecretKey(); + const iv = crypto.randomBytes(16); // AES block size + const cipher = crypto.createCipheriv(this.ENCRYPTION_ALGORITHM, key, iv); + const encrypted = Buffer.concat([ + cipher.update(credentialsString, 'utf8'), + cipher.final(), + ]); + return ( + iv.toString('base64') + + this.ENCRYPTION_SEPARATOR + + encrypted.toString('base64') + ); + } + + public decrypt(encryptedText: string) { + const [ivBase64, encryptedBase64] = encryptedText.split( + this.ENCRYPTION_SEPARATOR, + ); + const iv = Buffer.from(ivBase64, 'base64'); + const encrypted = Buffer.from(encryptedBase64, 'base64'); + const key = this.createSecretKey(); + const decipher = crypto.createDecipheriv( + this.ENCRYPTION_ALGORITHM, + key, + iv, + ); + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final(), + ]); + return decrypted.toString('utf8'); + } + + private createSecretKey() { + return crypto.pbkdf2Sync( + this.ENCRYPTION_PASSWORD, + this.ENCRYPTION_SALT, + this.ENCRYPTION_ITERATION, + this.ENCRYPTION_KEY_LENGTH, + 'sha512', + ); + } +} diff --git a/wren-ui/src/apollo/server/utils/index.ts b/wren-ui/src/apollo/server/utils/index.ts new file mode 100644 index 000000000..fb3c4d8c1 --- /dev/null +++ b/wren-ui/src/apollo/server/utils/index.ts @@ -0,0 +1,2 @@ +export * from './logger'; +export * from './encryptor'; diff --git a/wren-ui/src/apollo/server/utils/knex.ts b/wren-ui/src/apollo/server/utils/knex.ts new file mode 100644 index 000000000..0d036dfe3 --- /dev/null +++ b/wren-ui/src/apollo/server/utils/knex.ts @@ -0,0 +1,30 @@ +interface KnexOptions { + dbType: string; + pgUrl?: string; + debug?: boolean; + sqliteFile?: string; +} + +export const bootstrapKnex = (options: KnexOptions) => { + if (options.dbType === 'pg') { + const { pgUrl, debug } = options; + console.log('using pg'); + /* eslint-disable @typescript-eslint/no-var-requires */ + return require('knex')({ + client: 'pg', + connection: pgUrl, + debug, + pool: { min: 2, max: 10 }, + }); + } else { + console.log('using sqlite'); + // eslint-disable-next-line @typescript-eslint/no-var-requires + return require('knex')({ + client: 'better-sqlite3', + connection: { + filename: options.sqliteFile, + }, + useNullAsDefault: true, + }); + } +}; diff --git a/wren-ui/src/apollo/server/utils/logger.ts b/wren-ui/src/apollo/server/utils/logger.ts new file mode 100644 index 000000000..f19e429ef --- /dev/null +++ b/wren-ui/src/apollo/server/utils/logger.ts @@ -0,0 +1 @@ +export { getLogger } from 'log4js'; diff --git a/wren-ui/src/apollo/server/utils/tests/encryptor.test.ts b/wren-ui/src/apollo/server/utils/tests/encryptor.test.ts new file mode 100644 index 000000000..a31b59780 --- /dev/null +++ b/wren-ui/src/apollo/server/utils/tests/encryptor.test.ts @@ -0,0 +1,94 @@ +import { IConfig } from '../../config'; +import { Encryptor } from '../encryptor'; +import crypto from 'crypto'; + +jest.mock('crypto', () => ({ + randomBytes: jest.fn(), + createCipheriv: jest.fn(), + createDecipheriv: jest.fn(), + pbkdf2Sync: jest.fn(), +})); + +const credentials = { username: 'user', password: 'pass' }; + +describe('Encryptor', () => { + const mockConfig: IConfig = { + dbType: 'sqlite', + encryptionPassword: 'testPassword', + encryptionSalt: 'testSalt', + }; + + let encryptor: Encryptor; + beforeEach(() => { + encryptor = new Encryptor(mockConfig); + }); + + it('should encrypt data correctly', async () => { + // Arrange + const testData = JSON.parse(JSON.stringify(credentials)); + const mockIV = Buffer.from('mockIV'); + (crypto.randomBytes as jest.Mock).mockReturnValue(mockIV); + const mockCipher = { + update: jest.fn().mockReturnValue(Buffer.from('ciphered')), + final: jest.fn().mockReturnValue(Buffer.from('finalCiphered')), + }; + (crypto.createCipheriv as jest.Mock).mockReturnValue(mockCipher); + + // Act + const encryptedData = await encryptor.encrypt(testData); + + // Assert + expect(encryptedData).toContain(Buffer.from('mockIV').toString('base64')); // Basic check, more sophisticated assertions can be made + expect(encryptedData).toContain(':'); // contain seperator + expect(encryptedData).toContain( + Buffer.concat([ + Buffer.from('ciphered'), + Buffer.from('finalCiphered'), + ]).toString('base64'), + ); // contain ciphered data + expect(crypto.createCipheriv).toHaveBeenCalled(); + }); + + it('should decrypt data correctly', async () => { + // Setup + const encryptedData = 'mockIV:encryptedData'; + const mockDecrypted = Buffer.from(JSON.stringify(credentials)); + const mockDecipher = { + update: jest.fn().mockReturnValue(mockDecrypted), + final: jest.fn().mockReturnValue(Buffer.from('')), + }; + (crypto.createDecipheriv as jest.Mock).mockReturnValue(mockDecipher); + + // Act + const decryptedData = await encryptor.decrypt(encryptedData); + + // Assert + expect(decryptedData).toEqual(JSON.stringify(credentials)); + expect(crypto.createDecipheriv).toHaveBeenCalled(); + }); + + it('should return original data after encrypt and decrypt', async () => { + // Setup + const testData = JSON.parse('{"username":"user","password":"pass"}'); + const mockIV = Buffer.from('mockIV'); + (crypto.randomBytes as jest.Mock).mockReturnValue(mockIV); + const mockCipher = { + update: jest.fn().mockReturnValue(Buffer.from('ciphered')), + final: jest.fn().mockReturnValue(Buffer.from('finalCiphered')), + }; + (crypto.createCipheriv as jest.Mock).mockReturnValue(mockCipher); + + const mockDecipher = { + update: jest.fn().mockReturnValue(Buffer.from(JSON.stringify(testData))), + final: jest.fn().mockReturnValue(Buffer.from('')), + }; + (crypto.createDecipheriv as jest.Mock).mockReturnValue(mockDecipher); + + // Act + const encryptedData = await encryptor.encrypt(testData); + const decryptedData = await encryptor.decrypt(encryptedData); + + // Assert + expect(JSON.parse(decryptedData)).toEqual(testData); + }); +}); diff --git a/wren-ui/src/components/Background.tsx b/wren-ui/src/components/Background.tsx new file mode 100644 index 000000000..48e219240 --- /dev/null +++ b/wren-ui/src/components/Background.tsx @@ -0,0 +1,37 @@ +export default function Background() { + return ( + + + + + + + ); +} diff --git a/wren-ui/src/components/EditableWrapper.tsx b/wren-ui/src/components/EditableWrapper.tsx new file mode 100644 index 000000000..72d8db76b --- /dev/null +++ b/wren-ui/src/components/EditableWrapper.tsx @@ -0,0 +1,83 @@ +import { createContext, useContext, useEffect, useRef, useState } from 'react'; +import { Input, InputRef, Form, FormInstance } from 'antd'; +import styled from 'styled-components'; +import { get } from 'lodash'; +import EllipsisWrapper from '@/components/EllipsisWrapper'; + +interface Props { + children: React.ReactNode; + dataIndex: string; + record: any; + handleSave: (id: string, value: { [key: string]: string }) => void; +} + +const EditableStyle = styled.div` + line-height: 24px; + + .editable-cell-value-wrap { + padding: 0 7px; + border: 1px transparent solid; + border-radius: 4px; + cursor: pointer; + + &:hover { + border-color: var(--gray-5); + } + } + .ant-form-item-control-input { + min-height: 24px; + .ant-input { + line-height: 22px; + } + } +`; + +export const EditableContext = createContext | null>(null); + +export default function EditableWrapper(props: Props) { + const { children, dataIndex, record, handleSave } = props; + + const [editing, setEditing] = useState(false); + const inputRef = useRef(null); + const form = useContext(EditableContext); + const dataIndexKey = Array.isArray(dataIndex) + ? dataIndex.join('.') + : dataIndex; + + useEffect(() => { + if (editing) inputRef.current!.focus(); + }, [editing]); + + const toggleEdit = () => { + setEditing(!editing); + const value = get(record, dataIndexKey); + form.setFieldsValue({ [dataIndexKey]: value }); + }; + + const save = async () => { + try { + const values = await form.validateFields(); + + toggleEdit(); + handleSave(record.id, values); + } catch (errInfo) { + console.log('Save failed:', errInfo); + } + }; + + const childNode = editing ? ( + + + + ) : ( +
+ +
+ ); + + return {childNode}; +} diff --git a/wren-ui/src/components/EllipsisWrapper.tsx b/wren-ui/src/components/EllipsisWrapper.tsx new file mode 100644 index 000000000..01f73ede8 --- /dev/null +++ b/wren-ui/src/components/EllipsisWrapper.tsx @@ -0,0 +1,35 @@ +import { ReactNode, useEffect, useRef, useState } from 'react'; + +interface Props { + text?: string; + children?: ReactNode; +} + +export default function EllipsisWrapper(props: Props) { + const { text, children } = props; + const ref = useRef(null); + const [width, setWidth] = useState(undefined); + const hasWidth = width !== undefined; + + // Auto setup client width itself + useEffect(() => { + if (ref.current && !hasWidth) { + const cellWidth = ref.current.clientWidth; + setWidth(cellWidth); + } + }, []); + + const renderContent = () => { + if (!children) return text || '-'; + return children; + }; + + // Convert to string if React pass its children as array type to props + const title = Array.isArray(text) ? text.join('') : text; + + return ( +
+ {hasWidth ? renderContent() : null} +
+ ); +} diff --git a/wren-ui/src/components/HeaderBar.tsx b/wren-ui/src/components/HeaderBar.tsx new file mode 100644 index 000000000..98ff69481 --- /dev/null +++ b/wren-ui/src/components/HeaderBar.tsx @@ -0,0 +1,86 @@ +import { useRouter } from 'next/router'; +import styled from 'styled-components'; +import { Button, ButtonProps, Layout, Space } from 'antd'; +import LogoBar from '@/components/LogoBar'; +import SharePopover from '@/components/SharePopover'; +import { Path } from '@/utils/enum'; + +const { Header } = Layout; + +const StyledButton = styled(Button).attrs<{ + $isHighlight: boolean; +}>((props) => ({ + shape: 'round', + size: 'small', + style: { + background: props.$isHighlight ? 'rgba(255, 255, 255, 0.20)' : '#000', + fontWeight: props.$isHighlight ? '700' : 'normal', + border: 'none', + color: 'var(--gray-1)', + }, +}))`` as React.ForwardRefExoticComponent< + ButtonProps & React.RefAttributes & { $isHighlight: boolean } +>; + +const StyledHeader = styled(Header)` + height: 48px; + border-bottom: 1px solid var(--gray-5); + background: #000; + padding: 10px 16px; +`; + +export interface Connections { + database: string; + port: string; + username: string; + password: string; +} + +export default function HeaderBar(props: { connections?: Connections }) { + const { connections = {} as Connections } = props; + const router = useRouter(); + const { pathname } = router; + const showNav = !pathname.startsWith(Path.Onboarding); + const showConnectInfo = pathname.startsWith(Path.Modeling); + + const infoSources = [ + { title: 'Database', type: 'text', value: connections?.database }, + { title: 'Port', type: 'text', value: connections?.port }, + { title: 'Username', type: 'text', value: connections?.username }, + { title: 'Password', type: 'password', value: connections?.password }, + ]; + + return ( + +
+ + + {showNav && ( + + router.push(Path.Exploration)} + > + Exploration + + router.push(Path.Modeling)} + > + Modeling + + + )} + + {showConnectInfo && ( + + + + )} +
+
+ ); +} diff --git a/wren-ui/src/components/LogoBar.tsx b/wren-ui/src/components/LogoBar.tsx new file mode 100644 index 000000000..86cafae6a --- /dev/null +++ b/wren-ui/src/components/LogoBar.tsx @@ -0,0 +1,11 @@ +import { Space } from 'antd'; +import Image from 'next/image'; + +export default function LogoBar() { + return ( + + Logo + VulcanSQL + + ); +} diff --git a/wren-ui/src/components/PageLoading.tsx b/wren-ui/src/components/PageLoading.tsx new file mode 100644 index 000000000..8022045a4 --- /dev/null +++ b/wren-ui/src/components/PageLoading.tsx @@ -0,0 +1,36 @@ +import { Spin } from 'antd'; +import styled from 'styled-components'; +import LoadingOutlined from '@ant-design/icons/LoadingOutlined'; + +const Wrapper = styled.div` + position: absolute; + top: 48px; + left: 0; + right: 0; + bottom: 0; + z-index: 9999; + background-color: white; + display: none; + + &.isShow { + display: flex; + } +`; + +interface Props { + visible?: boolean; +} + +export default function PageLoading(props: Props) { + const { visible } = props; + return ( + +
+ } /> +
Loading...
+
+
+ ); +} diff --git a/wren-ui/src/components/PreviewDataContent.tsx b/wren-ui/src/components/PreviewDataContent.tsx new file mode 100644 index 000000000..b8fde843e --- /dev/null +++ b/wren-ui/src/components/PreviewDataContent.tsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; +import { Table, TableColumnProps } from 'antd'; +import { isString } from 'lodash'; + +const FONT_SIZE = 16; +const BASIC_COLUMN_WIDTH = 100; + +type TableColumn = TableColumnProps & { titleText?: string }; + +interface Props { + columns: TableColumn[]; + data: Array; +} + +const getValueByValueType = (value: any) => + ['boolean', 'object'].includes(typeof value) ? JSON.stringify(value) : value; + +const convertResultData = (data: Array, columns) => { + return data.map((datum: Array, index: number) => { + const obj = {}; + // should have a unique "key" prop. + obj['key'] = index; + + datum.forEach((value, index) => { + const columnName = columns[index].dataIndex; + obj[columnName] = getValueByValueType(value); + }); + + return obj; + }); +}; + +export default function PreviewDataContent(props: Props) { + const { columns = [], data = [] } = props; + const hasColumns = !!columns.length; + + const dynamicWidth = useMemo(() => { + return columns.reduce((result, column) => { + const width = isString(column.titleText || column.title) + ? (column.titleText || (column.title as string)).length * FONT_SIZE + : BASIC_COLUMN_WIDTH; + return result + width; + }, 0); + }, [columns]); + + const tableColumns = useMemo(() => { + return columns.map((column) => ({ + ...column, + ellipsis: true, + })); + }, [columns]); + + const dataSource = useMemo(() => convertResultData(data, columns), [data]); + + return ( + + ); +} diff --git a/wren-ui/src/components/SharePopover.tsx b/wren-ui/src/components/SharePopover.tsx new file mode 100644 index 000000000..6a72025c7 --- /dev/null +++ b/wren-ui/src/components/SharePopover.tsx @@ -0,0 +1,75 @@ +import { Popover, PopoverProps, Typography, Input, Space } from 'antd'; +import { ShareIcon } from '@/utils/icons'; +import styled from 'styled-components'; + +const { Title, Text } = Typography; + +const Content = styled.div` + width: 423px; + padding: 4px 0; + + .adm-share-title { + font-size: 14px; + margin-bottom: 16px; + } + + .adm-share-subtitle { + margin-bottom: 8px; + } +`; + +const StyledInput = styled(Input)` + display: block; + color: var(--gray-10); + + .ant-input { + background-color: var(--gray-4); + } +`; + +type Source = { title: string; type: string; value: string }; + +type Props = { + sources: Source[]; +} & PopoverProps; + +export default function SharePopover(props: Props) { + const { children, sources } = props; + + const content = ( + + + <Space> + <ShareIcon /> + Share + </Space> + +
+ You can connect it to query via SQL protocol. +
+ + + {sources.map(({ title, type, value }) => ( +
+
{title}
+ + } + /> +
+ ))} +
+
+ ); + return ( + + {children} + + ); +} diff --git a/wren-ui/src/components/TestDataSelect.tsx b/wren-ui/src/components/TestDataSelect.tsx new file mode 100644 index 000000000..8f9f7af43 --- /dev/null +++ b/wren-ui/src/components/TestDataSelect.tsx @@ -0,0 +1,19 @@ +import { Select } from 'antd'; +import styled from 'styled-components'; +import testData from '@/testData'; + +const StyledSelect = styled(Select)` + position: absolute; + left: 32px; + top: 32px; + width: 250px; + z-index: 999; +`; + +export default function TestDataSelect({ value, onSelect }) { + const options = Object.keys(testData).map((key) => ({ + label: key, + value: key, + })); + return ; +} diff --git a/wren-ui/src/components/ask/AnswerResult.tsx b/wren-ui/src/components/ask/AnswerResult.tsx new file mode 100644 index 000000000..ffb95962e --- /dev/null +++ b/wren-ui/src/components/ask/AnswerResult.tsx @@ -0,0 +1,67 @@ +import { Button, Skeleton, Typography } from 'antd'; +import CheckCircleOutlined from '@ant-design/icons/CheckCircleOutlined'; +import SaveOutlined from '@ant-design/icons/SaveOutlined'; +import QuestionCircleOutlined from '@ant-design/icons/QuestionCircleOutlined'; +import StepContent from '@/components/ask/StepContent'; + +const { Title } = Typography; + +interface Props { + loading: boolean; + onOpenSaveAsViewModal: ({ sql: string }) => void; + question: string; + description: string; + answerResultSteps: Array<{ + summary: string; + sql: string; + }>; + sql: string; +} + +export default function AnswerResult(props: Props) { + const { + loading, + onOpenSaveAsViewModal, + question, + description, + answerResultSteps, + sql, + } = props; + + return ( + + + + <QuestionCircleOutlined className="mr-2" /> + {question} + + + <CheckCircleOutlined className="mr-2" /> + Answer + + + {description} + + {answerResultSteps.map((step, index) => ( + + ))} + + + + ); +} diff --git a/wren-ui/src/components/ask/CollapseContent.tsx b/wren-ui/src/components/ask/CollapseContent.tsx new file mode 100644 index 000000000..406dbabbf --- /dev/null +++ b/wren-ui/src/components/ask/CollapseContent.tsx @@ -0,0 +1,83 @@ +import dynamic from 'next/dynamic'; +import { Button, Typography } from 'antd'; +import CopyOutlined from '@ant-design/icons/lib/icons/CopyOutlined'; +import UpCircleOutlined from '@ant-design/icons/UpCircleOutlined'; +import PreviewData from '@/components/ask/PreviewData'; + +const CodeBlock = dynamic(() => import('@/components/editor/CodeBlock'), { + ssr: false, +}); + +const { Text } = Typography; + +export interface Props { + isViewSQL?: boolean; + isViewFullSQL?: boolean; + isPreviewData?: boolean; + onCloseCollapse: () => void; + onCopyFullSQL?: () => void; + sql: string; +} + +export default function CollapseContent(props: Props) { + const { + isViewSQL, + isViewFullSQL, + isPreviewData, + onCloseCollapse, + onCopyFullSQL, + sql, + } = props; + return ( + <> + {(isViewSQL || isViewFullSQL) && ( +
+          
+        
+ )} + {isPreviewData && ( +
+ +
+ )} + {(isViewSQL || isPreviewData) && ( +
+ + {isPreviewData && ( + Showing up to 500 rows + )} +
+ )} + {isViewFullSQL && ( + <> + + + + )} + + ); +} diff --git a/wren-ui/src/components/ask/PreviewData.tsx b/wren-ui/src/components/ask/PreviewData.tsx new file mode 100644 index 000000000..96968ba3a --- /dev/null +++ b/wren-ui/src/components/ask/PreviewData.tsx @@ -0,0 +1,1409 @@ +import { useMemo } from 'react'; +import { Typography } from 'antd'; +import { getColumnTypeIcon } from '@/utils/columnType'; +import PreviewDataContent from '@/components/PreviewDataContent'; + +const { Text } = Typography; + +const getPreviewColumns = (cols) => + cols.map(({ name, description, type }: Record) => { + const columnTypeIcon = getColumnTypeIcon({ type }, { title: type }); + + return { + dataIndex: name, + titleText: name, + key: name, + ellipsis: true, + title: ( + <> + {columnTypeIcon} + + {name} + +
+ + {description || '-'} + + + ), + }; + }); + +export default function PreviewData(_props: { sql: string }) { + // TODO: call API to get real preview data + const previewData = { + id: '20240306_025536_00000_dnben', + columns: [ + { + name: 'id', + type: 'uuid', + description: + 'This is the unique identifier for the audit log entry. It is a UUID.', + }, + { + name: 'created_at', + type: 'timestamp', + description: 'This is the log creation time.', + }, + { + name: 'operation', + type: 'varchar', + description: + 'The activity log records the behavior of an operation, which may be - create, read, update, delete, download, view, apply, cancel, decrypt, refresh, deploy, explore...etc.', + }, + { + name: 'requester_id', + type: 'varchar', + description: 'The user ID of the operator.', + }, + { + name: 'requester_type', + type: 'varchar', + description: 'The user type of the operator.', + }, + { + name: 'workspace_id', + type: 'uuid', + description: 'Operators perform operations under their workspace ID.', + }, + { + name: 'resource', + type: 'varchar', + description: + 'The resource may be is - USER, ROLE, WORKSPACE, SQL, SQL_QUERY, DATA_SOURCE, TABLE, MATERIALIZED_VIEW, VIEW, SYNONYM, DATASET, EXTERNAL_QUERY_TABLE, DATA_API, DATA_POLICY, GROUP..etc', + }, + { + name: 'status', + type: 'varchar', + description: '', + }, + { + name: 'requester_from', + type: 'varchar', + description: '', + }, + { + name: 'properties', + type: 'json', + description: + 'The properties of the activity log records. It is a JSON object.', + }, + { + name: 'data_source_id', + type: 'uuid', + description: '', + }, + ], + data: [ + [ + '22f48920-3aa4-4f62-b78f-fe294262ee9c', + '2024-01-11 08:26:39.728', + 'LOGIN', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '3db9334d-9f40-4ad2-847b-d46892c4a194', + '2024-01-11 08:30:39.655', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'DATA_SOURCE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"b3403e9f-87a0-4a92-a8ff-bc82440e9ec4","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '32de4885-f21d-4f5b-b56e-14abff5fc512', + '2024-01-11 08:31:29.905', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'DATA_SOURCE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"b3403e9f-87a0-4a92-a8ff-bc82440e9ec4","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '962fdfec-c159-49b4-a03c-b5317692efed', + '2024-01-11 08:31:57.222', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'DATA_SOURCE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"b3403e9f-87a0-4a92-a8ff-bc82440e9ec4","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'd3796db1-4750-4313-b778-3dd9c338ad58', + '2024-01-11 08:32:06.813', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'DATA_SOURCE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"b3403e9f-87a0-4a92-a8ff-bc82440e9ec4","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '2cf5da90-6c65-4904-b96f-12fe8f6edeec', + '2024-01-11 08:37:23.353', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '1b708163-e6b6-49f8-80d5-dc4f6f199def', + '2024-01-11 08:37:35.370', + 'LOGIN', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"}}', + null, + ], + [ + '0d90a6ce-e7ca-4632-a2f8-e12bad8ea836', + '2024-01-11 08:47:19.025', + 'LOGOUT', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'deb430f9-bd6f-4418-93a2-c2caa65efc5f', + '2024-01-11 08:47:20.526', + 'LOGIN', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '88af64b9-7bc1-4a3f-95fd-61a5b8667030', + '2024-01-11 08:57:11.295', + 'LOGOUT', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"}}', + null, + ], + [ + '1b2e1b01-a9f6-498f-a6a5-9168ff4ad01b', + '2024-01-11 08:57:14.235', + 'LOGIN', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"}}', + null, + ], + [ + 'c4483a3f-4d11-4a90-90a9-4e47dd20e5f7', + '2024-01-11 08:57:49.935', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'ROLE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"b610344d-f321-49fc-8501-e4ea21908c2f","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'a2cc54fc-2f5c-4bac-95c9-2ce698645fd6', + '2024-01-11 08:57:59.903', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"user":{"accountEnabled":"Enabled","accountRole":"Member","canUseCredentials":"Disabled","canUsePersonalAccessToken":"Disabled","dataSourceCreationPermission":"Enabled","email":"contact+no-admin@cannerdata.com","groups":["E2E"],"impersonation":"Disabled","userName":"na","workspaceCreationPermission":"Enabled"}},"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '016d67de-00f4-41d8-9df3-959767ed2af2', + '2024-01-11 09:47:41.519', + 'DOWNLOAD', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER_PLATFORM_PERMISSION_LIST', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '43e90c41-f4f5-407c-b8ab-713d43e53de4', + '2024-01-11 09:48:59.355', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"user":{"accountEnabled":"Enabled","accountRole":"Member","canUseCredentials":"Enabled","canUsePersonalAccessToken":"Enabled","dataSourceCreationPermission":"Enabled","email":"contact+sa@cannerdata.com","groups":["-"],"impersonation":"Disabled","userName":"sa","workspaceCreationPermission":"Enabled"}},"id":"85353f5e-d248-4b23-ab68-de2d23abb576","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '53b8b13f-7bcb-4a63-8402-368ed1d00f86', + '2024-01-12 03:00:35.802', + 'LOGIN', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'd2ec64cd-b63f-46ed-aa25-d697baaa0dbd', + '2024-01-12 06:02:14.912', + 'LOGIN', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '50be82ba-8b94-4aac-9554-cd488e73bc1d', + '2024-01-12 06:02:45.669', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'ROLE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"19741eb6-a1a6-4b4d-83fe-4a10ca7dd7df","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '9f3169a5-d7e3-472d-9b6b-b6a4d8b976e3', + '2024-01-12 06:03:00.746', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Enable","canUsePersonalAccessToken":"Enable","dataSourceCreationPermission":"Enable","email":"contact+sa@cannerdata.com","groups":["CLI Team","E2E"],"impersonation":"Disable","userName":"sa","workspaceCreationPermission":"Enable"},"id":"85353f5e-d248-4b23-ab68-de2d23abb576","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '4c6bb269-8fd8-4bbf-8884-73870c1dd6d2', + '2024-01-12 06:03:38.743', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Disable","canUsePersonalAccessToken":"Disable","dataSourceCreationPermission":"Disable","email":"contact+asdf@cannerdata.com","groups":null,"impersonation":"Disable","userName":"asdf","workspaceCreationPermission":"Disable"},"id":"b7715ef6-1772-40fa-ac00-ccdbe523b0d5","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'f8d121dc-ea9a-4cae-b50f-7986bb95dab8', + '2024-01-12 06:04:02.959', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Disable","canUsePersonalAccessToken":"Disable","dataSourceCreationPermission":"Disable","email":"contact+asdf@cannerdata.com","groups":["CLI Team"],"impersonation":"Disable","userName":"asdf","workspaceCreationPermission":"Disable"},"id":"b7715ef6-1772-40fa-ac00-ccdbe523b0d5","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'c9711a55-3008-490c-a3b8-027898129e52', + '2024-01-12 06:05:52.311', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Disable","canUsePersonalAccessToken":"Disable","dataSourceCreationPermission":"Disable","email":"contact+tester-001@cannerdata.com","groups":null,"impersonation":"Disable","userName":"tester-001","workspaceCreationPermission":"Disable"},"id":"605910fb-10a0-4201-bc38-2f1d1e8fa486","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '634854b8-992f-48fa-bada-c99f59db74c4', + '2024-01-12 06:06:11.341', + 'LOGIN', + '605910fb-10a0-4201-bc38-2f1d1e8fa486', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"605910fb-10a0-4201-bc38-2f1d1e8fa486","requester":{"id":"605910fb-10a0-4201-bc38-2f1d1e8fa486","username":"tester-001"}}', + null, + ], + [ + '0a0ca1e0-b6e8-4711-968d-88945a5f4263', + '2024-01-12 06:07:28.448', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Disable","canUsePersonalAccessToken":"Disable","dataSourceCreationPermission":"Disable","email":"contact+tester-001@cannerdata.com","groups":["CLI Team"],"impersonation":"Disable","userName":"tester-001","workspaceCreationPermission":"Disable"},"id":"605910fb-10a0-4201-bc38-2f1d1e8fa486","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '07f6d95b-71f5-44c8-8b05-ba77d4a1d026', + '2024-01-12 06:10:30.975', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Disable","canUsePersonalAccessToken":"Disable","dataSourceCreationPermission":"Enable","email":"contact+tester-001@cannerdata.com","groups":["CLI Team"],"impersonation":"Disable","userName":"tester-001","workspaceCreationPermission":"Enable"},"id":"605910fb-10a0-4201-bc38-2f1d1e8fa486","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '9a68e134-69bd-404c-9895-7765a8e78593', + '2024-01-12 06:12:59.218', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Enable","canUsePersonalAccessToken":"Enable","dataSourceCreationPermission":"Enable","email":"contact+tester-001@cannerdata.com","groups":["CLI Team"],"impersonation":"Disable","userName":"tester-001","workspaceCreationPermission":"Enable"},"id":"605910fb-10a0-4201-bc38-2f1d1e8fa486","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'd6dfc2a3-9259-422e-8b08-5d5361ac4993', + '2024-01-12 06:25:40.461', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Disable","canUsePersonalAccessToken":"Disable","dataSourceCreationPermission":"Enable","email":"contact+asdf@cannerdata.com","groups":["CLI Team"],"impersonation":"Disable","userName":"asdf","workspaceCreationPermission":"Enable"},"id":"b7715ef6-1772-40fa-ac00-ccdbe523b0d5","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'f40be671-38cc-4335-a431-04b1e814fa9d', + '2024-01-12 06:39:32.055', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Enable","canUsePersonalAccessToken":"Disable","dataSourceCreationPermission":"Disable","email":"contact+tester-001@cannerdata.com","groups":["CLI Team"],"impersonation":"Disable","username":"tester-001","workspaceCreationPermission":"Disable"},"id":"605910fb-10a0-4201-bc38-2f1d1e8fa486","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '7d2c2ef3-99d8-4b6e-871d-2358394f6f71', + '2024-01-12 07:44:44.005', + 'LOGOUT', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '3207d3bb-621c-4079-8deb-eff98889ae43', + '2024-01-18 05:45:38.580', + 'LOGIN', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '0442f5a3-1945-4b48-965a-8a1869ae6eb5', + '2024-01-18 05:46:27.113', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'DATA_SOURCE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"displayName":"Web-DB"},"id":"7cd9d4ea-9cb9-4e01-8323-940bc39530c7","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + '7cd9d4ea-9cb9-4e01-8323-940bc39530c7', + ], + [ + 'd0528ffd-26e4-4054-b3b1-0395fb7a8ff0', + '2024-01-18 05:49:20.242', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'DATA_SOURCE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"displayName":"Web-DB-0118"},"before":{"displayName":"Web-DB"},"id":"7cd9d4ea-9cb9-4e01-8323-940bc39530c7","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + '7cd9d4ea-9cb9-4e01-8323-940bc39530c7', + ], + [ + 'a3727363-c265-4844-8fb5-7869f8ad58c9', + '2024-01-18 05:49:38.805', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + 'b6737108-3dff-487a-81f6-7a2036701d42', + 'WORKSPACE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"b6737108-3dff-487a-81f6-7a2036701d42","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '3e99945b-9789-4b8c-8286-3eed81cb9aaa', + '2024-01-18 05:49:50.664', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + 'b6737108-3dff-487a-81f6-7a2036701d42', + 'WORKSPACE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"workspaceName":"E2E-1"},"before":{"workspaceName":"E2E"},"id":"b6737108-3dff-487a-81f6-7a2036701d42","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '7e23c1c4-07b0-40e4-b6ad-e274452eaa8d', + '2024-01-18 05:50:57.243', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + 'b6737108-3dff-487a-81f6-7a2036701d42', + 'WORKSPACE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"b6737108-3dff-487a-81f6-7a2036701d42","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"},"workspaceName":"E2E-1"}', + null, + ], + [ + '21e5b08b-17a6-47fd-998c-0bda22e1c1e0', + '2024-01-18 05:51:02.425', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'DATA_SOURCE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"dataSourceName":"Web-DB-0118","id":"7cd9d4ea-9cb9-4e01-8323-940bc39530c7","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + '7cd9d4ea-9cb9-4e01-8323-940bc39530c7', + ], + [ + '440ea009-cb9d-406f-9777-259678a99de2', + '2024-01-18 07:21:16.115', + 'LOGIN', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'be29a4af-7f95-4a87-9d20-fa01736b8dd7', + '2024-01-18 07:36:23.269', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'DATA_SOURCE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"displayName":"Web-DB"},"id":"ef6752a9-bc39-47b3-8c19-2410accbb274","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + 'ef6752a9-bc39-47b3-8c19-2410accbb274', + ], + [ + 'b74e84ba-6b1d-4aec-aca1-bebda9eed3c3', + '2024-01-18 07:36:57.332', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'DATA_SOURCE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"displayName":"Web-DB-0"},"before":{"displayName":"Web-DB"},"id":"ef6752a9-bc39-47b3-8c19-2410accbb274","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + 'ef6752a9-bc39-47b3-8c19-2410accbb274', + ], + [ + 'a25f9a9c-6772-4dc7-8596-0c4141c4a978', + '2024-01-18 07:37:01.589', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'WORKSPACE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"4a214fd0-c679-40f8-b008-5ada95f373d9","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '30b59cdc-1343-46a6-81a9-2cc5ef1eec84', + '2024-01-18 07:37:12.862', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"applyPolicyPermission":"Disable","email":"contact+no-admin@cannerdata.com","name":"na","role":"Data Steward"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + 'ef6752a9-bc39-47b3-8c19-2410accbb274', + ], + [ + '0930407a-62d2-4e6d-85e1-99220628f64c', + '2024-01-18 07:37:26.398', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Disable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","email":"contact+no-admin@cannerdata.com","name":"na","role":"Data Consumer","sharedTableCreationPermission":"Disable","tableauPublishManagementPermission":"Enable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '8ef9f37f-a1a5-475b-949f-fe7c7508a60d', + '2024-01-18 07:50:00.081', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Enable","applyPolicyPermission":"Enable","dataServicePermission":"Enable","email":"contact+no-admin@cannerdata.com","name":"na","role":"Data Consumer","sharedTableCreationPermission":"Disable","tableauPublishManagementPermission":"Enable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '23f08015-83c7-4f4b-91de-0f44e21a2871', + '2024-01-18 07:50:15.172', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Disable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","email":"contact+no-admin@cannerdata.com","name":"na","role":"Data Consumer","sharedTableCreationPermission":"Disable","tableauPublishManagementPermission":"Disable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'dc4ad368-f7a6-4400-b2a4-b773b5774870', + '2024-01-18 07:50:40.389', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Enable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","name":"CLI Team","role":"Data Analyst","sharedTableCreationPermission":"Disable","tableauPublishManagementPermission":"Disable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'e27e2ade-8ceb-4ded-bc36-c0856e1abf4d', + '2024-01-18 07:50:51.546', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Disable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","name":"CLI Team","role":"Data Analyst","sharedTableCreationPermission":"Disable","tableauPublishManagementPermission":"Disable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'c906da0d-ff17-40fc-be65-33810185c940', + '2024-01-18 07:50:56.252', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Enable","applyPolicyPermission":"Enable","dataServicePermission":"Enable","name":"E2E","role":"Owner","sharedTableCreationPermission":"Enable","tableauPublishManagementPermission":"Enable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'f3db925d-3216-4ebe-91d1-ea1408a7f2c2', + '2024-01-18 07:51:08.474', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Disable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","name":"E2E","role":"Owner","sharedTableCreationPermission":"Disable","tableauPublishManagementPermission":"Disable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '8e7b6240-e6cb-4692-aa18-788217a28bbd', + '2024-01-18 07:51:19.896', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"applyPolicyPermission":"Enable","name":"CLI Team","role":"Data Steward"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + 'ef6752a9-bc39-47b3-8c19-2410accbb274', + ], + [ + '06477709-b727-4bdf-a8d9-d4134595e219', + '2024-01-18 07:51:31.478', + 'UPDATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"applyPolicyPermission":"Disable","name":"CLI Team","role":"Data Steward"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + 'ef6752a9-bc39-47b3-8c19-2410accbb274', + ], + [ + '24ae696e-fb59-4e38-992f-607ce160780d', + '2024-01-18 07:51:36.046', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"applyPolicyPermission":"Disable","name":"CLI Team","role":"Data Steward"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + 'ef6752a9-bc39-47b3-8c19-2410accbb274', + ], + [ + '8759552f-c687-4e46-b7b6-e19a02d4fb2f', + '2024-01-18 08:53:06.714', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Disable","canUsePersonalAccessToken":"Disable","dataSourceCreationPermission":"Disable","email":"contact+asdfsfda@cannerdata.com","groups":null,"impersonation":"Disable","username":"weesdf","workspaceCreationPermission":"Disable"},"id":"a505bf51-6b42-4475-a377-093782c33a6a","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '085c0a92-f341-4d14-8441-e6ea2f449dca', + '2024-01-18 08:53:54.959', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Disable","canUsePersonalAccessToken":"Disable","dataSourceCreationPermission":"Disable","email":"contact+asdfsfda@cannerdata.com","groups":null,"impersonation":"Disable","username":"weesdf","workspaceCreationPermission":"Disable"},"id":"a505bf51-6b42-4475-a377-093782c33a6a","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'fce22e0c-49f3-41ba-bd9b-99b50a52b3c8', + '2024-01-18 08:54:11.669', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Disable","canUsePersonalAccessToken":"Disable","dataSourceCreationPermission":"Disable","email":"contact+asdfsadfsda@cannerdata.com","groups":null,"impersonation":"Disable","username":"sadfsadfsadf","workspaceCreationPermission":"Disable"},"id":"1f723c76-28b3-4e6c-950a-59bded57f2c4","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '14431330-2334-4c33-8511-96b9e379370b', + '2024-01-18 08:55:56.559', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"accountEnabled":"Enable","accountRole":"Member","canUseCredentials":"Disable","canUsePersonalAccessToken":"Disable","dataSourceCreationPermission":"Disable","email":"contact+adsdfamin@cannerdata.com","groups":null,"impersonation":"Disable","username":"freasdfdalai","workspaceCreationPermission":"Disable"},"id":"0b7e6dec-695d-4f1e-9502-8882541fda02","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'f74122d5-ed6e-47be-88b6-23c10fb6f646', + '2024-01-23 02:14:19.042', + 'LOGIN', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '1b20b911-ec38-4d23-b731-f16233772b90', + '2024-01-23 02:16:17.907', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'TABLE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"76d2fca5-9539-41fb-b25e-ab78c26113cc","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '28ceb299-74fb-4b28-94fc-59277aa9995f', + '2024-01-23 02:16:29.240', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Enable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","name":"E2E","role":"Data Analyst","sharedTableCreationPermission":"Enable","tableauPublishManagementPermission":"Disable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '3c6ef5d8-3339-4c4e-914b-3f846b0281de', + '2024-01-23 02:17:36.294', + 'LOGIN', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"}}', + null, + ], + [ + '6930af9e-e618-4c3e-8e69-536dfb9a74a2', + '2024-01-23 02:18:02.921', + 'VIEW', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'DATASET', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"20240123_021801_00000_7xqq2","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"},"sql":"select * from \\"activity_logs\\" limit 100"}', + null, + ], + [ + '852e5352-0b53-4ad8-9576-a4b0016de493', + '2024-01-23 02:18:21.585', + 'REQUEST_TO_EXPORT_CSV_FILE', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'DATASET', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"requestReason":"sadf","sql":"select * from \\"activity_logs\\" limit 10000"}', + null, + ], + [ + 'ee2e5498-1413-4668-8af2-31248a778c0a', + '2024-01-23 02:19:06.323', + 'APPROVE_TO_EXPORT_CSV_FILE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'DATASET', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"requestReason":"sadf","requesterName":"na","reviewerName":"admin","sql":"select * from \\"activity_logs\\" limit 10000"}', + null, + ], + [ + '3cda2c67-188d-4ba2-856c-fea31ee0cdac', + '2024-01-23 02:19:16.728', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '4a214fd0-c679-40f8-b008-5ada95f373d9', + 'WORKSPACE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"4a214fd0-c679-40f8-b008-5ada95f373d9","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"},"workspaceName":"ww"}', + null, + ], + [ + '68aeba59-a6f4-4b88-88e1-ca02b9b6c2b6', + '2024-01-23 02:19:26.377', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'WORKSPACE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"129482ec-5980-4507-9813-5ca278a30b20","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '1ae2c798-6efe-490c-8cb0-67eaaced9812', + '2024-01-23 02:19:33.411', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '1fa97f67-f8c9-465f-84e3-5fe3f2672b79', + 'WORKSPACE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"1fa97f67-f8c9-465f-84e3-5fe3f2672b79","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '0e8f1e66-6fbc-4616-a8ae-f7525473e47a', + '2024-01-23 02:19:47.806', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '1fa97f67-f8c9-465f-84e3-5fe3f2672b79', + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Enable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","name":"E2E","role":"Data Analyst","sharedTableCreationPermission":"Enable","tableauPublishManagementPermission":"Disable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'bb34740b-d259-4d67-bf0b-d6dccdf1cc6f', + '2024-01-23 02:36:41.903', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'TABLE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"4ef146fe-ae19-4ed6-9c72-88187bbd765d","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '5eba9708-b52f-48ee-8a9e-9fe03f5794be', + '2024-01-23 02:36:54.128', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Enable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","name":"E2E","role":"Data Analyst","sharedTableCreationPermission":"Disable","tableauPublishManagementPermission":"Disable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'b7f4cb01-3bde-41d4-844c-d82ada7b28d1', + '2024-01-23 02:37:08.874', + 'LOGIN', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'c9085d1e-0492-4a2d-9b50-3a281d9492fb', + '2024-01-23 02:38:04.247', + 'LOGOUT', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '94f308ee-74ac-4876-9c76-7cc9ea0f86ba', + '2024-01-23 02:38:07.864', + 'LOGIN', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"}}', + null, + ], + [ + '57885218-67aa-4e67-9a50-a2449779f22f', + '2024-01-23 02:38:24.953', + 'VIEW', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'DATASET', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"20240123_023825_00002_7xqq2","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"},"sql":"select * from \\"activity_logs\\" limit 100"}', + null, + ], + [ + 'd3b583c2-7701-415d-900e-d23b13505a9f', + '2024-01-23 02:38:37.777', + 'REQUEST_TO_EXPORT_CSV_FILE', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'DATASET', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"requestReason":"will be removed from ws","sql":"select * from \\"activity_logs\\" limit 10000"}', + null, + ], + [ + '990433fa-6a6c-4ef8-89e0-bff48a5bafa5', + '2024-01-23 02:39:05.855', + 'APPROVE_TO_EXPORT_CSV_FILE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'DATASET', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"requestReason":"will be removed from ws","requesterName":"na","reviewerName":"admin","sql":"select * from \\"activity_logs\\" limit 10000"}', + null, + ], + [ + 'ae9e5b43-79ba-45fc-b48b-0344b2ed0853', + '2024-01-23 02:40:29.336', + 'DOWNLOAD', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'DATASET', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"20240123_024030_00004_7xqq2","requestReason":"will be removed from ws","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"},"reviewerName":"admin","sql":"select * from activity_logs limit 10000"}', + null, + ], + [ + 'd17b80be-6d38-4517-a408-7d62c6703968', + '2024-01-23 02:40:45.666', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Disable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","name":"E2E","role":"Data Analyst","sharedTableCreationPermission":"Disable","tableauPublishManagementPermission":"Disable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'd01d2f8b-b7b3-4798-b01f-c3257c236574', + '2024-01-23 02:41:26.843', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Disable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","name":"E2E","role":"Data Analyst","sharedTableCreationPermission":"Enable","tableauPublishManagementPermission":"Disable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'ffb166c8-231d-459a-9b08-081abd815029', + '2024-01-23 02:42:06.680', + 'REQUEST_TO_CREATE_SHARED_TABLE', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'SYNONYM', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"baseObjectSqlName":"activity_logs","columns":[{"args":[],"columns":[],"name":"created_at","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":26,"decimalDigits":3,"jdbcType":93,"jdbcTypeName":"timestamp"}},"type":"TIMESTAMP"},{"args":[],"columns":[],"name":"data_source_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"},{"args":[],"columns":[],"name":"id","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"},{"args":[],"columns":[],"name":"operation","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_operation"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"properties","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"json"}},"type":"JSON"},{"args":[],"columns":[],"name":"requester_from","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_requester_from"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"requester_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"varchar"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"requester_type","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_requester_type"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"resource","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_resource"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"status","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_status"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"workspace_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"}],"dataResourceType":"WORKSPACE_TABLE","destWorkspaceName":"w_desc","displayName":"activity_logs_shared","sqlName":"activity_logs_shared"}', + null, + ], + [ + '54aa2285-d80b-4824-9a18-7dfcad2ba2ec', + '2024-01-23 02:42:23.707', + 'VIEW', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'DATASET', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"20240123_024224_00006_7xqq2","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"},"sql":"select * from \\"activity_logs\\" limit 100"}', + null, + ], + [ + '99886746-8f73-43c4-92ec-25fc67ede58c', + '2024-01-23 02:42:39.734', + 'APPROVE_TO_CREATE_SHARED_TABLE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'SYNONYM', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"baseObjectSqlName":"activity_logs","baseObjectWorkspaceId":"129482ec-5980-4507-9813-5ca278a30b20","baseWorkspaceName":"w_source","columns":[{"args":[],"columns":[],"name":"created_at","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":26,"decimalDigits":3,"jdbcType":93,"jdbcTypeName":"timestamp"}},"type":"TIMESTAMP"},{"args":[],"columns":[],"name":"data_source_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"},{"args":[],"columns":[],"name":"id","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"},{"args":[],"columns":[],"name":"operation","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_operation"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"properties","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"json"}},"type":"JSON"},{"args":[],"columns":[],"name":"requester_from","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_requester_from"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"requester_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"varchar"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"requester_type","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_requester_type"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"resource","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_resource"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"status","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_status"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"workspace_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"}],"dataResourceType":"WORKSPACE_TABLE","destWorkspaceName":"w_desc","displayName":"activity_logs_shared","requestReason":null,"requesterName":"na","sqlName":"activity_logs_shared","workspaceId":"1fa97f67-f8c9-465f-84e3-5fe3f2672b79"}', + null, + ], + [ + '969aedf0-e7a2-4bf8-89ad-7d369e82c33a', + '2024-01-23 02:42:39.956', + 'CREATE', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + '1fa97f67-f8c9-465f-84e3-5fe3f2672b79', + 'SYNONYM', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"baseObjectSqlName":"activity_logs","baseObjectWorkspaceId":"129482ec-5980-4507-9813-5ca278a30b20","baseObjectWorkspaceSqlName":"w_source","baseWorkspaceName":"w_source","cached":false,"columns":[{"args":[],"columns":[],"name":"created_at","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":26,"decimalDigits":3,"jdbcType":93,"jdbcTypeName":"timestamp"}},"type":"TIMESTAMP"},{"args":[],"columns":[],"name":"data_source_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"},{"args":[],"columns":[],"name":"id","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"},{"args":[],"columns":[],"name":"operation","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_operation"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"properties","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"json"}},"type":"JSON"},{"args":[],"columns":[],"name":"requester_from","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_requester_from"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"requester_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"varchar"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"requester_type","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_requester_type"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"resource","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_resource"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"status","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_status"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"workspace_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"}],"dataResourceType":"WORKSPACE_TABLE","destWorkspaceName":"w_desc","displayName":"activity_logs_shared","id":"8f9c4501-6df5-4155-a23f-b0fa91c4d826","requestReason":null,"requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"},"reviewerName":"admin","semantic":false,"sqlName":"activity_logs_shared","workspaceId":"1fa97f67-f8c9-465f-84e3-5fe3f2672b79"}', + null, + ], + [ + '2e0cad73-f9c4-4391-9f74-c763985d4111', + '2024-01-23 02:42:55.092', + 'VIEW', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'DATASET', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"20240123_024256_00008_7xqq2","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"},"sql":"select * from \\"activity_logs\\" limit 100"}', + null, + ], + [ + '9947dd37-7110-47bc-8708-f13c057fa4f0', + '2024-01-23 02:43:35.228', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Disable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","name":"E2E","role":"Data Analyst","sharedTableCreationPermission":"Disable","tableauPublishManagementPermission":"Disable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '85225b8c-0694-4ff8-a15e-45a3269cfa10', + '2024-01-23 03:51:11.408', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'TABLE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"cf56d017-08cc-4c91-9067-5a100587b7b1","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '25200116-24bf-41c3-aaf0-b84f5a48c788', + '2024-01-23 03:51:25.183', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '1fa97f67-f8c9-465f-84e3-5fe3f2672b79', + 'SYNONYM', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"baseObjectSqlName":"t_test_02_28666","baseObjectWorkspaceId":"129482ec-5980-4507-9813-5ca278a30b20","baseObjectWorkspaceSqlName":"w_source","baseWorkspaceName":"w_source","cached":false,"columns":[{"args":[5,0],"columns":[],"name":"col_a","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":5,"decimalDigits":0,"jdbcType":2,"jdbcTypeName":"numeric"}},"type":"DECIMAL"},{"args":[5,3],"columns":[],"name":"col_b","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":5,"decimalDigits":3,"jdbcType":2,"jdbcTypeName":"numeric"}},"type":"DECIMAL"},{"args":[38,0],"columns":[],"name":"col_c","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":38,"decimalDigits":0,"jdbcType":2,"jdbcTypeName":"numeric"}},"type":"DECIMAL"},{"args":[],"columns":[],"name":"id","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":19,"decimalDigits":0,"jdbcType":-5,"jdbcTypeName":"int8"}},"type":"BIGINT"}],"dataResourceType":"WORKSPACE_TABLE","destWorkspaceName":"w_desc","displayName":"t_test_02_28666_shared","id":"98d1ef71-a73d-4ce7-8fcf-122c9dd6b904","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"},"semantic":false,"workspaceId":"1fa97f67-f8c9-465f-84e3-5fe3f2672b79"}', + null, + ], + [ + '94774246-7d6b-4948-a0e9-c4e4a2074f3d', + '2024-01-23 08:01:47.563', + 'LOGIN', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'd5851226-ed9b-4c03-ad01-95af9367a2ba', + '2024-01-23 08:46:34.773', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Enable","applyPolicyPermission":"Enable","dataServicePermission":"Enable","email":"contact+no-admin@cannerdata.com","name":"na","role":"Owner","sharedTableCreationPermission":"Enable","tableauPublishManagementPermission":"Enable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '26114552-426c-4257-88ae-98e553141046', + '2024-01-23 08:46:43.957', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '75520f79-81f5-41bf-8b8a-7ef41001ee63', + 'WORKSPACE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"75520f79-81f5-41bf-8b8a-7ef41001ee63","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'b9f32af3-a722-41b4-9e4c-ea7547777b72', + '2024-01-23 08:56:41.912', + 'LOGIN', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"}}', + null, + ], + [ + '7c2b31c0-fe3f-4a57-9953-f0b02280efa2', + '2024-01-23 08:57:03.622', + 'CREATE', + '781ce527-6e6c-43a1-a21b-aad85d327cc5', + 'BASIC_USER', + '75520f79-81f5-41bf-8b8a-7ef41001ee63', + 'SYNONYM', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"baseObjectSqlName":"activity_logs","baseObjectWorkspaceId":"129482ec-5980-4507-9813-5ca278a30b20","baseObjectWorkspaceSqlName":"w_source","baseWorkspaceName":"w_source","cached":false,"columns":[{"args":[],"columns":[],"name":"created_at","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":26,"decimalDigits":3,"jdbcType":93,"jdbcTypeName":"timestamp"}},"type":"TIMESTAMP"},{"args":[],"columns":[],"name":"data_source_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"},{"args":[],"columns":[],"name":"id","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"},{"args":[],"columns":[],"name":"operation","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_operation"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"properties","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"json"}},"type":"JSON"},{"args":[],"columns":[],"name":"requester_from","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_requester_from"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"requester_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"varchar"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"requester_type","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_requester_type"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"resource","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_resource"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"status","properties":{"jdbc-nullable":false,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":12,"jdbcTypeName":"enum_status"}},"type":"VARCHAR"},{"args":[],"columns":[],"name":"workspace_id","properties":{"jdbc-nullable":true,"jdbc-type-handle":{"columnSize":2147483647,"decimalDigits":0,"jdbcType":1111,"jdbcTypeName":"uuid"}},"type":"UUID"}],"dataResourceType":"WORKSPACE_TABLE","destWorkspaceName":"www","displayName":"activity_logs_45190","id":"a1296db8-3d20-428f-a4b1-787efa62a16d","requester":{"id":"781ce527-6e6c-43a1-a21b-aad85d327cc5","username":"na"},"semantic":false,"workspaceId":"75520f79-81f5-41bf-8b8a-7ef41001ee63"}', + null, + ], + [ + '9df11c00-7b00-4fd6-b2c2-ed2b8658fbf8', + '2024-01-23 11:00:11.245', + 'REVOKE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '75520f79-81f5-41bf-8b8a-7ef41001ee63', + 'SYNONYM', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"baseObjectSqlName":"activity_logs","baseWorkspaceName":"w_source","destWorkspaceName":"activity_logs_45190","displayName":"activity_logs_45190"}', + null, + ], + [ + '41ab6f89-0be0-49a7-8fb4-d3141b76553d', + '2024-01-23 11:00:11.245', + 'REVOKE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '1fa97f67-f8c9-465f-84e3-5fe3f2672b79', + 'SYNONYM', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"baseObjectSqlName":"activity_logs","baseWorkspaceName":"w_source","destWorkspaceName":"activity_logs_shared","displayName":"activity_logs_shared"}', + null, + ], + [ + '7b8ad02d-082e-4620-8e8d-7e50a91d1824', + '2024-01-23 11:00:11.378', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '129482ec-5980-4507-9813-5ca278a30b20', + 'WORKSPACE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"129482ec-5980-4507-9813-5ca278a30b20","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"},"workspaceName":"w_source"}', + null, + ], + [ + '9ae2a77e-08cb-4694-aa8d-4710e2fd5b8f', + '2024-01-23 11:00:35.238', + 'DELETE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '1fa97f67-f8c9-465f-84e3-5fe3f2672b79', + 'WORKSPACE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"1fa97f67-f8c9-465f-84e3-5fe3f2672b79","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"},"workspaceName":"w_desc"}', + null, + ], + [ + 'dc2b0082-2115-49e9-aeca-aed6988dcdf6', + '2024-01-23 12:06:41.455', + 'LOGOUT', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'e80131e9-6b98-4832-955b-d813ebd8faef', + '2024-01-24 07:08:10.647', + 'LOGIN', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '9ec0a8ba-d128-46de-9d7c-46bb954fcd32', + '2024-01-24 07:09:07.722', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '75520f79-81f5-41bf-8b8a-7ef41001ee63', + 'TABLE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"257cb3a3-5636-4fbf-9b1b-ad9d309e2147","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'a17d7696-6a04-494e-952a-be2c3a318d3a', + '2024-01-24 07:09:17.726', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '75520f79-81f5-41bf-8b8a-7ef41001ee63', + 'TABLE', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"id":"a2483a8b-462e-4118-8a17-e7bb6dfad20c","requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + '181c4d4f-81b3-4e81-91a2-de8a539a4c97', + '2024-01-24 07:09:25.137', + 'CREATE', + '618115d5-9243-4f3d-8ce9-1cf849cabc57', + 'BASIC_USER', + '75520f79-81f5-41bf-8b8a-7ef41001ee63', + 'GROUP', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"after":{"CSVExportPermission":"Enable","applyPolicyPermission":"Disable","dataServicePermission":"Disable","name":"E2E","role":"Data Analyst","sharedTableCreationPermission":"Enable","tableauPublishManagementPermission":"Disable"},"requester":{"id":"618115d5-9243-4f3d-8ce9-1cf849cabc57","username":"admin"}}', + null, + ], + [ + 'e1784e89-0b91-4cb6-ab00-3969e7ce6dfc', + '2024-01-24 07:10:10.667', + 'LOGIN', + '1f723c76-28b3-4e6c-950a-59bded57f2c4', + 'BASIC_USER', + null, + 'USER', + 'SUCCESS', + 'CANNERFLOW_UI', + '{"detail":{"ip":"::1"},"id":"1f723c76-28b3-4e6c-950a-59bded57f2c4","requester":{"id":"1f723c76-28b3-4e6c-950a-59bded57f2c4","username":"sadfsadfsadf"}}', + null, + ], + ], + startedAt: '2024-03-06T02:55:30.840Z', + error: null, + }; + + const columns = useMemo( + () => getPreviewColumns(previewData.columns), + [previewData], + ); + + return ; +} diff --git a/wren-ui/src/components/ask/StepContent.tsx b/wren-ui/src/components/ask/StepContent.tsx new file mode 100644 index 000000000..02ad3ddbc --- /dev/null +++ b/wren-ui/src/components/ask/StepContent.tsx @@ -0,0 +1,77 @@ +import { Avatar, Button, ButtonProps, Col, Row, Typography } from 'antd'; +import FunctionOutlined from '@ant-design/icons/FunctionOutlined'; +import { BinocularsIcon } from '@/utils/icons'; +import CollapseContent, { + Props as CollapseContentProps, +} from '@/components/ask/CollapseContent'; +import useAnswerStepContent from '@/hooks/useAnswerStepContent'; + +const { Title, Paragraph } = Typography; + +interface Props { + fullSql: string; + isLastStep: boolean; + sql: string; + stepNumber: number; + summary: string; +} + +export default function StepContent(props: Props) { + const { fullSql, isLastStep, sql, stepNumber, summary } = props; + + const { + collapseContentProps, + previewDataButtonProps, + viewSQLButtonProps, + viewSQLButtonText, + } = useAnswerStepContent({ + fullSql, + isLastStep, + sql, + }); + + return ( + +
+ + {stepNumber} + + + + + + {summary} + + + + + + + + ); +} diff --git a/wren-ui/src/components/diagram/Context.ts b/wren-ui/src/components/diagram/Context.ts new file mode 100644 index 000000000..375985716 --- /dev/null +++ b/wren-ui/src/components/diagram/Context.ts @@ -0,0 +1,18 @@ +import { createContext } from 'react'; +import { ModelData, MetricData, ViewData } from '@/utils/data'; + +export interface ClickPayload { + [key: string]: any; + title: string; + data: ModelData | MetricData | ViewData; +} + +type ContextProps = { + onMoreClick: (data: ClickPayload) => void; + onNodeClick: (data: ClickPayload) => void; +} | null; + +export const DiagramContext = createContext({ + onMoreClick: () => {}, + onNodeClick: () => {}, +}); diff --git a/wren-ui/src/components/diagram/CustomDropdown.tsx b/wren-ui/src/components/diagram/CustomDropdown.tsx new file mode 100644 index 000000000..5e56383c9 --- /dev/null +++ b/wren-ui/src/components/diagram/CustomDropdown.tsx @@ -0,0 +1,37 @@ +import { Dropdown, Menu } from 'antd'; +import { MORE_ACTION } from '@/utils/enum'; + +interface Props { + onMoreClick: (type: MORE_ACTION) => void; + children: React.ReactNode; +} + +export default function CustomDropdown(props: Props) { + const { onMoreClick, children } = props; + return ( + e.domEvent.stopPropagation()} + items={[ + { + label: 'Edit', + key: MORE_ACTION.EDIT, + onClick: () => onMoreClick(MORE_ACTION.EDIT), + }, + { + label: 'Delete', + className: 'red-5', + key: MORE_ACTION.DELETE, + onClick: () => onMoreClick(MORE_ACTION.DELETE), + }, + ]} + /> + } + > + {children} + + ); +} diff --git a/wren-ui/src/components/diagram/CustomPopover.tsx b/wren-ui/src/components/diagram/CustomPopover.tsx new file mode 100644 index 000000000..b3499568b --- /dev/null +++ b/wren-ui/src/components/diagram/CustomPopover.tsx @@ -0,0 +1,34 @@ +import { Popover, PopoverProps, Row, Col, Typography } from 'antd'; + +type Props = PopoverProps; + +export default function CustomPopover(props: Props) { + const { children } = props; + + return ( + + {children} + + ); +} + +const CustomPopoverCol = (props: { + title: string; + children: React.ReactNode; + code?: boolean; + span?: number; + marginBottom?: number; +}) => { + const { title, children, code, span = 24, marginBottom = 8 } = props; + return ( + +
{title}
+
+ {children} +
+ + ); +}; + +CustomPopover.Row = Row; +CustomPopover.Col = CustomPopoverCol; diff --git a/wren-ui/src/components/diagram/Marker.tsx b/wren-ui/src/components/diagram/Marker.tsx new file mode 100644 index 000000000..794f961e4 --- /dev/null +++ b/wren-ui/src/components/diagram/Marker.tsx @@ -0,0 +1,132 @@ +export default function Marker() { + return ( + + + + + + + + + + + + + + + + + + {/* seleceted */} + + + + + + + + + + + + + + + ); +} diff --git a/wren-ui/src/components/diagram/customEdge/MetricEdge.tsx b/wren-ui/src/components/diagram/customEdge/MetricEdge.tsx new file mode 100644 index 000000000..91d16abb0 --- /dev/null +++ b/wren-ui/src/components/diagram/customEdge/MetricEdge.tsx @@ -0,0 +1,33 @@ +import { memo } from 'react'; +import { BaseEdge, EdgeProps, getSmoothStepPath } from 'reactflow'; + +const MetricEdge = ({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerStart, + markerEnd, +}: EdgeProps) => { + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + + ); +}; + +export default memo(MetricEdge); diff --git a/wren-ui/src/components/diagram/customEdge/ModelEdge.tsx b/wren-ui/src/components/diagram/customEdge/ModelEdge.tsx new file mode 100644 index 000000000..11bc16b9b --- /dev/null +++ b/wren-ui/src/components/diagram/customEdge/ModelEdge.tsx @@ -0,0 +1,102 @@ +import { memo, useMemo } from 'react'; +import { + BaseEdge, + EdgeLabelRenderer, + EdgeProps, + getSmoothStepPath, +} from 'reactflow'; +import styled from 'styled-components'; +import CustomPopover from '../CustomPopover'; +import { getJoinTypeText } from '@/utils/data'; + +const Joint = styled.div` + position: absolute; + width: 30px; + height: 30px; + opacity: 0; +`; + +const ModelEdge = ({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerStart, + markerEnd, + data, +}: EdgeProps) => { + const [edgePath, labelX, labelY] = getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + const isPopoverShow = data.highlight; + const style = isPopoverShow + ? { + stroke: 'var(--geekblue-6)', + strokeWidth: 1.5, + } + : { stroke: 'var(--gray-5)' }; + + const relation = useMemo(() => { + const fromField = `${data.relation.fromField.model}.${data.relation.fromField.field}`; + const toField = `${data.relation.toField.model}.${data.relation.toField.field}`; + return { + name: data.relation.name, + joinType: getJoinTypeText(data.relation.joinType), + description: data.relation.properties?.description || '-', + fromField, + toField, + }; + }, [data.relation]); + + return ( + <> + + + + + {relation.name} + + + {relation.joinType} + + + {relation.fromField} + + + {relation.toField} + + + {relation.description} + + + } + > + + + + + ); +}; + +export default memo(ModelEdge); diff --git a/wren-ui/src/components/diagram/customEdge/index.ts b/wren-ui/src/components/diagram/customEdge/index.ts new file mode 100644 index 000000000..d4d5c1c76 --- /dev/null +++ b/wren-ui/src/components/diagram/customEdge/index.ts @@ -0,0 +1,2 @@ +export { default as ModelEdge } from './ModelEdge'; +export { default as MetricEdge } from './MetricEdge'; diff --git a/wren-ui/src/components/diagram/customNode/Column.tsx b/wren-ui/src/components/diagram/customNode/Column.tsx new file mode 100644 index 000000000..2c75c292d --- /dev/null +++ b/wren-ui/src/components/diagram/customNode/Column.tsx @@ -0,0 +1,123 @@ +import { ReactFlowInstance, useReactFlow } from 'reactflow'; +import styled from 'styled-components'; +import MarkerHandle from './MarkerHandle'; +import CustomPopover from '../CustomPopover'; + +const NodeColumn = styled.div` + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 8px; + color: var(--gray-9); + + &:hover { + background-color: var(--gray-3); + } + + svg { + cursor: auto; + flex-shrink: 0; + } + + .adm-column-title { + display: flex; + align-items: center; + min-width: 1px; + svg { + margin-right: 6px; + } + > span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +`; + +export const ColumnTitle = styled.div` + color: var(--gray-8); + padding: 4px 12px; + cursor: default; +`; + +type ColumnProps = { + id: string; + type: string; + displayName: string; + properties: { + [key: string]: any; + description?: string; + }; + relation?: any; + isCalculated?: boolean; + expression?: string; + style?: React.CSSProperties; + icon: React.ReactNode; + append?: React.ReactNode; + onMouseEnter?: (reactflowInstance: ReactFlowInstance) => void; + onMouseLeave?: (reactflowInstance: ReactFlowInstance) => void; +}; +export default function Column(props: ColumnProps) { + const { + id, + type, + onMouseEnter, + onMouseLeave, + displayName, + style = {}, + icon, + append, + properties, + relation, + isCalculated, + expression, + } = props; + const reactflowInstance = useReactFlow(); + const mouseEnter = onMouseEnter + ? () => onMouseEnter(reactflowInstance) + : undefined; + const mouseLeave = onMouseLeave + ? () => onMouseLeave(reactflowInstance) + : undefined; + + const isPopoverShow = !relation; + + const nodeColumn = ( + +
+ {icon} + {displayName} +
+ {append} + +
+ ); + + return isPopoverShow ? ( + + + {properties?.description || '-'} + + {isCalculated && ( + + {expression} + + )} + + } + > + {nodeColumn} + + ) : ( + nodeColumn + ); +} diff --git a/wren-ui/src/components/diagram/customNode/MarkerHandle.tsx b/wren-ui/src/components/diagram/customNode/MarkerHandle.tsx new file mode 100644 index 000000000..7913653e9 --- /dev/null +++ b/wren-ui/src/components/diagram/customNode/MarkerHandle.tsx @@ -0,0 +1,30 @@ +import { Handle, Position } from 'reactflow'; + +// parent should be position relative +export default function MarkerHandle({ id }: { id: string }) { + return ( + <> + {/* all handlers */} + + + + + + ); +} diff --git a/wren-ui/src/components/diagram/customNode/MetricNode.tsx b/wren-ui/src/components/diagram/customNode/MetricNode.tsx new file mode 100644 index 000000000..81a32dd48 --- /dev/null +++ b/wren-ui/src/components/diagram/customNode/MetricNode.tsx @@ -0,0 +1,86 @@ +import { memo, useCallback, useContext } from 'react'; +import { isEmpty } from 'lodash'; +import { + CachedIcon, + CustomNodeProps, + NodeBody, + NodeHeader, + StyledNode, +} from './utils'; +import MarkerHandle from './MarkerHandle'; +import { DiagramContext } from '../Context'; +import Column, { ColumnTitle } from './Column'; +import CustomDropdown from '../CustomDropdown'; +import { MetricIcon, MoreIcon } from '@/utils/icons'; +import { MORE_ACTION } from '@/utils/enum'; +import { MetricColumnData, MetricData } from '@/utils/data'; +import { getColumnTypeIcon } from '@/utils/columnType'; + +export const MetricNode = ({ data }: CustomNodeProps) => { + const context = useContext(DiagramContext); + const onMoreClick = (type: MORE_ACTION) => { + context?.onMoreClick({ + type, + title: data.originalData.displayName, + data: data.originalData, + }); + }; + const onNodeClick = () => { + context?.onNodeClick({ + title: data.originalData.displayName, + data: data.originalData, + }); + }; + const hasDimensions = !isEmpty(data.originalData.dimensions); + const hasMeasures = !isEmpty(data.originalData.measures); + const hasTimeGrains = !isEmpty(data.originalData.timeGrains); + const hasWindows = !isEmpty(data.originalData.windows); + + const renderColumns = useCallback(getColumns, []); + return ( + + + + + {data.originalData.displayName} + + + + + e.stopPropagation()} + /> + + + + + + + {hasDimensions ? Dimensions : null} + {renderColumns(data.originalData.dimensions || [])} + + {hasMeasures ? Measures : null} + {renderColumns(data.originalData.measures || [])} + + {hasTimeGrains ? Time Grains : null} + {renderColumns(data.originalData.timeGrains || [])} + + {hasWindows ? Windows : null} + {renderColumns(data.originalData.windows || [])} + + + ); +}; + +export default memo(MetricNode); + +function getColumns(columns: MetricColumnData[]) { + return columns.map((column) => ( + + )); +} diff --git a/wren-ui/src/components/diagram/customNode/ModelNode.tsx b/wren-ui/src/components/diagram/customNode/ModelNode.tsx new file mode 100644 index 000000000..a2cf9d889 --- /dev/null +++ b/wren-ui/src/components/diagram/customNode/ModelNode.tsx @@ -0,0 +1,120 @@ +import { memo, useCallback, useContext } from 'react'; +import { highlightEdges, highlightNodes, trimId } from '../utils'; +import { + CachedIcon, + CustomNodeProps, + NodeBody, + NodeHeader, + StyledNode, +} from './utils'; +import MarkerHandle from './MarkerHandle'; +import { DiagramContext } from '../Context'; +import Column, { ColumnTitle } from './Column'; +import CustomDropdown from '../CustomDropdown'; +import { PrimaryKeyIcon, ModelIcon, MoreIcon } from '@/utils/icons'; +import { MORE_ACTION } from '@/utils/enum'; +import { ModelColumnData, ModelData } from '@/utils/data'; +import { getColumnTypeIcon } from '@/utils/columnType'; + +export const ModelNode = ({ data }: CustomNodeProps) => { + const context = useContext(DiagramContext); + const onMoreClick = (type: MORE_ACTION) => { + context?.onMoreClick({ + type, + title: data.originalData.displayName, + data: data.originalData, + }); + }; + const onNodeClick = () => { + context?.onNodeClick({ + title: data.originalData.displayName, + data: data.originalData, + }); + }; + + const hasRelationTitle = !!data.originalData.relationFields.length; + const renderColumns = useCallback( + (columns: ModelColumnData[]) => getColumns(columns, data), + [data.highlight], + ); + + return ( + + + + + {data.originalData.displayName} + + + + + e.stopPropagation()} /> + + + + + + + {renderColumns([ + ...data.originalData.fields, + ...data.originalData.calculatedFields, + ])} + {hasRelationTitle ? Relations : null} + {renderColumns(data.originalData.relationFields)} + + + ); +}; + +export default memo(ModelNode); + +function getColumns( + columns: ModelColumnData[], + data: CustomNodeProps['data'], +) { + return columns.map((column) => { + const hasRelation = !!column.relation; + + const onMouseEnter = useCallback((reactflowInstance: any) => { + if (!hasRelation) return; + const { getEdges, setEdges, setNodes } = reactflowInstance; + const edges = getEdges(); + const relatedEdge = edges.find( + (edge: any) => + trimId(edge.sourceHandle) === column.id || + trimId(edge.targetHandle) === column.id, + ); + setEdges(highlightEdges([relatedEdge.id], true)); + setNodes( + highlightNodes( + [relatedEdge.source, relatedEdge.target], + [trimId(relatedEdge.sourceHandle), trimId(relatedEdge.targetHandle)], + ), + ); + }, []); + const onMouseLeave = useCallback((reactflowInstance: any) => { + if (!hasRelation) return; + const { setEdges, setNodes } = reactflowInstance; + setEdges(highlightEdges([], false)); + setNodes(highlightNodes([], [])); + }, []); + + return ( + : getColumnTypeIcon({ type: column.type }) + } + append={column.isPrimaryKey && } + onMouseLeave={onMouseLeave} + onMouseEnter={onMouseEnter} + /> + ); + }); +} diff --git a/wren-ui/src/components/diagram/customNode/ViewNode.tsx b/wren-ui/src/components/diagram/customNode/ViewNode.tsx new file mode 100644 index 000000000..232a00f83 --- /dev/null +++ b/wren-ui/src/components/diagram/customNode/ViewNode.tsx @@ -0,0 +1,74 @@ +import { memo, useCallback, useContext } from 'react'; +import { + CachedIcon, + CustomNodeProps, + NodeBody, + NodeHeader, + StyledNode, +} from './utils'; +import MarkerHandle from './MarkerHandle'; +import { DiagramContext } from '../Context'; +import Column from './Column'; +import CustomDropdown from '../CustomDropdown'; +import { MoreIcon, ViewIcon } from '@/utils/icons'; +import { MORE_ACTION } from '@/utils/enum'; +import { ViewColumnData, ViewData } from '@/utils/data'; +import { getColumnTypeIcon } from '@/utils/columnType'; + +export const ViewNode = ({ data }: CustomNodeProps) => { + const context = useContext(DiagramContext); + const onMoreClick = (type: MORE_ACTION) => { + context?.onMoreClick({ + type, + title: data.originalData.displayName, + data: data.originalData, + }); + }; + const onNodeClick = () => { + context?.onNodeClick({ + title: data.originalData.displayName, + data: data.originalData, + }); + }; + + const renderColumns = useCallback(getColumns, []); + + return ( + + + + + {data.originalData.displayName} + + + + + e.stopPropagation()} + /> + + + + + + {!!data.originalData.fields.length && ( + + {renderColumns(data.originalData.fields)} + + )} + + ); +}; + +export default memo(ViewNode); + +function getColumns(columns: ViewColumnData[]) { + return columns.map((column) => ( + + )); +} diff --git a/wren-ui/src/components/diagram/customNode/index.ts b/wren-ui/src/components/diagram/customNode/index.ts new file mode 100644 index 000000000..ab86bfbae --- /dev/null +++ b/wren-ui/src/components/diagram/customNode/index.ts @@ -0,0 +1,3 @@ +export { default as ModelNode } from './ModelNode'; +export { default as MetricNode } from './MetricNode'; +export { default as ViewNode } from './ViewNode'; diff --git a/wren-ui/src/components/diagram/customNode/utils.tsx b/wren-ui/src/components/diagram/customNode/utils.tsx new file mode 100644 index 000000000..88d980704 --- /dev/null +++ b/wren-ui/src/components/diagram/customNode/utils.tsx @@ -0,0 +1,138 @@ +import { MetricData, ModelData, ViewData } from '@/utils/data'; +import { LightningIcon } from '@/utils/icons'; +import { Tooltip } from 'antd'; +import { NodeProps } from 'reactflow'; +import styled from 'styled-components'; + +export type CustomNodeProps = NodeProps<{ + originalData: T; + index: number; + highlight: string[]; +}>; + +export const StyledNode = styled.div` + position: relative; + width: 200px; + border-radius: 4px; + overflow: hidden; + box-shadow: + 0px 3px 6px -4px rgba(0, 0, 0, 0.12), + 0px 6px 16px rgba(0, 0, 0, 0.08), + 0px 9px 28px 8px rgba(0, 0, 0, 0.05); + cursor: pointer; + + &:before { + content: ''; + pointer-events: none; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1; + border: 2px solid transparent; + transition: border-color 0.15s ease-in-out; + } + + &:hover, + &:focus { + &:before { + border-color: var(--geekblue-6); + } + } + + .react-flow__handle { + border: none; + opacity: 0; + + &-left { + left: 0; + } + + &-right { + right: 0; + } + } +`; + +export const NodeHeader = styled.div` + position: relative; + background-color: ${(props) => props.color || 'var(--geekblue-6)'}; + font-size: 14px; + color: white; + padding: 6px 8px; + display: flex; + align-items: center; + justify-content: space-between; + + &.dragHandle { + cursor: move; + } + + .adm-model-header { + display: flex; + align-items: center; + + svg { + margin-right: 6px; + } + + svg { + cursor: pointer; + } + } +`; + +export const NodeBody = styled.div` + background-color: white; + padding-bottom: 4px; +`; + +export const NodeColumn = styled.div` + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 12px; + color: var(--gray-9); + + svg { + cursor: auto; + flex-shrink: 0; + } + + .adm-column-title { + display: flex; + align-items: center; + min-width: 1px; + svg { + margin-right: 6px; + } + > span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } +`; + +export const CachedIcon = ({ + originalData, +}: { + originalData: ModelData | MetricData | ViewData; +}) => { + return originalData.cached ? ( + + Cached + {originalData.refreshTime + ? `: refresh every ${originalData.refreshTime}` + : null} + + } + placement="top" + > + + + ) : null; +}; diff --git a/wren-ui/src/components/diagram/index.tsx b/wren-ui/src/components/diagram/index.tsx new file mode 100644 index 000000000..8d69bed53 --- /dev/null +++ b/wren-ui/src/components/diagram/index.tsx @@ -0,0 +1,154 @@ +import { + ForwardedRef, + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useState, +} from 'react'; +import ReactFlow, { + MiniMap, + Background, + Controls, + ControlButton, + useNodesState, + useEdgesState, + Edge, + useReactFlow, + ReactFlowProvider, +} from 'reactflow'; +import { ModelNode, MetricNode, ViewNode } from './customNode'; +import { ModelEdge, MetricEdge } from './customEdge'; +import Marker from './Marker'; +import { DiagramContext, ClickPayload } from './Context'; +import { trimId, highlightNodes, highlightEdges } from './utils'; +import { AdaptedData } from '@/utils/data'; +import { RefreshIcon } from '@/utils/icons'; +import { EDGE_TYPE, NODE_TYPE } from '@/utils/enum'; +import { DiagramCreator } from '@/utils/diagram/creator'; +import { nextTick } from '@/utils/time'; + +import 'reactflow/dist/style.css'; + +const nodeTypes = { + [NODE_TYPE.MODEL]: ModelNode, + [NODE_TYPE.METRIC]: MetricNode, + [NODE_TYPE.VIEW]: ViewNode, +}; +const edgeTypes = { + [EDGE_TYPE.MODEL]: ModelEdge, + [EDGE_TYPE.METRIC]: MetricEdge, +}; +const minimapStyle = { + height: 120, +}; + +interface Props { + forwardRef?: ForwardedRef; + data: AdaptedData; + onMoreClick: (data: ClickPayload) => void; + onNodeClick: (data: ClickPayload) => void; +} + +const ReactFlowDiagram = forwardRef(function ReactFlowDiagram( + props: Props, + ref, +) { + const { data, onMoreClick, onNodeClick } = props; + const [forceRender, setForceRender] = useState(false); + const reactFlowInstance = useReactFlow(); + useImperativeHandle(ref, () => reactFlowInstance, [reactFlowInstance]); + + const diagram = useMemo(() => { + return new DiagramCreator(data).toJsonObject(); + }, [data]); + + useEffect(() => { + setNodes(diagram.nodes); + setEdges(diagram.edges); + + nextTick(50).then(() => reactFlowInstance.fitView()); + }, [diagram]); + + const [nodes, setNodes, onNodesChange] = useNodesState(diagram.nodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(diagram.edges); + + // const { openInfoModal, closeInfoModal, infoModalProps } = useInfoModal(); + + const onEdgeMouseEnter = useCallback( + (_event: React.MouseEvent, edge: Edge) => { + setEdges(highlightEdges([edge.id], true)); + setNodes( + highlightNodes( + [edge.source, edge.target], + [ + trimId(edge.sourceHandle as string), + trimId(edge.targetHandle as string), + ], + ), + ); + }, + [], + ); + + const onEdgeMouseLeave = useCallback( + (_event: React.MouseEvent, _edge: Edge) => { + setEdges(highlightEdges([], false)); + setNodes(highlightNodes([], [])); + }, + [], + ); + + const onRestore = async () => { + setNodes(diagram.nodes); + setEdges(diagram.edges); + }; + + const onInit = async () => { + await nextTick(); + reactFlowInstance.fitView(); + await nextTick(100); + setForceRender(!forceRender); + }; + + return ( + <> + + + + + + + + + + + + + + + ); +}); + +export const Diagram = (props: Props) => { + return ( + + + + ); +}; + +export default Diagram; diff --git a/wren-ui/src/components/diagram/types.ts b/wren-ui/src/components/diagram/types.ts new file mode 100644 index 000000000..343c0aed7 --- /dev/null +++ b/wren-ui/src/components/diagram/types.ts @@ -0,0 +1,117 @@ +export enum JOIN_TYPE { + MANY_TO_ONE = 'MANY_TO_ONE', + ONE_TO_MANY = 'ONE_TO_MANY', + ONE_TO_ONE = 'ONE_TO_ONE', +} + +export enum METRIC_TYPE { + DIMENSION = 'dimension', + MEASURE = 'measure', + TIME_GRAIN = 'timeGrain', +} + +export enum NODE_TYPE { + MODEL = 'model', + METRIC = 'metric', +} + +export enum MARKER_TYPE { + MANY = 'many', + ONE = 'one', +} + +export enum EDGE_TYPE { + STEP = 'step', + SMOOTHSTEP = 'smoothstep', + BEZIER = 'bezier', + MODEL = 'model', + METRIC = 'metric', +} + +export interface PayloadData { + catalog: string; + schema: string; + models: Model[]; + metrics: Metric[]; + relations: Relation[]; +} + +export interface Model { + id: string; + nodeType: NODE_TYPE | string; + name: string; + description?: string; + refSql: string; + cached: boolean; + refreshTime: string; + columns: ModelColumn[]; + fields: ModelColumn[]; + relationFields: ModelColumn[]; + calculatedFields: ModelColumn[]; + properties: Record; +} + +export interface ModelColumn { + id: string; + name: string; + type: string; + expression?: string; + relation?: Relation; + isPrimaryKey: boolean; + isCalculated: boolean; + properties: Record; +} + +export interface Relation { + name: string; + models: string[]; + joinType: JOIN_TYPE | string; + condition: string; + fromField: { model: string; field: string }; + toField: { model: string; field: string }; +} + +export type MetricColumn = { + id: string; + name: string; + type: string; + metricType: METRIC_TYPE | string; + properties: Record; +} & Partial; + +export interface Metric { + id: string; + nodeType: NODE_TYPE | string; + name: string; + description?: string; + baseObject: string; + cached: boolean; + refreshTime: string; + dimensions?: MetricColumn[]; + measures?: MetricColumn[]; + timeGrains?: MetricColumn[]; + windows?: MetricColumn[]; + properties: Record; +} + +export interface Dimension { + name: string; + type: string; +} + +export interface Measure { + name: string; + type: string; + expression: string; +} + +export interface TimeGrain { + name: string; + refColumn: string; + dateParts: string[]; +} + +export interface ClickPayload { + title: string; + data: Model | Metric; +} diff --git a/wren-ui/src/components/diagram/utils.ts b/wren-ui/src/components/diagram/utils.ts new file mode 100644 index 000000000..ea90b277d --- /dev/null +++ b/wren-ui/src/components/diagram/utils.ts @@ -0,0 +1,32 @@ +export const trimId = (id: string) => id.split('_')[0]; + +export const highlightEdges = (edgeIds: string[], highlight: boolean) => { + return (edges: any) => + edges.map((edge: any) => { + const selected = '_selected'; + const markerStart = edge.markerStart.replace(selected, ''); + const markerEnd = edge.markerEnd.replace(selected, ''); + return edgeIds.includes(edge.id) + ? { + ...edge, + data: { ...edge.data, highlight }, + markerStart: markerStart + selected, + markerEnd: markerEnd + selected, + } + : { + ...edge, + data: { ...edge.data, highlight: false }, + markerStart, + markerEnd, + }; + }); +}; + +export const highlightNodes = (nodeIds: string[], highlight: string[]) => { + return (nodes: any) => + nodes.map((node: any) => + nodeIds.includes(node.id) + ? { ...node, data: { ...node.data, highlight } } + : { ...node, data: { ...node.data, highlight: [] } }, + ); +}; diff --git a/wren-ui/src/components/editor/AceEditor.tsx b/wren-ui/src/components/editor/AceEditor.tsx new file mode 100644 index 000000000..6899b53db --- /dev/null +++ b/wren-ui/src/components/editor/AceEditor.tsx @@ -0,0 +1,7 @@ +import AceEditor from 'react-ace'; + +import 'ace-builds/src-noconflict/mode-sql'; +import 'ace-builds/src-noconflict/theme-tomorrow'; +import 'ace-builds/src-noconflict/ext-language_tools'; + +export default AceEditor; diff --git a/wren-ui/src/components/editor/CodeBlock.tsx b/wren-ui/src/components/editor/CodeBlock.tsx new file mode 100644 index 000000000..7732e2e32 --- /dev/null +++ b/wren-ui/src/components/editor/CodeBlock.tsx @@ -0,0 +1,93 @@ +import { Typography } from 'antd'; +import { useEffect } from 'react'; +import styled from 'styled-components'; +import '@/components/editor/AceEditor'; + +const Block = styled.div<{ inline?: boolean }>` + position: relative; + white-space: pre; + ${(props) => + props.inline + ? `display: inline; border: none; background: transparent !important; padding: 0;` + : `background: var(--gray-1); padding: 4px;`} + + .line-number { + user-select: none; + display: inline-block; + min-width: 1.5em; + text-align: right; + margin-right: 1em; + color: var(--gray-6); + font-weight: 700; + font-size: 14px; + } +`; + +const CopyText = styled(Typography.Text)` + position: absolute; + top: 8px; + right: 8px; + font-size: 0; + .ant-typography-copy { + font-size: 12px; + } +`; + +interface Props { + code: string; + inline?: boolean; + copyable?: boolean; + showLineNumbers?: boolean; +} + +const addThemeStyleManually = (cssText) => { + // same id as ace editor appended, it will exist only one. + const id = 'ace-tomorrow'; + const themeElement = document.getElementById(id); + if (!themeElement) { + const styleElement = document.createElement('style'); + styleElement.id = id; + document.head.appendChild(styleElement); + styleElement.appendChild(document.createTextNode(cssText)); + } +}; + +export default function CodeBlock(props: Props) { + const { code, copyable, inline, showLineNumbers } = props; + const { ace } = window as any; + const { Tokenizer } = ace.require('ace/tokenizer'); + const { SqlHighlightRules } = ace.require(`ace/mode/sql_highlight_rules`); + const rules = new SqlHighlightRules(); + const tokenizer = new Tokenizer(rules.getRules()); + + useEffect(() => { + const { cssText } = ace.require('ace/theme/tomorrow'); + addThemeStyleManually(cssText); + }, []); + + const lines = code.split('\n').map((line, index) => { + const tokens = tokenizer.getLineTokens(line).tokens; + const children = tokens.map((token, index) => { + const classNames = token.type.split('.').map((name) => `ace_${name}`); + return ( + + {token.value} + + ); + }); + + return ( + + {showLineNumbers && {index + 1}} + {children} + + ); + }); + + return ( + + {lines} + {copyable && {code}} + + ); +} diff --git a/wren-ui/src/components/editor/index.tsx b/wren-ui/src/components/editor/index.tsx new file mode 100644 index 000000000..232abd5e0 --- /dev/null +++ b/wren-ui/src/components/editor/index.tsx @@ -0,0 +1,75 @@ +import { forwardRef, useEffect, useRef, useContext } from 'react'; +import AceEditor from '@/components/editor/AceEditor'; +import { + FormItemInputContext, + FormItemStatusContextProps, +} from 'antd/lib/form/context'; + +export interface SQLEditorAutoCompleteSourceWordInfo { + // Show main string + caption: string; + // insert string into editor + value: string; + // Show hint type string + meta: string; +} + +interface Props { + autoCompleteSource: SQLEditorAutoCompleteSourceWordInfo[]; + value?: string; + onChange?: (value: any | null) => void; +} + +function SQLEditor(props: Props, ref: any) { + const { autoCompleteSource, value, onChange } = props; + + const editorRef = useRef(); + + const formItemContext = + useContext(FormItemInputContext); + const { status } = formItemContext; + + useEffect(() => { + const getCompletions = (editor, _session, _pos, _prefix, callback) => { + const popup = editor.completer.popup; + if (popup?.container) { + popup.container.style.width = '50%'; + popup.resize(); + } + + callback(null, autoCompleteSource); + }; + + const { ace } = window as any; + ace.require('ace/ext/language_tools').addCompleter({ getCompletions }); + + return () => editorRef.current?.editor?.completers?.pop(); + }, [autoCompleteSource]); + + const onTriggerChange = (changedValue: any) => { + onChange && onChange(changedValue); + }; + + return ( +
+ +
+ ); +} + +export default forwardRef(SQLEditor); diff --git a/wren-ui/src/components/editor/utils.ts b/wren-ui/src/components/editor/utils.ts new file mode 100644 index 000000000..f7090e75f --- /dev/null +++ b/wren-ui/src/components/editor/utils.ts @@ -0,0 +1,63 @@ +import { AdaptedData } from '@/utils/data'; +import { NODE_TYPE } from '@/utils/enum'; +import { SQLEditorAutoCompleteSourceWordInfo } from '@/components/editor'; + +const NODE_TYPE_CAPTION = { + [NODE_TYPE.MODEL]: 'Model', + [NODE_TYPE.METRIC]: 'Metric', +}; + +const convertColumns = ( + columnsArray: any, + previousSqlName: string, + previousLayerName = '', +) => + (columnsArray || []).flatMap((column) => { + const title = column.name; + const columnSqlQueryKey = previousLayerName + ? `${previousLayerName}.${title}` + : title; + + const isModelType = Boolean((column as any)?.relationship); + const columnType = isModelType ? 'Model' : column.type; + const columnInfo = { + caption: `${previousSqlName}.${columnSqlQueryKey}`, + meta: `Column(${columnType})`, + value: columnSqlQueryKey, + title: columnSqlQueryKey, + }; + + const nestedColumnsArray = column?.columns || []; + if (!['ARRAY'].includes(columnType) && nestedColumnsArray.length > 0) { + const childrenColumn = convertColumns( + nestedColumnsArray, + previousSqlName, + columnSqlQueryKey, + ); + return [columnInfo, ...childrenColumn]; + } + + return columnInfo; + }); + +export const convertToAutoCompleteSourceWordInfo = ( + adaptedData: AdaptedData, +): SQLEditorAutoCompleteSourceWordInfo[] => + Object.keys(adaptedData).reduce((allWorkdInfo, key) => { + if (!['metrics', 'models'].includes(key)) return allWorkdInfo; + + const data = adaptedData[key]; + const wordInfo = data.reduce((allWorkdInfo, item) => { + return [ + ...allWorkdInfo, + { + caption: item.name, + value: item.name, + meta: NODE_TYPE_CAPTION[item.nodeType], + }, + ...convertColumns(item.columns, item.name), + ]; + }, []); + + return [...allWorkdInfo, ...wordInfo]; + }, []); diff --git a/wren-ui/src/components/form/ExpressionProperties.tsx b/wren-ui/src/components/form/ExpressionProperties.tsx new file mode 100644 index 000000000..283aa0dec --- /dev/null +++ b/wren-ui/src/components/form/ExpressionProperties.tsx @@ -0,0 +1,78 @@ +import { Form, FormInstance, Input } from 'antd'; +import FunctionOutlined from '@ant-design/icons/FunctionOutlined'; +import { ERROR_TEXTS } from '@/utils/error'; +import useExpressionFieldOptions, { + CUSTOM_EXPRESSION_VALUE, +} from '@/hooks/useExpressionFieldOptions'; +import ModelFieldSelector from '@/components/selectors/modelFieldSelector'; +import { modelFieldSelectorValidator } from '@/utils/validator'; +import ExpressionSelector from '../selectors/ExpressionSelector'; +import useModelFieldOptions, { + ModelFieldResposeData, +} from '@/hooks/useModelFieldOptions'; + +interface Props { + model: string; + form: FormInstance; + + // The transientData is used to get the model fields which are not created in DB yet. + transientData?: ModelFieldResposeData[]; +} + +export default function ExpressionProperties(props: Props) { + const { form, model, transientData } = props; + + const expression = Form.useWatch('expression', form); + + const expressionOptions = useExpressionFieldOptions(); + const modelFieldOptions = useModelFieldOptions(transientData); + + return ( + <> + + + +
+ {expression === CUSTOM_EXPRESSION_VALUE ? ( +
+ + } /> + +
+ ) : ( + + + + )} + + ); +} diff --git a/wren-ui/src/components/layouts/SiderLayout.tsx b/wren-ui/src/components/layouts/SiderLayout.tsx new file mode 100644 index 000000000..93ef1eaae --- /dev/null +++ b/wren-ui/src/components/layouts/SiderLayout.tsx @@ -0,0 +1,39 @@ +import { Layout } from 'antd'; +import styled, { css } from 'styled-components'; +import SimpleLayout from '@/components/layouts/SimpleLayout'; +import Sidebar from '@/components/sidebar'; + +const { Sider } = Layout; + +const basicStyle = css` + height: calc(100vh - 48px); + overflow: auto; +`; + +const StyledContentLayout = styled(Layout)` + position: relative; + ${basicStyle} +`; + +const StyledSider = styled(Sider)` + ${basicStyle} +`; + +type Props = React.ComponentProps & { + sidebar: React.ComponentProps; +}; + +export default function SiderLayout(props: Props) { + const { connections, sidebar, loading } = props; + + return ( + + + + + + {props.children} + + + ); +} diff --git a/wren-ui/src/components/layouts/SimpleLayout.tsx b/wren-ui/src/components/layouts/SimpleLayout.tsx new file mode 100644 index 000000000..ee2356847 --- /dev/null +++ b/wren-ui/src/components/layouts/SimpleLayout.tsx @@ -0,0 +1,24 @@ +import { Layout } from 'antd'; +import HeaderBar, { Connections } from '@/components/HeaderBar'; +import PageLoading from '@/components/PageLoading'; + +const { Content } = Layout; + +interface Props { + children: React.ReactNode; + connections?: Connections; + loading?: boolean; +} + +export default function SimpleLayout(props: Props) { + const { children, connections, loading } = props; + return ( + + + {children} + + + ); +} diff --git a/wren-ui/src/components/modals/AddCalculatedFieldModal.tsx b/wren-ui/src/components/modals/AddCalculatedFieldModal.tsx new file mode 100644 index 000000000..e030524d5 --- /dev/null +++ b/wren-ui/src/components/modals/AddCalculatedFieldModal.tsx @@ -0,0 +1,96 @@ +import { useEffect } from 'react'; +import { Modal, Form, Input } from 'antd'; +import { ModalAction } from '@/hooks/useModalAction'; +import { FieldValue } from '@/components/selectors/modelFieldSelector/FieldSelect'; +import { ModelFieldResposeData } from '@/hooks/useModelFieldOptions'; +import { ERROR_TEXTS } from '@/utils/error'; +import ExpressionProperties from '@/components/form/ExpressionProperties'; +import Link from 'next/link'; + +export type CalculatedFieldValue = { + [key: string]: any; + name: string; + expression: string; + modelFields?: FieldValue[]; + customExpression?: string; +}; + +type Props = ModalAction & { + model: string; + loading?: boolean; + + // The transientData is used to get the model fields which are not created in DB yet. + transientData?: ModelFieldResposeData[]; +}; + +export default function AddCalculatedFieldModal(props: Props) { + const { + model, + transientData, + visible, + loading, + onSubmit, + onClose, + defaultValue, + } = props; + const [form] = Form.useForm(); + + useEffect(() => { + if (!visible) return; + form.setFieldsValue(defaultValue || {}); + }, [form, defaultValue, visible]); + + const submit = () => { + form + .validateFields() + .then(async (values) => { + await onSubmit({ ...defaultValue, ...values }); + onClose(); + }) + .catch(console.error); + }; + + return ( + form.resetFields()} + > +
+ Morem ipsum dolor sit amet, consectetur adipiscing elit. Nunc vulputate + libero et velit interdum, ac aliquet odio mattis.{' '} + + Learn more + +
+ +
+ + + + + +
+ ); +} diff --git a/wren-ui/src/components/modals/AddDimensionFieldModal.tsx b/wren-ui/src/components/modals/AddDimensionFieldModal.tsx new file mode 100644 index 000000000..ad76afb45 --- /dev/null +++ b/wren-ui/src/components/modals/AddDimensionFieldModal.tsx @@ -0,0 +1,141 @@ +import { useEffect, useMemo } from 'react'; +import { Modal, Form, Input, Select } from 'antd'; +import { ModalAction } from '@/hooks/useModalAction'; +import { GRANULARITY, COLUMN_TYPE } from '@/utils/enum'; +import { FieldValue } from '@/components/selectors/modelFieldSelector/FieldSelect'; +import ModelFieldSelector from '@/components/selectors/modelFieldSelector'; +import { modelFieldSelectorValidator } from '@/utils/validator'; +import useModelFieldOptions, { + ModelFieldResposeData, +} from '@/hooks/useModelFieldOptions'; +import { ERROR_TEXTS } from '@/utils/error'; +import Link from 'next/link'; + +export type DimensionFieldValue = { + [key: string]: any; + name: string; + modelFields?: FieldValue[]; +}; + +type Props = ModalAction & { + model: string; + loading?: boolean; + + // The transientData is used to get the model fields which are not created in DB yet. + transientData?: ModelFieldResposeData[]; +}; + +const granularityOptions = Object.values(GRANULARITY).map((value) => ({ + label: value, + value, +})); + +export default function AddDimensionFieldModal(props: Props) { + const { + model, + transientData, + visible, + loading, + onSubmit, + onClose, + defaultValue, + } = props; + const [form] = Form.useForm(); + + const modelFields: FieldValue[] = Form.useWatch('modelFields', form); + + const modelFieldOptions = useModelFieldOptions(transientData); + + const isGranularityShow = useMemo(() => { + const selectedField = modelFields + ? modelFields[modelFields.length - 1] + : null; + return [COLUMN_TYPE.DATE, COLUMN_TYPE.TIMESTAMP].includes( + selectedField?.type as COLUMN_TYPE, + ); + }, [modelFields]); + + useEffect(() => { + if (!visible) return; + form.setFieldsValue(defaultValue || {}); + }, [form, defaultValue, visible]); + + const submit = () => { + form + .validateFields() + .then(async (values) => { + await onSubmit({ ...defaultValue, ...values }); + onClose(); + }) + .catch(console.error); + }; + + return ( + form.resetFields()} + > +
+ Morem ipsum dolor sit amet, consectetur adipiscing elit. Nunc vulputate + libero et velit interdum, ac aliquet odio mattis.{' '} + + Learn more + +
+ +
+ + + + + + + {isGranularityShow && ( + + + + + +
+ ); +} diff --git a/wren-ui/src/components/modals/AddRelationModal.tsx b/wren-ui/src/components/modals/AddRelationModal.tsx new file mode 100644 index 000000000..97ac4d62e --- /dev/null +++ b/wren-ui/src/components/modals/AddRelationModal.tsx @@ -0,0 +1,154 @@ +import { useEffect } from 'react'; +import { isEmpty } from 'lodash'; +import { Modal, Form, Input, Select, Row, Col } from 'antd'; +import { ModalAction } from '@/hooks/useModalAction'; +import { ERROR_TEXTS } from '@/utils/error'; +import CombineFieldSelector from '@/components/selectors/CombineFieldSelector'; +import { JOIN_TYPE } from '@/utils/enum'; +import { RelationData, getJoinTypeText } from '@/utils/data'; +import useCombineFieldOptions from '@/hooks/useCombineFieldOptions'; +import { RelationsDataType } from '@/components/table/ModelRelationSelectionTable'; + +export type RelationFieldValue = { [key: string]: any } & Pick< + RelationData, + 'name' | 'joinType' | 'fromField' | 'toField' | 'properties' +>; + +type Props = ModalAction & { + model: string; + loading?: boolean; + allowSetDescription?: boolean; +}; + +export default function RelationModal(props: Props) { + const { + allowSetDescription = true, + defaultValue, + loading, + model, + onClose, + onSubmit, + visible, + } = props; + const [form] = Form.useForm(); + + useEffect(() => { + if (!visible) return; + form.setFieldsValue(defaultValue || {}); + }, [form, defaultValue, visible]); + + const relationTypeOptions = Object.keys(JOIN_TYPE).map((key) => ({ + label: getJoinTypeText(key), + value: JOIN_TYPE[key], + })); + + const fromCombineField = useCombineFieldOptions({ model }); + const toCombineField = useCombineFieldOptions({ + model: defaultValue?.toField.model, + excludeModels: [model], + }); + + const submit = () => { + form + .validateFields() + .then(async (values) => { + await onSubmit({ ...defaultValue, ...values }); + onClose(); + }) + .catch(console.error); + }; + + return ( + form.resetFields()} + > +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/wren-ui/src/components/modals/UpdateMetadataModal.tsx b/wren-ui/src/components/modals/UpdateMetadataModal.tsx new file mode 100644 index 000000000..e90704a96 --- /dev/null +++ b/wren-ui/src/components/modals/UpdateMetadataModal.tsx @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; +import { Modal, Form, Input } from 'antd'; +import { ModalAction } from '@/hooks/useModalAction'; +import { ERROR_TEXTS } from '@/utils/error'; + +interface MetadataValue { + displayName: string; + description?: string; +} + +type Props = ModalAction & { + loading?: boolean; +}; + +export default function UpdateMetadataModal(props: Props) { + const { visible, loading, onSubmit, onClose, defaultValue } = props; + const [form] = Form.useForm(); + + useEffect(() => { + if (!visible) return; + form.setFieldsValue(defaultValue || {}); + }, [form, defaultValue, visible]); + + const submit = () => { + form + .validateFields() + .then(async (values) => { + await onSubmit({ ...defaultValue, ...values }); + onClose(); + }) + .catch(console.error); + }; + + return ( + form.resetFields()} + > +
+ + + + + + + +
+ ); +} diff --git a/wren-ui/src/components/pages/explore/SelectDataToExploreModal.tsx b/wren-ui/src/components/pages/explore/SelectDataToExploreModal.tsx new file mode 100644 index 000000000..1c93fef59 --- /dev/null +++ b/wren-ui/src/components/pages/explore/SelectDataToExploreModal.tsx @@ -0,0 +1,362 @@ +import { useCallback, useMemo, useState } from 'react'; +import styled from 'styled-components'; +import { Menu, Modal, Input, Row, Col, Typography, Empty } from 'antd'; +import { ItemType } from 'antd/lib/menu/hooks/useItems'; +import SearchOutlined from '@ant-design/icons/SearchOutlined'; +import { ModalAction } from '@/hooks/useModalAction'; +import { compact } from 'lodash'; +import { MetricIcon, ModelIcon, ViewIcon } from '@/utils/icons'; +import { NODE_TYPE } from '@/utils/enum'; +import { makeMetadataBaseTable } from '@/components/table/MetadataBaseTable'; +import FieldTable from '@/components/table/FieldTable'; +import CalculatedFieldTable from '@/components/table/CalculatedFieldTable'; +import RelationTable from '@/components/table/RelationTable'; +import MeasureFieldTable from '@/components/table/MeasureFieldTable'; +import DimensionFieldTable from '@/components/table/DimensionFieldTable'; +import WindowFieldTable from '@/components/table/WindowFieldTable'; +import useSelectDataToExploreCollections from '@/hooks/useSelectDataToExploreCollections'; + +const StyledMenu = styled(Menu)` + border-right: none; + + .ant-menu-item-group-title { + font-size: 11px; + font-weight: 700; + color: var(--gray-8); + padding: 8px 8px 0; + } + + .ant-menu-item { + padding: 0; + margin: 4px 0 !important; + padding-left: 8px !important; + height: 32px; + line-height: 32px; + background: transparent; + color: var(--gray-9) !important; + border-radius: 4px; + + &:hover { + background: var(--gray-2); + } + + &.ant-menu-item-selected { + background: var(--gray-3); + border-radius: 4px; + + &:after { + display: none; + } + } + } +`; + +const MENU = { + MODEL: 'Models', + METRIC: 'Metrics', + VIEW: 'Views', +}; + +const MENU_GROUPS = { + [MENU.MODEL]: { + label: 'Models', + type: 'group', + }, + [MENU.METRIC]: { + label: 'Metrics', + type: 'group', + }, + [MENU.VIEW]: { + label: 'Views', + type: 'group', + }, +}; + +type Props = ModalAction & { + loading?: boolean; +}; + +const ModelMetadata = ({ + table, + description, + fields = [], + calculatedFields = [], + relations = [], +}) => { + const FieldMetadataTable = makeMetadataBaseTable(FieldTable)(); + const CalculatedFieldMetadataTable = + makeMetadataBaseTable(CalculatedFieldTable)(); + const RelationMetadataTable = makeMetadataBaseTable(RelationTable)(); + + return ( + <> + +
+
Description
+
{description || '-'}
+ + +
Source table name
+
{table}
+ + +
+ + Fields ({fields.length}) + + +
+ + {!!calculatedFields.length && ( +
+ + Calculated fields ({calculatedFields.length}) + + +
+ )} + + {!!relations.length && ( +
+ + Relations ({relations.length}) + + +
+ )} + + ); +}; + +const MetricMetadata = ({ + description, + measures = [], + dimensions = undefined, + windows = undefined, +}) => { + const MeasureFieldMetadataTable = makeMetadataBaseTable(MeasureFieldTable)(); + const DimensionFieldMetadataTable = + makeMetadataBaseTable(DimensionFieldTable)(); + const WindowFieldMetadataTable = makeMetadataBaseTable(WindowFieldTable)(); + + return ( + <> + + +
Description
+
{description || '-'}
+ + +
+ + Measures ({measures.length}) + + +
+ + {!!dimensions && ( +
+ + Dimensions ({dimensions.length}) + + +
+ )} + + {!!windows && ( +
+ + Windows ({windows.length}) + + +
+ )} + + ); +}; + +const ViewMetadata = ({ description, fields = [] }) => { + const FieldMetadataTable = makeMetadataBaseTable(FieldTable)(); + + return ( + <> + + +
Description
+
{description || '-'}
+ + +
+ + Fields ({fields.length}) + + +
+ + ); +}; + +export default function SelectDataToExploreModal(props: Props) { + const { visible, loading, onClose, onSubmit } = props; + const [searchValue, setSearchValue] = useState(''); + const [selectedItem, setSelectedItem] = useState(null); + + const { models, metrics, views } = useSelectDataToExploreCollections(); + + const goToExplore = async () => { + onSubmit && (await onSubmit(selectedItem)); + onClose(); + }; + + const search = (event) => { + const value = event.target.value; + setSearchValue(value.trim()); + }; + + const clickMenu = useCallback( + (item: ItemType) => { + const [type, id] = (item.key as string).split('_'); + if (type === MENU.MODEL) { + setSelectedItem(models.find((model) => model.id === id)); + } else if (type === MENU.METRIC) { + setSelectedItem(metrics.find((metric) => metric.id === id)); + } else if (type === MENU.VIEW) { + setSelectedItem(views.find((view) => view.id === id)); + } + }, + [models, metrics, views], + ); + + const reset = () => { + setSearchValue(''); + setSelectedItem(null); + }; + + const getLabel = useCallback( + (label: string, Icon) => { + let nextLabel: React.ReactNode = label; + if (searchValue) { + const regex = new RegExp(searchValue, 'gi'); + const splitedLabel = label.split(regex); + + const matchTexts = label.match(regex); + const restructure = matchTexts + ? matchTexts.reduce((result, text, index) => { + return ( + result + + splitedLabel.shift() + + `${text}` + + // the last part of the label + (index === matchTexts.length - 1 ? splitedLabel.pop() : '') + ); + }, '') + : label; + + nextLabel = ; + } + + return ( +
+ + {nextLabel} +
+ ); + }, + [searchValue], + ); + + const menu = useMemo(() => { + const filterSearch = (item) => + item.name.toLowerCase().includes(searchValue.toLowerCase()); + + const modelItems = models.filter(filterSearch).map((model) => ({ + label: getLabel(model.name, ModelIcon), + key: `${MENU.MODEL}_${model.id}`, + })); + + const metricItems = metrics.filter(filterSearch).map((metric) => ({ + label: getLabel(metric.name, MetricIcon), + key: `${MENU.METRIC}_${metric.id}`, + })); + + const viewItems = views.filter(filterSearch).map((view) => ({ + label: getLabel(view.name, ViewIcon), + key: `${MENU.VIEW}_${view.id}`, + })); + + const getGroupItems = (group: Record, items: any[]) => { + return items.length ? { ...group, children: items } : undefined; + }; + + const result = compact([ + getGroupItems(MENU_GROUPS[MENU.MODEL], modelItems), + getGroupItems(MENU_GROUPS[MENU.METRIC], metricItems), + getGroupItems(MENU_GROUPS[MENU.VIEW], viewItems), + ]) as ItemType[]; + + return result; + }, [models, metrics, views, searchValue]); + + return ( + reset()} + > + +
+ } + placeholder="Search" + onInput={search} + /> +
+ +
+ + + {selectedItem ? ( + <> +

+ {selectedItem.name} +

+
+ {selectedItem.nodeType === NODE_TYPE.MODEL && ( + + )} + {selectedItem.nodeType === NODE_TYPE.METRIC && ( + + )} + {selectedItem.nodeType === NODE_TYPE.VIEW && ( + + )} +
+ + ) : ( +
+ +
+ )} + + + + ); +} diff --git a/wren-ui/src/components/pages/modeling/GenerateMetadataModal.tsx b/wren-ui/src/components/pages/modeling/GenerateMetadataModal.tsx new file mode 100644 index 000000000..225dfc4b5 --- /dev/null +++ b/wren-ui/src/components/pages/modeling/GenerateMetadataModal.tsx @@ -0,0 +1,74 @@ +import { Modal, Form } from 'antd'; +import { ModalAction } from '@/hooks/useModalAction'; +import { NODE_TYPE } from '@/utils/enum'; +import GenerateModelMetadata, { + Props as GenerateModelProps, +} from '@/components/pages/modeling/metadata/GenerateModelMetadata'; +import GenerateViewMetadata, { + Props as GenerateViewProps, +} from '@/components/pages/modeling/metadata/GenerateViewMetadata'; +import { EditableContext } from '@/components/EditableWrapper'; + +type DefaultValue = (GenerateModelProps & GenerateViewProps) & { + nodeType: NODE_TYPE; +}; + +type Props = ModalAction & { + loading?: boolean; +}; + +const getDrawerTitle = (nodeType: NODE_TYPE) => { + return ( + { + [NODE_TYPE.MODEL]: "Generate model's metadata", + [NODE_TYPE.VIEW]: "Generate view's metadata", + }[nodeType] || 'Generate metadata' + ); +}; + +const formNamespace = 'generatedMetadata'; + +export default function GenerateMetadataModal(props: Props) { + const { visible, defaultValue, loading, onSubmit, onClose } = props; + const { nodeType } = defaultValue || {}; + + const [form] = Form.useForm(); + + const submit = async () => { + const values = form.getFieldValue(formNamespace); + await onSubmit(values); + onClose(); + }; + + return ( + + +
+ {nodeType === NODE_TYPE.MODEL && ( + + )} + {nodeType === NODE_TYPE.VIEW && ( + + )} + +
+
+ ); +} diff --git a/wren-ui/src/components/pages/modeling/MetadataDrawer.tsx b/wren-ui/src/components/pages/modeling/MetadataDrawer.tsx new file mode 100644 index 000000000..8dddcd8ea --- /dev/null +++ b/wren-ui/src/components/pages/modeling/MetadataDrawer.tsx @@ -0,0 +1,77 @@ +import { Drawer, Button } from 'antd'; +import { NODE_TYPE } from '@/utils/enum'; +import { DrawerAction } from '@/hooks/useDrawerAction'; +import { SparklesIcon } from '@/utils/icons'; +import ModelMetadata, { + Props as ModelMetadataProps, +} from './metadata/ModelMetadata'; +import MetricMetadata, { + Props as MetricMetadataProps, +} from './metadata/MetricMetadata'; +import ViewMetadata, { + Props as ViewMetadataProps, +} from './metadata/ViewMetadata'; +import useModalAction from '@/hooks/useModalAction'; +import GenerateMetadataModal from '@/components/pages/modeling/GenerateMetadataModal'; + +type Metadata = { nodeType: NODE_TYPE } & ModelMetadataProps & + MetricMetadataProps & + ViewMetadataProps; + +type Props = DrawerAction; + +const getDrawerTitle = (nodeType: NODE_TYPE) => { + return ( + { + [NODE_TYPE.MODEL]: "Model's metadata", + [NODE_TYPE.VIEW]: "View's metadata", + }[nodeType] || 'Metadata' + ); +}; + +export default function MetadataDrawer(props: Props) { + const { visible, defaultValue, onClose } = props; + const { nodeType = NODE_TYPE.MODEL } = defaultValue || {}; + + const generateMetadataModal = useModalAction(); + const openGeneratedMetadataModal = () => { + // TODO: put generated metadata in + generateMetadataModal.openModal(defaultValue); + }; + + const submitGenerateMetadata = async (values) => { + console.log(values); + }; + + return ( + + + + } + > + {nodeType === NODE_TYPE.MODEL && } + {nodeType === NODE_TYPE.METRIC && } + {nodeType === NODE_TYPE.VIEW && } + + + + ); +} diff --git a/wren-ui/src/components/pages/modeling/MetricDrawer.tsx b/wren-ui/src/components/pages/modeling/MetricDrawer.tsx new file mode 100644 index 000000000..cd4e0e0ba --- /dev/null +++ b/wren-ui/src/components/pages/modeling/MetricDrawer.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useState } from 'react'; +import { Drawer, Form, FormInstance } from 'antd'; +import { FORM_MODE, METRIC_STEP } from '@/utils/enum'; +import { DrawerAction } from '@/hooks/useDrawerAction'; +import MetricBasicForm, { + ButtonGroup as MetricBasicButtonGroup, + ButtonProps as MetricBasicButtonProps, +} from './form/MetricBasicForm'; +import MetricDetailForm, { + ButtonGroup as MetricDetailButtonGroup, + ButtonProps as MetricDetailButtonProps, +} from './form/MetricDetailForm'; + +type Props = DrawerAction; + +const DynamicForm = (props: { + formMode: FORM_MODE; + step: METRIC_STEP; + form: FormInstance; +}) => { + return ( + { + [METRIC_STEP.ONE]: , + [METRIC_STEP.TWO]: , + }[props.step] || null + ); +}; + +const DynamicButtonGroup = ( + props: { step: METRIC_STEP; form: FormInstance } & MetricBasicButtonProps & + MetricDetailButtonProps, +) => { + return ( + { + [METRIC_STEP.ONE]: , + [METRIC_STEP.TWO]: , + }[props.step] || null + ); +}; + +const getDrawerTitle = (formMode: FORM_MODE) => + ({ + [FORM_MODE.CREATE]: 'Create a metric', + [FORM_MODE.EDIT]: 'Update a metric', + })[formMode]; + +export default function MetricDrawer(props: Props) { + const { visible, formMode, defaultValue, onClose, onSubmit } = props; + const [internalValues, setInternalValues] = useState(defaultValue || null); + const [step, setStep] = useState(METRIC_STEP.ONE); + const [form] = Form.useForm(); + + useEffect(() => { + if (!visible) return; + form.setFieldsValue(defaultValue || {}); + }, [form, defaultValue, visible]); + + const afterVisibleChange = (visible: boolean) => { + if (!visible) { + setStep(METRIC_STEP.ONE); + form.resetFields(); + setInternalValues(null); + } + }; + + const preview = () => { + form + .validateFields() + .then((values) => { + console.log({ ...internalValues, ...values }); + }) + .catch(console.error); + }; + + const back = () => { + setStep(METRIC_STEP.ONE); + }; + + const next = () => { + form + .validateFields() + .then((values) => { + setInternalValues({ ...internalValues, ...values }); + setStep(METRIC_STEP.TWO); + }) + .catch(console.error); + }; + + const submit = () => { + form + .validateFields() + .then(async (values) => { + await onSubmit({ ...internalValues, ...values }); + onClose(); + }) + .catch(console.error); + }; + + return ( + + } + extra={<>Step {step}/2} + > + + + ); +} diff --git a/wren-ui/src/components/pages/modeling/ModelDrawer.tsx b/wren-ui/src/components/pages/modeling/ModelDrawer.tsx new file mode 100644 index 000000000..95fbb6354 --- /dev/null +++ b/wren-ui/src/components/pages/modeling/ModelDrawer.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useState } from 'react'; +import { Drawer, Form, FormInstance } from 'antd'; +import { FORM_MODE, MODEL_STEP } from '@/utils/enum'; +import { DrawerAction } from '@/hooks/useDrawerAction'; +import ModelBasicForm, { + ButtonGroup as ModelBasicButtonGroup, + ButtonProps as ModelBasicButtonProps, +} from './form/ModelBasicForm'; +import ModelDetailForm, { + ButtonGroup as ModelDetailButtonGroup, + ButtonProps as ModelDetailButtonProps, +} from './form/ModelDetailForm'; + +type Props = DrawerAction; + +const DynamicForm = (props: { + formMode: FORM_MODE; + step: MODEL_STEP; + form: FormInstance; +}) => { + return ( + { + [MODEL_STEP.ONE]: , + [MODEL_STEP.TWO]: , + }[props.step] || null + ); +}; + +const DynamicButtonGroup = ( + props: { step: MODEL_STEP; form: FormInstance } & ModelBasicButtonProps & + ModelDetailButtonProps, +) => { + return ( + { + [MODEL_STEP.ONE]: , + [MODEL_STEP.TWO]: , + }[props.step] || null + ); +}; + +const getDrawerTitle = (formMode: FORM_MODE) => + ({ + [FORM_MODE.CREATE]: 'Create a model', + [FORM_MODE.EDIT]: 'Update a model', + })[formMode]; + +export default function ModelDrawer(props: Props) { + const { visible, formMode, defaultValue, onClose, onSubmit } = props; + const [internalValues, setInternalValues] = useState(defaultValue || null); + const [step, setStep] = useState(MODEL_STEP.ONE); + const [form] = Form.useForm(); + + useEffect(() => { + if (!visible) return; + form.setFieldsValue(defaultValue || {}); + }, [form, defaultValue, visible]); + + const afterVisibleChange = (visible: boolean) => { + if (!visible) { + setStep(MODEL_STEP.ONE); + form.resetFields(); + setInternalValues(null); + } + }; + + const preview = () => { + form + .validateFields() + .then((values) => { + console.log({ ...internalValues, ...values }); + }) + .catch(console.error); + }; + + const back = () => { + setStep(MODEL_STEP.ONE); + }; + + const next = () => { + form + .validateFields() + .then((values) => { + setInternalValues({ ...internalValues, ...values }); + setStep(MODEL_STEP.TWO); + }) + .catch(console.error); + }; + + const submit = () => { + form + .validateFields() + .then(async (values) => { + await onSubmit({ ...internalValues, ...values }); + onClose(); + }) + .catch(console.error); + }; + + return ( + + } + extra={<>Step {step}/2} + > + + + ); +} diff --git a/wren-ui/src/components/pages/modeling/ViewDrawer.tsx b/wren-ui/src/components/pages/modeling/ViewDrawer.tsx new file mode 100644 index 000000000..28c9b76e8 --- /dev/null +++ b/wren-ui/src/components/pages/modeling/ViewDrawer.tsx @@ -0,0 +1,124 @@ +import React, { useEffect, useState } from 'react'; +import { Drawer, Form, FormInstance } from 'antd'; +import { FORM_MODE, MODEL_STEP } from '@/utils/enum'; +import { DrawerAction } from '@/hooks/useDrawerAction'; +import ViewBasicForm, { + ButtonGroup as ViewBasicButtonGroup, + ButtonProps as ViewBasicButtonProps, +} from './form/ViewBasicForm'; +import ViewDetailForm, { + ButtonGroup as ViewDetailButtonGroup, + ButtonProps as ViewDetailButtonProps, +} from './form/ViewDetailForm'; + +type Props = DrawerAction; + +const DynamicForm = (props: { + formMode: FORM_MODE; + step: MODEL_STEP; + form: FormInstance; +}) => { + return ( + { + [MODEL_STEP.ONE]: , + [MODEL_STEP.TWO]: , + }[props.step] || null + ); +}; + +const DynamicButtonGroup = ( + props: { step: MODEL_STEP; form: FormInstance } & ViewBasicButtonProps & + ViewDetailButtonProps, +) => { + return ( + { + [MODEL_STEP.ONE]: , + [MODEL_STEP.TWO]: , + }[props.step] || null + ); +}; + +const getDrawerTitle = (formMode: FORM_MODE) => + ({ + [FORM_MODE.CREATE]: 'Create a view', + [FORM_MODE.EDIT]: 'Update a view', + })[formMode]; + +export default function ViewDrawer(props: Props) { + const { visible, formMode, defaultValue, onClose, onSubmit } = props; + const [internalValues, setInternalValues] = useState(defaultValue || null); + const [step, setStep] = useState(MODEL_STEP.ONE); + const [form] = Form.useForm(); + + useEffect(() => { + if (!visible) return; + form.setFieldsValue(defaultValue || {}); + }, [form, defaultValue, visible]); + + const afterVisibleChange = (visible: boolean) => { + if (!visible) { + setStep(MODEL_STEP.ONE); + form.resetFields(); + setInternalValues(null); + } + }; + + const preview = () => { + form + .validateFields() + .then((values) => { + console.log({ ...internalValues, ...values }); + }) + .catch(console.error); + }; + + const back = () => { + setStep(MODEL_STEP.ONE); + }; + + const next = () => { + form + .validateFields() + .then((values) => { + setInternalValues({ ...internalValues, ...values }); + setStep(MODEL_STEP.TWO); + }) + .catch(console.error); + }; + + const submit = () => { + form + .validateFields() + .then(async (values) => { + await onSubmit({ ...internalValues, ...values }); + onClose(); + }) + .catch(console.error); + }; + + return ( + + } + extra={<>Step {step}/2} + > + + + ); +} diff --git a/wren-ui/src/components/pages/modeling/form/BasicInfoProperties.tsx b/wren-ui/src/components/pages/modeling/form/BasicInfoProperties.tsx new file mode 100644 index 000000000..08f52e879 --- /dev/null +++ b/wren-ui/src/components/pages/modeling/form/BasicInfoProperties.tsx @@ -0,0 +1,29 @@ +import { Form, Input, FormInstance } from 'antd'; + +export default function BasicInfoProperties(props: { + form: FormInstance; + label: string; + name: string; + errorTexts?: Record; +}) { + return ( + <> + + + + + + + + ); +} diff --git a/wren-ui/src/components/pages/modeling/form/CacheProperties.tsx b/wren-ui/src/components/pages/modeling/form/CacheProperties.tsx new file mode 100644 index 000000000..4aaf8fe97 --- /dev/null +++ b/wren-ui/src/components/pages/modeling/form/CacheProperties.tsx @@ -0,0 +1,95 @@ +import { useEffect, useState } from 'react'; +import { Form, Switch, Input, Select, Space, FormInstance } from 'antd'; +import { getCachePeriodText } from '@/utils/data'; +import { CACHED_PERIOD } from '@/utils/enum'; + +const splitCachedPeriod = (cachedPeriod: string) => { + const period = (cachedPeriod || '').split(/(?=\D)/); + const duration = period[0]; + const durationUnit = period[period.length - 1]; + return { duration, durationUnit }; +}; + +const CachedPeriodControl = (props: { + value?: string; + onChange?: (value: string) => void; +}) => { + const { value, onChange } = props; + const [internalValue, setInternalValue] = useState( + value ? splitCachedPeriod(value) : null, + ); + + const syncOnChange = () => { + if (internalValue.duration && internalValue.durationUnit) { + onChange && + onChange(`${internalValue.duration}${internalValue.durationUnit}`); + } + }; + + useEffect(syncOnChange, [internalValue]); + + const inputChange = (event) => { + setInternalValue({ ...internalValue, duration: event.target.value }); + }; + const selectChange = (durationUnit) => { + setInternalValue({ ...internalValue, durationUnit }); + }; + + return ( + + + + + + + + Simple + Cumulative + + + + + + + + {metricType === RADIO_VALUE.SIMPLE && ( + + + + )} + + {metricType === RADIO_VALUE.CUMULATIVE && ( + + + + )} + + + + + + ); +} + +export const ButtonGroup = (props: ButtonProps) => { + const { form, onPreview, onCancel, onBack, onSubmit } = props; + const measures = Form.useWatch('measures', form) || []; + const dimensions = Form.useWatch('dimensions', form) || []; + const windows = Form.useWatch('windows', form) || []; + + const canPreview = useMemo(() => { + return measures.length > 0 || dimensions.length > 0 || windows.length > 0; + }, [measures, dimensions, windows]); + + return ( +
+ + + + + + +
+ ); +}; diff --git a/wren-ui/src/components/pages/modeling/form/ModelBasicForm.tsx b/wren-ui/src/components/pages/modeling/form/ModelBasicForm.tsx new file mode 100644 index 000000000..3d5a7835e --- /dev/null +++ b/wren-ui/src/components/pages/modeling/form/ModelBasicForm.tsx @@ -0,0 +1,40 @@ +import { Form, FormInstance, Space, Button } from 'antd'; +import BasicInfoProperties from './BasicInfoProperties'; +import CacheProperties from './CacheProperties'; +import { FORM_MODE } from '@/utils/enum'; +import { ERROR_TEXTS } from '@/utils/error'; + +export interface ButtonProps { + onCancel: () => void; + onNext: () => void; +} + +export default function ModelBasicForm(props: { + form: FormInstance; + formMode: FORM_MODE; +}) { + const { form } = props; + return ( +
+ + + + ); +} + +export const ButtonGroup = (props: ButtonProps) => { + const { onNext, onCancel } = props; + return ( + + + + + ); +}; diff --git a/wren-ui/src/components/pages/modeling/form/ModelDetailForm.tsx b/wren-ui/src/components/pages/modeling/form/ModelDetailForm.tsx new file mode 100644 index 000000000..3ca267154 --- /dev/null +++ b/wren-ui/src/components/pages/modeling/form/ModelDetailForm.tsx @@ -0,0 +1,199 @@ +import { useEffect, useMemo } from 'react'; +import { Form, FormInstance, Radio, Select, Button, Space } from 'antd'; +import { FORM_MODE } from '@/utils/enum'; +import { ERROR_TEXTS } from '@/utils/error'; +import Editor from '@/components/editor'; +import Selector from '@/components/selectors/Selector'; +import CalulatedFieldTableFormControl, { + CalculatedFieldTableValue, +} from '@/components/tableFormControls/CalculatedFieldTableFormControl'; +import useModelDetailFormOptions from '@/hooks/useModelDetailFormOptions'; +import PreviewDataContent from '@/components/PreviewDataContent'; + +export interface ButtonProps { + form: FormInstance; + onPreview: () => void; + onCancel: () => void; + onSubmit: () => void; + onBack: () => void; +} + +type FieldValue = { name: string; type: string }; + +const RADIO_VALUE = { + TABLE: 'table', + CUSTOM: 'custom', +}; + +const getPreviewColumns = ( + fields: FieldValue[], + calculatedFields: CalculatedFieldTableValue, +) => { + return [ + fields.map((field) => field.name), + calculatedFields.map((field) => field.name), + ] + .flat() + .map((name) => ({ + title: name, + dataIndex: name, + })); +}; + +export default function ModelDetailForm(props: { + form: FormInstance; + formMode: FORM_MODE; +}) { + const { form, formMode } = props; + + const modelName = form.getFieldValue('name'); + const sourceType = Form.useWatch('sourceType', form); + const table = Form.useWatch('table', form); + const customSQL = Form.useWatch('customSQL', form); + const fields: FieldValue[] = Form.useWatch('fields', form) || []; + const calculatedFields: CalculatedFieldTableValue = + Form.useWatch('calculatedFields', form) || []; + + const { + dataSourceTableOptions, + dataSourceTableColumnOptions, + autoCompleteSource, + } = useModelDetailFormOptions({ selectedTable: table }); + + // Reset fields when table is changed. + useEffect(() => { + const allColumnNames = dataSourceTableColumnOptions.map( + (option) => option.value?.name, + ); + const isTableChange = fields.some( + (field) => !allColumnNames.includes(field.name), + ); + if (isTableChange) form.setFieldsValue({ fields: [] }); + }, [dataSourceTableColumnOptions]); + + const onSourceChange = (value) => { + if (sourceType !== value) { + form.setFieldsValue({ + table: undefined, + customSQL: undefined, + fields: undefined, + calculatedFields: undefined, + }); + } + }; + + // The transientData is used to get the model fields which are not created in DB yet. + const transientData = useMemo(() => { + return formMode === FORM_MODE.CREATE + ? [ + { + name: modelName, + columns: fields.map((field) => ({ + name: field.name, + properties: { type: field.type }, + })), + }, + ] + : undefined; + }, [fields]); + + const previewColumns = useMemo(() => { + return getPreviewColumns(fields, calculatedFields); + }, [fields, calculatedFields]); + + return ( +
+ + + Table + Custom SQL statement + + + + {sourceType === RADIO_VALUE.TABLE && ( + <> + + + + + + + + + + + + + + + + + ); +} diff --git a/wren-ui/src/components/pages/setup/utils.tsx b/wren-ui/src/components/pages/setup/utils.tsx new file mode 100644 index 000000000..0675fd65e --- /dev/null +++ b/wren-ui/src/components/pages/setup/utils.tsx @@ -0,0 +1,125 @@ +import Starter from './Starter'; +import ConnectDataSource from './ConnectDataSource'; +import SelectModels from './SelectModels'; +import CreateModels from './CreateModels'; +import RecommendRelations from './RecommendRelations'; +import DefineRelations from './DefineRelations'; +import { SETUP, DATA_SOURCES, DEMO_TEMPLATES } from '@/utils/enum'; +import BigQueryProperties from './dataSources/BigQueryProperties'; +import { merge } from 'lodash'; + +type SetupStep = { + step: number; + component: ( + props?: React.ComponentProps & + React.ComponentProps & + React.ComponentProps & + React.ComponentProps & + React.ComponentProps & + React.ComponentProps, + ) => JSX.Element; + maxWidth?: number; +}; + +export type ButtonOption = { + label: string; + logo: string; + guide: string; + disabled: boolean; +}; + +export const SETUP_STEPS = { + [SETUP.STARTER]: { + step: 0, + component: Starter, + }, + [SETUP.CREATE_DATA_SOURCE]: { + step: 0, + component: ConnectDataSource, + maxWidth: 800, + }, + [SETUP.SELECT_MODELS]: { + step: 1, + component: SelectModels, + }, + [SETUP.CREATE_MODELS]: { + step: 1, + component: CreateModels, + }, + [SETUP.RECOMMEND_RELATIONS]: { + step: 2, + component: RecommendRelations, + }, + [SETUP.DEFINE_RELATIONS]: { + step: 2, + component: DefineRelations, + }, +} as { [key: string]: SetupStep }; + +export const DATA_SOURCE_OPTIONS = { + [DATA_SOURCES.BIG_QUERY]: { + label: 'BigQuery', + logo: '/images/dataSource/bigQuery.svg', + guide: '', + disabled: false, + }, + [DATA_SOURCES.DATA_BRICKS]: { + label: 'Databricks', + logo: '/images/dataSource/dataBricks.svg', + guide: '', + disabled: true, + }, + [DATA_SOURCES.SNOWFLAKE]: { + label: 'Snowflake', + logo: '/images/dataSource/snowflake.svg', + guide: '', + disabled: true, + }, + [DATA_SOURCES.TRINO]: { + label: 'Trino', + logo: '', + guide: '', + disabled: true, + }, +} as { [key: string]: ButtonOption }; + +export const DATA_SOURCE_FORM = { + [DATA_SOURCES.BIG_QUERY]: { component: BigQueryProperties }, +}; + +export const TEMPLATE_OPTIONS = { + [DEMO_TEMPLATES.CRM]: { + label: 'CRM', + logo: '', + }, + [DEMO_TEMPLATES.ECORMERCE]: { + label: 'E-commerce', + logo: '', + }, +}; + +export const getDataSources = () => { + return Object.keys(DATA_SOURCE_OPTIONS).map((key) => ({ + ...DATA_SOURCE_OPTIONS[key], + value: key, + })) as (ButtonOption & { value: DATA_SOURCES })[]; +}; + +export const getDataSource = (dataSource: DATA_SOURCES) => { + const defaultDataSource = merge( + DATA_SOURCE_OPTIONS[DATA_SOURCES.BIG_QUERY], + DATA_SOURCE_FORM[DATA_SOURCES.BIG_QUERY], + ); + return ({ + [DATA_SOURCES.BIG_QUERY]: defaultDataSource, + }[dataSource] || defaultDataSource) as typeof defaultDataSource; +}; + +export const getTemplates = () => { + return Object.keys(TEMPLATE_OPTIONS).map((key) => ({ + ...TEMPLATE_OPTIONS[key], + value: key, + })) as (Omit & { + value: DEMO_TEMPLATES; + })[]; +}; diff --git a/wren-ui/src/components/selectors/CombineFieldSelector.tsx b/wren-ui/src/components/selectors/CombineFieldSelector.tsx new file mode 100644 index 000000000..bc2c9face --- /dev/null +++ b/wren-ui/src/components/selectors/CombineFieldSelector.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { Select, Input } from 'antd'; + +interface Value { + model?: string; + field?: string; +} + +interface Props { + modelOptions: { label: string; value: string }[]; + fieldOptions: { label: string; value: string }[]; + modelDisabled?: boolean; + fieldDisabled?: boolean; + modelValue?: string; + fieldValue?: string; + value?: Value; + onModelChange?: (value: string) => void; + onFieldChange?: (value: string) => void; + onChange?: (value: Value) => void; +} + +export default function CombineFieldSelector(props: Props) { + const { + modelValue, + fieldValue, + value = {}, + onModelChange, + onFieldChange, + onChange, + modelOptions, + fieldOptions, + modelDisabled, + fieldDisabled, + } = props; + + const [internalValue, setInternalValue] = useState({ + model: modelValue, + field: fieldValue, + ...value, + }); + + const syncOnChange = () => { + if (internalValue?.model && internalValue?.field) { + onChange && onChange(internalValue); + } + }; + + useEffect(syncOnChange, [internalValue]); + + const changeModel = async (model) => { + onModelChange && onModelChange(model); + setInternalValue({ ...internalValue, model }); + }; + + const changeField = (field) => { + onFieldChange && onFieldChange(field); + setInternalValue({ ...internalValue, field }); + }; + + return ( + + + + ); +} diff --git a/wren-ui/src/components/selectors/DescriptiveSelector.tsx b/wren-ui/src/components/selectors/DescriptiveSelector.tsx new file mode 100644 index 000000000..01a180a5c --- /dev/null +++ b/wren-ui/src/components/selectors/DescriptiveSelector.tsx @@ -0,0 +1,151 @@ +import React, { useState } from 'react'; +import { Select, Space, Typography } from 'antd'; +import styled from 'styled-components'; +import { omit } from 'lodash'; + +export interface Option> { + [key: string]: any; + label: string; + value?: string; + className?: string; + disabled?: boolean; + content?: TContent; + options?: Option[]; +} + +interface Props { + options: Option[]; + value?: string | string[] | null; + mode?: 'multiple' | 'tags'; + listHeight?: number; + onChange?: (value: any) => void; + descriptiveContentRender?: (option: any) => React.ReactNode; + placeholder?: string; + dropdownMatchSelectWidth?: number | boolean; +} + +const { Title } = Typography; + +const DescribeBox = styled.div` + display: flex; + .rc-virtual-list { + min-width: 230px; + } + + .describeBox { + &-codeBlock { + background: var(--gray-3); + border-radius: 4px; + padding: 6px 8px; + } + } +`; + +const defaultDescriptiveContentRender = (content: Record) => { + return ( + +
+
+ Description +
+ {content?.description || '-'} +
+
+
+ Example +
+ {content?.example ? ( +
{content?.example}
+ ) : ( + '-' + )} +
+
+ ); +}; + +export default function DescriptiveSelector(props: Props) { + const { + mode, + value, + options, + onChange, + descriptiveContentRender, + listHeight, + placeholder, + dropdownMatchSelectWidth, + } = props; + // Condition when met group option + const [firstOption] = options; + const [currentOption, setCurrentOption] = useState( + firstOption.options ? firstOption.options[0] : firstOption, + ); + // if descriptiveContentRender is not provided, the maxHeight will auto set for defaultDescriptiveContentRender + const maxHeight = descriptiveContentRender ? listHeight : 193; + + const renderDescriptiveMenu = (menu: React.ReactNode) => { + return ( + + {menu} +
+ + {currentOption?.label || currentOption?.value} + +
+ {(descriptiveContentRender + ? descriptiveContentRender + : defaultDescriptiveContentRender)(currentOption?.content)} +
+
+
+ ); + }; + + const extendOptionMouseEnter = (option) => { + setCurrentOption(option); + }; + + const getOptionStructure = (option) => ({ + ...omit(option, ['content']), + 'data-value': option.value, + onMouseEnter: (event) => { + extendOptionMouseEnter(option); + option.onMouseEnter && option.onMouseEnter(event); + }, + }); + + const mainOptions = options.map((option) => { + const isOptionGroup = Boolean(option.options); + return isOptionGroup + ? { ...option, options: option.options!.map(getOptionStructure) } + : getOptionStructure(option); + }); + + return ( + + ); +} diff --git a/wren-ui/src/components/selectors/modelFieldSelector/FieldSelect.tsx b/wren-ui/src/components/selectors/modelFieldSelector/FieldSelect.tsx new file mode 100644 index 000000000..41db2e933 --- /dev/null +++ b/wren-ui/src/components/selectors/modelFieldSelector/FieldSelect.tsx @@ -0,0 +1,98 @@ +import styled from 'styled-components'; +import { NODE_TYPE } from '@/utils/enum'; +import { ModelIcon } from '@/utils/icons'; +import { IterableComponent } from '@/utils/iteration'; +import Selector, { Option } from '@/components/selectors/Selector'; + +const FieldBox = styled.div` + border-radius: 4px; + background-color: white; + width: 170px; + box-shadow: + 0px 9px 28px 8px rgba(0, 0, 0, 0.05), + 0px 6px 16px 0px rgba(0, 0, 0, 0.08), + 0px 3px 6px -4px rgba(0, 0, 0, 0.12); + + + .adm-fieldBox { + position: relative; + margin-left: 40px; + &:before { + content: ''; + position: absolute; + top: 50%; + left: -40px; + width: 40px; + height: 1px; + background-color: var(--gray-8); + } + } + + .ant-select-selection-placeholder { + color: var(--geekblue-6); + } + + &:last-child { + border: 1px var(--geekblue-6) solid; + } +`; + +const FieldHeader = styled.div` + display: flex; + align-items: center; + border-bottom: 1px var(--gray-4) solid; +`; + +const StyledSelector = styled(Selector)` + &.ant-select-status-error.ant-select:not(.ant-select-disabled):not( + .ant-select-customize-input + ) + .ant-select-selector { + border-color: transparent !important; + } +`; + +export type FieldOption = Option; + +export interface FieldValue { + nodeType: NODE_TYPE; + name: string; + type?: string; +} + +type Props = FieldValue & { + options: FieldOption[]; + onChange?: (item: any, index: number) => void; +}; + +export default function FieldSelect(props: IterableComponent) { + const { name, nodeType, options, onChange, data, index } = props; + const currentIndex = data.findIndex((item) => item.name === name); + const selectedField = data[currentIndex + 1]; + + return nodeType === NODE_TYPE.MODEL ? ( + + + +
+ {name} +
+
+ + {selectedField?.nodeType === NODE_TYPE.MODEL && ( +
Relations
+ )} + + { + onChange && onChange(value, index); + }} + /> +
+ ) : null; +} diff --git a/wren-ui/src/components/selectors/modelFieldSelector/index.tsx b/wren-ui/src/components/selectors/modelFieldSelector/index.tsx new file mode 100644 index 000000000..155a06a77 --- /dev/null +++ b/wren-ui/src/components/selectors/modelFieldSelector/index.tsx @@ -0,0 +1,66 @@ +import { useRef, useContext, useMemo } from 'react'; +import styled from 'styled-components'; +import FieldSelect, { FieldValue, FieldOption } from './FieldSelect'; +import { nextTick } from '@/utils/time'; +import { makeIterable } from '@/utils/iteration'; +import { NODE_TYPE } from '@/utils/enum'; +import { parseJson } from '@/utils/helper'; +import { + FormItemInputContext, + FormItemStatusContextProps, +} from 'antd/lib/form/context'; + +interface Props { + model: string; + options: FieldOption[]; + onChange?: (value: FieldValue[]) => void; + value?: FieldValue[]; +} + +const Wrapper = styled.div` + border: 1px var(--gray-5) solid; + border-radius: 4px; + overflow-x: auto; + + &.adm-error { + border-color: var(--red-5); + } +`; + +const SelectResult = makeIterable(FieldSelect); + +export default function ModelFieldSelector(props: Props) { + const wrapper = useRef(null); + const { model, value = [], onChange, options } = props; + + const formItemContext = + useContext(FormItemInputContext); + const { status } = formItemContext; + + const data = useMemo(() => { + const modelValue = [{ name: model, nodeType: NODE_TYPE.MODEL }]; + return [modelValue, value].flat(); + }, [model, value]); + + const change = async (selectValue, index) => { + const parsePayload = parseJson(selectValue) as FieldValue; + + const prevValue = value.slice(0, index); + const nextValue = [...prevValue, parsePayload]; + onChange && onChange(nextValue); + + await nextTick(); + wrapper.current?.scrollTo({ left: wrapper.current?.scrollWidth }); + }; + + return ( + + + + ); +} diff --git a/wren-ui/src/components/sidebar/Exploration.tsx b/wren-ui/src/components/sidebar/Exploration.tsx new file mode 100644 index 000000000..7072214e0 --- /dev/null +++ b/wren-ui/src/components/sidebar/Exploration.tsx @@ -0,0 +1,131 @@ +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import copy from 'copy-to-clipboard'; +import styled from 'styled-components'; +import { Button, message } from 'antd'; +import { DataNode } from 'antd/es/tree'; +import PlusOutlined from '@ant-design/icons/PlusOutlined'; +import SidebarTree, { useSidebarTreeState } from './SidebarTree'; +import { createTreeGroupNode } from '@/components/sidebar/utils'; +import { Path } from '@/utils/enum'; +import ExplorationTreeTitle from '@/components/sidebar/exploration/ExplorationTreeTitle'; + +// TODO: update it to real exploration data type +interface ExplorationData { + id: string; + name: string; +} + +export interface Props { + data: ExplorationData[]; + onSelect: (selectKeys) => void; +} + +const ExplorationSidebarTree = styled(SidebarTree)` + .adm-treeNode { + &.adm-treeNode__exploration { + padding: 0px 16px 0px 4px !important; + + .ant-tree-title { + flex-grow: 1; + display: inline-flex; + align-items: center; + span:first-child, + .adm-treeTitle__title { + flex-grow: 1; + } + } + } + } +`; + +export default function Exploration(props: Props) { + const { data, onSelect } = props; + const router = useRouter(); + + const getExplorationGroupNode = createTreeGroupNode({ + groupName: 'Exploration', + groupKey: 'exploration', + icons: [], + }); + + const [tree, setTree] = useState(getExplorationGroupNode()); + const { treeSelectedKeys, setTreeSelectedKeys } = useSidebarTreeState(); + + useEffect(() => { + router.query.id && setTreeSelectedKeys([router.query.id] as string[]); + }, [router.query.id]); + + // initial workspace + useEffect(() => { + setTree( + data.map((exploration) => { + const nodeKey = exploration.id; + + return { + className: 'adm-treeNode adm-treeNode__exploration', + id: nodeKey, + isLeaf: true, + key: nodeKey, + title: ( + { + // TODO: Call API to rename the exploration result title + console.log( + 'Call API to rename the exploration result title:', + newExplorationName, + ); + }} + onDelete={onDeleteExploration} + /> + ), + }; + }), + ); + }, [data]); + + const onDeleteExploration = (explorationId: string) => { + // TODO: Call API to delete the exploration result + console.log('Call delete API:', explorationId); + if (router.query.id === explorationId) { + router.push(Path.Exploration); + } + }; + + const onCopyLink = (explorationId: string) => { + copy(`${window.location.toString()}/${explorationId}`); + message.success('Copied link to clipboard.'); + }; + + const onTreeSelect = (selectedKeys: React.Key[], _info: any) => { + // prevent deselected + if (selectedKeys.length === 0) return; + + setTreeSelectedKeys(selectedKeys); + onSelect(selectedKeys); + }; + + return ( + <> +
+ +
+ + + ); +} diff --git a/wren-ui/src/components/sidebar/LabelTitle.tsx b/wren-ui/src/components/sidebar/LabelTitle.tsx new file mode 100644 index 000000000..abe9420e1 --- /dev/null +++ b/wren-ui/src/components/sidebar/LabelTitle.tsx @@ -0,0 +1,18 @@ +interface LabelTitleProps { + title: string; + appendIcon?: React.ReactNode | null; +} + +export default function LabelTitle({ + title, + appendIcon = null, +}: LabelTitleProps) { + return ( + <> + + {title} + + {appendIcon && {appendIcon}} + + ); +} diff --git a/wren-ui/src/components/sidebar/Modeling.tsx b/wren-ui/src/components/sidebar/Modeling.tsx new file mode 100644 index 000000000..551ad2aa5 --- /dev/null +++ b/wren-ui/src/components/sidebar/Modeling.tsx @@ -0,0 +1,70 @@ +import styled from 'styled-components'; +import SidebarTree from './SidebarTree'; +import ModelTree from './modeling/ModelTree'; +import MetricTree from './modeling/MetricTree'; +import ViewTree from './modeling/ViewTree'; +import { AdaptedData } from '@/utils/data'; + +export const StyledSidebarTree = styled(SidebarTree)` + .ant-tree-title { + flex-grow: 1; + display: inline-flex; + align-items: center; + span:first-child, + .adm-treeTitle__title { + flex-grow: 1; + } + } + + .adm-treeNode { + .ant-tree-title { + display: inline-flex; + flex-wrap: nowrap; + min-width: 1px; + flex-grow: 0; + } + } +`; + +export interface Props { + data: AdaptedData; + onOpenModelDrawer: () => void; + onOpenMetricDrawer: () => void; + onOpenViewDrawer: () => void; + onSelect: (selectKeys) => void; +} + +export default function Modeling(props: Props) { + // TODO: get sidebar data + const { + data, + onSelect, + onOpenModelDrawer, + onOpenMetricDrawer, + onOpenViewDrawer, + } = props; + const { models = [], metrics = [], views = [] } = data || {}; + + return ( + <> + + + + + ); +} diff --git a/wren-ui/src/components/sidebar/SidebarTree.tsx b/wren-ui/src/components/sidebar/SidebarTree.tsx new file mode 100644 index 000000000..d629f7c07 --- /dev/null +++ b/wren-ui/src/components/sidebar/SidebarTree.tsx @@ -0,0 +1,215 @@ +import { useState } from 'react'; +import styled, { css } from 'styled-components'; +import { Tree, TreeProps } from 'antd'; + +const anticonStyle = css` + [class^='anticon anticon-'] { + transition: background-color ease-out 0.12s; + border-radius: 2px; + width: 12px; + height: 12px; + font-size: 12px; + vertical-align: middle; + + &:hover { + background-color: var(--gray-5); + } + &:active { + background-color: var(--gray-6); + } + + &[disabled] { + cursor: not-allowed; + color: var(--gray-6); + &:hover, + &:active { + background-color: transparent; + } + } + } + .anticon + .anticon { + margin-left: 4px; + } +`; + +const StyledTree = styled(Tree)` + &.ant-tree { + background-color: transparent; + color: var(--gray-8); + + .ant-tree-indent-unit { + width: 12px; + } + + .ant-tree-node-content-wrapper { + display: flex; + align-items: center; + line-height: 18px; + min-height: 28px; + min-width: 1px; + padding: 0; + } + + .ant-tree-node-content-wrapper:hover, + .ant-tree-node-content-wrapper.ant-tree-node-selected { + background-color: transparent; + } + + .ant-tree-treenode { + padding: 0 16px; + background-color: transparent; + transition: background-color ease-out 0.12s; + + &-selected { + color: var(--geekblue-6); + background-color: var(--gray-4); + } + + .ant-tree-switcher { + width: 12px; + align-self: center; + .ant-tree-switcher-icon { + font-size: 12px; + vertical-align: middle; + } + ${anticonStyle} + } + + .ant-tree-iconEle { + flex-shrink: 0; + } + } + + .adm { + &-treeTitle__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &-treeNode { + &:hover { + background-color: var(--gray-4); + } + &:active { + background-color: var(--gray-6); + } + + .ant-tree-title { + display: inline-flex; + flex-wrap: nowrap; + min-width: 1px; + } + + &--relation, + &--primary { + margin-left: 4px; + } + + &--group { + color: var(--gray-8); + margin-top: 16px; + + font-size: 14px; + font-weight: 500; + + .ant-tree-switcher-noop { + display: none; + } + + > * { + cursor: inherit; + } + } + + &--empty { + color: var(--gray-7); + font-size: 12px; + .ant-tree-switcher { + display: none; + } + .ant-tree-node-content-wrapper { + min-height: auto; + } + } + + &--selectNode { + * { + cursor: auto; + } + &:hover, + &:active { + background-color: transparent; + } + } + + &--subtitle { + color: var(--gray-7); + font-size: 12px; + font-weight: 500; + .ant-tree-switcher { + display: none; + } + .ant-tree-node-content-wrapper { + min-height: auto; + } + } + + &--selectNone { + * { + cursor: auto; + } + &:hover, + &:active { + background-color: transparent; + } + } + } + + &-actionIcon { + font-size: 14px; + border-radius: 2px; + margin-right: -3px; + &:not(.adm-actionIcon--disabled) { + cursor: pointer; + &:hover { + background-color: var(--gray-5); + } + } + .anticon { + padding: 2px; + cursor: inherit; + } + } + } + } +`; + +export const useSidebarTreeState = () => { + const [treeSelectedKeys, setTreeSelectedKeys] = useState([]); + const [treeExpandKeys, setTreeExpandKeys] = useState([]); + const [treeLoadedKeys, setTreeLoadedKeys] = useState([]); + const [autoExpandParent, setAutoExpandParent] = useState(true); + + return { + treeSelectedKeys, + treeExpandKeys, + treeLoadedKeys, + autoExpandParent, + setTreeSelectedKeys, + setTreeExpandKeys, + setTreeLoadedKeys, + setAutoExpandParent, + }; +}; + +export default function SidebarTree(props: TreeProps) { + return ( + + ); +} diff --git a/wren-ui/src/components/sidebar/exploration/ExplorationTitleInput.tsx b/wren-ui/src/components/sidebar/exploration/ExplorationTitleInput.tsx new file mode 100644 index 000000000..8f0cd08b4 --- /dev/null +++ b/wren-ui/src/components/sidebar/exploration/ExplorationTitleInput.tsx @@ -0,0 +1,30 @@ +import { Input } from 'antd'; + +const ESCAPE = 'escape'; + +export default function ExplorationTitleInput(props: { + title: string; + onCancelChange: () => void; + onSetTitle: (newTitle: string) => void; + onRename: (newName: string) => void; +}) { + const { title, onCancelChange, onRename, onSetTitle } = props; + + return ( + e.stopPropagation()} + onKeyDown={(e: React.KeyboardEvent) => { + // change back to the original title + if (e.key.toLowerCase() === ESCAPE) onCancelChange(); + }} + onChange={(e: React.ChangeEvent) => + onSetTitle(e.target.value) + } + onPressEnter={(_e) => onRename(title)} + onBlur={(_e) => onRename(title)} + /> + ); +} diff --git a/wren-ui/src/components/sidebar/exploration/ExplorationTreeTitle.tsx b/wren-ui/src/components/sidebar/exploration/ExplorationTreeTitle.tsx new file mode 100644 index 000000000..7d17a82f9 --- /dev/null +++ b/wren-ui/src/components/sidebar/exploration/ExplorationTreeTitle.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react'; +import styled from 'styled-components'; +import { Dropdown, Menu } from 'antd'; +import EditOutlined from '@ant-design/icons/EditOutlined'; +import LinkOutlined from '@ant-design/icons/LinkOutlined'; +import MoreOutlined from '@ant-design/icons/MoreOutlined'; +import LabelTitle from '@/components/sidebar/LabelTitle'; +import { DeleteIconModal } from '@/components/modals/DeleteModal'; +import ExplorationTitleInput from '@/components/sidebar/exploration/ExplorationTitleInput'; + +const MENU_ITEM_KEYS = { + RENAME: 'rename', + COPY_LINK: 'copy-link', + DELETE: 'delete', +}; + +const StyledMenu = styled(Menu)` + a:hover { + color: white; + } +`; + +export default function ExplorationTreeTitle(props: { + explorationId: string; + onCopyLink: (explorationId: string) => void; + onDelete: (explorationId: string) => void; + onRename: (newName: string) => void; + title: string; +}) { + const { explorationId, onCopyLink, onDelete, onRename } = props; + const [title, setTitle] = useState(props.title); + const [isEditing, setIsEditing] = useState(false); + + const onCancelChange = () => { + setIsEditing(false); + setTitle(props.title); + }; + + const onChangeTitle = (newExplorationTitle: string) => { + setIsEditing(false); + setTitle(newExplorationTitle); + onRename(newExplorationTitle); + }; + + return isEditing ? ( + + ) : ( + + + Rename + + ), + key: MENU_ITEM_KEYS.RENAME, + onClick: ({ domEvent }) => { + domEvent.stopPropagation(); + setIsEditing(true); + }, + }, + { + label: ( + <> + + Copy link + + ), + key: MENU_ITEM_KEYS.COPY_LINK, + onClick: ({ domEvent }) => { + domEvent.stopPropagation(); + onCopyLink(explorationId); + }, + }, + { type: 'divider' }, + { + label: ( + onDelete(explorationId)} + /> + ), + danger: true, + key: MENU_ITEM_KEYS.DELETE, + onClick: ({ domEvent }) => { + domEvent.stopPropagation(); + }, + }, + ]} + /> + } + > + event.stopPropagation()} /> + + } + /> + ); +} diff --git a/wren-ui/src/components/sidebar/index.tsx b/wren-ui/src/components/sidebar/index.tsx new file mode 100644 index 000000000..6074d302d --- /dev/null +++ b/wren-ui/src/components/sidebar/index.tsx @@ -0,0 +1,44 @@ +import { useRouter } from 'next/router'; +import styled from 'styled-components'; +import { Path } from '@/utils/enum'; +import Exploration, { Props as ExplorationSidebarProps } from './Exploration'; +import Modeling, { Props as ModelingSidebarProps } from './Modeling'; + +const Layout = styled.div` + position: relative; + min-height: 100%; + background-color: var(--gray-3); + color: var(--gray-8); + padding-bottom: 24px; + overflow-x: hidden; +`; + +type Props = ModelingSidebarProps | ExplorationSidebarProps; + +const DynamicSidebar = ( + props: Props & { + pathname: string; + }, +) => { + const { pathname, ...restProps } = props; + + if (pathname.startsWith(Path.Exploration)) { + return ; + } + + if (pathname.startsWith(Path.Modeling)) { + return ; + } + + return null; +}; + +export default function Sidebar(props: Props) { + const router = useRouter(); + + return ( + + + + ); +} diff --git a/wren-ui/src/components/sidebar/modeling/GroupTreeTitle.tsx b/wren-ui/src/components/sidebar/modeling/GroupTreeTitle.tsx new file mode 100644 index 000000000..148433330 --- /dev/null +++ b/wren-ui/src/components/sidebar/modeling/GroupTreeTitle.tsx @@ -0,0 +1,50 @@ +import Icon from '@ant-design/icons'; + +export type IconsType = { + icon: any; + key: React.Key; + className?: string; + style?: React.CSSProperties; + disabled?: boolean; +}; + +interface GroupTitleProps { + title: string; + quotaUsage?: number; + icons: IconsType[]; +} + +const ActionIcons = ({ icons }: { icons: IconsType[] }) => { + const iconComponents = icons.map( + ({ key, icon, disabled = false, className = '', ...restProps }) => ( + + ), + ); + + return <>{iconComponents}; +}; + +export default function GroupTreeTitle({ + title, + quotaUsage = 0, + ...restProps +}: GroupTitleProps) { + return ( + <> + + {title} + + ({quotaUsage}) + + + + + ); +} diff --git a/wren-ui/src/components/sidebar/modeling/MetricTree.tsx b/wren-ui/src/components/sidebar/modeling/MetricTree.tsx new file mode 100644 index 000000000..2b27f46af --- /dev/null +++ b/wren-ui/src/components/sidebar/modeling/MetricTree.tsx @@ -0,0 +1,69 @@ +import { useEffect, useState } from 'react'; +import { DataNode } from 'antd/es/tree'; +import { startCase } from 'lodash'; +import PlusSquareOutlined from '@ant-design/icons/PlusSquareOutlined'; +import { getNodeTypeIcon } from '@/utils/nodeType'; +import { createTreeGroupNode, getColumnNode } from '@/components/sidebar/utils'; +import LabelTitle from '@/components/sidebar/LabelTitle'; +import { METRIC_TYPE } from '@/utils/enum'; +import { StyledSidebarTree } from '@/components/sidebar/Modeling'; + +export default function MetricTree(props) { + const { onOpenMetricDrawer, metrics } = props; + + const getMetricGroupNode = createTreeGroupNode({ + groupName: 'Metrics', + groupKey: 'metrics', + icons: [ + { + key: 'add-metric', + icon: () => onOpenMetricDrawer()} />, + }, + ], + }); + + const [tree, setTree] = useState(getMetricGroupNode()); + + // initial workspace + useEffect(() => { + setTree((_tree) => + getMetricGroupNode({ + quotaUsage: metrics.length, + children: metrics.map((metric) => { + const nodeKey = metric.id; + + const children = [ + ...getColumnNode( + nodeKey, + [...(metric.dimensions || []), ...(metric.timeGrains || [])], + startCase(METRIC_TYPE.DIMENSION), + ), + ...getColumnNode( + nodeKey, + metric.measures || [], + startCase(METRIC_TYPE.MEASURE), + ), + ...getColumnNode( + nodeKey, + metric.windows || [], + startCase(METRIC_TYPE.WINDOW), + ), + ]; + + return { + children, + className: 'adm-treeNode', + icon: getNodeTypeIcon({ nodeType: metric.nodeType }), + id: nodeKey, + isLeaf: false, + key: nodeKey, + title: , + type: metric.nodeType, + }; + }), + }), + ); + }, [metrics]); + + return ; +} diff --git a/wren-ui/src/components/sidebar/modeling/ModelTree.tsx b/wren-ui/src/components/sidebar/modeling/ModelTree.tsx new file mode 100644 index 000000000..3c840053e --- /dev/null +++ b/wren-ui/src/components/sidebar/modeling/ModelTree.tsx @@ -0,0 +1,63 @@ +import { useEffect, useState } from 'react'; +import { DataNode } from 'antd/es/tree'; +import { startCase } from 'lodash'; +import PlusSquareOutlined from '@ant-design/icons/PlusSquareOutlined'; +import { getNodeTypeIcon } from '@/utils/nodeType'; +import { createTreeGroupNode, getColumnNode } from '@/components/sidebar/utils'; +import LabelTitle from '@/components/sidebar/LabelTitle'; +import { NODE_TYPE } from '@/utils/enum'; +import { StyledSidebarTree } from '@/components/sidebar/Modeling'; + +export default function ModelTree(props) { + const { onOpenModelDrawer, models } = props; + + const getModelGroupNode = createTreeGroupNode({ + groupName: 'Models', + groupKey: 'models', + icons: [ + { + key: 'add-model', + icon: () => onOpenModelDrawer()} />, + }, + ], + }); + + const [tree, setTree] = useState(getModelGroupNode()); + + // initial workspace + useEffect(() => { + setTree((_tree) => + getModelGroupNode({ + quotaUsage: models.length, + children: models.map((model) => { + const nodeKey = model.id; + + const children = [ + ...getColumnNode(nodeKey, [ + ...model.fields, + ...model.calculatedFields, + ]), + ...getColumnNode( + nodeKey, + model.relationFields, + startCase(NODE_TYPE.RELATION), + ), + ]; + + return { + children, + className: 'adm-treeNode', + icon: getNodeTypeIcon({ nodeType: model.nodeType }), + id: nodeKey, + isLeaf: false, + key: nodeKey, + title: , + type: model.nodeType, + }; + }), + }), + ); + }, [models]); + + return ; +} diff --git a/wren-ui/src/components/sidebar/modeling/ViewTree.tsx b/wren-ui/src/components/sidebar/modeling/ViewTree.tsx new file mode 100644 index 000000000..06ff28f41 --- /dev/null +++ b/wren-ui/src/components/sidebar/modeling/ViewTree.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from 'react'; +import { DataNode } from 'antd/es/tree'; +import PlusSquareOutlined from '@ant-design/icons/PlusSquareOutlined'; +import { getNodeTypeIcon } from '@/utils/nodeType'; +import { createTreeGroupNode, getColumnNode } from '@/components/sidebar/utils'; +import LabelTitle from '@/components/sidebar/LabelTitle'; +import { StyledSidebarTree } from '@/components/sidebar/Modeling'; + +export default function ViewTree(props) { + const { onOpenViewDrawer, views } = props; + + const getViewGroupNode = createTreeGroupNode({ + groupName: 'Views', + groupKey: 'views', + icons: [ + { + key: 'add-view', + icon: () => onOpenViewDrawer()} />, + }, + ], + }); + + const [tree, setTree] = useState(getViewGroupNode()); + + // initial workspace + useEffect(() => { + setTree((_tree) => + getViewGroupNode({ + quotaUsage: views.length, + children: views.map((view) => { + const nodeKey = view.id || view.name; + + // TODO: remove [] when we have real views data since columns should be required + const children = getColumnNode(nodeKey, view.columns || []); + + return { + children, + className: 'adm-treeNode', + icon: getNodeTypeIcon({ nodeType: view.nodeType }), + id: nodeKey, + isLeaf: false, + key: nodeKey, + title: , + type: view.nodeType, + }; + }), + }), + ); + }, [views]); + + return ; +} diff --git a/wren-ui/src/components/sidebar/utils.tsx b/wren-ui/src/components/sidebar/utils.tsx new file mode 100644 index 000000000..a9f45909b --- /dev/null +++ b/wren-ui/src/components/sidebar/utils.tsx @@ -0,0 +1,127 @@ +import { DataNode } from 'antd/lib/tree'; +import { getColumnTypeIcon } from '@/utils/columnType'; +import { PrimaryKeyIcon, RelationshipIcon } from '@/utils/icons'; +import { MetricColumnData, ModelColumnData } from '@/utils/data'; +import { assign, isEmpty, snakeCase } from 'lodash'; +import GroupTreeTitle, { IconsType } from './modeling/GroupTreeTitle'; +import { getJoinTypeText } from '@/utils/data'; +import { NODE_TYPE } from '@/utils/enum'; +import { getNodeTypeIcon } from '@/utils/nodeType'; + +type TreeNode = DataNode; + +const ColumnNode = ({ title, relation, primary }) => { + const append = ( + <> + {relation && ( + + + + )} + {primary && ( + + + + )} + + ); + + return ( + <> + {title} + {append} + + ); +}; + +const getChildrenSubtitle = (nodeKey: string, title: string) => [ + { + title, + key: `${nodeKey}_${snakeCase(title)}`, + className: 'adm-treeNode--subtitle adm-treeNode--selectNone', + selectable: false, + isLeaf: true, + }, +]; + +export const getColumnNode = ( + nodeKey: string, + columns: ModelColumnData[] | MetricColumnData[], + title?: string, +): TreeNode[] => { + if (columns.length === 0) return []; + + return [ + ...(title ? getChildrenSubtitle(nodeKey, title) : []), + ...columns.map((column): TreeNode => { + // show the model icon for relation item + const icon = column.relation + ? getNodeTypeIcon({ nodeType: NODE_TYPE.MODEL }) + : getColumnTypeIcon(column, { title: column.type }); + + return { + icon, + className: 'adm-treeNode adm-treeNode-column adm-treeNode--selectNode', + title: ( + + ), + key: column.id, + selectable: false, + isLeaf: true, + }; + }), + ]; +}; + +interface GroupSet { + groupName: string; + groupKey: string; + quotaUsage?: number; + children?: DataNode[]; + icons: IconsType[]; +} + +export const createTreeGroupNode = + (sourceData: GroupSet) => (updatedData?: Partial) => { + const { + groupName = '', + groupKey = '', + quotaUsage, + icons, + children = [], + } = assign(sourceData, updatedData); + + const emptyChildren = [ + { + title: `No ${groupName}`, + key: `${groupKey}-empty`, + selectable: false, + className: 'adm-treeNode adm-treeNode--empty adm-treeNode--selectNode', + }, + ]; + const childrenData = isEmpty(children) ? emptyChildren : children; + + return [ + { + className: 'adm-treeNode--group', + title: ( + + ), + key: groupKey, + selectable: false, + isLeaf: true, + }, + ...childrenData, + ]; + }; diff --git a/wren-ui/src/components/table/BaseTable.tsx b/wren-ui/src/components/table/BaseTable.tsx new file mode 100644 index 000000000..2787c75ee --- /dev/null +++ b/wren-ui/src/components/table/BaseTable.tsx @@ -0,0 +1,101 @@ +import { useMemo } from 'react'; +import { Table, TableProps } from 'antd'; +import EllipsisWrapper from '@/components/EllipsisWrapper'; +import CodeBlock from '@/components/editor/CodeBlock'; +import { getColumnTypeIcon } from '@/utils/columnType'; +import { ModelColumnData, getJoinTypeText } from '@/utils/data'; + +export const COLUMN = { + DISPLAY_NAME: { + title: 'Display name', + dataIndex: 'displayName', + key: 'displayName', + width: 140, + ellipsis: true, + render: (name) => name || '-', + }, + REFERENCE_NAME: { + title: 'Reference name', + dataIndex: 'referenceName', + key: 'referenceName', + width: 150, + ellipsis: true, + render: (name) => name || '-', + }, + TYPE: { + title: 'Type', + dataIndex: 'type', + render: (type) => { + return ( +
+ {getColumnTypeIcon({ type }, { className: 'mr-2' })} + {type} +
+ ); + }, + }, + EXPRESSION: { + title: 'Expression', + dataIndex: 'expression', + key: 'expression', + render: (expression) => { + return ( + + + + ); + }, + }, + RELATION: { + title: 'Relation', + dataIndex: 'joinType', + key: 'joinType', + render: (joinType) => getJoinTypeText(joinType), + }, + DESCRIPTION: { + title: 'Description', + dataIndex: ['properties', 'description'], + key: 'description', + width: 200, + ellipsis: true, + render: (text) => text || '-', + }, +}; + +type BaseTableProps = TableProps; + +export type Props = BaseTableProps & { + actionColumns?: BaseTableProps['columns']; +}; + +export default function BaseTable(props: Props) { + const { dataSource = [], columns = [], actionColumns, ...restProps } = props; + + const tableColumns = useMemo( + () => columns.concat(actionColumns || []), + [dataSource], + ); + + const tableData = useMemo( + () => + (dataSource || []).map((record, index) => ({ + ...record, + key: `${record.id}-${index}`, + })), + [dataSource], + ); + + return ( +
0} + columns={tableColumns} + pagination={{ + hideOnSinglePage: true, + pageSize: 10, + size: 'small', + }} + /> + ); +} diff --git a/wren-ui/src/components/table/CalculatedFieldTable.tsx b/wren-ui/src/components/table/CalculatedFieldTable.tsx new file mode 100644 index 000000000..f660db899 --- /dev/null +++ b/wren-ui/src/components/table/CalculatedFieldTable.tsx @@ -0,0 +1,18 @@ +import BaseTable, { Props, COLUMN } from '@/components/table/BaseTable'; + +export default function CalculatedFieldTable(props: Props) { + const { columns } = props; + return ( + + ); +} diff --git a/wren-ui/src/components/table/DimensionFieldTable.tsx b/wren-ui/src/components/table/DimensionFieldTable.tsx new file mode 100644 index 000000000..b25d77250 --- /dev/null +++ b/wren-ui/src/components/table/DimensionFieldTable.tsx @@ -0,0 +1,20 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +/* @ts-nocheck */ +// This file just remain for future scope. +import BaseTable, { Props, COLUMN } from '@/components/table/BaseTable'; + +export default function DimensionFieldTable(props: Props) { + const { columns } = props; + return ( + + ); +} diff --git a/wren-ui/src/components/table/EditableBaseTable.tsx b/wren-ui/src/components/table/EditableBaseTable.tsx new file mode 100644 index 000000000..e322ec4fe --- /dev/null +++ b/wren-ui/src/components/table/EditableBaseTable.tsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState } from 'react'; +import { set, cloneDeep, isEmpty } from 'lodash'; +import { COLUMN, Props as BaseTableProps } from '@/components/table/BaseTable'; +import EditableWrapper from '@/components/EditableWrapper'; + +type Props = BaseTableProps & { + onChange?: (value: any) => void; +}; + +const EditableCell = (props) => { + const { editable, record, handleSave, dataIndex, children } = props; + const childNode = editable ? ( + + {children} + + ) : ( + children + ); + return ; +}; + +export const makeEditableBaseTable = (BaseTable: React.FC) => { + const EditableBaseTable = (props: Props) => { + const { columns, dataSource, onChange } = props; + const [data, setData] = useState(dataSource); + const components = { + body: { cell: !isEmpty(dataSource) ? EditableCell : undefined }, + }; + + useEffect(() => { + onChange && onChange(data); + }, [data]); + + const handleSave = (id: string, value: { [key: string]: string }) => { + const [dataIndexKey] = Object.keys(value); + + // sync value back to data state + const newData = cloneDeep(data); + newData.forEach((item) => { + if (id === item.id) set(item, dataIndexKey, value[dataIndexKey]); + }); + + setData(newData); + }; + + const tableColumns = columns.map((column) => ({ + ...column, + onCell: (record) => ({ + editable: [ + COLUMN.DISPLAY_NAME.title, + COLUMN.DESCRIPTION.title, + ].includes(column.title as string), + dataIndex: (column as any).dataIndex, + record, + handleSave, + }), + })) as Props['columns']; + + return ( + + ); + }; + + return EditableBaseTable; +}; diff --git a/wren-ui/src/components/table/FieldTable.tsx b/wren-ui/src/components/table/FieldTable.tsx new file mode 100644 index 000000000..5102a4625 --- /dev/null +++ b/wren-ui/src/components/table/FieldTable.tsx @@ -0,0 +1,18 @@ +import BaseTable, { Props, COLUMN } from '@/components/table/BaseTable'; + +export default function FieldTable(props: Props) { + const { columns } = props; + return ( + + ); +} diff --git a/wren-ui/src/components/table/MeasureFieldTable.tsx b/wren-ui/src/components/table/MeasureFieldTable.tsx new file mode 100644 index 000000000..81dfde915 --- /dev/null +++ b/wren-ui/src/components/table/MeasureFieldTable.tsx @@ -0,0 +1,20 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +/* @ts-nocheck */ +// This file just remain for future scope. +import BaseTable, { Props, COLUMN } from '@/components/table/BaseTable'; + +export default function MeasureFieldTable(props: Props) { + const { columns } = props; + return ( + + ); +} diff --git a/wren-ui/src/components/table/MetadataBaseTable.tsx b/wren-ui/src/components/table/MetadataBaseTable.tsx new file mode 100644 index 000000000..5377dae1b --- /dev/null +++ b/wren-ui/src/components/table/MetadataBaseTable.tsx @@ -0,0 +1,84 @@ +import { Space, Button } from 'antd'; +import EditOutlined from '@ant-design/icons/EditOutlined'; +import React, { ReactElement, useMemo } from 'react'; +import useModalAction from '@/hooks/useModalAction'; +import { Props as BaseTableProps } from '@/components/table/BaseTable'; + +interface Props { + dataSource: any[]; + metadataIndex?: Record; + onEditValue?: (value: any) => any; + onSubmitRemote?: (value: any) => void; + modalProps?: Partial; + onCellRender?: (data: any) => ReactElement; +} + +export const makeMetadataBaseTable = + (BaseTable: React.FC) => + (ModalComponent?: React.FC>) => { + const isEditable = !!ModalComponent; + + const MetadataBaseTable = (props: Props) => { + const { + dataSource, + onEditValue = (value) => value, + onSubmitRemote, + modalProps, + onCellRender, + } = props; + + const modalComponent = useModalAction(); + + const actionColumns = useMemo( + () => + isEditable + ? [ + { + key: 'action', + width: 64, + render: (record) => { + return ( + + + + ); + }, + }, + ] + : [], + [dataSource], + ); + + const submitModal = async (values: any) => { + onSubmitRemote && (await onSubmitRemote(values)); + }; + + return ( + <> + + {isEditable && ( + + )} + + ); + }; + + return MetadataBaseTable; + }; diff --git a/wren-ui/src/components/table/ModelRelationSelectionTable.tsx b/wren-ui/src/components/table/ModelRelationSelectionTable.tsx new file mode 100644 index 000000000..ecc771881 --- /dev/null +++ b/wren-ui/src/components/table/ModelRelationSelectionTable.tsx @@ -0,0 +1,59 @@ +import type { ColumnsType } from 'antd/es/table'; +import { JOIN_TYPE } from '@/utils/enum'; +import { ModelIcon } from '@/utils/icons'; +import SelectionTable from '@/components/table/SelectionTable'; + +interface ModelField { + model: string; + field: string; +} + +export interface SourceTableColumn { + name: string; + type: string; +} + +export interface SourceTable { + id: string; + sqlName: string; + displayName: string; + columns: SourceTableColumn[]; +} + +export interface RelationsDataType { + name: string; + fromField: ModelField; + isAutoGenerated: boolean; + joinType: JOIN_TYPE; + toField: ModelField; + properties: Record; +} + +interface Props { + columns: ColumnsType | ColumnsType; + dataSource: RelationsDataType[] | SourceTableColumn[]; + enableRowSelection?: boolean; + extra?: ( + onCollapseOpen: ( + event: React.MouseEvent, + key: string, + ) => void, + ) => React.ReactNode; + onChange?: (value: any | null) => void; + tableTitle: string; + rowKey: (record: RelationsDataType | SourceTableColumn) => string; +} + +export default function ModelRelationSelectionTable(props: Props) { + return ( + + + {props.tableTitle} + + } + /> + ); +} diff --git a/wren-ui/src/components/table/MultiSelectBox.tsx b/wren-ui/src/components/table/MultiSelectBox.tsx new file mode 100644 index 000000000..ffa345662 --- /dev/null +++ b/wren-ui/src/components/table/MultiSelectBox.tsx @@ -0,0 +1,104 @@ +import { useState, useMemo, useContext } from 'react'; +import styled from 'styled-components'; +import { isString } from 'lodash'; +import { Input, Table } from 'antd'; +import { ColumnsType } from 'antd/lib/table'; +import { SearchOutlined } from '@ant-design/icons'; +import { + FormItemInputContext, + FormItemStatusContextProps, +} from 'antd/lib/form/context'; + +const StyledBox = styled.div` + border: 1px solid var(--gray-5); + border-radius: 4px; + + &.multiSelectBox-input-error { + border-color: var(--red-5); + } + + .ant-table { + border: 0; + } + .ant-table-body, + .ant-table-placeholder { + height: 195px; + } +`; + +const StyledTotal = styled.div` + padding: 8px 12px; + border-bottom: 1px var(--gray-3) solid; +`; + +interface Props { + columns: ColumnsType; + items: { [key: string]: any; value: string }[]; + value?: string[]; + onChange?: (value: string[]) => void; +} + +export default function MultiSelectBox(props: Props) { + const { columns, items, onChange, value } = props; + const [selectedRowKeys, setSelectedRowKeys] = useState( + value || [], + ); + const [searchValue, setSearchValue] = useState(''); + const formItemContext = + useContext(FormItemInputContext); + const { status } = formItemContext; + + const dataSource = useMemo(() => { + return searchValue + ? items.filter((item) => + columns + .map((column) => item[column['dataIndex']]) + .some((value) => isString(value) && value.includes(searchValue)), + ) + : items; + }, [items, searchValue]); + + const onSelect = (rowKeys: React.Key[]) => { + setSelectedRowKeys(rowKeys); + onChange && onChange(rowKeys as string[]); + }; + + const onSearchChange = (event) => { + event.persist(); + const { value } = event.target; + setSearchValue(value); + }; + + const total = + selectedRowKeys.length === 0 + ? items.length + : `${selectedRowKeys.length}/${items.length}`; + + return ( + + {total} table(s) +
+ } + onChange={onSearchChange} + placeholder="Search here" + allowClear + /> +
+
{childNode}
record.value} + columns={columns} + dataSource={dataSource} + scroll={{ y: 195 }} + pagination={false} + /> + + ); +} diff --git a/wren-ui/src/components/table/RelationTable.tsx b/wren-ui/src/components/table/RelationTable.tsx new file mode 100644 index 000000000..e7660b455 --- /dev/null +++ b/wren-ui/src/components/table/RelationTable.tsx @@ -0,0 +1,18 @@ +import BaseTable, { Props, COLUMN } from '@/components/table/BaseTable'; + +export default function RelationTable(props: Props) { + const { columns } = props; + return ( + + ); +} diff --git a/wren-ui/src/components/table/SelectionTable.tsx b/wren-ui/src/components/table/SelectionTable.tsx new file mode 100644 index 000000000..f927cde1e --- /dev/null +++ b/wren-ui/src/components/table/SelectionTable.tsx @@ -0,0 +1,166 @@ +import { forwardRef, useContext, useState } from 'react'; +import styled from 'styled-components'; +import { Collapse, Row, RowProps, Table, TableProps } from 'antd'; +import { + FormItemInputContext, + FormItemStatusContextProps, +} from 'antd/lib/form/context'; + +const { Panel } = Collapse; + +const StyledCollapse = styled(Collapse)` + &.ant-collapse.adm-error { + border-color: var(--red-5); + border-bottom: 1px solid var(--red-5); + } + + &.ant-collapse { + background-color: white; + border-color: var(--gray-4); + + > .ant-collapse-item > .ant-collapse-header { + padding: 16px 12px; + align-items: center; + } + + > .ant-collapse-item, + .ant-collapse-content { + border-color: var(--gray-4); + } + + .ant-collapse-content-box { + padding: 0px; + } + + .ant-table { + border: none; + + .ant-table-thead > tr > th { + color: var(--gray-7); + background-color: white; + } + + &.ant-table-empty { + .ant-empty-normal { + margin: 16px 0; + } + } + } + } +`; + +const StyledRow = styled(Row).attrs<{ + $isRowSelection: boolean; +}>((props) => ({ + className: `${props.$isRowSelection ? '' : 'ml-1'}`, +}))`` as React.ForwardRefExoticComponent< + RowProps & React.RefAttributes & { $isRowSelection: boolean } +>; + +type Props = TableProps & { + enableRowSelection?: boolean; + extra?: ( + onCollapseOpen: ( + event: React.MouseEvent, + collapseKey: string, + ) => void, + ) => React.ReactNode; + onChange?: (value: any | null) => void; + rowKey: (record: T) => string; + tableTitle: string; + tableHeader: React.ReactNode; +}; + +function SelectionTable>( + props: Props, + ref: React.Ref, +) { + const { + columns, + dataSource, + extra, + enableRowSelection, + onChange, + rowKey, + tableHeader, + tableTitle, + } = props; + + const formItemContext = + useContext(FormItemInputContext); + const { status } = formItemContext; + + const collapseState = useCollapseState(tableTitle); + + const isRowSelection = Boolean(enableRowSelection); + + const rowSelection: TableProps['rowSelection'] = isRowSelection + ? { + type: 'checkbox', + onChange: (_selectedRowKeys: React.Key[], selectedRows) => { + onChange && onChange(selectedRows); + }, + } + : undefined; + + return ( + + + {tableHeader} + + } + key={tableTitle} + showArrow={false} + > +
+ + + ); +} + +export default forwardRef(SelectionTable); + +function useCollapseState(tableTitleName: string) { + const [collapseDefaultActiveKey, setCollapseDefaultActiveKey] = useState< + string[] + >([tableTitleName]); + + const onChangeCollapsePanelState = (key: string | string[]) => + setCollapseDefaultActiveKey(key as string[]); + + const onCollapseOpen = ( + event: React.MouseEvent, + collapseKey: string, + ) => { + // Make sure the panel is open + onChangeCollapsePanelState([collapseKey]); + if (collapseDefaultActiveKey.includes(collapseKey)) { + event.stopPropagation(); + } + }; + + return { + collapseDefaultActiveKey, + onChangeCollapsePanelState, + onCollapseOpen, + }; +} diff --git a/wren-ui/src/components/table/WindowFieldTable.tsx b/wren-ui/src/components/table/WindowFieldTable.tsx new file mode 100644 index 000000000..7482b368d --- /dev/null +++ b/wren-ui/src/components/table/WindowFieldTable.tsx @@ -0,0 +1,20 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +/* @ts-nocheck */ +// This file just remain for future scope. +import BaseTable, { Props, COLUMN } from '@/components/table/BaseTable'; + +export default function WindowFieldTable(props: Props) { + const { columns } = props; + return ( + + ); +} diff --git a/wren-ui/src/components/tableFormControls/CalculatedFieldTableFormControl.tsx b/wren-ui/src/components/tableFormControls/CalculatedFieldTableFormControl.tsx new file mode 100644 index 000000000..71a98c314 --- /dev/null +++ b/wren-ui/src/components/tableFormControls/CalculatedFieldTableFormControl.tsx @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; +import { makeTableFormControl } from './base'; +import AddCalculatedFieldModal, { + CalculatedFieldValue, +} from '@/components/modals/AddCalculatedFieldModal'; +import { getCalculatedFieldTableColumns } from '@/components/table/CalculatedFieldTable'; + +export type CalculatedFieldTableValue = CalculatedFieldValue[]; + +type Props = Omit, 'columns'>; + +const TableFormControl = makeTableFormControl(AddCalculatedFieldModal); + +export default function CalculatedFieldTableFormControl(props: Props) { + const columns = useMemo(getCalculatedFieldTableColumns, [props.value]); + return ; +} diff --git a/wren-ui/src/components/tableFormControls/DimensionTableFormControl.tsx b/wren-ui/src/components/tableFormControls/DimensionTableFormControl.tsx new file mode 100644 index 000000000..7266dce00 --- /dev/null +++ b/wren-ui/src/components/tableFormControls/DimensionTableFormControl.tsx @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; +import { makeTableFormControl } from './base'; +import AddDimensionFieldModal, { + DimensionFieldValue, +} from '@/components/modals/AddDimensionFieldModal'; +import { getDimensionFieldTableColumns } from '@/components/table/DimensionFieldTable'; + +export type DimensionTableValue = DimensionFieldValue[]; + +type Props = Omit, 'columns'>; + +const TableFormControl = makeTableFormControl(AddDimensionFieldModal); + +export default function DimensionTableFormControl(props: Props) { + const columns = useMemo(getDimensionFieldTableColumns, [props.value]); + return ; +} diff --git a/wren-ui/src/components/tableFormControls/MeasureTableFormControl.tsx b/wren-ui/src/components/tableFormControls/MeasureTableFormControl.tsx new file mode 100644 index 000000000..173c8377b --- /dev/null +++ b/wren-ui/src/components/tableFormControls/MeasureTableFormControl.tsx @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; +import { makeTableFormControl } from './base'; +import AddMeasureFieldModal, { + MeasureFieldValue, +} from '@/components/modals/AddMeasureFieldModal'; +import { getMeasureFieldTableColumns } from '@/components/table/MeasureFieldTable'; + +export type MeasureTableValue = MeasureFieldValue[]; + +type Props = Omit, 'columns'>; + +const TableFormControl = makeTableFormControl(AddMeasureFieldModal); + +export default function MeasureTableFormControl(props: Props) { + const columns = useMemo(getMeasureFieldTableColumns, [props.value]); + return ; +} diff --git a/wren-ui/src/components/tableFormControls/RelationTableFormControl.tsx b/wren-ui/src/components/tableFormControls/RelationTableFormControl.tsx new file mode 100644 index 000000000..29070bbbe --- /dev/null +++ b/wren-ui/src/components/tableFormControls/RelationTableFormControl.tsx @@ -0,0 +1,24 @@ +import { useMemo } from 'react'; +import { makeTableFormControl } from './base'; +import AddRelationModal, { + RelationFieldValue, +} from '@/components/modals/AddRelationModal'; +import { getRelationTableColumns } from '@/components/table/RelationTable'; +import { getMetadataColumns } from '@/components/table/MetadataBaseTable'; + +export type RelationTableValue = RelationFieldValue[]; + +type Props = Omit, 'columns'>; + +const TableFormControl = makeTableFormControl(AddRelationModal); + +export default function RelationTableFormControl(props: Props) { + const columns = useMemo(getRelationTableColumns, [props.value]); + return ( + + ); +} diff --git a/wren-ui/src/components/tableFormControls/WindowTableFormControl.tsx b/wren-ui/src/components/tableFormControls/WindowTableFormControl.tsx new file mode 100644 index 000000000..76430722e --- /dev/null +++ b/wren-ui/src/components/tableFormControls/WindowTableFormControl.tsx @@ -0,0 +1,17 @@ +import { useMemo } from 'react'; +import { makeTableFormControl } from './base'; +import AddWindowFieldModal, { + WindowFieldValue, +} from '@/components/modals/AddWindowFieldModal'; +import { getWindowFieldTableColumns } from '@/components/table/WindowFieldTable'; + +export type WindowTableValue = WindowFieldValue[]; + +type Props = Omit, 'columns'>; + +const TableFormControl = makeTableFormControl(AddWindowFieldModal); + +export default function WindowTableFormControl(props: Props) { + const columns = useMemo(getWindowFieldTableColumns, [props.value]); + return ; +} diff --git a/wren-ui/src/components/tableFormControls/base.tsx b/wren-ui/src/components/tableFormControls/base.tsx new file mode 100644 index 000000000..dcdc7ebac --- /dev/null +++ b/wren-ui/src/components/tableFormControls/base.tsx @@ -0,0 +1,135 @@ +import { useEffect, useMemo, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { Table, Button, TableColumnProps, Space, Popconfirm } from 'antd'; +import EditOutlined from '@ant-design/icons/EditOutlined'; +import DeleteOutlined from '@ant-design/icons/DeleteOutlined'; +import useModalAction from '@/hooks/useModalAction'; + +interface Props { + columns: TableColumnProps[]; + value?: Record[]; + disabled?: boolean; + onChange?: (value: Record[]) => void; + onRemoteSubmit?: (value: Record[]) => void; + onRemoteDelete?: (value: Record[]) => void; + modalProps?: Partial; +} + +const setupInternalId = (value: any[]) => { + return value.map((item) => ({ ...item, _id: uuidv4() })); +}; + +export const makeTableFormControl = ( + ModalComponent: React.FC>, +) => { + const TableFormControl = (props: Props) => { + const { + columns, + onChange, + onRemoteSubmit, + onRemoteDelete, + value, + modalProps, + disabled, + } = props; + const [internalValue, setInternalValue] = useState( + setupInternalId(value || []), + ); + const modalComponent = useModalAction(); + + useEffect(() => { + onChange && onChange(internalValue); + }, [internalValue]); + + const tableColumns: TableColumnProps[] = useMemo( + () => [ + ...columns, + { + key: 'action', + width: 80, + render: (record) => { + return ( + + + + removeData(record._id)} + > + + + + ); + }, + }, + ], + [internalValue], + ); + + const removeData = async (id) => { + // It means directly update to the db + if (onRemoteDelete) return await onRemoteDelete(id); + + setInternalValue(internalValue.filter((record) => record._id !== id)); + }; + + const submitModal = async (item) => { + // It means directly update to the db + if (onRemoteSubmit) return await onRemoteSubmit(item); + + const isNewItem = !item._id; + if (isNewItem) item._id = uuidv4(); + + setInternalValue( + isNewItem + ? [...internalValue, item] + : internalValue.map((record) => + record._id === item._id ? { ...record, ...item } : record, + ), + ); + }; + + return ( + <> + {!!internalValue.length && ( +
+ )} + + + + ); + }; + + return TableFormControl; +}; diff --git a/wren-ui/src/hooks/.gitkeep b/wren-ui/src/hooks/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/wren-ui/src/hooks/useAnswerStepContent.tsx b/wren-ui/src/hooks/useAnswerStepContent.tsx new file mode 100644 index 000000000..ca4671f35 --- /dev/null +++ b/wren-ui/src/hooks/useAnswerStepContent.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import copy from 'copy-to-clipboard'; +import { message } from 'antd'; +import { COLLAPSE_CONTENT_TYPE } from '@/utils/enum'; + +function getButtonProps({ + isLastStep, + isPreviewData, + isViewSQL, + onViewSQL, + onPreviewData, +}) { + const previewDataButtonProps = isLastStep + ? { type: 'primary', className: 'mr-2' } + : { + type: 'text', + className: `mr-2 ${isPreviewData ? 'gray-9' : 'gray-6'}`, + }; + + const [viewSQLButtonText, viewSQLButtonProps] = isLastStep + ? ['View Full SQL', { className: 'adm-btn-gray' }] + : [ + 'View SQL', + { type: 'text', className: isViewSQL ? 'gray-9' : 'gray-6' }, + ]; + + return { + viewSQLButtonText, + viewSQLButtonProps: { + ...viewSQLButtonProps, + onClick: onViewSQL, + }, + previewDataButtonProps: { + ...previewDataButtonProps, + onClick: onPreviewData, + }, + }; +} + +export default function useAnswerStepContent({ + fullSql, + isLastStep, + sql, +}: { + fullSql: string; + isLastStep: boolean; + sql: string; +}) { + const [collapseContentType, setCollapseContentType] = + useState(COLLAPSE_CONTENT_TYPE.NONE); + + const onViewSQL = () => + setCollapseContentType(COLLAPSE_CONTENT_TYPE.VIEW_SQL); + + const onPreviewData = () => + setCollapseContentType(COLLAPSE_CONTENT_TYPE.PREVIEW_DATA); + + const onCloseCollapse = () => + setCollapseContentType(COLLAPSE_CONTENT_TYPE.NONE); + + const onCopyFullSQL = () => { + copy(fullSql); + message.success('Copied SQL to clipboard.'); + }; + + const isViewSQL = collapseContentType === COLLAPSE_CONTENT_TYPE.VIEW_SQL; + + const isPreviewData = + collapseContentType === COLLAPSE_CONTENT_TYPE.PREVIEW_DATA; + + const buttonProps = getButtonProps({ + isLastStep, + isPreviewData, + isViewSQL, + onPreviewData, + onViewSQL, + }); + + return { + collapseContentProps: { + isPreviewData, + onCloseCollapse, + onCopyFullSQL, + ...(isLastStep + ? { + isViewFullSQL: isViewSQL, + sql: fullSql, + } + : { + isViewSQL, + sql, + }), + }, + ...buttonProps, + }; +} diff --git a/wren-ui/src/hooks/useAutoCompleteSource.tsx b/wren-ui/src/hooks/useAutoCompleteSource.tsx new file mode 100644 index 000000000..b63db3929 --- /dev/null +++ b/wren-ui/src/hooks/useAutoCompleteSource.tsx @@ -0,0 +1,46 @@ +import { useMemo } from 'react'; +import { SQLEditorAutoCompleteSourceWordInfo } from '@/components/editor'; + +type TableColumn = { name: string; type: string }; +type Scope = { name: string; columns: TableColumn[] }; + +const checkSqlName = (name: string) => { + return name.match(/^\d+/g) === null ? name : `"${name}"`; +}; + +export default function useAutoCompleteSource(scopes?: Scope[]) { + const allTables = [ + { + name: 'customer', + columns: [{ name: 'custKey', type: 'UUID' }], + }, + { + name: 'order', + columns: [{ name: 'orderKey', type: 'UUID' }], + }, + ]; + + const tables = scopes ? scopes : allTables; + + const autoCompleteSource: SQLEditorAutoCompleteSourceWordInfo[] = + useMemo(() => { + return tables.reduce((result, item) => { + result.push({ + caption: item.name, + value: checkSqlName(item.name), + meta: 'Table', + }); + item.columns && + item.columns.forEach((column) => { + result.push({ + caption: `${item.name}.${column.name}`, + value: checkSqlName(column.name), + meta: `Column(${column.type})`, + }); + }); + return result; + }, []); + }, [tables]); + + return autoCompleteSource; +} diff --git a/wren-ui/src/hooks/useCombineFieldOptions.tsx b/wren-ui/src/hooks/useCombineFieldOptions.tsx new file mode 100644 index 000000000..5c5a37b50 --- /dev/null +++ b/wren-ui/src/hooks/useCombineFieldOptions.tsx @@ -0,0 +1,64 @@ +import { useMemo, useState } from 'react'; + +interface Props { + // The initial base model of model select + model?: string; + // The models to be excluded from model select + excludeModels?: string[]; +} + +export default function useCombineFieldOptions(props: Props) { + const { model, excludeModels } = props; + const [baseModel, setBaseModel] = useState(model || ''); + + const response = [ + { + name: 'Customer', + columns: [ + { + name: 'orders', + properties: { type: 'Orders' }, + }, + ], + }, + { + name: 'Orders', + columns: [ + { + name: 'lineitem', + properties: { type: 'Lineitem' }, + }, + ], + }, + { + name: 'Lineitem', + columns: [ + { + name: 'extendedprice', + properties: { type: 'REAL' }, + }, + { + name: 'discount', + properties: { type: 'REAL' }, + }, + ], + }, + ].filter((item) => !(excludeModels && excludeModels.includes(item.name))); + + const modelOptions = useMemo(() => { + return response.map((item) => ({ + label: item.name, + value: item.name, + })); + }, [response]); + + const fieldOptions = useMemo(() => { + const model = response.find((item) => item.name === baseModel); + return (model?.columns || []).map((column) => ({ + label: column.name, + value: column.name, + })); + }, [baseModel]); + + return { modelOptions, fieldOptions, onModelChange: setBaseModel }; +} diff --git a/wren-ui/src/hooks/useDrawerAction.tsx b/wren-ui/src/hooks/useDrawerAction.tsx new file mode 100644 index 000000000..1d725ad7d --- /dev/null +++ b/wren-ui/src/hooks/useDrawerAction.tsx @@ -0,0 +1,39 @@ +import { useState } from 'react'; +import { FORM_MODE } from '@/utils/enum'; + +export interface DrawerAction { + visible: boolean; + onClose: () => void; + onSubmit?: (values: any) => Promise; + formMode?: FORM_MODE; + // use as form default value or view data + defaultValue?: TData; +} + +export default function useDrawerAction() { + const [visible, setVisible] = useState(false); + const [formMode, setFormMode] = useState(FORM_MODE.CREATE); + const [defaultValue, setDefaultValue] = useState(null); + + const openDrawer = (value?: any) => { + value && setDefaultValue(value); + value && setFormMode(FORM_MODE.EDIT); + setVisible(true); + }; + + const closeDrawer = () => { + setVisible(false); + setDefaultValue(null); + setFormMode(FORM_MODE.CREATE); + }; + + return { + state: { + visible, + formMode, + defaultValue, + }, + openDrawer, + closeDrawer, + }; +} diff --git a/wren-ui/src/hooks/useExpressionFieldOptions.tsx b/wren-ui/src/hooks/useExpressionFieldOptions.tsx new file mode 100644 index 000000000..12e42685e --- /dev/null +++ b/wren-ui/src/hooks/useExpressionFieldOptions.tsx @@ -0,0 +1,45 @@ +import { useMemo } from 'react'; +import { ExpressionOption } from '@/components/selectors/ExpressionSelector'; + +export const CUSTOM_EXPRESSION_VALUE = 'customExpression'; + +export default function useExpressionFieldOptions() { + const expressionOptions = useMemo(() => { + return [ + { + label: 'Aggregation', + options: [ + { + label: 'Sum', + value: 'sum', + content: { + title: 'Sum(column)', + description: 'Adds up all the value of the column.', + expression: 'Sum([order.price])', + }, + }, + { + label: 'Average', + value: 'average', + content: { + title: 'Average(column)', + description: 'Adds up all the value of the column.', + expression: 'Average([order.price])', + }, + }, + ], + }, + { + label: 'Custom', + options: [ + { + label: 'Custom expression', + value: CUSTOM_EXPRESSION_VALUE, + }, + ], + }, + ] as ExpressionOption[]; + }, []); + + return expressionOptions; +} diff --git a/wren-ui/src/hooks/useMetricDetailFormOptions.tsx b/wren-ui/src/hooks/useMetricDetailFormOptions.tsx new file mode 100644 index 000000000..537d3d5d3 --- /dev/null +++ b/wren-ui/src/hooks/useMetricDetailFormOptions.tsx @@ -0,0 +1,22 @@ +import { useMemo } from 'react'; + +export default function useMetricDetailFormOptions() { + const models = [ + { name: 'Model1', columns: [{ name: 'custKey', type: 'UUID' }] }, + ]; + + const metrics = [ + { name: 'Metric1', columns: [{ name: 'custKey', type: 'UUID' }] }, + ]; + + const modelMetricOptions = useMemo(() => { + return [...models, ...metrics].map((item) => ({ + label: item.name, + value: item.name, + })); + }, [models, metrics]); + + return { + modelMetricOptions, + }; +} diff --git a/wren-ui/src/hooks/useModalAction.tsx b/wren-ui/src/hooks/useModalAction.tsx new file mode 100644 index 000000000..d6299e6fc --- /dev/null +++ b/wren-ui/src/hooks/useModalAction.tsx @@ -0,0 +1,38 @@ +import { useState } from 'react'; +import { FORM_MODE } from '@/utils/enum'; + +export interface ModalAction { + visible: boolean; + onClose: () => void; + onSubmit?: (values: SData) => Promise; + formMode?: FORM_MODE; + defaultValue?: TData; +} + +export default function useModalAction() { + const [visible, setVisible] = useState(false); + const [formMode, setFormMode] = useState(FORM_MODE.CREATE); + const [defaultValue, setDefaultValue] = useState(null); + + const openModal = (value?: any) => { + value && setDefaultValue(value); + value && setFormMode(FORM_MODE.EDIT); + setVisible(true); + }; + + const closeModal = () => { + setVisible(false); + setDefaultValue(null); + setFormMode(FORM_MODE.CREATE); + }; + + return { + state: { + visible, + formMode, + defaultValue, + }, + openModal, + closeModal, + }; +} diff --git a/wren-ui/src/hooks/useModelDetailFormOptions.tsx b/wren-ui/src/hooks/useModelDetailFormOptions.tsx new file mode 100644 index 000000000..ca054c33f --- /dev/null +++ b/wren-ui/src/hooks/useModelDetailFormOptions.tsx @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; +import useAutoCompleteSource from '@/hooks/useAutoCompleteSource'; + +interface Props { + selectedTable?: string; +} + +export default function useModelDetailFormOptions(props: Props) { + const { selectedTable } = props; + const response = [ + { + name: 'customer', + columns: [{ name: 'custKey', type: 'UUID' }], + }, + { + name: 'order', + columns: [{ name: 'orderKey', type: 'UUID' }], + }, + ]; + + const dataSourceTableOptions = useMemo(() => { + return response.map((item) => ({ + label: item.name, + value: item.name, + })); + }, [response]); + + const dataSourceTableColumnOptions = useMemo(() => { + const table = response.find((table) => table.name === selectedTable); + return (table?.columns || []).map((column) => ({ + label: column.name, + value: { name: column.name, type: column.type }, + })); + }, [selectedTable]); + + const autoCompleteSource = useAutoCompleteSource(response); + + return { + dataSourceTableOptions, + dataSourceTableColumnOptions, + autoCompleteSource, + }; +} diff --git a/wren-ui/src/hooks/useModelFieldOptions.tsx b/wren-ui/src/hooks/useModelFieldOptions.tsx new file mode 100644 index 000000000..53d145c2e --- /dev/null +++ b/wren-ui/src/hooks/useModelFieldOptions.tsx @@ -0,0 +1,105 @@ +import { NODE_TYPE } from '@/utils/enum'; +import { compactObject } from '@/utils/helper'; +import { getNodeTypeIcon } from '@/utils/nodeType'; + +interface SelectValue { + nodeType: NODE_TYPE; + name: string; + type?: string; +} + +export interface ModelFieldResposeData { + name: string; + columns: { + name: string; + properties: { + type: string; + }; + }[]; +} + +export type ModelFieldOption = { + label: string | JSX.Element; + value?: SelectValue; + options?: ModelFieldOption[]; +}; + +export default function useModelFieldOptions( + transientData?: ModelFieldResposeData[], +) { + const response = transientData + ? transientData + : [ + { + name: 'Customer', + columns: [ + { + name: 'orders', + properties: { type: 'Orders' }, + }, + { + name: 'orderDate', + properties: { type: 'TIMESTAMP' }, + }, + ], + }, + { + name: 'Orders', + columns: [ + { + name: 'lineitem', + properties: { type: 'Lineitem' }, + }, + ], + }, + { + name: 'Lineitem', + columns: [ + { + name: 'extendedprice', + properties: { type: 'REAL' }, + }, + { + name: 'discount', + properties: { type: 'REAL' }, + }, + ], + }, + ]; + + const currentModel = response[0]; + const lineage = response.slice(1, response.length); + + if (currentModel === undefined) return []; + + const convertor = (item: any) => { + const isModel = !!item.columns; + const nodeType = isModel ? NODE_TYPE.MODEL : NODE_TYPE.FIELD; + const columnType = item.properties?.type; + const value: SelectValue = compactObject({ + nodeType, + name: item.name, + type: columnType, + }); + + return { + label: ( +
+ {getNodeTypeIcon( + { nodeType, type: columnType }, + { className: 'mr-1' }, + )} + {item.name} +
+ ), + value, + }; + }; + + const columns: ModelFieldOption[] = currentModel.columns.map(convertor) || []; + const relations: ModelFieldOption[] = lineage.length + ? [{ label: 'Relations', options: lineage.map(convertor) }] + : []; + + return [columns, relations].flat(); +} diff --git a/wren-ui/src/hooks/useSelectDataToExploreCollections.tsx b/wren-ui/src/hooks/useSelectDataToExploreCollections.tsx new file mode 100644 index 000000000..69c8dcf08 --- /dev/null +++ b/wren-ui/src/hooks/useSelectDataToExploreCollections.tsx @@ -0,0 +1,100 @@ +import { NODE_TYPE } from '@/utils/enum'; + +export default function useSelectDataToExploreCollections() { + // TODO: Replace with real data + const models = [ + { + id: '1', + name: 'Customer', + nodeType: NODE_TYPE.MODEL, + description: 'customer_description', + table: 'customer', + fields: [ + { + name: 'custKey', + type: 'UUID', + }, + ], + calculatedFields: [ + { + name: 'test', + expression: 'Sum', + modelFields: [ + { nodeType: NODE_TYPE.MODEL, name: 'customer' }, + { nodeType: NODE_TYPE.FIELD, name: 'custKey', type: 'UUID' }, + ], + }, + ], + }, + { + id: '2', + name: 'Customer2', + nodeType: NODE_TYPE.MODEL, + description: 'customer_description', + table: 'customer', + fields: [ + { + name: 'custKey', + type: 'UUID', + }, + ], + calculatedFields: [ + { + name: 'test', + expression: 'Sum', + modelFields: [ + { nodeType: NODE_TYPE.MODEL, name: 'customer' }, + { nodeType: NODE_TYPE.FIELD, name: 'custKey', type: 'UUID' }, + ], + }, + ], + }, + { + id: '3', + name: 'Customer3', + nodeType: NODE_TYPE.MODEL, + description: 'customer_description', + table: 'customer', + fields: [ + { + name: 'custKey', + type: 'UUID', + }, + ], + calculatedFields: [ + { + name: 'test', + expression: 'Sum', + modelFields: [ + { nodeType: NODE_TYPE.MODEL, name: 'customer' }, + { nodeType: NODE_TYPE.FIELD, name: 'custKey', type: 'UUID' }, + ], + }, + ], + }, + ]; + + const metrics = [ + { id: 'o1', name: 'Test1', nodeType: NODE_TYPE.METRIC }, + { id: 'o2', name: 'Test2', nodeType: NODE_TYPE.METRIC }, + { id: 'o3', name: 'Test3', nodeType: NODE_TYPE.METRIC }, + { id: 'o4', name: 'Test4', nodeType: NODE_TYPE.METRIC }, + { id: 'o5', name: 'Test5', nodeType: NODE_TYPE.METRIC }, + { id: 'o6', name: 'Test6', nodeType: NODE_TYPE.METRIC }, + ]; + + const views = [ + { id: 'c1', name: 'Test1', nodeType: NODE_TYPE.VIEW }, + { id: 'c2', name: 'Test2', nodeType: NODE_TYPE.VIEW }, + { id: 'c3', name: 'Test3', nodeType: NODE_TYPE.VIEW }, + { id: 'c4', name: 'Test4', nodeType: NODE_TYPE.VIEW }, + { id: 'c5', name: 'Test5', nodeType: NODE_TYPE.VIEW }, + { id: 'c6', name: 'Test6', nodeType: NODE_TYPE.VIEW }, + ]; + + return { + models, + metrics, + views, + }; +} diff --git a/wren-ui/src/hooks/useSetupConnection.tsx b/wren-ui/src/hooks/useSetupConnection.tsx new file mode 100644 index 000000000..7b02a4ed3 --- /dev/null +++ b/wren-ui/src/hooks/useSetupConnection.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react'; +import { DATA_SOURCES, SETUP } from '@/utils/enum'; +import { useRouter } from 'next/router'; + +export default function useSetupConnection() { + const [stepKey, setStepKey] = useState(SETUP.STARTER); + const [dataSource, setDataSource] = useState(DATA_SOURCES.BIG_QUERY); + const router = useRouter(); + + const submitDataSource = async (_data: any) => { + // TODO: implement submitDataSource API + router.push('/setup/models'); + }; + + const onBack = () => { + if (stepKey === SETUP.CREATE_DATA_SOURCE) { + setStepKey(SETUP.STARTER); + } + }; + + const onNext = (data?: { dataSource: DATA_SOURCES }) => { + if (stepKey === SETUP.STARTER) { + if (data.dataSource) { + setDataSource(data?.dataSource); + setStepKey(SETUP.CREATE_DATA_SOURCE); + } else { + // TODO: implement template chosen + } + } else if (stepKey === SETUP.CREATE_DATA_SOURCE) { + submitDataSource(data); + } + }; + + return { + stepKey, + dataSource, + onBack, + onNext, + }; +} diff --git a/wren-ui/src/hooks/useSetupModels.tsx b/wren-ui/src/hooks/useSetupModels.tsx new file mode 100644 index 000000000..89f4c38b9 --- /dev/null +++ b/wren-ui/src/hooks/useSetupModels.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react'; +import { SETUP } from '@/utils/enum'; +import { useRouter } from 'next/router'; +import { SelectedSourceTables } from '@/components/pages/setup/CreateModels'; + +export default function useSetupModels() { + const [stepKey, setStepKey] = useState(SETUP.SELECT_MODELS); + const [selectedModels, setSelectedModels] = useState( + undefined, + ); + + const router = useRouter(); + + const submitModels = async (_models: SelectedSourceTables) => { + // TODO: implement submitModels API + router.push('/setup/relations'); + }; + + const onBack = () => { + if (stepKey === SETUP.CREATE_MODELS) { + setStepKey(SETUP.SELECT_MODELS); + } else { + router.push('/setup/connection'); + } + }; + + const onNext = (data?: { + selectedModels: string[]; + models: SelectedSourceTables; + }) => { + if (stepKey === SETUP.SELECT_MODELS) { + setSelectedModels(data.selectedModels); + setStepKey(SETUP.CREATE_MODELS); + } + + if (stepKey === SETUP.CREATE_MODELS) { + submitModels(data.models); + } + }; + + return { + stepKey, + selectedModels, + tables, + onBack, + onNext, + }; +} + +// TODO: remove it when connecting to backend +const tables = [ + { + id: 'a3e9aba0-c1a7-43bb-8bae-da65256ec5a3', + sqlName: 'customer', + displayName: 'customer', + columns: [ + { + name: 'address', + type: 'VARCHAR', + }, + { + name: 'custkey', + type: 'BIGINT', + }, + { + name: 'name', + type: 'VARCHAR', + }, + { + name: 'nationkey', + type: 'BIGINT', + }, + ], + }, + { + id: '3f74ab82-aa22-476c-9577-273db3d1f75c', + sqlName: 'lineitem', + displayName: 'lineitem', + columns: [ + { + name: 'comment', + type: 'VARCHAR', + }, + { + name: 'commitdate', + type: 'DATE', + }, + { + name: 'discount', + type: 'DOUBLE', + }, + { + name: 'extendedprice', + type: 'DOUBLE', + }, + { + name: 'linenumber', + type: 'INTEGER', + }, + { + name: 'linestatus', + type: 'VARCHAR', + }, + { + name: 'orderkey', + type: 'BIGINT', + }, + { + name: 'partkey', + type: 'BIGINT', + }, + { + name: 'quantity', + type: 'DOUBLE', + }, + { + name: 'receiptdate', + type: 'DATE', + }, + { + name: 'returnflag', + type: 'VARCHAR', + }, + { + name: 'shipdate', + type: 'DATE', + }, + { + name: 'shipinstruct', + type: 'VARCHAR', + }, + { + name: 'shipmode', + type: 'VARCHAR', + }, + { + name: 'suppkey', + type: 'BIGINT', + }, + { + name: 'tax', + type: 'DOUBLE', + }, + ], + }, + { + id: 'a6339b68-0ffb-4268-8cfd-68b8206a852f', + sqlName: 'nation', + displayName: 'nation', + columns: [ + { + name: 'comment', + type: 'VARCHAR', + }, + { + name: 'name', + type: 'VARCHAR', + }, + { + name: 'nationkey', + type: 'BIGINT', + }, + { + name: 'regionkey', + type: 'BIGINT', + }, + ], + }, + { + id: '1e6b4ca6-c1ba-43de-ad5c-dcd203e5fed2', + sqlName: 'orders', + displayName: 'orders', + columns: [ + { + name: 'clerk', + type: 'VARCHAR', + }, + { + name: 'comment', + type: 'VARCHAR', + }, + { + name: 'custkey', + type: 'BIGINT', + }, + { + name: 'orderdate', + type: 'DATE', + }, + { + name: 'orderkey', + type: 'BIGINT', + }, + { + name: 'orderpriority', + type: 'VARCHAR', + }, + { + name: 'orderstatus', + type: 'VARCHAR', + }, + { + name: 'shippriority', + type: 'INTEGER', + }, + { + name: 'totalprice', + type: 'DOUBLE', + }, + ], + }, +]; diff --git a/wren-ui/src/hooks/useSetupRelations.tsx b/wren-ui/src/hooks/useSetupRelations.tsx new file mode 100644 index 000000000..f2431bb49 --- /dev/null +++ b/wren-ui/src/hooks/useSetupRelations.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { JOIN_TYPE, SETUP } from '@/utils/enum'; +import { useRouter } from 'next/router'; +import { SelectedRecommendRelations } from '@/components/pages/setup/DefineRelations'; +import { Path } from '@/utils/enum'; + +export default function useSetupRelations() { + const [stepKey, setStepKey] = useState(SETUP.RECOMMEND_RELATIONS); + const [selectedRecommendRelations, setSelectedRecommendRelations] = useState< + SelectedRecommendRelations | undefined + >(undefined); + + const router = useRouter(); + + const submitReleations = async (_relations: SelectedRecommendRelations) => { + // TODO: implement submitReleations API + router.push(Path.Exploration); + }; + + const onBack = () => { + if (stepKey === SETUP.DEFINE_RELATIONS) { + setStepKey(SETUP.RECOMMEND_RELATIONS); + } else { + router.push('/setup/models'); + } + }; + + const onNext = (data: { + selectedRecommendRelations: SelectedRecommendRelations; + relations: SelectedRecommendRelations; + }) => { + if (stepKey === SETUP.RECOMMEND_RELATIONS) { + setSelectedRecommendRelations(data.selectedRecommendRelations); + setStepKey(SETUP.DEFINE_RELATIONS); + } + + if (stepKey === SETUP.DEFINE_RELATIONS) { + submitReleations(data.relations); + } + }; + + return { + stepKey, + recommendRelations, + selectedRecommendRelations, + onBack, + onNext, + }; +} + +// TODO: remove it when connecting to backend +const recommendRelations = [ + { + name: 'Customer', + relations: [ + { + name: 'Customer_Order', + fromField: { model: 'Customer', field: 'custkey' }, + toField: { model: 'Orders', field: 'custkey' }, + type: JOIN_TYPE.ONE_TO_MANY, + isAutoGenerated: true, + }, + { + name: 'Customer_trans', + fromField: { model: 'Customer', field: 'custkey' }, + toField: { model: 'trans', field: 'custkey' }, + type: JOIN_TYPE.ONE_TO_MANY, + isAutoGenerated: true, + }, + ], + }, + { + name: 'Order', + relations: [ + { + name: 'Order_Lineitem', + fromField: { model: 'Order', field: 'orderkey' }, + toField: { model: 'Lineitem', field: 'orderkey' }, + type: JOIN_TYPE.ONE_TO_MANY, + isAutoGenerated: true, + }, + ], + }, +]; diff --git a/wren-ui/src/pages/_app.tsx b/wren-ui/src/pages/_app.tsx new file mode 100644 index 000000000..dbb87add5 --- /dev/null +++ b/wren-ui/src/pages/_app.tsx @@ -0,0 +1,22 @@ +import { AppProps } from 'next/app'; +import Head from 'next/head'; +import apolloClient from '@/apollo/client'; +import { ApolloProvider } from '@apollo/client'; +require('../styles/index.less'); + +function App({ Component, pageProps }: AppProps) { + return ( + <> + + Admin ui + + +
+ +
+
+ + ); +} + +export default App; diff --git a/wren-ui/src/pages/_document.tsx b/wren-ui/src/pages/_document.tsx new file mode 100644 index 000000000..e103ebb63 --- /dev/null +++ b/wren-ui/src/pages/_document.tsx @@ -0,0 +1,43 @@ +/* eslint-disable react/display-name */ +import Document, { + Html, + Head, + Main, + NextScript, + DocumentContext, + DocumentInitialProps, +} from 'next/document'; +import { ServerStyleSheet } from 'styled-components'; + +export default class AppDocument extends Document { + static async getInitialProps( + ctx: DocumentContext, + ): Promise { + const originalRenderPage = ctx.renderPage; + + const sheet = new ServerStyleSheet(); + + ctx.renderPage = () => + originalRenderPage({ + enhanceApp: (App) => (props) => sheet.collectStyles(), + enhanceComponent: (Component) => Component, + }); + + const intialProps = await Document.getInitialProps(ctx); + const styles = sheet.getStyleElement(); + + return { ...intialProps, styles }; + } + + render() { + return ( + + {this.props.styles} + +
+ + + + ); + } +} diff --git a/wren-ui/src/pages/api/graphql.ts b/wren-ui/src/pages/api/graphql.ts new file mode 100644 index 000000000..046012f36 --- /dev/null +++ b/wren-ui/src/pages/api/graphql.ts @@ -0,0 +1,69 @@ +import microCors from 'micro-cors'; +import { NextApiRequest, NextApiResponse, PageConfig } from 'next'; +import { ApolloServer } from 'apollo-server-micro'; +import { typeDefs } from '@/apollo/server'; +import resolvers from '@/apollo/server/resolvers'; +import { IContext } from '@/apollo/server/types'; +import { + ModelColumnRepository, + ModelRepository, + ProjectRepository, + RelationRepository, +} from '@/apollo/server/repositories'; +import { bootstrapKnex } from '../../apollo/server/utils/knex'; +import { GraphQLError } from 'graphql'; +import { getLogger } from '@/apollo/server/utils'; +import { getConfig } from '@/apollo/server/config'; + +const serverConfig = getConfig(); +const apolloLogger = getLogger('APOLLO'); + +const cors = microCors(); + +export const config: PageConfig = { + api: { + bodyParser: false, + }, +}; +const knex = bootstrapKnex({ + dbType: serverConfig.dbType, + pgUrl: serverConfig.pgUrl, + debug: serverConfig.debug, + sqliteFile: serverConfig.sqliteFile, +}); +const projectRepository = new ProjectRepository(knex); +const modelRepository = new ModelRepository(knex); +const modelColumnRepository = new ModelColumnRepository(knex); +const relationRepository = new RelationRepository(knex); + +const apolloServer: ApolloServer = new ApolloServer({ + typeDefs, + resolvers, + formatError: (error: GraphQLError) => { + apolloLogger.error(error.extensions); + return error; + }, + introspection: process.env.NODE_ENV !== 'production', + context: (): IContext => ({ + config: serverConfig, + + // repository + projectRepository, + modelRepository, + modelColumnRepository, + relationRepository, + }), +}); + +const startServer = apolloServer.start(); + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + await startServer; + await apolloServer.createHandler({ + path: '/api/graphql', + })(req, res); +}; + +export default cors((req: NextApiRequest, res: NextApiResponse) => + req.method === 'OPTIONS' ? res.status(200).end() : handler(req, res), +); diff --git a/wren-ui/src/pages/ask/[id].tsx b/wren-ui/src/pages/ask/[id].tsx new file mode 100644 index 000000000..b80122a81 --- /dev/null +++ b/wren-ui/src/pages/ask/[id].tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef } from 'react'; +import { useRouter } from 'next/router'; +import styled from 'styled-components'; +import { Divider } from 'antd'; +import { Path } from '@/utils/enum'; +import useModalAction from '@/hooks/useModalAction'; +import SiderLayout from '@/components/layouts/SiderLayout'; +import AnswerResult from '@/components/ask/AnswerResult'; +import SaveAsViewModal from '@/components/modals/SaveAsViewModal'; + +const AnswerResultsBlock = styled.div` + width: 768px; + margin-left: auto; + margin-right: auto; + + h4.ant-typography { + margin-top: 10px; + } + + .ant-typography pre { + border: none; + border-radius: 4px; + } + + .ace_editor { + border: none; + } + + button { + vertical-align: middle; + } +`; + +export default function AnswerBlock() { + const divRef = useRef(null); + const router = useRouter(); + const saveAsViewModal = useModalAction(); + + // TODO: implement scroll when has new answer result + useEffect(() => { + if (divRef.current) { + const contentLayout = divRef.current.parentElement; + const lastChild = divRef.current.lastElementChild as HTMLElement; + const lastChildDivider = lastChild.firstElementChild as HTMLElement; + if ( + contentLayout.clientHeight < + lastChild.offsetTop + lastChild.clientHeight + ) { + contentLayout.scrollTo({ + top: lastChildDivider.offsetTop, + behavior: 'smooth', + }); + } + } + }, [divRef]); + + // TODO: call API to get real exploration list data + const data = []; + + const onSelect = (selectKeys: string[]) => { + router.push(`${Path.ASK}/${selectKeys[0]}`); + }; + + // TODO: call API to get real answer results + const answerResults = [ + { + status: 'finished', + query: 'What is our MoM of sales revenue in 2023?', + summary: 'MoM of sales revenue in 2023', + sql: 'SELECT * FROM customer', + description: + 'To calculate the Month-over-Month (MoM) growth rate of sales revenue in 2023. We can use `sales` model to calculate MoM.', + steps: [ + { + summary: + 'First, we calculate the total revenue for each month in 2023 with `sales` model.', + sql: `SELECT * FROM Revenue`, + }, + { + summary: + "Then, we calculate the previous month's revenue for each month.", + sql: `WITH Revenue AS (\n SELECT \n custkey,\n orderstatus,\n sum(totalprice) as totalprice\n FROM Orders\n GROUP BY 1, 2\n)\nSELECT * FROM Revenue`, + }, + { + summary: + "At last, we calculate the Month-over-Month growth rate as a percentage. This is done by subtracting the previous month's revenue from the current month's revenue, dividing by the previous month's revenue, and then multiplying by 100 to get a percentage.", + sql: 'SELECT *\nFROM tpch.sf1.lineitem\nlimit 200', + }, + ], + }, + ]; + + return ( + + + {answerResults.map((answerResult, index) => ( +
+ {index > 0 && } + +
+ ))} +
+ { + console.log('save as view', values); + }} + /> +
+ ); +} diff --git a/wren-ui/src/pages/component.tsx b/wren-ui/src/pages/component.tsx new file mode 100644 index 000000000..7bd0dac6a --- /dev/null +++ b/wren-ui/src/pages/component.tsx @@ -0,0 +1,279 @@ +import dynamic from 'next/dynamic'; +import { useEffect } from 'react'; +import { Form, Button } from 'antd'; +import { JOIN_TYPE, NODE_TYPE } from '@/utils/enum'; +import { useForm } from 'antd/lib/form/Form'; +import useModalAction from '@/hooks/useModalAction'; +import useModelFieldOptions from '@/hooks/useModelFieldOptions'; +import AddCalculatedFieldModal from '@/components/modals/AddCalculatedFieldModal'; +import AddMeasureFieldModal from '@/components/modals/AddMeasureFieldModal'; +import AddDimensionFieldModal from '@/components/modals/AddDimensionFieldModal'; +import AddWindowFieldModal from '@/components/modals/AddWindowFieldModal'; +import AddRelationModal from '@/components/modals/AddRelationModal'; +import ModelDrawer from '@/components/pages/modeling/ModelDrawer'; +import MetricDrawer from '@/components/pages/modeling/MetricDrawer'; +import MetadataDrawer from '@/components/pages/modeling/MetadataDrawer'; +import SelectDataToExploreModal from '@/components/pages/explore/SelectDataToExploreModal'; +import useDrawerAction from '@/hooks/useDrawerAction'; + +const ModelFieldSelector = dynamic( + () => import('@/components/selectors/modelFieldSelector'), + { ssr: false }, +); + +const initialValue = [ + { nodeType: NODE_TYPE.MODEL, name: 'Orders' }, + { nodeType: NODE_TYPE.MODEL, name: 'Lineitem' }, + { nodeType: NODE_TYPE.FIELD, name: 'orders', type: 'Orders' }, +]; + +export default function Component() { + const [form] = useForm(); + + const addCalculatedFieldModal = useModalAction(); + const addMeasureFieldModal = useModalAction(); + const addDimensionFieldModal = useModalAction(); + const addWindowFieldModal = useModalAction(); + const addRelationModal = useModalAction(); + const selectDataToExploreModal = useModalAction(); + + const modelDrawer = useDrawerAction(); + const metricDrawer = useDrawerAction(); + const metadataDrawer = useDrawerAction(); + + const fieldOptions = useModelFieldOptions(); + const modelFields = Form.useWatch('modelFields', form); + + useEffect(() => { + console.log('modelFields', modelFields); + }, [modelFields]); + + return ( + + + + + +
+ value: +
+          {JSON.stringify(modelFields, undefined, 2)}
+        
+
+ + + + + + + + + + + + + + + + + + + + { + console.log(values); + }} + onClose={addCalculatedFieldModal.closeModal} + // defaultValue={{ + // fieldName: 'test', + // expression: 'Sum', + // modelFields: [ + // { nodeType: NODE_TYPE.MODEL, name: 'Orders' }, + // { nodeType: NODE_TYPE.FIELD, name: 'orders', type: 'Orders' }, + // ], + // // expression: 'customExpression', + // // customExpression: 'test', + // }} + /> + + { + console.log(values); + }} + onClose={addMeasureFieldModal.closeModal} + /> + + { + console.log(values); + }} + onClose={addDimensionFieldModal.closeModal} + /> + + { + console.log(values); + }} + onClose={addWindowFieldModal.closeModal} + /> + + { + console.log(values); + }} + onClose={addRelationModal.closeModal} + defaultValue={{ + joinType: JOIN_TYPE.ONE_TO_ONE, + fromField: { + model: 'Customer', + field: 'orders', + }, + toField: { + model: 'Lineitem', + field: 'discount', + }, + name: 'customer_orders', + properties: { + description: 'customer_orders_description', + }, + }} + /> + + { + console.log(values); + }} + defaultValue={{ + modelName: 'Customer', + description: 'customer_description', + table: 'customer', + fields: [ + { + name: 'custKey', + type: 'UUID', + }, + ], + calculatedFields: [ + { + fieldName: 'test', + expression: 'Sum', + modelFields: [ + { nodeType: NODE_TYPE.MODEL, name: 'customer' }, + { nodeType: NODE_TYPE.FIELD, name: 'custKey', type: 'UUID' }, + ], + }, + ], + cached: true, + cachedPeriod: '1m', + }} + /> + + { + console.log(values); + }} + /> + + { + console.log(values); + }} + // defaultValue={{ + // name: 'Customer', + // nodeType: NODE_TYPE.MODEL, + // description: 'customer_description', + // fields: [ + // { + // name: 'custKey', + // type: 'UUID', + // }, + // ], + // calculatedFields: [ + // { + // fieldName: 'test', + // expression: 'Sum', + // modelFields: [ + // { nodeType: NODE_TYPE.MODEL, name: 'customer' }, + // { nodeType: NODE_TYPE.FIELD, name: 'custKey', type: 'UUID' }, + // ], + // }, + // ], + // relations: [], + // }} + defaultValue={{ + name: 'Metric', + nodeType: NODE_TYPE.METRIC, + measures: [ + { + fieldName: 'test', + expression: 'Sum', + modelFields: [ + { nodeType: NODE_TYPE.MODEL, name: 'customer' }, + { nodeType: NODE_TYPE.FIELD, name: 'custKey', type: 'UUID' }, + ], + }, + ], + dimensions: [ + { + fieldName: 'test', + expression: 'Sum', + modelFields: [ + { nodeType: NODE_TYPE.MODEL, name: 'customer' }, + { nodeType: NODE_TYPE.FIELD, name: 'custKey', type: 'UUID' }, + ], + }, + ], + properties: { + description: 'metric description', + }, + }} + /> + + + + ); +} diff --git a/wren-ui/src/pages/exploration/index.tsx b/wren-ui/src/pages/exploration/index.tsx new file mode 100644 index 000000000..f9849927e --- /dev/null +++ b/wren-ui/src/pages/exploration/index.tsx @@ -0,0 +1,52 @@ +import { useRouter } from 'next/router'; +import { Button } from 'antd'; +import { Path } from '@/utils/enum'; +import { ExploreIcon } from '@/utils/icons'; +import SiderLayout from '@/components/layouts/SiderLayout'; +import SelectDataToExploreModal from '@/components/pages/explore/SelectDataToExploreModal'; +import Background from '@/components/Background'; +import useModalAction from '@/hooks/useModalAction'; + +export default function Exploration() { + const selectDataToExploreModal = useModalAction(); + const router = useRouter(); + + // TODO: call API to get real exploration list data + const data = [ + { + id: 'id-1', + name: 'global customer', + }, + { + id: 'id-2', + name: 'customer order amount exceeding 5000 ', + }, + ]; + + const onSelect = (selectKeys: string[]) => { + router.push(`${Path.Exploration}/${selectKeys[0]}`); + }; + + return ( + + + +
+ +
+ + +
+ ); +} diff --git a/wren-ui/src/pages/index.tsx b/wren-ui/src/pages/index.tsx new file mode 100644 index 000000000..b4bed125e --- /dev/null +++ b/wren-ui/src/pages/index.tsx @@ -0,0 +1,16 @@ +import { GetServerSideProps } from 'next'; + +export function Index() { + return <>; +} + +export default Index; + +export const getServerSideProps: GetServerSideProps = async () => { + return { + redirect: { + destination: '/setup/connection', + permanent: true, + }, + }; +}; diff --git a/wren-ui/src/pages/modeling.tsx b/wren-ui/src/pages/modeling.tsx new file mode 100644 index 000000000..4745c2039 --- /dev/null +++ b/wren-ui/src/pages/modeling.tsx @@ -0,0 +1,142 @@ +import { GetServerSideProps } from 'next'; +import dynamic from 'next/dynamic'; +import getConfig from 'next/config'; +import { forwardRef, useMemo, useRef } from 'react'; +import styled from 'styled-components'; +import { MORE_ACTION, NODE_TYPE } from '@/utils/enum'; +import SiderLayout from '@/components/layouts/SiderLayout'; +import { adapter, Manifest } from '@/utils/data'; +import MetadataDrawer from '@/components/pages/modeling/MetadataDrawer'; +import ModelDrawer from '@/components/pages/modeling/ModelDrawer'; +import MetricDrawer from '@/components/pages/modeling/MetricDrawer'; +import ViewDrawer from '@/components/pages/modeling/ViewDrawer'; +import useDrawerAction from '@/hooks/useDrawerAction'; +import { useManifestQuery } from '@/apollo/client/graphql/manifest.generated'; + +const Diagram = dynamic(() => import('@/components/diagram'), { ssr: false }); +// https://github.com/vercel/next.js/issues/4957#issuecomment-413841689 +const ForwardDiagram = forwardRef(function ForwardDiagram(props: any, ref) { + return ; +}); + +const DiagramWrapper = styled.div` + position: relative; + height: 100%; +`; + +export function Modeling({ connections }) { + const diagramRef = useRef(null); + + const { data } = useManifestQuery(); + + const adaptedManifest = useMemo(() => { + if (!data) return null; + return adapter(data?.manifest as Manifest); + }, [data]); + + const metadataDrawer = useDrawerAction(); + const modelDrawer = useDrawerAction(); + const metricDrawer = useDrawerAction(); + const viewDrawer = useDrawerAction(); + + const onSelect = (selectKeys) => { + if (diagramRef.current) { + const { getNodes, fitBounds } = diagramRef.current; + const node = getNodes().find((node) => node.id === selectKeys[0]); + const position = { + ...node.position, + width: node.width, + height: node.height, + }; + fitBounds(position); + } + }; + + const onNodeClick = (payload) => { + metadataDrawer.openDrawer(payload.data); + }; + + const onMoreClick = (payload) => { + const { type, data } = payload; + const action = { + [MORE_ACTION.EDIT]: () => { + const { nodeType } = data; + if (nodeType === NODE_TYPE.MODEL) modelDrawer.openDrawer(data); + if (nodeType === NODE_TYPE.METRIC) metricDrawer.openDrawer(data); + if (nodeType === NODE_TYPE.VIEW) viewDrawer.openDrawer(data); + }, + [MORE_ACTION.DELETE]: () => { + // TODO: call delete API + console.log(data); + }, + }; + action[type] && action[type](); + }; + + return ( + + + + + + { + console.log(values); + }} + /> + { + console.log(values); + }} + /> + { + console.log(values); + }} + /> + + ); +} + +export default Modeling; + +export const getServerSideProps: GetServerSideProps = async () => { + const { serverRuntimeConfig } = getConfig(); + const { PG_DATABASE, PG_PORT, PG_USERNAME, PG_PASSWORD } = + serverRuntimeConfig; + + return { + props: { + connections: { + database: PG_DATABASE, + port: PG_PORT, + username: PG_USERNAME, + password: PG_PASSWORD, + }, + }, + }; +}; diff --git a/wren-ui/src/pages/setup/connection.tsx b/wren-ui/src/pages/setup/connection.tsx new file mode 100644 index 000000000..e15105ccf --- /dev/null +++ b/wren-ui/src/pages/setup/connection.tsx @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; +import SimpleLayout from '@/components/layouts/SimpleLayout'; +import ContainerCard from '@/components/pages/setup/ContainerCard'; +import useSetupConnection from '@/hooks/useSetupConnection'; +import { SETUP_STEPS } from '@/components/pages/setup/utils'; + +export default function SetupConnection() { + const { stepKey, dataSource, onNext, onBack } = useSetupConnection(); + + const current = useMemo(() => SETUP_STEPS[stepKey], [stepKey]); + + return ( + + + + + + ); +} diff --git a/wren-ui/src/pages/setup/models.tsx b/wren-ui/src/pages/setup/models.tsx new file mode 100644 index 000000000..d1d659b5b --- /dev/null +++ b/wren-ui/src/pages/setup/models.tsx @@ -0,0 +1,24 @@ +import { useMemo } from 'react'; +import SimpleLayout from '@/components/layouts/SimpleLayout'; +import ContainerCard from '@/components/pages/setup/ContainerCard'; +import useSetupModels from '@/hooks/useSetupModels'; +import { SETUP_STEPS } from '@/components/pages/setup/utils'; + +export default function SetupModels() { + const { stepKey, selectedModels, tables, onNext, onBack } = useSetupModels(); + + const current = useMemo(() => SETUP_STEPS[stepKey], [stepKey]); + + return ( + + + + + + ); +} diff --git a/wren-ui/src/pages/setup/relations.tsx b/wren-ui/src/pages/setup/relations.tsx new file mode 100644 index 000000000..b20950507 --- /dev/null +++ b/wren-ui/src/pages/setup/relations.tsx @@ -0,0 +1,30 @@ +import { useMemo } from 'react'; +import SimpleLayout from '@/components/layouts/SimpleLayout'; +import ContainerCard from '@/components/pages/setup/ContainerCard'; +import useSetupRelations from '@/hooks/useSetupRelations'; +import { SETUP_STEPS } from '@/components/pages/setup/utils'; + +export default function SetupRelations() { + const { + stepKey, + recommendRelations, + selectedRecommendRelations, + onNext, + onBack, + } = useSetupRelations(); + + const current = useMemo(() => SETUP_STEPS[stepKey], [stepKey]); + + return ( + + + + + + ); +} diff --git a/wren-ui/src/styles/antd-variables.less b/wren-ui/src/styles/antd-variables.less new file mode 100644 index 000000000..db6ef8f14 --- /dev/null +++ b/wren-ui/src/styles/antd-variables.less @@ -0,0 +1,82 @@ +// Using in appendData, only for variables defination +@import '~antd/lib/style/themes/default.less'; + +@prefix: adm; + +// -------- Colors +// >> Secondary +@citrus-base: #f58433; +@citrus-1: #fff9f0; +@citrus-2: #ffedd9; +@citrus-3: #ffd9b0; +@citrus-4: #ffc187; +@citrus-5: #ffa75e; +@citrus-6: @citrus-base; +@citrus-7: #cf6421; +@citrus-8: #a84713; +@citrus-9: #822f08; +@citrus-10: #5c1d05; + +// >> Neutral +@gray-1: #fff; +@gray-2: #fafafa; +@gray-3: #f5f5f5; +@gray-4: #f0f0f0; +@gray-5: #d9d9d9; +@gray-6: #bfbfbf; +@gray-7: #8c8c8c; +@gray-8: #65676c; +@gray-9: #434343; +@gray-10: #262626; +@gray-11: #1f1f1f; +@gray-12: #141414; +@gray-13: #000; + +@preset-colors: pink, magenta, red, volcano, orange, yellow, gold, cyan, lime, + green, blue, geekblue, purple, citrus, gray; + +@black: @gray-13; +@white: @gray-1; + +@primary-color: @geekblue-6; + +@text-color: fade(@black, 65%); +@success-color: @green-6; +@warning-color: @gold-6; +@error-color: @red-5; +@disabled-color: rgba(0, 0, 0, 0.25); + +// Functions +.make-color-variables(@i: length(@preset-colors)) when (@i > 0) { + .make-color-variables(@i - 1); + @color: extract(@preset-colors, @i); + each(range(1, 10, 1), { + @colorVar: '@{color}-@{index}'; + --@{color}-@{index}: @@colorVar; + }); +} + +// Header +@layout-header-height: 20px; + +// Components +@layout-body-background: #fff; +@layout-header-background: #fff; + +@card-background: #fff; +@breadcrumb-last-item-color: @text-color; +@table-padding-vertical: 12px; +@table-header-cell-split-color: transparent; +@border-radius-base: 4px; + +// Typography +@typography-title-font-weight: 700; +@heading-1-size: ceil(@font-size-base * 2.85); +// @heading-2-size: ceil(@font-size-base * 2.14); +// @heading-3-size: ceil(@font-size-base * 1.71); +// @heading-4-size: ceil(@font-size-base * 1.42); +// @heading-5-size: ceil(@font-size-base * 1.14); + +// Avatar +@avatar-size-xs: 16px; +@avatar-font-size-xs: 12px; \ No newline at end of file diff --git a/wren-ui/src/styles/components/avatar.less b/wren-ui/src/styles/components/avatar.less new file mode 100644 index 000000000..50adf5a2d --- /dev/null +++ b/wren-ui/src/styles/components/avatar.less @@ -0,0 +1,5 @@ +.adm-avatar { + &-xs { + .avatar-size(@avatar-size-xs, @avatar-font-size-xs); + } +} \ No newline at end of file diff --git a/wren-ui/src/styles/components/button.less b/wren-ui/src/styles/components/button.less new file mode 100644 index 000000000..e02930bd9 --- /dev/null +++ b/wren-ui/src/styles/components/button.less @@ -0,0 +1,16 @@ +.adm-btn { + &-gray { + .button-color(@gray-9; @gray-6; @gray-6); + + &:hover, + &:focus { + .button-color(@gray-9; @gray-5; @gray-5); + } + + &:active { + .button-color(@gray-9; @gray-7; @gray-7); + } + + .button-disabled(); + } +} diff --git a/wren-ui/src/styles/components/editor.less b/wren-ui/src/styles/components/editor.less new file mode 100644 index 000000000..122d21031 --- /dev/null +++ b/wren-ui/src/styles/components/editor.less @@ -0,0 +1,21 @@ +.ace_editor { + font-family: monospace !important; + border-radius: 4px; + border: 1px solid @gray-5; +} + +.ace_editor-error { + border-color: @red-5 !important; +} + +.ace_active-line { + background-color: #e6f7ff !important; +} + +.ace_gutter-active-line { + background-color: #91d5ff !important; +} + +.ace_scroll-left { + box-shadow: none !important; +} diff --git a/wren-ui/src/styles/components/select.less b/wren-ui/src/styles/components/select.less new file mode 100644 index 000000000..6774434cc --- /dev/null +++ b/wren-ui/src/styles/components/select.less @@ -0,0 +1,3 @@ +.ant-select-item-option-grouped { + padding-left: 12px; +} diff --git a/wren-ui/src/styles/components/table.less b/wren-ui/src/styles/components/table.less new file mode 100644 index 000000000..110127bbc --- /dev/null +++ b/wren-ui/src/styles/components/table.less @@ -0,0 +1,28 @@ +// override antd styles +.ant-table { + border: 1px @gray-4 solid; + + .ant-table-row:last-child .ant-table-cell { + border-bottom: none; + } + + &.ant-table-empty { + .ant-table-body { + overflow: auto !important; + } + + .ant-table-tbody .ant-table-cell { + border-bottom: none; + } + } +} + +.ant-table-wrapper:not(.ant-table-has-header) { + .ant-table-empty { + border: none; + + .ant-empty-normal { + margin: 80px 0; + } + } +} diff --git a/wren-ui/src/styles/index.less b/wren-ui/src/styles/index.less new file mode 100644 index 000000000..153233936 --- /dev/null +++ b/wren-ui/src/styles/index.less @@ -0,0 +1,20 @@ +@import '~antd/dist/antd.less'; + +// Components +@import './components/editor.less'; +@import './components/table.less'; +@import './components/select.less'; +@import './components/avatar.less'; +@import './components/button.less'; + +// Layouts +@import './layouts/global.less'; +@import './layouts/main.less'; + +// Utilities +@import './utilities/display.less'; +@import './utilities/flex.less'; +@import './utilities/text.less'; +@import './utilities/color.less'; +@import './utilities/spacing.less'; +@import './utilities/border.less'; diff --git a/wren-ui/src/styles/layouts/global.less b/wren-ui/src/styles/layouts/global.less new file mode 100644 index 000000000..fa440d4d9 --- /dev/null +++ b/wren-ui/src/styles/layouts/global.less @@ -0,0 +1,17 @@ +:root { + .make-color-variables(); + --disabled: @disabled-color; +} + +body { + min-width: 360px; +} + +@media (prefers-color-scheme: dark) { + html body { + background: #fff; + } + h1.next-error-h1 { + border-right: 1px solid rgba(0, 0, 0, 0.3); + } +} diff --git a/wren-ui/src/styles/layouts/main.less b/wren-ui/src/styles/layouts/main.less new file mode 100644 index 000000000..cfe203f9d --- /dev/null +++ b/wren-ui/src/styles/layouts/main.less @@ -0,0 +1,21 @@ +.@{prefix}-main { + min-height: 100vh; + height: 100%; +} + +.logo-span { + color: #fff; + font-size: 22px; + font-style: normal; + font-weight: 1000; + line-height: 30.8px; +} + +.adm-layout { + height: 100%; +} + +.adm-content { + height: calc(100vh - 48px); + overflow: auto; +} diff --git a/wren-ui/src/styles/utilities/border.less b/wren-ui/src/styles/utilities/border.less new file mode 100644 index 000000000..78b5f2d6f --- /dev/null +++ b/wren-ui/src/styles/utilities/border.less @@ -0,0 +1,25 @@ +.border { + border-width: 1px !important; + border-style: solid !important; + + &-t { + border-top-width: 1px !important; + border-top-style: solid !important; + } + &-r { + border-right-width: 1px !important; + border-right-style: solid !important; + } + &-l { + border-left-width: 1px !important; + border-left-style: solid !important; + } + &-b { + border-bottom-width: 1px !important; + border-bottom-style: solid !important; + } +} + +.rounded { + border-radius: 4px !important; +} diff --git a/wren-ui/src/styles/utilities/color.less b/wren-ui/src/styles/utilities/color.less new file mode 100644 index 000000000..843242b8d --- /dev/null +++ b/wren-ui/src/styles/utilities/color.less @@ -0,0 +1,18 @@ +.make-color-classes(@i: length(@preset-colors)) when (@i > 0) { + .make-color-classes(@i - 1); + @color: extract(@preset-colors, @i); + each(range(1, 10), { + @colorVar: ~'var(--@{color}-@{index})'; + .bg-@{color}-@{index} { + background-color: @colorVar !important; + } + .@{color}-@{index} { + color: @colorVar !important; + } + .border-@{color}-@{index} { + border-color: @colorVar !important; + } + }); +} + +.make-color-classes(); diff --git a/wren-ui/src/styles/utilities/display.less b/wren-ui/src/styles/utilities/display.less new file mode 100644 index 000000000..79bf9d8a1 --- /dev/null +++ b/wren-ui/src/styles/utilities/display.less @@ -0,0 +1,33 @@ +.make-display-classes() { + @preset-display: { + block: block; + inline: inline; + inline-block: inline-block; + flex: flex; + inline-flex: inline-flex; + none: none; + }; + each(@preset-display, { + .d-@{key} { + display: @value !important; + } + }); +} + +.make-display-classes(); + +.cursor-pointer { + cursor: pointer !important; +} + +.overflow-hidden { + overflow: hidden !important; +} + +.scrollable-y { + overflow: hidden auto !important; +} + +.scrollable-x { + overflow: auto hidden !important; +} diff --git a/wren-ui/src/styles/utilities/flex.less b/wren-ui/src/styles/utilities/flex.less new file mode 100644 index 000000000..c599df106 --- /dev/null +++ b/wren-ui/src/styles/utilities/flex.less @@ -0,0 +1,55 @@ +.justify { + &-start { + justify-content: flex-start !important; + } + &-end { + justify-content: flex-end !important; + } + &-center { + justify-content: center !important; + } + &-space-between { + justify-content: space-between !important; + } +} + +.align { + &-start { + align-items: flex-start !important; + } + &-end { + align-items: flex-end !important; + } + &-center { + align-items: center !important; + } + &-baseline { + align-items: baseline !important; + } +} + +.flex { + &-shrink-0 { + flex-shrink: 0 !important; + } + + &-shrink-1 { + flex-shrink: 1 !important; + } + + &-grow-0 { + flex-grow: 0 !important; + } + + &-grow-1 { + flex-grow: 1 !important; + } + + &-row { + flex-direction: row !important; + } + + &-column { + flex-direction: column !important; + } +} diff --git a/wren-ui/src/styles/utilities/spacing.less b/wren-ui/src/styles/utilities/spacing.less new file mode 100644 index 000000000..46ebc052d --- /dev/null +++ b/wren-ui/src/styles/utilities/spacing.less @@ -0,0 +1,62 @@ +.make-spacing-classes(@i: 15) when (@i >= 0) { + .make-spacing-classes(@i - 1); + @preset: { + m: margin; + p: padding; + }; + @base-spacing: 4px; + @spacing: @i * @base-spacing; + + each(@preset, { + .@{key}-@{i} { + @{value}: @spacing !important; + } + .@{key}x-@{i} { + @{value}-left: @spacing !important; + @{value}-right: @spacing !important; + } + .@{key}y-@{i} { + @{value}-top: @spacing !important; + @{value}-bottom: @spacing !important; + } + .@{key}t-@{i} { + @{value}-top: @spacing !important; + } + .@{key}l-@{i} { + @{value}-left: @spacing !important; + } + .@{key}r-@{i} { + @{value}-right: @spacing !important; + } + .@{key}b-@{i} { + @{value}-bottom: @spacing !important; + } + + // Negative spacing + .-@{key}-@{i} { + @{value}: -@spacing !important; + } + .-@{key}x-@{i} { + @{value}-left: -@spacing !important; + @{value}-right: -@spacing !important; + } + .-@{key}y-@{i} { + @{value}-top: -@spacing !important; + @{value}-bottom: -@spacing !important; + } + .-@{key}t-@{i} { + @{value}-top: -@spacing !important; + } + .-@{key}l-@{i} { + @{value}-left: -@spacing !important; + } + .-@{key}r-@{i} { + @{value}-right: -@spacing !important; + } + .-@{key}b-@{i} { + @{value}-bottom: -@spacing !important; + } + }); +} + +.make-spacing-classes(); diff --git a/wren-ui/src/styles/utilities/text.less b/wren-ui/src/styles/utilities/text.less new file mode 100644 index 000000000..136d035e2 --- /dev/null +++ b/wren-ui/src/styles/utilities/text.less @@ -0,0 +1,34 @@ +.make-text-classes() { + @preset-text: { + base: @font-size-base; + xs: @font-size-sm - 2px; + sm: @font-size-sm; + md: @font-size-lg; + lg: @font-size-lg + 2px; + }; + each(@preset-text, { + .text-@{key} { + font-size: @value !important; + } + }); +} + +.text-left { + text-align: left !important; +} + +.text-right { + text-align: right !important; +} + +.text-center { + text-align: center !important; +} + +.make-text-classes(); + +.text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/wren-ui/src/testData/index.ts b/wren-ui/src/testData/index.ts new file mode 100644 index 000000000..4385d43e2 --- /dev/null +++ b/wren-ui/src/testData/index.ts @@ -0,0 +1,13 @@ +import tpch_mdl from './tpch_mdl.json'; +import tpch_mdl_from_core from './tpch_mdl_from_core.json'; +import tpch_mdl_relationToModel from './tpch_mdl_relationToModel.json'; +import more_mdl_model from './more_mdl_model.json'; + +const testData = { + tpch_mdl, + tpch_mdl_from_core, + tpch_mdl_relationToModel, + more_mdl_model, +}; + +export default testData; diff --git a/wren-ui/src/testData/more_mdl_model.json b/wren-ui/src/testData/more_mdl_model.json new file mode 100644 index 000000000..79778285c --- /dev/null +++ b/wren-ui/src/testData/more_mdl_model.json @@ -0,0 +1,329 @@ +{ + "catalog": "canner-cml", + "schema": "holistics", + "models": [ + { + "name": "Users", + "refSql": "select * from \"canner-cml\".db.users", + "columns": [ + { + "name": "age", + "expression": "age", + "type": "integer" + }, + { + "name": "age_group", + "expression": "age_group", + "type": "text" + }, + { + "name": "birth_date", + "expression": "birth_date", + "type": "date" + }, + { + "name": "city_id", + "expression": "city_id", + "type": "integer" + }, + { + "name": "email", + "expression": "email", + "type": "text" + }, + { + "name": "first_name", + "expression": "first_name", + "type": "text" + }, + { + "name": "full_name", + "expression": "full_name", + "type": "text" + }, + { + "name": "gender", + "expression": "gender", + "type": "text" + }, + { + "name": "id", + "expression": "id", + "type": "integer" + }, + { + "name": "last_name", + "expression": "last_name", + "type": "text" + }, + { + "name": "sign_up_at", + "expression": "sign_up_at", + "type": "text" + }, + { + "name": "sign_up_date", + "expression": "sign_up_date", + "type": "text" + }, + { + "name": "orders", + "type": "Orders", + "relationship": "UsersOrders" + } + ] + }, + { + "name": "Orders", + "refSql": "select * from \"canner-cml\".db.orders", + "columns": [ + { + "name": "id", + "expression": "id", + "type": "integer" + }, + { + "name": "created_at", + "expression": "created_at", + "type": "datetime" + }, + { + "name": "discount", + "expression": "discount", + "type": "integer" + }, + { + "name": "status", + "expression": "u_created_at", + "type": "text" + }, + { + "name": "user_id", + "expression": "user_id", + "type": "integer" + }, + { + "name": "users", + "type": "Users", + "relationship": "UsersOrders" + }, + { + "name": "orderItems", + "type": "Order_items", + "relationship": "OrdersOrder_items" + } + ] + }, + { + "name": "Products", + "refSql": "select * from \"canner-cml\".db.products", + "columns": [ + { + "name": "category_id", + "expression": "category_id", + "type": "integer" + }, + { + "name": "created_at", + "expression": "created_at", + "type": "datetime" + }, + { + "name": "id", + "expression": "id", + "type": "integer" + }, + { + "name": "id_name_price", + "expression": "id_name_price", + "type": "text" + }, + { + "name": "merchant_id", + "expression": "merchant_id", + "type": "integer" + }, + { + "name": "name", + "expression": "name", + "type": "text" + }, + { + "name": "price", + "expression": "price", + "type": "integer" + }, + { + "name": "categories", + "type": "Categories", + "relationship": "CategoriesProducts" + }, + { + "name": "orderMaster", + "type": "Orders_Master", + "relationship": "ProductsOrders_Master" + } + ] + }, + { + "name": "Orders_Master", + "refSql": "select * from \"canner-cml\".db.orders_master", + "columns": [ + { + "name": "category_id", + "expression": "category_id", + "type": "integer" + }, + { + "name": "id_name_price_of_product", + "expression": "id_name_price_of_product", + "type": "text" + }, + { + "name": "order_id", + "expression": "order_id", + "type": "integer" + }, + { + "name": "price", + "expression": "price", + "type": "float" + }, + { + "name": "product_id", + "expression": "product_id", + "type": "integer" + }, + { + "name": "product_name", + "expression": "product_name", + "type": "text" + }, + { + "name": "quantity", + "expression": "quantity", + "type": "integer" + }, + { + "name": "revenue", + "expression": "revenue", + "type": "float" + }, + { + "name": "products", + "type": "Products", + "relationship": "ProductsOrders_Master" + }, + { + "name": "orderItems", + "type": "Order_items", + "relationship": "Orders_MasterOrder_items" + } + ] + }, + { + "name": "Categories", + "refSql": "select * from \"canner-cml\".db.categories", + "columns": [ + { + "name": "id", + "expression": "id", + "type": "integer" + }, + { + "name": "name", + "expression": "name", + "type": "text" + }, + { + "name": "parent_id", + "expression": "parent_id", + "type": "integer" + }, + { + "name": "products", + "type": "products", + "relationship": "CategoriesProducts" + } + ] + }, + { + "name": "Order_items", + "refSql": "select * from \"canner-cml\".db.order_items", + "columns": [ + { + "name": "order_id", + "expression": "order_id", + "type": "integer" + }, + { + "name": "product_id", + "expression": "product_id", + "type": "text" + }, + { + "name": "quantity", + "expression": "quantity", + "type": "integer" + }, + { + "name": "orders", + "type": "Orders", + "relationship": "OrdersOrder_items" + }, + { + "name": "orderMaster", + "type": "Orders_Master", + "relationship": "Orders_MasterOrder_items" + } + ] + } + ], + "relationships": [ + { + "name": "UsersOrders", + "models": [ + "Users", + "Orders" + ], + "joinType": "ONE_TO_MANY", + "condition": "Users.id = Orders.user_id" + }, + { + "name": "OrdersOrder_items", + "models": [ + "Orders", + "Order_items" + ], + "joinType": "ONE_TO_MANY", + "condition": "Orders.id = Order_items.order_id" + }, + { + "name": "CategoriesProducts", + "models": [ + "Categories", + "Products" + ], + "joinType": "ONE_TO_MANY", + "condition": "Categories.id = Products.category_id" + }, + { + "name": "ProductsOrders_Master", + "models": [ + "Products", + "Orders_Master" + ], + "joinType": "MANY_TO_ONE", + "condition": "Products.id_name_price = Orders_Master.id_name_price_of_product" + }, + { + "name": "Orders_MasterOrder_items", + "models": [ + "Orders_Master", + "Order_items" + ], + "joinType": "ONE_TO_MANY", + "condition": "Orders_Master.order_id = Order_items.order_id" + } + ] +} \ No newline at end of file diff --git a/wren-ui/src/testData/tpch_mdl.json b/wren-ui/src/testData/tpch_mdl.json new file mode 100644 index 000000000..2fcfe980e --- /dev/null +++ b/wren-ui/src/testData/tpch_mdl.json @@ -0,0 +1,107 @@ +{ + "catalog": "canner-cml", + "schema": "tpch_tiny", + "models": [ + { + "name": "Orders", + "refSql": "select * from \"canner-cml\".tpch_tiny.orders", + "columns": [ + { + "name": "orderkey", + "expression": "o_orderkey", + "type": "integer" + }, + { + "name": "custkey", + "expression": "o_custkey", + "type": "integer" + }, + { + "name": "orderstatus", + "expression": "o_orderstatus", + "type": "string" + }, + { + "name": "totalprice", + "expression": "o_totalprice", + "type": "float" + }, + { + "name": "customer", + "type": "Customer", + "relationship": "OrdersCustomer" + }, + { + "name": "orderdate", + "expression": "o_orderdate", + "type": "date" + } + ], + "primaryKey": "orderkey" + }, + { + "name": "Customer", + "refSql": "select * from \"canner-cml\".tpch_tiny.customer", + "columns": [ + { + "name": "custkey", + "expression": "c_custkey", + "type": "integer" + }, + { + "name": "name", + "expression": "c_name", + "type": "string" + }, + { + "name": "orders", + "type": "Orders", + "relationship": "OrdersCustomer" + } + ], + "primaryKey": "custkey" + } + ], + "relationships": [ + { + "name": "OrdersCustomer", + "models": [ + "Orders", + "Customer" + ], + "joinType": "MANY_TO_ONE", + "condition": "Orders.custkey = Customer.custkey" + } + ], + "metrics": [ + { + "name": "Revenue", + "baseModel": "Orders", + "dimension": [ + { + "name": "custkey", + "type": "integer" + } + ], + "measure": [ + { + "name": "totalprice", + "type": "integer", + "expression": "sum(totalprice)" + } + ], + "timeGrain": [ + { + "name": "orderdate", + "refColumn": "orderdate", + "dateParts": [ + "YEAR", + "MONTH" + ] + } + ], + "preAggregated": true, + "refreshTime": "2m" + } + ] +} \ No newline at end of file diff --git a/wren-ui/src/testData/tpch_mdl_from_core.json b/wren-ui/src/testData/tpch_mdl_from_core.json new file mode 100644 index 000000000..10e2c4558 --- /dev/null +++ b/wren-ui/src/testData/tpch_mdl_from_core.json @@ -0,0 +1,101 @@ +{ + "catalog": "canner-cml", + "schema": "tpch_tiny", + "models": [ + { + "name": "Orders", + "refSql": "select * from \"canner-cml\".tpch_tiny.orders", + "columns": [ + { "name": "orderkey", "type": "integer" }, + { "name": "custkey", "type": "integer" }, + { "name": "orderstatus", "type": "string" }, + { "name": "totalprice", "type": "float" }, + { + "name": "customer", + "type": "Customer", + "relationship": "OrdersCustomer" + }, + { "name": "orderdate", "type": "date" } + ], + "primaryKey": "orderkey" + }, + { + "name": "Customer", + "refSql": "select * from \"canner-cml\".tpch_tiny.customer", + "columns": [ + { "name": "custkey", "type": "integer" }, + { "name": "name", "type": "string" }, + { "name": "orders", "type": "Orders", "relationship": "OrdersCustomer" } + ], + "primaryKey": "custkey" + }, + { + "name": "Lineitem", + "refSql": "select * from \"canner-cml\".tpch_tiny.lineitem", + "columns": [ + { "name": "orderkey", "type": "integer" }, + { "name": "partkey", "type": "integer" }, + { "name": "linenumber", "type": "integer" }, + { "name": "extendedprice", "type": "double" }, + { "name": "discount", "type": "double" }, + { "name": "shipdate", "type": "date" }, + { "name": "order", "type": "Orders", "relationship": "OrdersLineitem" }, + { "name": "part", "type": "Part", "relationship": "LineitemPart" }, + { "name": "orderkey_linenumber", "type": "string" } + ], + "primaryKey": "orderkey_linenumber" + }, + { + "name": "Part", + "refSql": "select * from \"canner-cml\".tpch_tiny.part", + "columns": [ + { "name": "partkey", "type": "integer" }, + { "name": "name", "type": "string" } + ], + "primaryKey": "partkey" + } + ], + "relationships": [ + { + "name": "OrdersCustomer", + "models": ["Orders", "Customer"], + "joinType": "MANY_TO_ONE", + "condition": "Orders.custkey = Customer.custkey" + }, + { + "name": "OrdersLineitem", + "models": ["Orders", "Lineitem"], + "joinType": "ONE_TO_MANY", + "condition": "Orders.orderkey = Lineitem.orderkey" + }, + { + "name": "LineitemPart", + "models": ["Lineitem", "Part"], + "joinType": "MANY_TO_ONE", + "condition": "Lineitem.partkey = Part.partkey" + } + ], + "metrics": [ + { + "name": "Revenue", + "baseModel": "Orders", + "dimension": [{ "name": "custkey", "type": "integer" }], + "measure": [ + { + "name": "totalprice", + "type": "integer", + "expression": "sum(totalprice)" + } + ], + "timeGrain": [ + { + "name": "orderdate", + "refColumn": "orderdate", + "dateParts": ["YEAR", "MONTH"] + } + ], + "preAggregated": true, + "refreshTime": "2m" + } + ] +} diff --git a/wren-ui/src/testData/tpch_mdl_relationToModel.json b/wren-ui/src/testData/tpch_mdl_relationToModel.json new file mode 100644 index 000000000..288e0d1f2 --- /dev/null +++ b/wren-ui/src/testData/tpch_mdl_relationToModel.json @@ -0,0 +1,148 @@ +{ + "catalog": "canner-cml", + "schema": "tpch_tiny", + "models": [ + { + "name": "Orders", + "refSql": "select * from \"canner-cml\".tpch_tiny.orders", + "columns": [ + { + "name": "orderkey", + "expression": "o_orderkey", + "type": "integer" + }, + { + "name": "custkey", + "expression": "o_custkey", + "type": "integer" + }, + { + "name": "prodkeys", + "expression": "o_prodkeys", + "type": "text[]" + }, + { + "name": "orderstatus", + "expression": "o_orderstatus", + "type": "string" + }, + { + "name": "totalprice", + "expression": "o_totalprice", + "type": "float" + }, + { + "name": "customer", + "type": "Customer", + "relationship": "OrdersCustomer" + }, + { + "name": "orderdate", + "expression": "o_orderdate", + "type": "date" + }, + { + "name": "products", + "type": "Products", + "relationship": "OrdersProducts" + } + ], + "primaryKey": "orderkey" + }, + { + "name": "Customer", + "refSql": "select * from \"canner-cml\".tpch_tiny.customer", + "columns": [ + { + "name": "custkey", + "expression": "c_custkey", + "type": "integer" + }, + { + "name": "name", + "expression": "c_name", + "type": "string" + } + ], + "primaryKey": "custkey" + }, + { + "name": "Products", + "refSql": "select * from \"canner-cml\".tpch_tiny.products", + "columns": [ + { + "name": "name", + "expression": "p_name", + "type": "string" + }, + { + "name": "prodKey", + "expression": "p_prodKey", + "type": "integer" + }, + { + "name": "quantity", + "expression": "p_quantity", + "type": "integer" + }, + { + "name": "price", + "expression": "p_price", + "type": "float" + }, + { + "name": "createdDate", + "expression": "p_createdDate", + "type": "date" + } + ], + "primaryKey": "prodKey" + } + ], + "relationships": [ + { + "name": "OrdersCustomer", + "models": [ + "Orders", + "Customer" + ], + "joinType": "MANY_TO_ONE", + "condition": "Orders.custkey = Customer.custkey" + }, + { + "name": "OrdersProducts", + "models": ["Orders", "Products"], + "joinType": "ONE_TO_MANY", + "condition": "Products.prodKey = ANY(Orders.prodkeys)" + } + ], + "metrics": [ + { + "name": "Revenue", + "baseModel": "Orders", + "dimension": [ + { + "name": "custkey", + "type": "integer" + } + ], + "measure": [ + { + "name": "totalprice", + "type": "integer", + "expression": "sum(totalprice)" + } + ], + "timeGrain": [ + { + "name": "orderdate", + "refColumn": "orderdate", + "dateParts": [ + "YEAR", + "MONTH" + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/wren-ui/src/utils/columnType.tsx b/wren-ui/src/utils/columnType.tsx new file mode 100644 index 000000000..a7fa792c9 --- /dev/null +++ b/wren-ui/src/utils/columnType.tsx @@ -0,0 +1,55 @@ +import { + NumericIcon, + ColumnsIcon, + JsonBracesIcon, + ArrayBracketsIcon, + StringIcon, + TextIcon, + CalendarIcon, + TickIcon, +} from './icons'; +import { COLUMN_TYPE } from './enum'; + +export const getColumnTypeIcon = (payload: { type: string }, attrs?: any) => { + const { type } = payload; + switch (type.toUpperCase()) { + case COLUMN_TYPE.INTEGER: + case COLUMN_TYPE.TINYINT: + case COLUMN_TYPE.SMALLINT: + case COLUMN_TYPE.BIGINT: + case COLUMN_TYPE.INT: + case COLUMN_TYPE.DECIMAL: + case COLUMN_TYPE.DOUBLE: + case COLUMN_TYPE.REAL: + case COLUMN_TYPE.NUMBER: + return ; + + case COLUMN_TYPE.BOOLEAN: + return ; + + case COLUMN_TYPE.CHAR: + case COLUMN_TYPE.JSON: + case COLUMN_TYPE.VARBINARY: + case COLUMN_TYPE.VARCHAR: + case COLUMN_TYPE.STRING: + return ; + + case COLUMN_TYPE.TEXT: + return ; + + case COLUMN_TYPE.DATE: + case COLUMN_TYPE.DATETIME: + case COLUMN_TYPE.TIME: + case COLUMN_TYPE.TIMESTAMP: + return ; + + case COLUMN_TYPE.MONGO_ARRAY: + return ; + + case COLUMN_TYPE.MONGO_ROW: + return ; + + default: + return ; + } +}; diff --git a/wren-ui/src/utils/data/adapter.ts b/wren-ui/src/utils/data/adapter.ts new file mode 100644 index 000000000..658ff908a --- /dev/null +++ b/wren-ui/src/utils/data/adapter.ts @@ -0,0 +1,40 @@ +import { Manifest } from '@/utils/data/type'; +import { + MetricData, + ModelData, + ViewData, +} from '@/utils/data/model'; + +export interface AdaptedData + extends Omit { + models: ModelData[]; + metrics: MetricData[]; + views: ViewData[]; +} + +export const adapter = (data: Manifest): AdaptedData => { + const { + models = [], + metrics = [], + cumulativeMetrics = [], + views = [], + } = data; + const adaptModels = models.map((model) => { + return new ModelData(model, data); + }); + const adaptMetrics = [...metrics, ...cumulativeMetrics].map((metric) => { + // cumulative metric has window property + return new MetricData(metric, !!metric.window); + }); + + const adaptViews = views.map((view) => { + return new ViewData(view); + }); + + return { + ...data, + models: adaptModels, + metrics: adaptMetrics, + views: adaptViews, + }; +}; diff --git a/wren-ui/src/utils/data/dictionary.ts b/wren-ui/src/utils/data/dictionary.ts new file mode 100644 index 000000000..0f1f80c54 --- /dev/null +++ b/wren-ui/src/utils/data/dictionary.ts @@ -0,0 +1,16 @@ +import { CACHED_PERIOD, JOIN_TYPE } from '@/utils/enum'; + +export const getJoinTypeText = (type) => + ({ + [JOIN_TYPE.MANY_TO_ONE]: 'Many-to-one', + [JOIN_TYPE.ONE_TO_MANY]: 'One-to-many', + [JOIN_TYPE.ONE_TO_ONE]: 'One-to-one', + }[type] || 'Unknown'); + +export const getCachePeriodText = (period) => + ({ + [CACHED_PERIOD.DAY]: 'day(s)', + [CACHED_PERIOD.HOUR]: 'hour(s)', + [CACHED_PERIOD.MINUTE]: 'minute(s)', + [CACHED_PERIOD.SECOND]: 'second(s)', + }[period] || 'Unknown'); diff --git a/wren-ui/src/utils/data/index.ts b/wren-ui/src/utils/data/index.ts new file mode 100644 index 000000000..6547eeb3a --- /dev/null +++ b/wren-ui/src/utils/data/index.ts @@ -0,0 +1,4 @@ +export * from './type'; +export * from './model'; +export * from './adapter'; +export * from './dictionary'; diff --git a/wren-ui/src/utils/data/model/index.ts b/wren-ui/src/utils/data/model/index.ts new file mode 100644 index 000000000..d3afeb48a --- /dev/null +++ b/wren-ui/src/utils/data/model/index.ts @@ -0,0 +1,3 @@ +export * from './model'; +export * from './metric'; +export * from './view'; diff --git a/wren-ui/src/utils/data/model/metric.ts b/wren-ui/src/utils/data/model/metric.ts new file mode 100644 index 000000000..845bffb26 --- /dev/null +++ b/wren-ui/src/utils/data/model/metric.ts @@ -0,0 +1,104 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +/* @ts-nocheck */ +// This file just remain for future scope. +import { v4 as uuidv4 } from 'uuid'; +import { METRIC_TYPE, NODE_TYPE } from '@/utils/enum'; +import { Metric, MetricColumn } from '@/utils/data/type'; + +export class MetricData { + public readonly nodeType: NODE_TYPE = NODE_TYPE.METRIC; + + public readonly id: string; + public readonly displayName: string; + public readonly referenceName: string; + public readonly baseObject: string; + public readonly cached: boolean; + public readonly refreshTime: string; + public readonly properties: Metric['properties']; + + public readonly columns: MetricColumnData[]; + public readonly dimensions: MetricColumnData[]; + public readonly measures: MetricColumnData[]; + public readonly timeGrains: MetricColumnData[]; + public readonly windows: MetricColumnData[]; + + constructor(metric: Metric, isCumulative: boolean = false) { + this.id = uuidv4(); + this.displayName = metric.name; + this.referenceName = metric.name; + + this.baseObject = metric.baseObject; + this.cached = metric.cached || false; + this.refreshTime = metric.refreshTime || null; + this.properties = metric.properties; + + this.columns = Object.entries({ + [METRIC_TYPE.DIMENSION]: metric.dimension, + [METRIC_TYPE.MEASURE]: metric.measure, + [METRIC_TYPE.TIME_GRAIN]: metric.timeGrain, + [METRIC_TYPE.WINDOW]: metric.window, + }).reduce((result, [metricType, columns]) => { + // cumulative metrics measure & window not array type + const isObject = typeof columns === 'object' && !Array.isArray(columns); + return [ + ...result, + ...(isObject ? [columns] : columns || []).map( + (column) => + new MetricColumnData( + column as MetricColumn, + metricType as METRIC_TYPE + ) + ), + ]; + }, []); + + this.measures = this.columns.filter( + (column) => column.metricType === METRIC_TYPE.MEASURE + ); + this.timeGrains = this.columns.filter( + (column) => column.metricType === METRIC_TYPE.TIME_GRAIN + ); + this.dimensions = !isCumulative + ? this.columns.filter( + (column) => column.metricType === METRIC_TYPE.DIMENSION + ) + : undefined; + this.windows = isCumulative + ? this.columns.filter( + (column) => column.metricType === METRIC_TYPE.WINDOW + ) + : undefined; + } +} + +export class MetricColumnData { + public readonly id: string; + public readonly displayName: string; + public readonly type: string; + public readonly metricType: METRIC_TYPE; + public readonly operator?: string; + public readonly refColumn?: string; + public readonly dateParts?: string[]; + public readonly timeUnit?: string; + public readonly start?: string; + public readonly end?: string; + public readonly isCalculated?: boolean; + public readonly properties: MetricColumn['properties']; + // TODO: construct this property + public readonly modelFields: string[] = []; + + constructor(column: MetricColumn, metricType: METRIC_TYPE) { + this.id = uuidv4(); + this.displayName = column.name; + this.type = column?.type || ''; + this.metricType = metricType; + this.operator = column?.operator; + this.refColumn = column?.refColumn; + this.dateParts = column?.dateParts; + this.timeUnit = column?.timeUnit; + this.start = column?.start; + this.end = column?.end; + this.isCalculated = column?.isCalculated; + this.properties = column?.properties; + } +} diff --git a/wren-ui/src/utils/data/model/model.ts b/wren-ui/src/utils/data/model/model.ts new file mode 100644 index 000000000..b51c3433d --- /dev/null +++ b/wren-ui/src/utils/data/model/model.ts @@ -0,0 +1,119 @@ +import { v4 as uuidv4 } from 'uuid'; +import { JOIN_TYPE, NODE_TYPE } from '@/utils/enum'; +import { + Relationship, + ModelColumn, + Model, + Manifest, +} from '@/utils/data/type'; + +export class ModelData { + public readonly nodeType: NODE_TYPE = NODE_TYPE.MODEL; + + public readonly id: string; + public readonly displayName: string; + public readonly referenceName: string; + public readonly sourceTableName: string; + public readonly description: string; + public readonly refSql: string; + public readonly cached: boolean; + public readonly refreshTime: string; + public readonly relations: RelationData[]; + public readonly properties: Model['properties']; + + public readonly columns: ModelColumnData[]; + public readonly fields: ModelColumnData[]; + public readonly relationFields: ModelColumnData[]; + public readonly calculatedFields: ModelColumnData[]; + + constructor(model: Model, data: Manifest) { + this.id = uuidv4(); + // TODO: this will redefine when API come out + this.displayName = model.name; + this.referenceName = model.name; + this.sourceTableName = model.name; + + this.description = model?.description || ''; + this.refSql = model.refSql; + this.cached = model.cached; + this.refreshTime = model.refreshTime; + this.relations = data.relationships + .filter((relationship) => + relationship.models.includes(this.referenceName) + ) + .map((relationship) => new RelationData(relationship)); + + this.columns = model.columns.map( + (column) => new ModelColumnData(column, model, this.relations) + ); + this.fields = this.columns.filter( + (column) => !column.isCalculated && !column.relation + ); + this.relationFields = this.columns.filter((column) => column.relation); + this.calculatedFields = this.columns.filter( + (column) => column.isCalculated && !column.relation + ); + } +} + +export class ModelColumnData { + public readonly id: string; + public readonly displayName: string; + public readonly referenceName: string; + public readonly type: string; + public readonly relation?: RelationData; + public readonly expression?: string; + public readonly isPrimaryKey: boolean; + public readonly isCalculated: boolean; + public readonly properties: ModelColumn['properties']; + + constructor(column: ModelColumn, model: Model, relations: RelationData[]) { + this.id = uuidv4(); + // TODO: this will redefine when API come out + this.displayName = column.name; + this.referenceName = column.name; + + this.type = column.type; + if (column?.relationship) { + const relation = relations.find( + (item) => item.referenceName === column?.relationship + ); + this.relation = relation; + } + if (column?.expression) { + this.expression = column.expression; + } + this.isPrimaryKey = column.name === model.primaryKey; + this.isCalculated = column.isCalculated; + } +} + +export class RelationData { + public readonly id: string; + public readonly displayName: string; + public readonly referenceName: string; + public readonly models: string[]; + public readonly joinType: JOIN_TYPE; + public readonly condition: string; + public readonly fromField: { model: string; field: string }; + public readonly toField: { model: string; field: string }; + public readonly properties: Relationship['properties']; + + constructor(relationship: Relationship) { + this.id = uuidv4(); + // TODO: this will redefine when API come out + this.displayName = relationship.name; + this.referenceName = relationship.name; + + this.models = relationship.models; + this.joinType = relationship.joinType; + this.condition = relationship.condition; + this.properties = relationship.properties; + + const [fromCondition, toCondition] = relationship.condition.split(' = '); + const [fromModel, fromField] = fromCondition.split('.'); + const [toModel, toField] = toCondition.split('.'); + this.fromField = { model: fromModel, field: fromField }; + this.toField = { model: toModel, field: toField }; + } +} diff --git a/wren-ui/src/utils/data/model/view.ts b/wren-ui/src/utils/data/model/view.ts new file mode 100644 index 000000000..0f4637ff0 --- /dev/null +++ b/wren-ui/src/utils/data/model/view.ts @@ -0,0 +1,51 @@ +import { v4 as uuidv4 } from 'uuid'; +import { NODE_TYPE } from '@/utils/enum'; +import { View, ViewColumn } from '@/utils/data/type'; + +export class ViewData { + public readonly nodeType: NODE_TYPE = NODE_TYPE.VIEW; + + public readonly id: string; + public readonly displayName: string; + public readonly referenceName: string; + public readonly statement: string; + public readonly cached: boolean; + public readonly refreshTime: string; + public readonly properties: View['properties']; + + public readonly fields: ViewColumnData[]; + + constructor(view: View) { + this.id = uuidv4(); + // TODO: this will redefine when API come out + this.displayName = view.name; + this.referenceName = view.name; + + this.statement = view.statement; + this.cached = view.cached; + this.refreshTime = view.refreshTime; + this.properties = view.properties; + + this.fields = (view.columns || []).map( + (column) => new ViewColumnData(column) + ); + } +} + +export class ViewColumnData { + public readonly id: string; + public readonly displayName: string; + public readonly referenceName: string; + public readonly type: string; + public readonly properties: ViewColumn['properties']; + + constructor(column: ViewColumn) { + this.id = uuidv4(); + // TODO: this will redefine when API come out + this.displayName = column.name; + this.referenceName = column.name; + + this.type = column.type || ''; + this.properties = column.properties; + } +} diff --git a/wren-ui/src/utils/data/type/index.ts b/wren-ui/src/utils/data/type/index.ts new file mode 100644 index 000000000..1ae4382ea --- /dev/null +++ b/wren-ui/src/utils/data/type/index.ts @@ -0,0 +1 @@ +export * from './modeling'; diff --git a/wren-ui/src/utils/data/type/modeling.ts b/wren-ui/src/utils/data/type/modeling.ts new file mode 100644 index 000000000..8016b9814 --- /dev/null +++ b/wren-ui/src/utils/data/type/modeling.ts @@ -0,0 +1,117 @@ +import { JOIN_TYPE } from '@/utils/enum'; + +export interface Manifest { + catalog: string; + schema: string; + models: Model[]; + relationships: Relationship[]; + metrics: Metric[]; + cumulativeMetrics: Metric[]; + macros: Macro[]; + views: View[]; +} + +export interface View { + name: string; + statement: string; + description: string; + columns: ViewColumn[]; + // TODO: waiting to confirm cached available in view + cached: boolean; + refreshTime: string; + properties: Record; +} + +// TODO: waiting to confirm view columns available +export interface ViewColumn { + name: string; + type: string; + properties: Record; +} + +export interface Macro { + name: string; + definition: string; + properties: Record; +} + +export interface Model { + name: string; + description?: string; + refSql: string; + columns: ModelColumn[]; + cached: boolean; + refreshTime: string; + primaryKey: string; + properties: Record; +} + +export interface ModelColumn { + name: string; + type: string; + isCalculated: boolean; + notNull: boolean; + properties: Record; + expression?: string; + relationship?: string; +} + +export interface Relationship { + name: string; + models: string[]; + joinType: JOIN_TYPE; + condition: string; + manySideSortKeys: { + name: string; + descending: boolean; + }[]; + properties: Record; +} + +export type MetricColumn = Dimension & Measure & TimeGrain & Window; + +export interface Metric { + name: string; + description?: string; + baseObject: string; + cached: boolean; + refreshTime: string; + properties: Record; + dimension?: Dimension[]; + measure?: Measure[]; + timeGrain?: TimeGrain[]; + window?: Window[]; +} + +export interface Dimension { + name: string; + type: string; + isCalculated: boolean; + properties: Record; +} + +export interface Measure { + name: string; + type: string; + properties: Record; + refColumn?: string; + operator?: string; + isCalculated?: boolean; + notNull?: boolean; +} + +export interface TimeGrain { + name: string; + refColumn: string; + dateParts: string[]; + properties: Record; +} + +export interface Window { + name: string; + refColumn: string; + timeUnit: string; + start: string; + end: string; + properties: Record; +} diff --git a/wren-ui/src/utils/diagram/creator.ts b/wren-ui/src/utils/diagram/creator.ts new file mode 100644 index 000000000..43cef81f5 --- /dev/null +++ b/wren-ui/src/utils/diagram/creator.ts @@ -0,0 +1,23 @@ +import { Edge, Node, Viewport, ReactFlowJsonObject } from 'reactflow'; +import { AdaptedData } from '@/utils/data'; +import { Transformer } from './transformer'; + +export class DiagramCreator { + private nodes: Node[]; + private edges: Edge[]; + private viewport: Viewport = { x: 0, y: 0, zoom: 1 }; + + constructor(data: AdaptedData) { + const transformedData = new Transformer(data); + this.nodes = transformedData.nodes; + this.edges = transformedData.edges; + } + + public toJsonObject(): ReactFlowJsonObject { + return { + nodes: this.nodes, + edges: this.edges, + viewport: this.viewport, + }; + } +} diff --git a/wren-ui/src/utils/diagram/model.ts b/wren-ui/src/utils/diagram/model.ts new file mode 100644 index 000000000..1f61b64b5 --- /dev/null +++ b/wren-ui/src/utils/diagram/model.ts @@ -0,0 +1,18 @@ +export const config = { + // the number of model in one row + modelsInRow: 4, + // the width of the model + width: 200, + // height should be calculated depending on the number of columns + height: undefined, + // the height of the model header + headerHeight: 32, + // the height of the model column + columnHeight: 32, + // the overflow of the model body + bodyOverflow: 'auto', + // the margin x between the model and the other models + marginX: 100, + // the margin y between the model and the other models + marginY: 50, +}; diff --git a/wren-ui/src/utils/diagram/transformer.ts b/wren-ui/src/utils/diagram/transformer.ts new file mode 100644 index 000000000..b9514712b --- /dev/null +++ b/wren-ui/src/utils/diagram/transformer.ts @@ -0,0 +1,309 @@ +import { Edge, Node, Position } from 'reactflow'; +import { EDGE_TYPE, MARKER_TYPE, NODE_TYPE, JOIN_TYPE } from '@/utils/enum'; +import { + ModelData, + MetricData, + ModelColumnData, + MetricColumnData, + RelationData, + AdaptedData, + ViewData, +} from '@/utils/data'; + +const config = { + // the number of model in one row + modelsInRow: 4, + // the width of the model + width: 200, + // height should be calculated depending on the number of columns + height: undefined, + // the height of the model header + headerHeight: 32, + // the height of the model column + columnHeight: 32, + // the overflow of the model body + bodyOverflow: 'auto', + // the margin x between the model and the other models + marginX: 100, + // the margin y between the model and the other models + marginY: 50, +}; + +type ComposeData = ModelData | MetricData | ViewData; + +type NodeWithData = Node<{ + originalData: ComposeData; + index: number; + // highlight column ids inside + highlight: string[]; +}>; + +type EdgeWithData = Edge<{ + relation?: RelationData; + highlight: boolean; +}>; + +type StartPoint = { x: number; y: number; floor: number }; + +export class Transformer { + private readonly config: typeof config = config; + private models: ModelData[]; + private metrics: MetricData[]; + private views: ViewData[]; + public nodes: NodeWithData[] = []; + public edges: Edge[] = []; + private start: StartPoint = { + x: 0, + y: 0, + floor: 0, + }; + + constructor(data: AdaptedData) { + this.models = data?.models || []; + this.metrics = data?.metrics || []; + this.views = data?.views || []; + this.init(); + } + + public init() { + const allNodeData = [...this.models, ...this.metrics, ...this.views]; + for (const data of allNodeData) { + this.addOne(data); + } + } + + public addOne(data: ComposeData) { + const { nodeType } = data; + // set position + const nodeX = this.start.x; + const nodeY = this.start.y; + const node = this.createNode({ nodeType, data, x: nodeX, y: nodeY }); + + // from the first model + this.nodes.push(node); + + // update started point + this.updateNextStartedPoint(); + } + + private updateNextStartedPoint() { + const width = this.getModelWidth(); + let floorHeight = 0; + const { length } = this.nodes; + const { marginX, marginY, modelsInRow } = this.config; + const isNextFloor = length % modelsInRow === 0; + if (isNextFloor) { + this.start.floor++; + const lastFloorIndex = modelsInRow * (this.start.floor - 1); + const models = this.models.slice(lastFloorIndex, lastFloorIndex + 4); + + const modelWithMostColumns = models.reduce((prev, current) => { + return prev.columns.length > current.columns.length ? prev : current; + }, models[0]); + + floorHeight = this.getModelHeight(modelWithMostColumns.columns) + marginY; + } + + this.start.x = this.start.x + width + marginX; + if (isNextFloor) this.start.x = 0; + this.start.y = this.start.y + floorHeight; + } + + private createNode(props: { + nodeType: NODE_TYPE | string; + data: ComposeData; + x: number; + y: number; + }): NodeWithData { + const { nodeType, data, x, y } = props; + // check nodeType and add edge + switch (nodeType) { + case NODE_TYPE.MODEL: + this.addModelEdge(data as ModelData); + break; + case NODE_TYPE.METRIC: + this.addMetricEdge(data as MetricData); + break; + default: + break; + } + + return { + id: data.id, + type: nodeType, + position: { x, y }, + dragHandle: '.dragHandle', + data: { + originalData: data, + index: this.nodes.length, + highlight: [], + }, + }; + } + + private addModelEdge(data: ModelData) { + const { columns } = data; + for (const column of columns) { + if (column?.relation) { + // check if edge already exist + const hasEdgeExist = this.edges.some((edge) => { + const [id] = (edge.targetHandle || '').split('_'); + return id === column.id; + }); + if (hasEdgeExist) break; + + // prepare to add new edge + const targetModel = this.models.find( + (model) => + model.id !== data.id && + column.relation?.models.includes(model.referenceName), + )!; + const targetColumn = targetModel?.columns.find( + (targetColumn) => + targetColumn.relation?.referenceName === + column.relation?.referenceName, + ); + + // check what source and target relation order + const { joinType, models } = column.relation; + const sourceJoinIndex = models.findIndex( + (name) => name === data.referenceName, + ); + const targetJoinIndex = models.findIndex( + (name) => name === targetModel?.referenceName, + ); + + targetModel && + this.edges.push( + this.createEdge({ + type: EDGE_TYPE.MODEL, + joinType, + sourceModel: data, + sourceColumn: column, + sourceJoinIndex, + targetModel, + targetColumn, + targetJoinIndex, + }), + ); + } + } + } + + private addMetricEdge(data: MetricData) { + const { baseObject } = data; + const targetModel = this.models.find( + (model) => model.referenceName === baseObject, + )!; + targetModel && + this.edges.push( + this.createEdge({ + type: EDGE_TYPE.METRIC, + sourceModel: data, + targetModel, + }), + ); + } + + private createEdge(props: { + type?: EDGE_TYPE; + sourceModel: ComposeData; + sourceColumn?: ModelColumnData | MetricColumnData; + sourceJoinIndex?: number; + targetModel: ComposeData; + targetColumn?: ModelColumnData | MetricColumnData; + targetJoinIndex?: number; + joinType?: JOIN_TYPE | string; + animated?: boolean; + }): EdgeWithData { + const { + type, + sourceModel, + sourceColumn, + sourceJoinIndex, + targetModel, + targetColumn, + targetJoinIndex, + joinType, + animated, + } = props; + const source = sourceModel.id; + const target = targetModel.id; + const [sourcePos, targetPos] = this.detectEdgePosition(source, target); + const sourceHandle = `${sourceColumn?.id || source}_${sourcePos}`; + const targetHandle = `${targetColumn?.id || target}_${targetPos}`; + + const markerStart = this.getMarker(joinType!, sourceJoinIndex!, sourcePos); + const markerEnd = this.getMarker(joinType!, targetJoinIndex!, targetPos); + + return { + id: `${sourceHandle}_${targetHandle}`, + type, + source, + target, + sourceHandle, + targetHandle, + markerStart, + markerEnd, + data: { + relation: (sourceColumn as ModelColumnData)?.relation, + highlight: false, + }, + animated, + }; + } + + private getFloorIndex(index: number): number { + const { modelsInRow } = this.config; + return index % modelsInRow; + } + + private detectEdgePosition(source: string, target: string) { + const position = []; + const [sourceIndex, targetIndex] = [...this.models, ...this.metrics].reduce( + (result, current, index) => { + if (current.id === source) result[0] = index; + if (current.id === target) result[1] = index; + return result; + }, + [-1, -1], + ); + const sourceFloorIndex = this.getFloorIndex(sourceIndex); + const targetFloorIndex = this.getFloorIndex(targetIndex); + + if (sourceFloorIndex === targetFloorIndex) { + position[0] = Position.Left; + position[1] = Position.Left; + } else if (sourceFloorIndex > targetFloorIndex) { + position[0] = Position.Left; + position[1] = Position.Right; + } else { + position[0] = Position.Right; + position[1] = Position.Left; + } + return position; + } + + private getMarker( + joinType: JOIN_TYPE | string, + joinIndex: number, + position?: Position, + ) { + const markers = + { + [JOIN_TYPE.ONE_TO_ONE]: [MARKER_TYPE.ONE, MARKER_TYPE.ONE], + [JOIN_TYPE.ONE_TO_MANY]: [MARKER_TYPE.ONE, MARKER_TYPE.MANY], + [JOIN_TYPE.MANY_TO_ONE]: [MARKER_TYPE.MANY, MARKER_TYPE.ONE], + }[joinType] || []; + return markers[joinIndex] + (position ? `_${position}` : ''); + } + + private getModelWidth() { + return this.config.width; + } + + private getModelHeight(columns: ModelColumnData[]) { + const { height: diagramHeight, headerHeight, columnHeight } = this.config; + return headerHeight + (diagramHeight || columnHeight * columns.length); + } +} diff --git a/wren-ui/src/utils/enum/ask.ts b/wren-ui/src/utils/enum/ask.ts new file mode 100644 index 000000000..e9c7c36f0 --- /dev/null +++ b/wren-ui/src/utils/enum/ask.ts @@ -0,0 +1,5 @@ +export enum COLLAPSE_CONTENT_TYPE { + NONE = 'none', + VIEW_SQL = 'view_sql', + PREVIEW_DATA = 'preview_data', +} diff --git a/wren-ui/src/utils/enum/columnType.ts b/wren-ui/src/utils/enum/columnType.ts new file mode 100644 index 000000000..b214e048c --- /dev/null +++ b/wren-ui/src/utils/enum/columnType.ts @@ -0,0 +1,37 @@ +export enum COLUMN_TYPE { + // Boolean + BOOLEAN = 'BOOLEAN', + + // Date and Time + DATE = 'DATE', + TIME = 'TIME', + TIMESTAMP = 'TIMESTAMP', + DATETIME = 'DATETIME', + + // Integer + INTEGER = 'INTEGER', + TINYINT = 'TINYINT', + SMALLINT = 'SMALLINT', + BIGINT = 'BIGINT', + INT = 'INT', + NUMBER = 'NUMBER', + + // Floating-Point + DOUBLE = 'DOUBLE', + REAL = 'REAL', + + // Fixed-Precision + DECIMAL = 'DECIMAL', + + // String + CHAR = 'CHAR', + JSON = 'JSON', + TEXT = 'TEXT', + VARBINARY = 'VARBINARY', + VARCHAR = 'VARCHAR', + STRING = 'STRING', + + // Mongo DB + MONGO_ARRAY = 'ARRAY', + MONGO_ROW = 'ROW', +} diff --git a/wren-ui/src/utils/enum/dataSources.ts b/wren-ui/src/utils/enum/dataSources.ts new file mode 100644 index 000000000..ea2da0594 --- /dev/null +++ b/wren-ui/src/utils/enum/dataSources.ts @@ -0,0 +1,6 @@ +export enum DATA_SOURCES { + BIG_QUERY = 'bigQuery', + DATA_BRICKS = 'dataBricks', + SNOWFLAKE = 'snowflake', + TRINO = 'trino', +} diff --git a/wren-ui/src/utils/enum/diagram.ts b/wren-ui/src/utils/enum/diagram.ts new file mode 100644 index 000000000..42d59a451 --- /dev/null +++ b/wren-ui/src/utils/enum/diagram.ts @@ -0,0 +1,12 @@ +export enum MARKER_TYPE { + MANY = 'many', + ONE = 'one', +} + +export enum EDGE_TYPE { + STEP = 'step', + SMOOTHSTEP = 'smoothstep', + BEZIER = 'bezier', + MODEL = 'model', + METRIC = 'metric', +} diff --git a/wren-ui/src/utils/enum/form.ts b/wren-ui/src/utils/enum/form.ts new file mode 100644 index 000000000..fb8ff449b --- /dev/null +++ b/wren-ui/src/utils/enum/form.ts @@ -0,0 +1,4 @@ +export enum FORM_MODE { + CREATE = 'CREATE', + EDIT = 'EDIT', +} diff --git a/wren-ui/src/utils/enum/index.ts b/wren-ui/src/utils/enum/index.ts new file mode 100644 index 000000000..102b85f1c --- /dev/null +++ b/wren-ui/src/utils/enum/index.ts @@ -0,0 +1,8 @@ +export * from './form'; +export * from './setup'; +export * from './dataSources'; +export * from './columnType'; +export * from './modeling'; +export * from './path'; +export * from './diagram'; +export * from './ask'; diff --git a/wren-ui/src/utils/enum/modeling.ts b/wren-ui/src/utils/enum/modeling.ts new file mode 100644 index 000000000..1123b3d2c --- /dev/null +++ b/wren-ui/src/utils/enum/modeling.ts @@ -0,0 +1,60 @@ +export enum JOIN_TYPE { + MANY_TO_ONE = 'MANY_TO_ONE', + ONE_TO_MANY = 'ONE_TO_MANY', + ONE_TO_ONE = 'ONE_TO_ONE', +} + +export enum METRIC_TYPE { + DIMENSION = 'dimension', + MEASURE = 'measure', + TIME_GRAIN = 'timeGrain', + WINDOW = 'window', +} + +export enum NODE_TYPE { + MODEL = 'model', + METRIC = 'metric', + VIEW = 'view', + RELATION = 'relation', + FIELD = 'field', + CALCULATED_FIELD = 'calculatedField', +} + +export enum CACHED_PERIOD { + DAY = 'd', + HOUR = 'h', + MINUTE = 'm', + SECOND = 's', +} + +export enum GRANULARITY { + DAY = 'day', + MONTH = 'month', + YEAR = 'year', +} + +export enum TIME_UNIT { + YEAR = 'year', + QUARTER = 'quarter', + MONTH = 'month', + WEEK = 'week', + DAY = 'day', + HOUR = 'hour', + MINUTE = 'minute', + SECOND = 'second', +} + +export enum MODEL_STEP { + ONE = '1', + TWO = '2', +} + +export enum METRIC_STEP { + ONE = '1', + TWO = '2', +} + +export enum MORE_ACTION { + EDIT = 'edit', + DELETE = 'delete', +} diff --git a/wren-ui/src/utils/enum/path.ts b/wren-ui/src/utils/enum/path.ts new file mode 100644 index 000000000..18b089a51 --- /dev/null +++ b/wren-ui/src/utils/enum/path.ts @@ -0,0 +1,6 @@ +export enum Path { + Exploration = '/exploration', + Modeling = '/modeling', + Onboarding = '/setup', + ASK = '/ask', +} diff --git a/wren-ui/src/utils/enum/setup.ts b/wren-ui/src/utils/enum/setup.ts new file mode 100644 index 000000000..c5b36d328 --- /dev/null +++ b/wren-ui/src/utils/enum/setup.ts @@ -0,0 +1,13 @@ +export enum SETUP { + STARTER = 'starter', + CREATE_DATA_SOURCE = 'createDataSource', + SELECT_MODELS = 'selectModels', + CREATE_MODELS = 'createModels', + RECOMMEND_RELATIONS = 'recommendRelations', + DEFINE_RELATIONS = 'defineRelations', +} + +export enum DEMO_TEMPLATES { + CRM = 'CRM', + ECORMERCE = 'Ecommerce', +} diff --git a/wren-ui/src/utils/error/dictionary.ts b/wren-ui/src/utils/error/dictionary.ts new file mode 100644 index 000000000..14a4d3631 --- /dev/null +++ b/wren-ui/src/utils/error/dictionary.ts @@ -0,0 +1,124 @@ +export const ERROR_TEXTS = { + CONNECTION: { + DISPLAY_NAME: { + REQUIRED: 'Please input display name.', + }, + PROJECT_ID: { + REQUIRED: 'Please input project id.', + }, + DATASET: { + REQUIRED: 'Please input dataset name.', + }, + LOCATION: { + REQUIRED: 'Please input data location.', + }, + CREDENTIAL: { + REQUIRED: 'Please upload credential.', + }, + }, + ADD_CALCULATED_FIELD: { + FIELD_NAME: { + REQUIRED: 'Please input field name.', + }, + }, + ADD_MEASURE_FIELD: { + FIELD_NAME: { + REQUIRED: 'Please input measure name.', + }, + }, + ADD_DIMENSION_FIELD: { + FIELD_NAME: { + REQUIRED: 'Please input dimension name.', + }, + MODEL_FIELD: { + REQUIRED: 'Please select a field.', + }, + GRANULARITY: { + REQUIRED: 'Please select granularity.', + }, + }, + ADD_WINDOW_FIELD: { + FIELD_NAME: { + REQUIRED: 'Please input window name.', + }, + MODEL_FIELD: { + REQUIRED: 'Please select a field.', + }, + TIME_UNIT: { + REQUIRED: 'Please select time unit.', + }, + }, + EXPRESS_PROPERTIES: { + EXPRESSION: { + REQUIRED: 'Please select an expression.', + }, + MODEL_FIELD: { + REQUIRED: 'Please select a field.', + }, + CUSTOM_FIELD: { + REQUIRED: 'Please input expression.', + }, + }, + ADD_RELATION: { + NAME: { + REQUIRED: 'Please input name.', + }, + FROM_FIELD: { + REQUIRED: 'Please select a field.', + }, + TO_FIELD: { + REQUIRED: 'Please select a field.', + }, + RELATION_TYPE: { + REQUIRED: 'Please select a relation type.', + }, + }, + UPDATE_METADATA: { + NAME: { + REQUIRED: 'Please input name.', + }, + }, + MODELING_CREATE_MODEL: { + NAME: { + REQUIRED: 'Please input model name.', + }, + TABLE: { + REQUIRED: 'Please select a table.', + }, + CUSTOM_SQL: { + REQUIRED: 'Please input SQL.', + }, + FIELDS: { + REQUIRED: 'Please select fields.', + }, + }, + MODELING_CREATE_METRIC: { + NAME: { + REQUIRED: 'Please input metric name.', + }, + SOURCE: { + REQUIRED: 'Please select a model or metric.', + }, + }, + MODELING_CREATE_VIEW: { + NAME: { + REQUIRED: 'Please input view name.', + }, + SQL: { + REQUIRED: 'Please input SQL.', + }, + }, + SETUP_MODEL: { + TABLE: { + REQUIRED: 'Please select at least one table.', + }, + FIELDS: { + REQUIRED: 'Please select at least one field.', + }, + }, + SAVE_AS_VIEW: { + DISPLAY_NAME: { + REQUIRED: 'Please input display name.', + }, + }, +}; diff --git a/wren-ui/src/utils/error/index.ts b/wren-ui/src/utils/error/index.ts new file mode 100644 index 000000000..6199627fe --- /dev/null +++ b/wren-ui/src/utils/error/index.ts @@ -0,0 +1 @@ +export * from './dictionary'; diff --git a/wren-ui/src/utils/helper.ts b/wren-ui/src/utils/helper.ts new file mode 100644 index 000000000..c2e08ae11 --- /dev/null +++ b/wren-ui/src/utils/helper.ts @@ -0,0 +1,21 @@ +import { omitBy, isUndefined } from 'lodash'; + +/** + * @function + * @description Remove undefined property value in an object + */ +export const compactObject = (obj: T) => { + return omitBy(obj, isUndefined) as T; +}; + +/** + * @function + * @description Retrieve json without error + */ +export const parseJson = (data) => { + try { + return JSON.parse(data); + } catch (_e) { + return data; + } +}; diff --git a/wren-ui/src/utils/icons.ts b/wren-ui/src/utils/icons.ts new file mode 100644 index 000000000..e32332e5e --- /dev/null +++ b/wren-ui/src/utils/icons.ts @@ -0,0 +1,97 @@ +import styled from 'styled-components'; +import { Columns } from '@styled-icons/fa-solid'; +import { + Calendar, + Text, + InfoCircle, + Cube, + LineChart, +} from '@styled-icons/boxicons-regular'; +import { Braces, Brackets, Map2 } from '@styled-icons/remix-line'; +import { + SortNumerically, + SortAlphabetically, + Tick, +} from '@styled-icons/typicons'; +import { + VpnKey, + CenterFocusWeak, + Refresh, + Pageview, + Explore, +} from '@styled-icons/material-outlined'; +import MonitorOutlined from '@ant-design/icons/MonitorOutlined'; +import SwapOutlined from '@ant-design/icons/SwapOutlined'; +import ShareAltOutlined from '@ant-design/icons/ShareAltOutlined'; +import { Binoculars, LightningCharge } from '@styled-icons/bootstrap'; +import MoreOutlined from '@ant-design/icons/MoreOutlined'; +import { Sparkles } from '@styled-icons/ionicons-outline'; + +export const NumericIcon = styled(SortNumerically)` + height: 1em; +`; +export const TickIcon = styled(Tick)` + height: 1em; +`; +export const StringIcon = styled(SortAlphabetically)` + height: 1em; +`; +export const TextIcon = styled(Text)` + height: 1em; +`; +export const CalendarIcon = styled(Calendar)` + height: 1em; +`; +export const ArrayBracketsIcon = styled(Brackets)` + height: 1em; +`; +export const JsonBracesIcon = styled(Braces)` + height: 1em; +`; +export const ColumnsIcon = styled(Columns)` + height: 1em; +`; +export const InfoIcon = styled(InfoCircle)` + height: 1em; +`; +export const PrimaryKeyIcon = styled(VpnKey)` + height: 1em; +`; +export const ModelIcon = styled(Cube)` + height: 1em; +`; +export const FocusIcon = styled(CenterFocusWeak)` + height: 1em; +`; +export const MapIcon = styled(Map2)` + height: 1em; +`; +export const RelationshipIcon = styled(SwapOutlined)` + height: 1em; +`; +export const MonitorIcon = styled(MonitorOutlined)` + height: 1em; +`; +export const RefreshIcon = styled(Refresh)``; +export const MetricIcon = styled(LineChart)` + height: 1em; +`; +export const ShareIcon = styled(ShareAltOutlined)``; +export const LightningIcon = styled(LightningCharge)` + height: 1em; +`; +export const MoreIcon = styled(MoreOutlined)``; +export const ViewIcon = styled(Pageview)` + height: 1em; +`; +export const ExploreIcon = styled(Explore)` + height: 1em; +`; +export const SparklesIcon = styled(Sparkles)` + height: 1em; +`; + +export const BinocularsIcon = styled(Binoculars)` + height: 16px; + width: 14px; +`; diff --git a/wren-ui/src/utils/iteration.tsx b/wren-ui/src/utils/iteration.tsx new file mode 100644 index 000000000..294c49035 --- /dev/null +++ b/wren-ui/src/utils/iteration.tsx @@ -0,0 +1,34 @@ +interface Props { + [key: string]: any; + data: any[]; + // by default it will use item['key'] as keyIndex unless specifying keyIndex + keyIndex?: string | ((item: any) => string); +} + +export type IterableComponent = { + data: T[]; + index: number; + key: string; +} & T; + +export const makeIterable = (Template: React.FC>) => { + const Iterator = (props: Props) => { + const { data, keyIndex = 'key', ...restProps } = props; + const result = data.map((item, index) => { + const key = + typeof keyIndex === 'function' ? keyIndex(item) : item[keyIndex]; + return ( +