diff --git a/.github/workflows/master-deployment.yml b/.github/workflows/master-deployment.yml index 81b0814de..2212a3c08 100644 --- a/.github/workflows/master-deployment.yml +++ b/.github/workflows/master-deployment.yml @@ -79,7 +79,7 @@ jobs: context: . file: ./Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_LABS_USERNAME }}/neodash:latest,${{ secrets.DOCKER_HUB_LABS_USERNAME }}/neodash:2.3.0 + tags: ${{ secrets.DOCKER_HUB_LABS_USERNAME }}/neodash:latest,${{ secrets.DOCKER_HUB_LABS_USERNAME }}/neodash:2.3.1 build-docker-legacy: needs: build-test runs-on: ubuntu-latest @@ -103,7 +103,7 @@ jobs: context: . file: ./Dockerfile push: true - tags: ${{ secrets.DOCKER_HUB_USERNAME }}/neodash:latest,${{ secrets.DOCKER_HUB_USERNAME }}/neodash:2.3.0 + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/neodash:latest,${{ secrets.DOCKER_HUB_USERNAME }}/neodash:2.3.1 deploy-gallery: runs-on: ubuntu-latest strategy: diff --git a/Dockerfile b/Dockerfile index c443d4fd3..c4c7a6467 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,4 +43,4 @@ USER nginx EXPOSE $NGINX_PORT HEALTHCHECK cmd curl --fail "http://localhost:$NGINX_PORT" || exit 1 -LABEL version="2.3.0" +LABEL version="2.3.1" diff --git a/README.md b/README.md index 414f5185e..5c4db07c9 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ To manually run linting of all your .ts and .tsx files, run: yarn run lint ``` -To manually run linting of all your .ts and .tsx staged files, run: +To manually run linting of all your .ts and .tsx staged files, run: ``` yarn run lint-staged ``` diff --git a/changelog.md b/changelog.md index 89691e4a2..96febc4f5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,25 @@ +## NeoDash 2.3.1 +What's new in NeoDash 2.3.1? A few bug fixes, improvement of natural language queries with support of Azure Open AI and parameters, Graph Vizualization relationship styling and more below! + +- Natural language queries + - **Support of Azure Open AI** ([@BennuFire](https://github.com/bennufire), [#515](https://github.com/neo4j-labs/neodash/pull/515)) + - Support parameters on natural language queries ([@BennuFire](https://github.com/bennufire), [#514](https://github.com/neo4j-labs/neodash/pull/514)) + +- Graph Visualization + - Added styling rules for relationship color ([@brahmprakashMishra](https://github.com/brahmprakashMishra) [@BennuFire](https://github.com/bennufire), [#537](https://github.com/neo4j-labs/neodash/pull/537)) + +- Table Chart + - Update TableChart to use first returned row values as titles when transposed ([@bastienhubert](https://github.com/bastienhubert), [#513](https://github.com/neo4j-labs/neodash/pull/513)) + - Fix falsy boolean display on table ([@bastienhubert](https://github.com/bastienhubert), [#536](https://github.com/neo4j-labs/neodash/pull/536)) + +- Report Actions + - Fix on Style and Action modal that was preventing from setting params on low resolutions ([@mariusconjeaud](https://github.com/mariusconjeaud), [#533](https://github.com/neo4j-labs/neodash/pull/533)) + +- Others + - New setting for parameters selector to allow selection of multiple values instead of one + Fix multi selector on dates ([@BennuFire](https://github.com/bennufire), [#535](https://github.com/neo4j-labs/neodash/pull/535)) + - Fix bug where protocol was not set properly on share links ([@nielsdejong](https://github.com/nielsdejong), [#521](https://github.com/neo4j-labs/neodash/pull/521)) + - Update word-wrap from 1.2.3 to 1.2.4 ([@BennuFire](https://github.com/bennufire), [#526](https://github.com/neo4j-labs/neodash/pull/526) [#527](https://github.com/neo4j-labs/neodash/pull/527)) + ## NeoDash 2.3.0 NeoDash 2.3 is out! This release brings a brand new look-and-feel, improved speed for large dashboards, and a new extension for querying Neo4j with natural language (using LLMs). diff --git a/cypress/support/index.js b/cypress/support/index.js new file mode 100644 index 000000000..37a498fb5 --- /dev/null +++ b/cypress/support/index.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands'; + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/docs/modules/ROOT/pages/developer-guide/deploy-a-build.adoc b/docs/modules/ROOT/pages/developer-guide/deploy-a-build.adoc index 340c3c7d5..013aba985 100644 --- a/docs/modules/ROOT/pages/developer-guide/deploy-a-build.adoc +++ b/docs/modules/ROOT/pages/developer-guide/deploy-a-build.adoc @@ -37,7 +37,7 @@ Depending on the webserver type and version, this could be different directory. As an example - to copy the files to an nginx webserver using `scp`: ```bash -scp neodash-2.3.0 username@host:/usr/share/nginx/html +scp neodash-2.3.1 username@host:/usr/share/nginx/html ``` NeoDash should now be visible by visiting your (sub)domain in the browser. diff --git a/docs/modules/ROOT/pages/user-guide/extensions/natural-language-queries.adoc b/docs/modules/ROOT/pages/user-guide/extensions/natural-language-queries.adoc index 71530a055..2698497d5 100644 --- a/docs/modules/ROOT/pages/user-guide/extensions/natural-language-queries.adoc +++ b/docs/modules/ROOT/pages/user-guide/extensions/natural-language-queries.adoc @@ -11,7 +11,7 @@ To enable Natural Language Queries in NeoDash, follow these configuration steps: 1. Open NeoDash and navigate to the "Extensions" section in the left sidebar. 2. Locate the "Natural Language Queries" extension and click on it to activate it. 3. Once activated, a new button will appear in the sidebar(see image below). Click on the button to open the configuration window. -4. In the configuration window, you will be prompted to provide the necessary information to connect to the Language Model (LLM). Enter the model provider, API key, and select the desired model to use. +4. In the configuration window, you will be prompted to provide the necessary information to connect to the Language Model (LLM). Enter the model provider, API key, deployment url if needed by the model provider, and select the desired model to use. 5. After providing the required information, click on the "Start Querying" button to finalize the configuration. image::extensionbutton.png[Extension Button enables Natural Language Queries button in the sidebar] diff --git a/gallery/yarn.lock b/gallery/yarn.lock index 86964acbb..2d61aaaee 100644 --- a/gallery/yarn.lock +++ b/gallery/yarn.lock @@ -9453,9 +9453,9 @@ which@^2.0.1: isexe "^2.0.0" word-wrap@^1.2.3, word-wrap@~1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + version "1.2.4" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" + integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== workbox-background-sync@6.5.4: version "6.5.4" diff --git a/package.json b/package.json index f712990c1..c6188e70a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "neodash", - "version": "2.3.0", + "version": "2.3.1", "description": "NeoDash - Neo4j Dashboard Builder", "neo4jDesktop": { "apiVersion": "^1.2.0" @@ -34,6 +34,7 @@ "keywords": [], "author": "Neo4j Labs", "dependencies": { + "@azure/openai": "^1.0.0-beta.2", "@codemirror/lang-markdown": "^6.1.1", "@codemirror/language-data": "^6.3.1", "@mui/icons-material": "^5.11.16", diff --git a/release-notes.md b/release-notes.md index 7c2ca4a0d..8ef08272e 100644 --- a/release-notes.md +++ b/release-notes.md @@ -1,29 +1,21 @@ -## NeoDash 2.3.0 -NeoDash 2.3 is out! This release brings a brand new look-and-feel, improved speed for large dashboards, and a new extension for querying Neo4j with natural language (using LLMs). +## NeoDash 2.3.1 +What's new in NeoDash 2.3.1? A few bug fixes, improvement of natural language queries with support of Azure Open AI and parameters, Graph Vizualization relationship styling and more below! -Highlights: -- Write **[Natural Language Queries](https://neo4j.com/labs/neodash/2.3/user-guide/extensions/natural-language-queries/)** and use OpenAI to generate Cypher queries for your visualizations. (OpenAI API key required) -- UI updated to use the **[Neo4j Design Language](https://www.neo4j.design/)**, giving NeoDash a similar look-and-feel to other Neo4j tools. -- Customize branding, colors dynamically with a new [Style Configuration File](https://neo4j.com/labs/neodash/2.3/developer-guide/style-configuration). - -Other changes: -- Fixed issues with date picker / free-text parameter sometimes not initializing. -- Improved documentation by fixing broken links, and adding more details around complex concepts. -- **Pro Extensions have evolved to open Expert Extensions.** -- Fixed issue where deep-linked parameters were not set from the URL. -- Added option to specify absolute width for table columns (in pixels or as percentages). -- Fixed map charts to auto-cluster markers when they collide, or are too close together. -- ... and dozens of other improvements! +- Natural language queries + - **Support of Azure Open AI** ([@BennuFire](https://github.com/bennufire), [#515](https://github.com/neo4j-labs/neodash/pull/515)) + - Support parameters on natural language queries ([@BennuFire](https://github.com/bennufire), [#514](https://github.com/neo4j-labs/neodash/pull/514)) +- Graph Visualization + - Added styling rules for relationship color ([@brahmprakashMishra](https://github.com/brahmprakashMishra) [@BennuFire](https://github.com/bennufire), [#537](https://github.com/neo4j-labs/neodash/pull/537)) +- Table Chart + - Update TableChart to use first returned row values as titles when transposed ([@bastienhubert](https://github.com/bastienhubert), [#513](https://github.com/neo4j-labs/neodash/pull/513)) + - Fix falsy boolean display on table ([@bastienhubert](https://github.com/bastienhubert), [#536](https://github.com/neo4j-labs/neodash/pull/536)) -Contributors to this release: -- [Alfredo Rubin](https://github.com/alfredorubin96) -- [Harold Agudelo](https://github.com/BennuFire) -- [Aleksandar Simeunovic](https://github.com/AleSim94) -- [Marius Conjeaud](https://github.com/mariusconjeaud) -- [Brahm Prakash Mishra](https://github.com/brahmprakashMishra) -- [Pierre Martignon](https://github.com/pierremartignon) -- [Kim Zachariassen](https://github.com/KiZach) -- [Paolo Baldini](https://github.com/8Rav3n) -- [Niels de Jong](https://github.com/nielsdejong/) +- Report Actions + - Fix on Style and Action modal that was preventing from setting params on low resolutions ([@mariusconjeaud](https://github.com/mariusconjeaud), [#533](https://github.com/neo4j-labs/neodash/pull/533)) + +- Others + - New setting for parameters selector to allow selection of multiple values instead of one + Fix multi selector on dates ([@BennuFire](https://github.com/bennufire), [#535](https://github.com/neo4j-labs/neodash/pull/535)) + - Fix bug where protocol was not set properly on share links ([@nielsdejong](https://github.com/nielsdejong), [#521](https://github.com/neo4j-labs/neodash/pull/521)) + - Update word-wrap from 1.2.3 to 1.2.4 ([@BennuFire](https://github.com/bennufire), [#526](https://github.com/neo4j-labs/neodash/pull/526) [#527](https://github.com/neo4j-labs/neodash/pull/527)) diff --git a/src/application/ApplicationThunks.ts b/src/application/ApplicationThunks.ts index bbe18fe5c..6f8140b70 100644 --- a/src/application/ApplicationThunks.ts +++ b/src/application/ApplicationThunks.ts @@ -221,13 +221,12 @@ export const handleSharedDashboardsThunk = () => (dispatch: any) => { const database = connection.split('@')[1].split(':')[0]; const url = connection.split('@')[1].split(':')[1]; const port = connection.split('@')[1].split(':')[2]; - if (url == password) { // Special case where a connect link is generated without a password. // Here, the format is parsed incorrectly and we open the connection window instead. dispatch(resetShareDetails()); - dispatch(setConnectionProperties('neo4j', url, '7687', database, username.split('@')[0], '')); + dispatch(setConnectionProperties(protocol, url, port, database, username.split('@')[0], '')); dispatch(setWelcomeScreenOpen(false)); dispatch(setConnectionModalOpen(true)); // window.history.pushState({}, document.title, "/"); diff --git a/src/card/Card.tsx b/src/card/Card.tsx index fb529325a..9bc1ae28a 100644 --- a/src/card/Card.tsx +++ b/src/card/Card.tsx @@ -175,6 +175,7 @@ const NeoCard = ({ height={report.height} heightPx={height} fields={report.fields} + schema={report.schema} type={report.type} expanded={expanded} extensions={extensions} @@ -207,7 +208,7 @@ const NeoCard = ({ // Look into React Portals: https://stackoverflow.com/questions/61432878/how-to-render-child-component-outside-of-its-parent-component-dom-hierarchy if (expanded) { return ( - <Dialog size='large' open={expanded} aria-labelledby='form-dialog-title' style={{ maxWidth: '100%' }}> + <Dialog open={expanded} aria-labelledby='form-dialog-title' className='dialog-xxl'> <Dialog.Content style={{ height: document.documentElement.clientHeight }}>{component}</Dialog.Content> </Dialog> ); diff --git a/src/card/CardActions.ts b/src/card/CardActions.ts index a09c22cdd..8b4fa765e 100644 --- a/src/card/CardActions.ts +++ b/src/card/CardActions.ts @@ -50,6 +50,12 @@ export const updateFields = (pagenumber: number, id: number, fields: any) => ({ payload: { pagenumber, id, fields }, }); +export const UPDATE_SCHEMA = 'PAGE/CARD/UPDATE_SCHEMA'; +export const updateSchema = (pagenumber: number, id: number, schema: any) => ({ + type: UPDATE_SCHEMA, + payload: { pagenumber, id, schema }, +}); + export const UPDATE_SELECTION = 'PAGE/CARD/UPDATE_SELECTION'; export const updateSelection = (pagenumber: number, id: number, selectable: any, field: any) => ({ type: UPDATE_SELECTION, diff --git a/src/card/CardReducer.ts b/src/card/CardReducer.ts index cd9dde11e..b3a8127ae 100644 --- a/src/card/CardReducer.ts +++ b/src/card/CardReducer.ts @@ -5,6 +5,7 @@ import { UPDATE_ALL_SELECTIONS, UPDATE_CYPHER_PARAMETERS, UPDATE_FIELDS, + UPDATE_SCHEMA, UPDATE_REPORT_QUERY, UPDATE_REPORT_SETTING, UPDATE_REPORT_SIZE, @@ -72,6 +73,11 @@ export const cardReducer = (state = CARD_INITIAL_STATE, action: { type: any; pay state = update(state, { fields: fields }); return state; } + case UPDATE_SCHEMA: { + const { schema } = payload; + state = update(state, { schema: schema }); + return state; + } case UPDATE_REPORT_TYPE: { const { type } = payload; state = update(state, { type: type }); diff --git a/src/card/CardThunks.ts b/src/card/CardThunks.ts index 1c5efd052..18c68ae74 100644 --- a/src/card/CardThunks.ts +++ b/src/card/CardThunks.ts @@ -10,6 +10,7 @@ import { clearSelection, updateAllSelections, updateReportDatabase, + updateSchema, } from './CardActions'; import { createNotificationThunk } from '../page/PageThunks'; import { getReportTypes } from '../extensions/ExtensionUtils'; @@ -67,68 +68,76 @@ export const updateReportTypeThunk = (id, type) => (dispatch: any, getState: any dispatch(updateReportType(pagenumber, id, type)); dispatch(updateFields(pagenumber, id, [])); + dispatch(updateSchema(pagenumber, id, [])); dispatch(clearSelection(pagenumber, id)); } catch (e) { dispatch(createNotificationThunk('Cannot update report type', e)); } }; -export const updateFieldsThunk = (id, fields) => (dispatch: any, getState: any) => { - try { - const state = getState(); - const { pagenumber } = state.dashboard.settings; - const extensions = Object.fromEntries(Object.entries(state.dashboard.extensions).filter(([_, v]) => v.active)); - const oldReport = state.dashboard.pages[pagenumber].reports.find((o) => o.id === id); +export const updateFieldsThunk = + (id, fields, schema = false) => + (dispatch: any, getState: any) => { + try { + const state = getState(); + const { pagenumber } = state.dashboard.settings; + const extensions = Object.fromEntries(Object.entries(state.dashboard.extensions).filter(([_, v]) => v.active)); + const oldReport = state.dashboard.pages[pagenumber].reports.find((o) => o.id === id); - if (!oldReport) { - return; - } - const oldFields = oldReport.fields; - const reportType = oldReport.type; - const oldSelection = oldReport.selection; - const reportTypes = getReportTypes(extensions); - const selectableFields = reportTypes[reportType].selection; // The dictionary of selectable fields as defined in the config. - const { autoAssignSelectedProperties } = reportTypes[reportType]; - const selectables = selectableFields ? Object.keys(selectableFields) : []; + if (!oldReport) { + return; + } + const oldFields = schema ? oldReport.schema : oldReport.fields; + const reportType = oldReport.type; + const oldSelection = oldReport.selection; + const reportTypes = getReportTypes(extensions); + const selectableFields = reportTypes[reportType].selection; // The dictionary of selectable fields as defined in the config. + const { autoAssignSelectedProperties } = reportTypes[reportType]; + const selectables = selectableFields ? Object.keys(selectableFields) : []; - // If the new set of fields is not equal to the current set of fields, we ned to update the field selection. - if (!isEqual(oldFields, fields) || Object.keys(oldSelection).length === 0) { - selectables.forEach((selection, i) => { - if (fields.includes(oldSelection[selection])) { - // If the current selection is still present in the new set of fields, no need to reset. - // Also we ignore this on a node property selector. - /* continue */ - } else if (selectableFields[selection].optional) { - // If the fields change, always set optional selections to none. - if (selectableFields[selection].multiple) { - dispatch(updateSelection(pagenumber, id, selection, ['(none)'])); - } else { - dispatch(updateSelection(pagenumber, id, selection, '(none)')); - } - } else if (fields.length > 0) { - // For multi selections, select the Nth item of the result fields as a single item array. - if (selectableFields[selection].multiple) { - // only update if the old selection no longer covers the new set of fields... - if (!oldSelection[selection] || !oldSelection[selection].every((v) => fields.includes(v))) { - dispatch(updateSelection(pagenumber, id, selection, [fields[Math.min(i, fields.length - 1)]])); + // If the new set of fields is not equal to the current set of fields, we ned to update the field selection. + if (!isEqual(oldFields, fields) || Object.keys(oldSelection).length === 0) { + selectables.forEach((selection, i) => { + if (fields.includes(oldSelection[selection])) { + // If the current selection is still present in the new set of fields, no need to reset. + // Also we ignore this on a node property selector. + /* continue */ + } else if (selectableFields[selection].optional) { + // If the fields change, always set optional selections to none. + if (selectableFields[selection].multiple) { + dispatch(updateSelection(pagenumber, id, selection, ['(none)'])); + } else { + dispatch(updateSelection(pagenumber, id, selection, '(none)')); + } + } else if (fields.length > 0) { + // For multi selections, select the Nth item of the result fields as a single item array. + if (selectableFields[selection].multiple) { + // only update if the old selection no longer covers the new set of fields... + if (!oldSelection[selection] || !oldSelection[selection].every((v) => fields.includes(v))) { + dispatch(updateSelection(pagenumber, id, selection, [fields[Math.min(i, fields.length - 1)]])); + } + } else if (selectableFields[selection].type == SELECTION_TYPES.NODE_PROPERTIES) { + // For node property selections, select the most obvious properties of the node to display. + const selection = getSelectionBasedOnFields(fields, oldSelection, autoAssignSelectedProperties); + dispatch(updateAllSelections(pagenumber, id, selection)); + } else { + // Else, default the selection to the Nth item of the result set fields. + dispatch(updateSelection(pagenumber, id, selection, fields[Math.min(i, fields.length - 1)])); } - } else if (selectableFields[selection].type == SELECTION_TYPES.NODE_PROPERTIES) { - // For node property selections, select the most obvious properties of the node to display. - const selection = getSelectionBasedOnFields(fields, oldSelection, autoAssignSelectedProperties); - dispatch(updateAllSelections(pagenumber, id, selection)); - } else { - // Else, default the selection to the Nth item of the result set fields. - dispatch(updateSelection(pagenumber, id, selection, fields[Math.min(i, fields.length - 1)])); } + }); + // Set the new set of fields for the report so that we may select them. + + if (schema) { + dispatch(updateSchema(pagenumber, id, fields)); + } else { + dispatch(updateFields(pagenumber, id, fields)); } - }); - // Set the new set of fields for the report so that we may select them. - dispatch(updateFields(pagenumber, id, fields)); + } + } catch (e) { + dispatch(createNotificationThunk('Cannot update report fields', e)); } - } catch (e) { - dispatch(createNotificationThunk('Cannot update report fields', e)); - } -}; + }; export const updateSelectionThunk = (id, selectable, field) => (dispatch: any, getState: any) => { try { diff --git a/src/card/settings/CardSettings.tsx b/src/card/settings/CardSettings.tsx index df7f56f2b..c9e0fe375 100644 --- a/src/card/settings/CardSettings.tsx +++ b/src/card/settings/CardSettings.tsx @@ -19,6 +19,7 @@ const NeoCardSettings = ({ reportSettings, reportSettingsOpen, fields, + schema, heightPx, extensions, // A set of enabled extensions. onQueryUpdate, @@ -78,6 +79,7 @@ const NeoCardSettings = ({ <NeoCardSettingsFooter type={type} fields={fields} + schema={schema} extensions={extensions} reportSettings={reportSettings} reportSettingsOpen={reportSettingsOpen} diff --git a/src/card/settings/CardSettingsFooter.tsx b/src/card/settings/CardSettingsFooter.tsx index 1c86d6b0f..3aa4b3b02 100644 --- a/src/card/settings/CardSettingsFooter.tsx +++ b/src/card/settings/CardSettingsFooter.tsx @@ -19,6 +19,7 @@ const update = (state, mutations) => Object.assign({}, state, mutations); const NeoCardSettingsFooter = ({ type, fields, + schema, reportSettings, reportSettingsOpen, extensions, @@ -124,6 +125,7 @@ const NeoCardSettingsFooter = ({ settingValue={reportSettings[settingToCustomize]} type={type} fields={fields} + schema={schema} customReportStyleModalOpen={customReportStyleModalOpen} setCustomReportStyleModalOpen={setCustomReportStyleModalOpen} onReportSettingUpdate={onReportSettingUpdate} diff --git a/src/card/settings/custom/CardSettingsContentPropertySelect.tsx b/src/card/settings/custom/CardSettingsContentPropertySelect.tsx index cbb462ed8..4a9f7ce5b 100644 --- a/src/card/settings/custom/CardSettingsContentPropertySelect.tsx +++ b/src/card/settings/custom/CardSettingsContentPropertySelect.tsx @@ -237,7 +237,7 @@ const NeoCardSettingsContentPropertySelect = ({ value={settings.entityType ? settings.entityType : ''} defaultValue={''} placeholder={'Enter a parameter name here...'} - style={{ width: 335, marginLeft: '5px', marginTop: '13px' }} + style={{}} onChange={(value) => { setLabelInputText(value); handleNodeLabelSelectionUpdate(value); @@ -253,7 +253,7 @@ const NeoCardSettingsContentPropertySelect = ({ value={settings?.entityType || ''} defaultValue={''} placeholder={'Enter a parameter name here...'} - style={{ width: 350, marginLeft: '5px', marginTop: '0px' }} + style={{}} onChange={(value) => { setLabelInputText(value); handleNodeLabelSelectionUpdate(value); @@ -261,7 +261,6 @@ const NeoCardSettingsContentPropertySelect = ({ }} /> <br /> - <br /> <div style={{ display: labelInputText ? 'inherit' : 'none' }}> <NeoCodeEditorComponent value={queryText} @@ -301,7 +300,7 @@ const NeoCardSettingsContentPropertySelect = ({ : labelRecords.map((r) => (r._fields ? r._fields[0] : '(no data)')) } getOptionLabel={(option) => option || ''} - style={{ width: 350, marginLeft: '5px', marginTop: '13px' }} + style={{ marginTop: '13px' }} inputValue={labelInputText} onInputChange={(event, value) => { setLabelInputText(value); @@ -344,7 +343,7 @@ const NeoCardSettingsContentPropertySelect = ({ : propertyRecords.map((r) => (r._fields ? r._fields[0] : '(no data)')) } getOptionLabel={(option) => (option ? option : '')} - style={{ display: 'inline-block', width: 170, marginLeft: '5px', marginTop: '13px' }} + style={{ display: 'inline-block', width: '65%', marginTop: '13px', marginRight: '5%' }} inputValue={propertyInputText} onInputChange={(event, value) => { setPropertyInputText(value); @@ -374,13 +373,14 @@ const NeoCardSettingsContentPropertySelect = ({ {overridePropertyDisplayName ? ( <Autocomplete id='autocomplete-property-display' + size={'small'} options={ manualPropertyNameSpecification ? [settings.propertyTypeDisplay || settings.propertyType] : propertyRecords.map((r) => (r._fields ? r._fields[0] : '(no data)')) } getOptionLabel={(option) => (option ? option : '')} - style={{ display: 'inline-block', width: 170, marginLeft: '5px', marginTop: '13px' }} + style={{ display: 'inline-block', width: '65%', marginTop: '13px', marginRight: '5%' }} inputValue={propertyInputDisplayText} onInputChange={(event, value) => { setPropertyInputDisplayText(value); @@ -408,14 +408,14 @@ const NeoCardSettingsContentPropertySelect = ({ ) : ( <></> )} - <NeoField + <TextField placeholder='number' label='Number (optional)' disabled={!settings.propertyType} value={settings.id} - style={{ width: '170px', marginTop: '13px', marginLeft: '5px' }} - onChange={(value) => { - handleIdSelectionUpdate(value); + style={{ width: '30%', display: 'inline-block', marginTop: '13px' }} + onChange={(e) => { + handleIdSelectionUpdate(e.target.value); }} size={'small'} /> diff --git a/src/card/view/CardView.tsx b/src/card/view/CardView.tsx index eb376111d..508b834fa 100644 --- a/src/card/view/CardView.tsx +++ b/src/card/view/CardView.tsx @@ -138,7 +138,7 @@ const NeoCardView = ({ }, [JSON.stringify(localParameters)]); useEffect(() => { - if (!settingsOpen) { + if (!settingsOpen && (selectorChange || type === 'select')) { setLastRunTimestamp(Date.now()); } setSelectorChange(false); diff --git a/src/chart/Utils.ts b/src/chart/Utils.ts index cc0c4a984..d2360c545 100644 --- a/src/chart/Utils.ts +++ b/src/chart/Utils.ts @@ -111,3 +111,8 @@ export const rgbaToHex = (color: string): string => { } return color; }; + +export enum EntityType { + Node, + Relationship, +} diff --git a/src/chart/graph/util/RecordUtils.ts b/src/chart/graph/util/RecordUtils.ts index a8d9f623b..a1767aea9 100644 --- a/src/chart/graph/util/RecordUtils.ts +++ b/src/chart/graph/util/RecordUtils.ts @@ -1,4 +1,4 @@ -import { evaluateRulesOnNode } from '../../../extensions/styling/StyleRuleEvaluator'; +import { evaluateRulesOnNode, evaluateRulesOnLink } from '../../../extensions/styling/StyleRuleEvaluator'; import { extractNodePropertiesFromRecords, mergeNodePropsFieldsLists } from '../../../report/ReportRecordProcessing'; import { valueIsArray, valueIsNode, valueIsRelationship, valueIsPath } from '../../ChartUtils'; import { GraphChartVisualizationProps } from '../GraphChartVisualization'; @@ -162,10 +162,15 @@ export function buildGraphVisualizationObjectFromRecords( ); }); }); - // Assign proper curvatures to relationships. - // This is needed for pairs of nodes that have multiple relationships between them, or self-loops. + // Assign proper curvatures and colors to relationships. + // Assigning curvature is needed for pairs of nodes that have multiple relationships between them, or self-loops. const linksList = Object.values(links).map((linkArray) => { return linkArray.map((link, i) => { + let defaultColor = link.color; + + // Assign color from json based on style rule evaluation if specified + let evaluatedColor = evaluateRulesOnLink(link, 'relationship color', defaultColor, styleRules); + link.color = evaluatedColor; const mirroredNodePair = links[`${link.target},${link.source}`]; return assignCurvatureToLink(link, i, linkArray.length, mirroredNodePair ? mirroredNodePair.length : 0); }); diff --git a/src/chart/parameter/ParameterSelectionChart.tsx b/src/chart/parameter/ParameterSelectionChart.tsx index 7fc72ceba..b2b70e14b 100644 --- a/src/chart/parameter/ParameterSelectionChart.tsx +++ b/src/chart/parameter/ParameterSelectionChart.tsx @@ -27,6 +27,7 @@ export const NeoParameterSelectionChart = (props: ChartProps) => { const setParameterValue = (value) => setGlobalParameter(parameterName, value); const setParameterDisplayValue = (value) => setGlobalParameter(parameterDisplayName, value); const allParameters = props.parameters; + const multiSelector = props?.settings?.multiSelector; // in NeoDash 2.2.1 or earlier, there was no means to have a different display value in the selector. This condition handles that. const compatibilityMode = !query?.toLowerCase().includes('as display') || false; @@ -65,6 +66,7 @@ export const NeoParameterSelectionChart = (props: ChartProps) => { settings={props.settings} allParameters={allParameters} compatibilityMode={compatibilityMode} + multiSelector={multiSelector} /> ); } else if (type == 'Relationship Property') { @@ -81,6 +83,7 @@ export const NeoParameterSelectionChart = (props: ChartProps) => { settings={props.settings} allParameters={allParameters} compatibilityMode={compatibilityMode} + multiSelector={multiSelector} /> ); } else if (type == 'Date Picker') { @@ -113,6 +116,7 @@ export const NeoParameterSelectionChart = (props: ChartProps) => { settings={props.settings} allParameters={allParameters} compatibilityMode={compatibilityMode} + multiSelector={multiSelector} /> ); } diff --git a/src/chart/parameter/component/NodePropertyParameterSelect.tsx b/src/chart/parameter/component/NodePropertyParameterSelect.tsx index 86767ce00..04cc757da 100644 --- a/src/chart/parameter/component/NodePropertyParameterSelect.tsx +++ b/src/chart/parameter/component/NodePropertyParameterSelect.tsx @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { debounce, TextField } from '@mui/material'; import Autocomplete from '@mui/material/Autocomplete'; import { ParameterSelectProps } from './ParameterSelect'; +import { RenderSubValue } from '../../../report/ReportRecordProcessing'; const NodePropertyParameterSelectComponent = (props: ParameterSelectProps) => { const suggestionsUpdateTimeout = @@ -11,10 +12,22 @@ const NodePropertyParameterSelectComponent = (props: ParameterSelectProps) => { ? props.settings.defaultValue : ''; + const getInitialValue = (value, multi) => { + if (value && Array.isArray(value)) { + return multi ? value : null; + } else if (value) { + return multi ? [value] : value; + } + return multi ? [] : value; + }; + const { multiSelector } = props; const allParameters = props.allParameters ? props.allParameters : {}; const [extraRecords, setExtraRecords] = React.useState([]); - // const [inputText, setInputText] = React.useState(props.parameterValue); - const [inputDisplayText, setInputDisplayText] = React.useState(props.parameterDisplayValue); + const [inputDisplayText, setInputDisplayText] = React.useState( + props.parameterDisplayValue && multiSelector ? '' : props.parameterDisplayValue + ); + const [inputValue, setInputValue] = React.useState(getInitialValue(props.parameterDisplayValue, multiSelector)); + const debouncedQueryCallback = useCallback(debounce(props.queryCallback, suggestionsUpdateTimeout), []); const label = props.settings && props.settings.entityType ? props.settings.entityType : ''; const propertyType = props.settings && props.settings.propertyType ? props.settings.propertyType : ''; @@ -29,50 +42,83 @@ const NodePropertyParameterSelectComponent = (props: ParameterSelectProps) => { const realValueRowIndex = props.compatibilityMode ? 0 : 1 - displayValueRowIndex; + const handleCrossClick = (isMulti, value) => { + if (isMulti) { + if (value.length == 0 && clearParameterOnFieldClear) { + setInputValue([]); + props.setParameterValue(undefined); + props.setParameterDisplayValue(undefined); + return; + } + if (value.length == 0) { + setInputValue([]); + props.setParameterValue([]); + props.setParameterDisplayValue([]); + + } + } else { + if (value && clearParameterOnFieldClear) { + setInputValue(null); + props.setParameterValue(undefined); + props.setParameterDisplayValue(undefined); + return; + } + if (value == null) { + setInputValue(null); + props.setParameterValue(defaultValue); + props.setParameterDisplayValue(defaultValue); + + } + } + }; + const propagateSelection = (event, newDisplay) => { + const isMulti = Array.isArray(newDisplay); + handleCrossClick(isMulti, newDisplay); + let newValue; + // Multiple and new entry + if (isMulti && inputValue.length < newDisplay.length) { + newValue = Array.isArray(props.parameterValue) ? [...props.parameterValue] : [props.parameterValue]; + const newDisplayValue = [...newDisplay].slice(-1)[0]; + + let val = extraRecords.filter((r) => r._fields[displayValueRowIndex].toString() == newDisplayValue)[0]._fields[ + realValueRowIndex + ]; + + newValue.push(val?.low ?? val); + } else if (!isMulti) { + newValue = extraRecords.filter((r) => r._fields[displayValueRowIndex].toString() == newDisplay)[0]._fields[ + realValueRowIndex + ]; + + newValue = newValue?.low || newValue; + } else { + let ele = props.parameterDisplayValue.filter((x) => !newDisplay.includes(x))[0]; + newValue = [...props.parameterValue]; + newValue.splice(props.parameterDisplayValue.indexOf(ele), 1); + } + + setInputDisplayText(isMulti ? '' : newDisplay); + setInputValue(newDisplay); + + props.setParameterValue(newValue); + props.setParameterDisplayValue(newDisplay); + }; return ( <Autocomplete id='autocomplete' - options={extraRecords - .map((r) => - (r._fields && r._fields[displayValueRowIndex] !== null ? r._fields[displayValueRowIndex] : '(no data)') - ) - .sort()} - getOptionLabel={(option) => (option ? option.toString() : '')} - style={{ maxWidth: 'calc(100% - 30px)', marginLeft: '15px', marginTop: '6.5px' }} - inputValue={inputDisplayText !== null ? `${inputDisplayText}` : ''} + multiple={multiSelector} + options={extraRecords.map((r) => r?._fields?.[displayValueRowIndex] || '(no data)').sort()} + style={{ maxWidth: 'calc(100% - 30px)', marginLeft: '15px', marginTop: '5px' }} + inputValue={inputDisplayText} onInputChange={(event, value) => { - setInputDisplayText(value !== null ? `${value}` : ''); + setInputDisplayText(value); debouncedQueryCallback(props.query, { input: `${value}`, ...allParameters }, setExtraRecords); }} isOptionEqualToValue={(option, value) => { return (option && option.toString()) === (value && value.toString()); }} - value={props.parameterDisplayValue !== null ? `${props.parameterDisplayValue}` : ''} - onChange={(event, newDisplayValue) => { - if (newDisplayValue == null && clearParameterOnFieldClear) { - props.setParameterValue(undefined); - props.setParameterDisplayValue(undefined); - return; - } - if (newDisplayValue == null) { - props.setParameterValue(defaultValue); - props.setParameterDisplayValue(defaultValue); - return; - } - - let newValue = extraRecords.filter((r) => r._fields[displayValueRowIndex].toString() == newDisplayValue)[0] - ._fields[realValueRowIndex]; - setInputDisplayText(newDisplayValue); - if (newValue && newValue.low) { - newValue = newValue.low; - } - if (newDisplayValue && newDisplayValue.low) { - newDisplayValue = newDisplayValue.low; - } - - props.setParameterValue(newValue); - props.setParameterDisplayValue(newDisplayValue); - }} + value={inputValue} + onChange={propagateSelection} renderInput={(params) => ( <TextField {...params} @@ -82,6 +128,7 @@ const NodePropertyParameterSelectComponent = (props: ParameterSelectProps) => { variant='outlined' /> )} + getOptionLabel={(option) => RenderSubValue(option)} /> ); }; diff --git a/src/chart/parameter/component/ParameterSelect.ts b/src/chart/parameter/component/ParameterSelect.ts index d0346d8f3..fc70f9fe0 100644 --- a/src/chart/parameter/component/ParameterSelect.ts +++ b/src/chart/parameter/component/ParameterSelect.ts @@ -46,4 +46,8 @@ export interface ParameterSelectProps { * Create the parameter selector in compatibility mode for NeoDash 2.2.1 or earlier. */ compatibilityMode: boolean; + /** + * Add the possibility for multiple selections + */ + multiSelector?: boolean; } diff --git a/src/chart/parameter/component/QueryParameterSelect.tsx b/src/chart/parameter/component/QueryParameterSelect.tsx index 81869b563..e4fdeb807 100644 --- a/src/chart/parameter/component/QueryParameterSelect.tsx +++ b/src/chart/parameter/component/QueryParameterSelect.tsx @@ -16,6 +16,7 @@ const QueryParameterSelectComponent = (props: ParameterSelectProps) => { settings={props.settings} allParameters={props.allParameters} compatibilityMode={props.compatibilityMode} + multiSelector={props.multiSelector} /> ); }; diff --git a/src/chart/parameter/component/RelationshipPropertyParameterSelect.tsx b/src/chart/parameter/component/RelationshipPropertyParameterSelect.tsx index 6f3091229..4bc7366d8 100644 --- a/src/chart/parameter/component/RelationshipPropertyParameterSelect.tsx +++ b/src/chart/parameter/component/RelationshipPropertyParameterSelect.tsx @@ -20,6 +20,7 @@ const RelationshipPropertyParameterSelectComponent = (props: ParameterSelectProp settings={props.settings} allParameters={props.allParameters} compatibilityMode={props.compatibilityMode} + multiSelector={props.multiSelector} /> ); }; diff --git a/src/chart/table/TableChart.tsx b/src/chart/table/TableChart.tsx index 317528275..61e41d1ac 100644 --- a/src/chart/table/TableChart.tsx +++ b/src/chart/table/TableChart.tsx @@ -114,19 +114,19 @@ export const NeoTableChart = (props: ChartProps) => { const actionableFields = actionsRules.map((r) => r.field); const columns = transposed - ? ['Field'].concat(records.map((r, j) => `Value${j == 0 ? '' : ` ${(j + 1).toString()}`}`)).map((key, i) => { - const value = key; + ? [records[0].keys[0]].concat(records.map((record) => record._fields[0]?.toString() || '')).map((key, i) => { + const uniqueKey = `${String(key)}_${i}`; return ApplyColumnType( { key: `col-key-${i}`, - field: generateSafeColumnKey(key), + field: generateSafeColumnKey(uniqueKey), headerName: generateSafeColumnKey(key), headerClassName: 'table-small-header', disableColumnSelector: true, flex: columnWidths && i < columnWidths.length ? columnWidths[i] : 1, disableClickEventBubbling: true, }, - value, + key, actionableFields.includes(key) ); }) @@ -166,15 +166,32 @@ export const NeoTableChart = (props: ChartProps) => { ...columns.filter((x) => x.field.startsWith(HIDDEN_COLUMN_PREFIX)).map((x) => ({ [x.field]: false })) ); + const getTransposedRows = (records) => { + // Skip first key + const rowKeys = [...records[0].keys]; + rowKeys.shift(); + + // Add values in rows + const rowsWithValues = rowKeys.map((key, i) => + Object.assign( + { id: i, Field: key }, + ...records.map((record, j) => ({ + [`${record._fields[0]}_${j + 1}`]: RenderSubValue(record._fields[i + 1]), + })) + ) + ); + + // Add field in rows + const rowsWithFieldAndValues = rowsWithValues.map((row, i) => ({ + ...row, + [`${records[0].keys[0]}_${0}`]: rowKeys[i], + })); + + return rowsWithFieldAndValues; + }; + const rows = transposed - ? records[0].keys.map((key, i) => { - return Object.assign( - { id: i, Field: key }, - ...records.map((r, j) => ({ - [`Value${j == 0 ? '' : ` ${(j + 1).toString()}`}`]: RenderSubValue(r._fields[i]), - })) - ); - }) + ? getTransposedRows(records) : records.map((record, rownumber) => { return Object.assign( { id: rownumber }, @@ -247,7 +264,7 @@ export const NeoTableChart = (props: ChartProps) => { } }} pageSize={tablePageSize > 0 ? tablePageSize : 5} - rowsPerPageOptions={[5]} + rowsPerPageOptions={rows.length < 5 ? [rows.length, 5] : [5]} disableSelectionOnClick components={{ ColumnSortedDescendingIcon: () => <></>, diff --git a/src/config/ReportConfig.tsx b/src/config/ReportConfig.tsx index 02ed4e6c8..72b69b3a3 100644 --- a/src/config/ReportConfig.tsx +++ b/src/config/ReportConfig.tsx @@ -1290,6 +1290,12 @@ export const REPORT_TYPES = { type: SELECTION_TYPES.COLOR, default: '#fafafa', }, + multiSelector: { + label: 'Multiple Selection', + type: SELECTION_TYPES.LIST, + values: [true, false], + default: false, + }, overridePropertyDisplayName: { label: 'Property Display Name Override', type: SELECTION_TYPES.LIST, diff --git a/src/dashboard/drawer/DashboardDrawer.tsx b/src/dashboard/drawer/DashboardDrawer.tsx index e8d9e4efa..6fb5dfaf1 100644 --- a/src/dashboard/drawer/DashboardDrawer.tsx +++ b/src/dashboard/drawer/DashboardDrawer.tsx @@ -51,9 +51,13 @@ export const NeoDrawer = ({ function renderDrawerExtensionsButtons() { const res = ( <> - {Object.keys(EXTENSIONS_DRAWER_BUTTONS).map((name) => { + {Object.keys(EXTENSIONS_DRAWER_BUTTONS).map((name, idx) => { const Component = extensions[name] ? EXTENSIONS_DRAWER_BUTTONS[name] : ''; - return Component ? <Component database={connection.database} navItemClass={navItemClass} /> : <></>; + return Component ? ( + <Component key={`ext-${ idx}`} database={connection.database} navItemClass={navItemClass} /> + ) : ( + <></> + ); })} </> ); diff --git a/src/extensions/actions/ActionsRuleCreationModal.tsx b/src/extensions/actions/ActionsRuleCreationModal.tsx index d2d4e57d7..8082a60fa 100644 --- a/src/extensions/actions/ActionsRuleCreationModal.tsx +++ b/src/extensions/actions/ActionsRuleCreationModal.tsx @@ -204,27 +204,21 @@ export const NeoCustomReportActionsModal = ({ if (customization == 'set variable') { return ( <> - <td - style={{ - paddingLeft: '5px', - paddingRight: '0px', - paddingTop: '5px', - paddingBottom: '5px', - }} - > - <TextInput - style={{ width: '100px', color: 'black', marginRight: '-5px' }} - disabled={true} - value='$neodash_' - ></TextInput> - </td> - <td style={{ paddingLeft: '5px', paddingRight: '5px' }}> - <TextInput - placeholder='' - value={rule.customizationValue} - onChange={(e) => updateRuleField(index, 'customizationValue', e.target.value)} - ></TextInput> - </td> + <TextInput + className='n-inline-block n-align-middle n-w-1/4 n-pr-1' + fluid + style={{ minWidth: 80, color: 'black' }} + disabled={true} + value='$neodash_' + ></TextInput> + <TextInput + className='n-inline-block n-align-middle n-w-1/2' + fluid + style={{ minWidth: 150 }} + placeholder='' + value={rule.customizationValue} + onChange={(e) => updateRuleField(index, 'customizationValue', e.target.value)} + ></TextInput> </> ); } else if (customization == 'set page') { @@ -283,127 +277,110 @@ export const NeoCustomReportActionsModal = ({ return ( <> <tr> - <td style={{ paddingLeft: '2px', paddingRight: '2px' }}> - <span style={{ color: 'black', width: '50px' }}>{index + 1}.</span> - </td> - <td style={{ paddingLeft: '20px', paddingRight: '20px' }}> - <span style={{ fontWeight: 'bold', color: 'black', width: '50px' }}> ON</span> + <td width='2.5%' className='n-pr-1'> + <span className='n-pr-1'>{index + 1}.</span> + <span className='n-font-bold'>ON</span> </td> - <td> - <div style={{ border: '2px dashed grey' }}> - <td style={{ paddingLeft: '5px', paddingRight: '5px' }}> - <Dropdown - type='select' - fluid - style={{ marginLeft: '1%', display: 'inline-block', width: '200px' }} - selectProps={{ - onChange: (newValue) => updateRuleField(index, 'condition', newValue.value), - options: - RULE_CONDITIONS[type] && - RULE_CONDITIONS[type].map((option) => ({ - label: option.label, - value: option.value, - })), - value: { label: ruleTrigger ? ruleTrigger.label : '', value: rule.condition }, - }} - ></Dropdown> - </td> - <td className='n-align-top'> - <Autocomplete - disableClearable={true} - id='autocomplete-label-type' - size='small' - noOptionsText='*Specify an exact field name' - options={createFieldVariableSuggestionsFromRule(rule, true)} - value={rule.field ? rule.field : ''} - inputValue={rule.field ? rule.field : ''} - popupIcon={<></>} - style={{ width: 150, padding: 0 }} - onInputChange={(event, value) => { - updateRuleField(index, 'field', value); - }} - onChange={(event, newValue) => { - updateRuleField(index, 'field', newValue); - }} - renderInput={(params) => ( - <TextField - {...params} - placeholder='Field name...' - style={{ padding: 0 }} - InputLabelProps={{ shrink: true }} - /> - )} - /> - </td> + <td width='30%'> + <div style={{ border: '2px dashed grey' }} className='n-p-1'> + <Dropdown + type='select' + className='n-align-middle n-w-2/5 n-pr-1' + style={{ minWidth: 80, display: 'inline-block' }} + selectProps={{ + onChange: (newValue) => updateRuleField(index, 'condition', newValue.value), + options: + RULE_CONDITIONS[type] && + RULE_CONDITIONS[type].map((option) => ({ + label: option.label, + value: option.value, + })), + value: { label: ruleTrigger ? ruleTrigger.label : '', value: rule.condition }, + }} + ></Dropdown> + <Autocomplete + className='n-align-middle n-inline-block n-w-3/5' + disableClearable={true} + id='autocomplete-label-type' + size='small' + noOptionsText='*Specify an exact field name' + options={createFieldVariableSuggestionsFromRule(rule, true)} + value={rule.field ? rule.field : ''} + inputValue={rule.field ? rule.field : ''} + popupIcon={<></>} + style={{ + minWidth: 125, + }} + onInputChange={(event, value) => { + updateRuleField(index, 'field', value); + }} + onChange={(event, newValue) => { + updateRuleField(index, 'field', newValue); + }} + renderInput={(params) => ( + <TextField + {...params} + placeholder='Field name...' + style={{ padding: 0 }} + InputLabelProps={{ shrink: true }} + /> + )} + /> </div> </td> - <td style={{ paddingLeft: '20px', paddingRight: '20px' }}> - <span style={{ fontWeight: 'bold', color: 'black', width: '50px' }}>SET</span> + <td width='2.5%' className='n-text-center'> + <span style={{ fontWeight: 'bold', color: 'black' }}>SET</span> </td> - <td> - <div style={{ border: '2px dashed grey', marginBottom: '5px' }}> - <td - style={{ - paddingLeft: '5px', - paddingRight: '5px', - paddingTop: '5px', - paddingBottom: '5px', + <td width='45%'> + <div style={{ border: '2px dashed grey' }} className='n-p-1'> + <Dropdown + type='select' + className='n-align-middle n-w-1/4' + style={{ minWidth: 80, display: 'inline-block' }} + fluid + selectProps={{ + onChange: (newValue) => updateRuleField(index, 'customization', newValue.value), + options: + RULE_BASED_REPORT_ACTIONS_CUSTOMIZATIONS[type] && + RULE_BASED_REPORT_ACTIONS_CUSTOMIZATIONS[type].map((option) => ({ + label: option.label, + value: option.value, + })), + value: { label: ruleType ? ruleType.label : '', value: rule.customization }, }} - > - <Dropdown - type='select' - style={{ width: '150px', display: 'inline-block' }} - fluid - selectProps={{ - onChange: (newValue) => updateRuleField(index, 'customization', newValue.value), - options: - RULE_BASED_REPORT_ACTIONS_CUSTOMIZATIONS[type] && - RULE_BASED_REPORT_ACTIONS_CUSTOMIZATIONS[type].map((option) => ({ - label: option.label, - value: option.value, - })), - value: { label: ruleType ? ruleType.label : '', value: rule.customization }, - }} - ></Dropdown> - </td> + ></Dropdown> {getActionHelper(rule, index, rules[index].customization)} </div> </td> - <td style={{ paddingLeft: '20px', paddingRight: '20px' }}> - <span style={{ fontWeight: 'bold', color: 'black', width: '50px' }}>TO</span> + <td width='2.5%' className='n-text-center'> + <span style={{ fontWeight: 'bold', color: 'black' }}>TO</span> </td> - <td> - <div style={{ border: '2px dashed grey' }}> - <td className='n-align-top'> - <Autocomplete - disableClearable={true} - id='autocomplete-label-type' - noOptionsText='*Specify an exact field name' - options={createFieldVariableSuggestionsFromRule(rule, false)} - value={rule.value || ''} - inputValue={rule.value || ''} - popupIcon={<></>} - style={{ display: 'inline-block', width: 185, marginLeft: '5px', marginTop: '5px' }} - onInputChange={(event, value) => { - updateRuleField(index, 'value', value); - }} - onChange={(event, newValue) => { - updateRuleField(index, 'value', newValue); - }} - renderInput={(params) => ( - <TextField - {...params} - placeholder='Field name...' - InputLabelProps={{ shrink: true }} - /> - )} - /> - </td> + <td width='20%'> + <div style={{ border: '2px dashed grey' }} className='n-p-1'> + <Autocomplete + disableClearable={true} + id='autocomplete-label-type' + noOptionsText='*Specify an exact field name' + options={createFieldVariableSuggestionsFromRule(rule, false)} + value={rule.value || ''} + inputValue={rule.value || ''} + popupIcon={<></>} + style={{ minWidth: 125 }} + onInputChange={(event, value) => { + updateRuleField(index, 'value', value); + }} + onChange={(event, newValue) => { + updateRuleField(index, 'value', newValue); + }} + renderInput={(params) => ( + <TextField {...params} placeholder='Field name...' InputLabelProps={{ shrink: true }} /> + )} + /> </div> </td> - <td style={{ width: '2.5%' }}> + <td width='2.5%'> <IconButton aria-label='remove rule' size='medium' @@ -415,15 +392,14 @@ export const NeoCustomReportActionsModal = ({ <XMarkIconOutline /> </IconButton> </td> - <hr /> </tr> </> ); })} <tr> - <td colSpan={5}> - <div style={{ textAlign: 'center', marginBottom: '5px' }}> + <td colSpan={7}> + <div className='n-text-center n-mt-1'> <IconButton aria-label='add' size='medium' @@ -440,7 +416,8 @@ export const NeoCustomReportActionsModal = ({ </tr> </table> </div> - + </Dialog.Content> + <Dialog.Actions> <Button onClick={() => { handleClose(); @@ -451,7 +428,7 @@ export const NeoCustomReportActionsModal = ({ Save <SparklesIconOutline className='btn-icon-lg-r' /> </Button> - </Dialog.Content> + </Dialog.Actions> </Dialog> ) : ( <></> diff --git a/src/extensions/query-translator/QueryTranslatorConfig.ts b/src/extensions/query-translator/QueryTranslatorConfig.ts index b3cd62456..8b7138b74 100644 --- a/src/extensions/query-translator/QueryTranslatorConfig.ts +++ b/src/extensions/query-translator/QueryTranslatorConfig.ts @@ -4,6 +4,7 @@ import { OpenAiClient } from './clients/OpenAi/OpenAiClient'; // TODO: implement VertexAiClient import { VertexAiClient } from './clients/VertexAiClient'; +import { AzureOpenAiClient } from './clients/AzureOpenAi/AzureOpenAiClient'; interface ClientSettingEntry { label: string; @@ -58,29 +59,33 @@ export const QUERY_TRANSLATOR_CONFIG: QueryTranslatorConfig = { }, }, }, - // vertexAi: { - // clientName: "vertexAi", - // clientClass: VertexAiClient, - // settings: { - // apiKey: { - // label: 'Api Key to authenticate the client', - // type: SELECTION_TYPES.TEXT, - // default: '', - // }, - // modelType: { - // label: 'Select from the possible model types', - // type: SELECTION_TYPES.LIST, - // needsStateValues: true, - // default: "Insert your Api Key first", - // }, - // region: { - // label: 'GCP Region', - // type: SELECTION_TYPES.LIST, - // needsStateValues: true, - // default: [], - // } - // } - // }, + AzureOpenAI: { + clientName: 'AzureOpenAI', + clientClass: AzureOpenAiClient, + settings: { + endpoint: { + label: 'Azure OpenAI EndPoint', + type: SELECTION_TYPES.TEXT, + default: '', + hasAuthButton: false, + authentication: true, + }, + apiKey: { + label: 'Subscription Key', + type: SELECTION_TYPES.TEXT, + default: '', + hasAuthButton: true, + authentication: true, + }, + modelType: { + label: 'Model', + type: SELECTION_TYPES.LIST, + methodFromClient: 'getListModels', + default: '', + authentication: false, + }, + }, + }, }, }; diff --git a/src/extensions/query-translator/clients/AzureOpenAi/AzureOpenAiClient.ts b/src/extensions/query-translator/clients/AzureOpenAi/AzureOpenAiClient.ts new file mode 100644 index 000000000..c00bbfb9a --- /dev/null +++ b/src/extensions/query-translator/clients/AzureOpenAi/AzureOpenAiClient.ts @@ -0,0 +1,70 @@ +import { AzureKeyCredential, OpenAIClient } from '@azure/openai'; + +import { OpenAiClient } from '../OpenAi/OpenAiClient'; + +const consoleLogAsync = async (message: string, other?: any) => { + await new Promise((resolve) => setTimeout(resolve, 0)).then(() => console.info(message, other)); +}; + +export class AzureOpenAiClient extends OpenAiClient { + modelType: string | undefined; + + createSystemMessage: any; + + modelClient!: OpenAIClient; + + driver: any; + + constructor(settings) { + super(settings); + } + + /** + * Function used to create the OpenAiApi object. + * */ + setModelClient() { + if (typeof this.endpoint === 'string') { + this.modelClient = new OpenAIClient(this.endpoint, new AzureKeyCredential(this.apiKey)); + } + } + + async getListModels() { + let res; + try { + if (!this.modelClient) { + throw new Error('no client defined'); + } + + const response = await fetch( + `${this.endpoint + (this.endpoint?.endsWith('/') ? '' : '/')}openai/deployments?api-version=2023-03-15-preview`, + { + method: 'GET', + mode: 'cors', + headers: { + 'Api-Key': this.apiKey, + }, + } + ); + const req = await response.json(); + res = req.data.filter((x) => x.model.startsWith('gpt-')).map((x) => x.id); + } catch (e) { + consoleLogAsync('Error while loading the model list: ', e); + res = []; + } + this.setListAvailableModels(res); + return res; + } + + async chatCompletion(history) { + let completion; + if (typeof this.modelType === 'string') { + completion = await this.modelClient.getChatCompletions(this.modelType, history); + } + // If the status is correct + if (completion?.choices?.[0]?.message || false) { + let { message } = completion.choices[0]; + return message; + } + throw Error(`Request returned with status: ${completion.id}`); + } +} diff --git a/src/extensions/query-translator/clients/AzureOpenAi/AzureOpenAiLogo.png b/src/extensions/query-translator/clients/AzureOpenAi/AzureOpenAiLogo.png new file mode 100644 index 000000000..0f237a226 Binary files /dev/null and b/src/extensions/query-translator/clients/AzureOpenAi/AzureOpenAiLogo.png differ diff --git a/src/extensions/query-translator/clients/ModelClient.ts b/src/extensions/query-translator/clients/ModelClient.ts index 77cfed716..e7740abca 100644 --- a/src/extensions/query-translator/clients/ModelClient.ts +++ b/src/extensions/query-translator/clients/ModelClient.ts @@ -21,10 +21,13 @@ export abstract class ModelClient { driver: any; + endpoint: string | undefined; + constructor(settings) { this.apiKey = settings.apiKey; this.modelType = settings.modelType; this.listAvailableModels = []; + this.endpoint = settings.endpoint; this.setModelClient(); } diff --git a/src/extensions/query-translator/clients/OpenAi/OpenAiClient.ts b/src/extensions/query-translator/clients/OpenAi/OpenAiClient.ts index fb3125141..a60773c34 100644 --- a/src/extensions/query-translator/clients/OpenAi/OpenAiClient.ts +++ b/src/extensions/query-translator/clients/OpenAi/OpenAiClient.ts @@ -83,6 +83,7 @@ export class OpenAiClient extends ModelClient { consoleLogAsync('Error while loading the model list: ', e); res = []; } + this.setListAvailableModels(res); return res; } @@ -117,7 +118,7 @@ export class OpenAiClient extends ModelClient { let queryExample = reportExampleQueries[reportType]; let finalMessage = `${content}. Please use the following query structure as an example for ${reportTypesToDesc[reportType]}: ${queryExample} - Remember to respect the schema and remove any unnecessary comments or explanations from your result.`; + Remember to respect the schema and remove any unnecessary comments or explanations from your result. Remember that every $ prefixed word is a parameter.`; return { role: ChatCompletionRequestMessageRoleEnum.User, content: plain ? content : finalMessage }; } diff --git a/src/extensions/query-translator/component/ClientSettings.tsx b/src/extensions/query-translator/component/ClientSettings.tsx index 7acdf33ec..c88576d72 100644 --- a/src/extensions/query-translator/component/ClientSettings.tsx +++ b/src/extensions/query-translator/component/ClientSettings.tsx @@ -118,6 +118,7 @@ export const ClientSettings = ({ // Prevent authentication if all required fields are not full (EX: look at checkIfDisabled) const authButton = ( <IconButton + key={'auth-setting'} aria-label='connect' onClick={(e) => { e.preventDefault(); @@ -152,7 +153,7 @@ export const ClientSettings = ({ .map((setting) => { let disabled = checkIfDisabled(setting); return ( - <ListItem style={{ padding: 0 }}> + <ListItem key={`list-${ setting}`} style={{ padding: 0 }}> <NeoSetting key={setting} style={{ marginLeft: 0, marginRight: 0 }} diff --git a/src/extensions/styling/StyleRuleCreationModal.tsx b/src/extensions/styling/StyleRuleCreationModal.tsx index 437146182..33af3975c 100644 --- a/src/extensions/styling/StyleRuleCreationModal.tsx +++ b/src/extensions/styling/StyleRuleCreationModal.tsx @@ -53,6 +53,11 @@ export const RULE_BASED_REPORT_CUSTOMIZATIONS = { value: 'node label color', label: 'Node Label Color', }, + { + value: 'relationship color', + label: 'Relationship Color', + on: 'relationship', + }, ], map: [ { @@ -124,6 +129,7 @@ export const NeoCustomReportStyleModal = ({ settingValue, type, fields, + schema, setCustomReportStyleModalOpen, onReportSettingUpdate, }) => { @@ -157,20 +163,20 @@ export const NeoCustomReportStyleModal = ({ * This will be dynamic based on the type of report we are customizing. */ const createFieldVariableSuggestions = () => { - if (!fields) { + if (!schema) { return []; } if (type == 'graph' || type == 'map') { - return fields + return schema .map((node, index) => { if (!Array.isArray(node)) { return undefined; } - return fields[index].map((property, propertyIndex) => { + return schema[index].map((property, propertyIndex) => { if (propertyIndex == 0) { return undefined; } - return `${fields[index][0]}.${property}`; + return `${schema[index][0]}.${property}`; }); }) .flat() @@ -229,120 +235,128 @@ export const NeoCustomReportStyleModal = ({ (el) => el.value === rule.customization ); return ( - <tr> - <td style={{ paddingLeft: '2px', paddingRight: '2px', width: '2.5%' }}> - <span style={{ color: 'black' }}>{index + 1}.</span> - </td> - <td style={{ width: '2.5%' }}> - <span style={{ fontWeight: 'bold', color: 'black' }}>IF</span> - </td> - <td style={{ padding: '5px', width: '40%' }}> - <div style={{ border: '2px dashed grey' }} className='n-flex n-flex-row n-flex-wrap n-p-1'> - <Autocomplete - disableClearable={true} - id={`autocomplete-label-type${index}`} - noOptionsText='*Specify an exact field name' - options={createFieldVariableSuggestions().filter((e) => - e.toLowerCase().includes(rule.field.toLowerCase()) - )} - value={rule.field ? rule.field : ''} - inputValue={rule.field ? rule.field : ''} - popupIcon={<></>} - style={{ display: 'inline-block', width: '38%' }} - onInputChange={(event, value) => { - updateRuleField(index, 'field', value); - }} - onChange={(event, newValue) => { - updateRuleField(index, 'field', newValue); - }} - renderInput={(params) => ( - <TextField - {...params} - placeholder='Field name...' - InputLabelProps={{ shrink: true }} - style={{ padding: '6px 0 7px' }} - size={'small'} - variant={'standard'} - /> - )} - /> - <Dropdown - type='select' - selectProps={{ - onChange: (newValue) => updateRuleField(index, 'condition', newValue.value), - options: RULE_CONDITIONS.map((option) => ({ - label: option.label, - value: option.value, - })), - value: { label: rule.condition, value: rule.condition }, - }} - style={{ marginLeft: '1%', width: '20%', display: 'inline-block' }} - fluid - /> - <div style={{ marginLeft: '1%', width: '40%', display: 'inline-block' }}> + <> + <tr> + <td width='2.5%' className='n-pr-1'> + <span className='n-pr-1'>{index + 1}.</span> + <span className='n-font-bold'>IF</span> + </td> + <td width='45%'> + <div style={{ border: '2px dashed grey' }} className='n-p-1'> + <Autocomplete + className='n-align-middle n-inline-block n-w-5/12 n-pr-1' + disableClearable={true} + id={`autocomplete-label-type${index}`} + noOptionsText='*Specify an exact field name' + options={createFieldVariableSuggestions().filter((e) => + e.toLowerCase().includes(rule.field.toLowerCase()) + )} + value={rule.field ? rule.field : ''} + inputValue={rule.field ? rule.field : ''} + popupIcon={<></>} + style={{ minWidth: 125 }} + onInputChange={(event, value) => { + updateRuleField(index, 'field', value); + }} + onChange={(event, newValue) => { + updateRuleField(index, 'field', newValue); + }} + renderInput={(params) => ( + <TextField + {...params} + placeholder='Field name...' + InputLabelProps={{ shrink: true }} + style={{ padding: '6px 0 7px' }} + size={'small'} + variant={'standard'} + /> + )} + /> + <Dropdown + type='select' + className='n-align-middle n-w-2/12 n-pr-1' + selectProps={{ + onChange: (newValue) => updateRuleField(index, 'condition', newValue.value), + options: RULE_CONDITIONS.map((option) => ({ + label: option.label, + value: option.value, + })), + value: { label: rule.condition, value: rule.condition }, + }} + style={{ minWidth: 70, display: 'inline-block' }} + fluid + /> <TextInput + className='n-align-middle n-inline-block n-w-5/12' + style={{ minWidth: 100 }} placeholder='Value...' value={rule.value} onChange={(e) => updateRuleField(index, 'value', e.target.value)} fluid ></TextInput> </div> - </div> - </td> - <td style={{ paddingLeft: '20px', paddingRight: '20px', width: '2.5%' }}> - <span style={{ fontWeight: 'bold', color: 'black' }}>THEN</span> - </td> - <td style={{ padding: '5px', width: '40%' }}> - <div style={{ border: '2px dashed grey' }} className='n-flex n-flex-row n-flex-wrap n-p-1'> - <Dropdown - type='select' - selectProps={{ - onChange: (newValue) => updateRuleField(index, 'customization', newValue.value), - options: RULE_BASED_REPORT_CUSTOMIZATIONS[type].map((option) => ({ - label: option.label, - value: option.value, - })), - value: { - label: ruleType ? ruleType.label : '', - value: rule.customization, - }, - }} - style={{ width: '40%', display: 'inline-block' }} - fluid - /> - <div style={{ marginLeft: '1%', width: '13%', display: 'inline-block' }}> - <TextInput disabled={true} value={'='} fluid></TextInput> - </div> - <div style={{ marginLeft: '1%', width: '45%', display: 'inline-block' }}> - <NeoColorPicker - label='' - defaultValue='#ffffff' - key={undefined} - value={rule.customizationValue} - onChange={(value) => updateRuleField(index, 'customizationValue', value)} - ></NeoColorPicker> + </td> + <td width='5%' className='n-text-center'> + <span style={{ fontWeight: 'bold', color: 'black' }}>THEN</span> + </td> + <td width='45%'> + <div style={{ border: '2px dashed grey' }} className='n-p-1'> + <Dropdown + type='select' + className='n-align-middle n-w-4/12 n-pr-1' + style={{ minWidth: 125, display: 'inline-block' }} + selectProps={{ + onChange: (newValue) => updateRuleField(index, 'customization', newValue.value), + options: RULE_BASED_REPORT_CUSTOMIZATIONS[type].map((option) => ({ + label: option.label, + value: option.value, + })), + value: { + label: ruleType ? ruleType.label : '', + value: rule.customization, + }, + }} + fluid + /> + <TextInput + className='n-align-middle n-inline-block n-w-2/12 n-pr-1' + style={{ minWidth: 50 }} + disabled={true} + value={'='} + fluid + ></TextInput> + <div className='n-align-middle n-w-6/12 n-inline-block'> + <NeoColorPicker + style={{ minWidth: 125 }} + label='' + defaultValue='#ffffff' + key={undefined} + value={rule.customizationValue} + onChange={(value) => updateRuleField(index, 'customizationValue', value)} + ></NeoColorPicker> + </div> </div> - </div> - </td> - <td style={{ width: '2.5%' }}> - <IconButton - aria-label='remove rule' - size='medium' - floating - onClick={() => { - setRules([...rules.slice(0, index), ...rules.slice(index + 1)]); - }} - > - <XMarkIconOutline /> - </IconButton> - </td> - </tr> + </td> + <td width='2.5%'> + <IconButton + aria-label='remove rule' + size='medium' + floating + onClick={() => { + setRules([...rules.slice(0, index), ...rules.slice(index + 1)]); + }} + > + <XMarkIconOutline /> + </IconButton> + </td> + </tr> + </> ); })} <tr> <td colSpan={5}> - <div style={{ textAlign: 'center', marginBottom: '5px' }}> + <div className='n-text-center n-mt-1'> <IconButton aria-label='add' size='medium' @@ -370,7 +384,7 @@ export const NeoCustomReportStyleModal = ({ floating > Save - <PlayIconSolid className='btn-icon-lg-r' /> + <AdjustmentsHorizontalIconOutline className='btn-icon-lg-r' /> </Button> </Dialog.Actions> </Dialog> diff --git a/src/extensions/styling/StyleRuleEvaluator.ts b/src/extensions/styling/StyleRuleEvaluator.ts index c8dd7da9c..5fec47867 100644 --- a/src/extensions/styling/StyleRuleEvaluator.ts +++ b/src/extensions/styling/StyleRuleEvaluator.ts @@ -1,6 +1,5 @@ import { makeStyles } from '@mui/styles'; -import { extensionEnabled } from '../ExtensionUtils'; -import React, { useEffect } from 'react'; +import { EntityType } from '../../chart/Utils'; /** * Evaluates the specified rule set on a row returned by the Neo4j driver. @@ -88,17 +87,30 @@ export const evaluateRulesOnDict = (dict, rules, customizations) => { * @returns a user-defined value if a rule is met, or the default value if none are. */ export const evaluateRulesOnNode = (node, customization, defaultValue, rules) => { - if (!node || !customization || !rules) { + return evaluateRules(node, customization, defaultValue, rules, EntityType.Node); +}; + +export const evaluateRulesOnLink = (link, customization, defaultValue, rules) => { + return evaluateRules(link, customization, defaultValue, rules, EntityType.Relationship); +}; + +export const evaluateRules = (entity, customization, defaultValue, rules, entityType) => { + if (!entity || !customization || !rules) { return defaultValue; } + for (const [index, rule] of rules.entries()) { // Only look at rules relevant to the target customization. if (rule.customization == customization) { // if the row contains the specified field... - const label = rule.field.split('.')[0]; + const typeOrLabel = rule.field.split('.')[0]; const property = rule.field.split('.')[1]; - if (node.labels.includes(label)) { - const realValue = node.properties[property] ? node.properties[property] : ''; + + if ( + (entityType === EntityType.Node && entity.labels.includes(typeOrLabel)) || + (entityType === EntityType.Relationship && entity.type == typeOrLabel) + ) { + const realValue = entity?.properties?.[property] || ''; const ruleValue = rule.value; if (evaluateCondition(realValue, rule.condition, ruleValue)) { return rule.customizationValue; diff --git a/src/modal/AboutModal.tsx b/src/modal/AboutModal.tsx index 164d23aa5..9cacf6fb8 100644 --- a/src/modal/AboutModal.tsx +++ b/src/modal/AboutModal.tsx @@ -4,7 +4,7 @@ import { BookOpenIconOutline, BeakerIconOutline } from '@neo4j-ndl/react/icons'; import { Section, SectionTitle, SectionContent } from './ModalUtils'; export const NeoAboutModal = ({ open, handleClose, getDebugState }) => { - const version = '2.3.0'; + const version = '2.3.1'; const downloadDebugFile = () => { const element = document.createElement('a'); diff --git a/src/report/Report.tsx b/src/report/Report.tsx index 4740bc84b..7c0f457c7 100644 --- a/src/report/Report.tsx +++ b/src/report/Report.tsx @@ -19,6 +19,7 @@ import { EXTENSIONS } from '../extensions/ExtensionConfig'; import { getPageNumber } from '../settings/SettingsSelectors'; import { getPrepopulateReportExtension } from '../extensions/state/ExtensionSelectors'; import { deleteSessionStoragePrepopulationReportFunction } from '../extensions/state/ExtensionActions'; +import { updateFieldsThunk } from '../card/CardThunks'; const DEFAULT_LOADING_ICON = <LoadingSpinner size='large' className='centered' style={{ marginTop: '-30px' }} />; @@ -36,6 +37,7 @@ export const NeoReport = ({ setFields = (f) => { fields = f; }, // The callback to update the set of query fields after query execution. + setSchema, setGlobalParameter = () => {}, // callback to update global (dashboard) parameters. getGlobalParameter = (_: string) => { return ''; @@ -118,7 +120,10 @@ export const NeoReport = ({ useNodePropsAsFields, useReturnValuesAsFields, HARD_ROW_LIMITING, - queryTimeLimit + queryTimeLimit, + (schema) => { + setSchema(id, schema); + } ); } else { runCypherQuery( @@ -134,7 +139,10 @@ export const NeoReport = ({ useNodePropsAsFields, useReturnValuesAsFields, HARD_ROW_LIMITING, - queryTimeLimit + queryTimeLimit, + (schema) => { + setSchema(id, schema); + } ); } }; @@ -206,7 +214,10 @@ export const NeoReport = ({ false, false, HARD_ROW_LIMITING, - queryTimeLimit + queryTimeLimit, + (schema) => { + setSchema(id, schema); + } ); }, [database] @@ -330,6 +341,9 @@ const mapDispatchToProps = (dispatch) => ({ getCustomDispatcher: () => { return dispatch; }, + setSchema: (id: any, schema: any) => { + dispatch(updateFieldsThunk(id, schema, true)); + }, }); export default connect(mapStateToProps, mapDispatchToProps)(NeoReport); diff --git a/src/report/ReportQueryRunner.ts b/src/report/ReportQueryRunner.ts index 483b1a6ad..34dbf6834 100644 --- a/src/report/ReportQueryRunner.ts +++ b/src/report/ReportQueryRunner.ts @@ -1,4 +1,4 @@ -import { extractNodePropertiesFromRecords } from './ReportRecordProcessing'; +import { extractNodePropertiesFromRecords, extractNodeAndRelPropertiesFromRecords } from './ReportRecordProcessing'; import isEqual from 'lodash.isequal'; export enum QueryStatus { @@ -49,7 +49,11 @@ export async function runCypherQuery( useNodePropsAsFields = false, useReturnValuesAsFields = false, useHardRowLimit = false, - queryTimeLimit = 20 + queryTimeLimit = 20, + setSchema = (schema) => { + // eslint-disable-next-line no-console + console.log(`Query runner attempted to set schema: ${JSON.stringify(schema)}`); + } ) { // If no query specified, we don't do anything. if (query.trim() == '') { @@ -98,6 +102,8 @@ export async function runCypherQuery( setFields(nodePropsAsFields); } + setSchema(extractNodeAndRelPropertiesFromRecords(records)); + if (records == null) { setStatus(QueryStatus.NO_DRAWABLE_DATA); // console.log("TODO remove this - QUERY RETURNED NO DRAWABLE DATA!") diff --git a/src/report/ReportRecordProcessing.tsx b/src/report/ReportRecordProcessing.tsx index 6236209ff..efe53664b 100644 --- a/src/report/ReportRecordProcessing.tsx +++ b/src/report/ReportRecordProcessing.tsx @@ -29,6 +29,24 @@ export function extractNodePropertiesFromRecords(records: any) { return fields.length > 0 ? fields : []; } +/** + * Collects all node labels and node properties in a set of Neo4j records. + * @param records : a list of Neo4j records. + * @returns a list of lists, where each inner list is [NodeLabel] + [prop1, prop2, prop3]... + */ +export function extractNodeAndRelPropertiesFromRecords(records: any) { + const fieldsDict = {}; + records.forEach((record) => { + record._fields.forEach((field) => { + saveNodeAndRelPropertiesToDictionary(field, fieldsDict); + }); + }); + const fields = Object.keys(fieldsDict).map((label) => { + return [label].concat(Object.values(fieldsDict[label])); + }); + return fields.length > 0 ? fields : []; +} + /** * Merges an existing set of fields (node labels and their properties) with a new one. * This is used when we explore the graph and want to update the report footer. @@ -75,6 +93,32 @@ export function saveNodePropertiesToDictionary(field, fieldsDict) { } } +export function saveNodeAndRelPropertiesToDictionary(field, fieldsDict) { + // TODO - instead of doing this discovery ad-hoc, we could also use CALL db.schema.nodeTypeProperties(). + if (field == undefined) { + return; + } + if (valueIsArray(field)) { + field.forEach((v) => saveNodeAndRelPropertiesToDictionary(v, fieldsDict)); + } else if (valueIsNode(field)) { + field.labels.forEach((l) => { + fieldsDict[l] = fieldsDict[l] + ? [...new Set(fieldsDict[l].concat(Object.keys(field.properties)))] + : Object.keys(field.properties); + }); + } else if (valueIsRelationship(field)) { + let l = field.type; + fieldsDict[l] = fieldsDict[l] + ? [...new Set(fieldsDict[l].concat(Object.keys(field.properties)))] + : Object.keys(field.properties); + } else if (valueIsPath(field)) { + field.segments.forEach((segment) => { + saveNodeAndRelPropertiesToDictionary(segment.start, fieldsDict); + saveNodeAndRelPropertiesToDictionary(segment.end, fieldsDict); + }); + } +} + /* HELPER FUNCTIONS FOR RENDERING A FIELD BASED ON TYPE */ const HtmlTooltip = withStyles((theme) => ({ tooltip: { @@ -214,7 +258,7 @@ function RenderArray(value) { } function RenderString(value) { - const str = value ? value.toString() : ''; + const str = value?.toString() || ''; if (str.startsWith('http') || str.startsWith('https')) { return ( <TextLink externalLink target='_blank' href={str}> @@ -331,6 +375,10 @@ export const rendererForType: any = { type: 'string', renderValue: (c) => RenderString(c.value), }, + boolean: { + type: 'string', + renderValue: (c) => RenderString(c.value), + }, }; export function getRendererForValue(value) { diff --git a/tsconfig.json b/tsconfig.json index 76cd5cc88..334a9aa3f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,6 +15,7 @@ "noEmit": true, "jsx": "react", "noImplicitAny": false, + "useDefineForClassFields": false, "noFallthroughCasesInSwitch": true }, "include": ["src"] diff --git a/yarn.lock b/yarn.lock index 6d62d0eb3..2fdec12c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -28,6 +28,81 @@ "@jridgewell/gen-mapping" "^0.3.0" "@jridgewell/trace-mapping" "^0.3.9" +"@azure-rest/core-client@^1.1.3": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@azure-rest/core-client/-/core-client-1.1.4.tgz#628381c3653f6dbae584ca6f2ae5f74a5c015526" + integrity sha512-RUIQOA8T0WcbNlddr8hjl2MuC5GVRqmMwPXqBVsgvdKesLy+eg3y/6nf3qe2fvcJMI1gF6VtgU5U4hRaR4w4ag== + dependencies: + "@azure/abort-controller" "^1.1.0" + "@azure/core-auth" "^1.3.0" + "@azure/core-rest-pipeline" "^1.5.0" + "@azure/core-tracing" "^1.0.1" + "@azure/core-util" "^1.0.0" + tslib "^2.2.0" + +"@azure/abort-controller@^1.0.0", "@azure/abort-controller@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@azure/abort-controller/-/abort-controller-1.1.0.tgz#788ee78457a55af8a1ad342acb182383d2119249" + integrity sha512-TrRLIoSQVzfAJX9H1JeFjzAoDGcoK1IYX1UImfceTZpsyYfWr09Ss1aHW1y5TrrR3iq6RZLBwJ3E24uwPhwahw== + dependencies: + tslib "^2.2.0" + +"@azure/core-auth@^1.3.0", "@azure/core-auth@^1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@azure/core-auth/-/core-auth-1.4.0.tgz#6fa9661c1705857820dbc216df5ba5665ac36a9e" + integrity sha512-HFrcTgmuSuukRf/EdPmqBrc5l6Q5Uu+2TbuhaKbgaCpP2TfAeiNaQPAadxO+CYBRHGUzIDteMAjFspFLDLnKVQ== + dependencies: + "@azure/abort-controller" "^1.0.0" + tslib "^2.2.0" + +"@azure/core-rest-pipeline@^1.10.2", "@azure/core-rest-pipeline@^1.5.0": + version "1.11.0" + resolved "https://registry.yarnpkg.com/@azure/core-rest-pipeline/-/core-rest-pipeline-1.11.0.tgz#fc0e8f56caac08a9d4ac91c07a6c5a360ea31c82" + integrity sha512-nB4KXl6qAyJmBVLWA7SakT4tzpYZTCk4pvRBeI+Ye0WYSOrlTqlMhc4MSS/8atD3ufeYWdkN380LLoXlUUzThw== + dependencies: + "@azure/abort-controller" "^1.0.0" + "@azure/core-auth" "^1.4.0" + "@azure/core-tracing" "^1.0.1" + "@azure/core-util" "^1.3.0" + "@azure/logger" "^1.0.0" + form-data "^4.0.0" + http-proxy-agent "^5.0.0" + https-proxy-agent "^5.0.0" + tslib "^2.2.0" + +"@azure/core-tracing@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@azure/core-tracing/-/core-tracing-1.0.1.tgz#352a38cbea438c4a83c86b314f48017d70ba9503" + integrity sha512-I5CGMoLtX+pI17ZdiFJZgxMJApsK6jjfm85hpgp3oazCdq5Wxgh4wMr7ge/TTWW1B5WBuvIOI1fMU/FrOAMKrw== + dependencies: + tslib "^2.2.0" + +"@azure/core-util@^1.0.0", "@azure/core-util@^1.3.0": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@azure/core-util/-/core-util-1.3.2.tgz#3f8cfda1e87fac0ce84f8c1a42fcd6d2a986632d" + integrity sha512-2bECOUh88RvL1pMZTcc6OzfobBeWDBf5oBbhjIhT1MV9otMVWCzpOJkkiKtrnO88y5GGBelgY8At73KGAdbkeQ== + dependencies: + "@azure/abort-controller" "^1.0.0" + tslib "^2.2.0" + +"@azure/logger@^1.0.0", "@azure/logger@^1.0.3": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@azure/logger/-/logger-1.0.4.tgz#28bc6d0e5b3c38ef29296b32d35da4e483593fa1" + integrity sha512-ustrPY8MryhloQj7OWGe+HrYx+aoiOxzbXTtgblbV3xwCqpzUK36phH3XNHQKj3EPonyFUuDTfR3qFhTEAuZEg== + dependencies: + tslib "^2.2.0" + +"@azure/openai@^1.0.0-beta.2": + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/@azure/openai/-/openai-1.0.0-beta.2.tgz#2ec6e31d815ff07e956d1d4c247fef310b457725" + integrity sha512-Sc7urPoTP92kcSQdbwQgokeRAUMWFI5cXLpBfBX/5qexGhnlRI2JLzfqerRiKOqKk9HYJ0OWhKW8YQ0Yd8Qt7A== + dependencies: + "@azure-rest/core-client" "^1.1.3" + "@azure/core-auth" "^1.4.0" + "@azure/core-rest-pipeline" "^1.10.2" + "@azure/logger" "^1.0.3" + tslib "^2.4.0" + "@babel/cli@^7.16.8": version "7.20.7" resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.20.7.tgz#8fc12e85c744a1a617680eacb488fab1fcd35b7c" @@ -4336,6 +4411,11 @@ resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" integrity sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A== +"@tootallnate/once@2": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" + integrity sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A== + "@tweenjs/tween.js@18": version "18.6.4" resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-18.6.4.tgz#40a3d0a93647124872dec8e0fd1bd5926695b6ca" @@ -5046,6 +5126,13 @@ acorn@^8.5.0, acorn@^8.7.1, acorn@^8.8.0: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== +agent-base@6: + version "6.0.2" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" + integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ== + dependencies: + debug "4" + aggregate-error@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" @@ -6719,6 +6806,13 @@ debug@4.3.4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3. dependencies: ms "2.1.2" +debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.2, debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + debug@^3.1.0, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -8396,6 +8490,15 @@ http-parser-js@>=0.5.1: resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" integrity sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q== +http-proxy-agent@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz#5129800203520d434f142bc78ff3c170800f2b43" + integrity sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w== + dependencies: + "@tootallnate/once" "2" + agent-base "6" + debug "4" + http-proxy-middleware@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz#e1a4dd6979572c7ab5a4e4b55095d1f32a74963f" @@ -8433,6 +8536,14 @@ http2-wrapper@^1.0.0-beta.5.2: quick-lru "^5.1.1" resolve-alpn "^1.0.0" +https-proxy-agent@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" + integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== + dependencies: + agent-base "6" + debug "4" + human-signals@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" @@ -12969,6 +13080,11 @@ tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.0.tgz#b295854684dbda164e181d259a22cd779dcd7bc3" integrity sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA== +tslib@^2.2.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.0.tgz#b295854684dbda164e181d259a22cd779dcd7bc3" + integrity sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -13483,9 +13599,9 @@ wildcard@^2.0.0: integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw== word-wrap@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== + version "1.2.4" + resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" + integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== wrap-ansi@^6.2.0: version "6.2.0"