diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml new file mode 100644 index 00000000..c7560c6d --- /dev/null +++ b/.github/workflows/playwright.yml @@ -0,0 +1,63 @@ +name: Playwright Tests + +on: + push: + branches: [ main, release/* ] + pull_request: + branches: [ main, release/* ] + +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 16 + + - name: Install frontend dependencies + run: cd src && npm ci --legacy-peer-deps + + - name: Install wait-on + run: npm install -g wait-on + + - name: Install Playwright browsers + run: npx playwright install --with-deps + + - name: Build and run backend + run: | + docker run --rm -p 5000:5000 -e FML_DEV_MODE=true gresearch/fasttrackml:main & + npx wait-on http://localhost:5000 + + - name: Install k6 + run: | + sudo apt-get update + sudo apt-get install -y gnupg software-properties-common ca-certificates curl + curl -s https://dl.k6.io/key.gpg | sudo apt-key add - + echo "deb https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list + sudo apt-get update + sudo apt-get install -y k6 + + - name: Seed database with k6 + run: | + wget https://raw.githubusercontent.com/G-Research/fasttrackml/main/docs/example/k6_load.js + k6 run k6_load.js + + - name: Start frontend dev server and run E2E tests + run: | + cd src + npm start & + npx wait-on http://localhost:3000 + npx playwright test src/e2e + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/README.md b/README.md index b08026d8..eb4f8f5c 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,18 @@ An embed directory will be created with the built UI, ready to be embedded in th Once you're happy with them, create a pull request **against the `release/vX.Y.Z` branch*** that you started from (***not `main`!***). Once merged, the CI will run and build the UI. It will then push it to a new tag that is compatible with the Go module rules. For example, the first customization to `v3.16.2` of Aim will end up in a tag named `v0.31602.1`. +### How to run E2E tests? + +The E2E tests can be run locally by following these steps: +1. Start the FastTrackML server +2. Run the Aim UI in development mode (on localhost:3000) +3. In another terminal, run `cd src/e2e` +4. Run `npx playwright test` to run the test suite + +New tests can be added directly to the `src/e2e` directory. You may also run `npx playwright show-report` to see the test results. + +For a guide on how to write a test, see [Playwright's example tests](https://github.com/microsoft/playwright/blob/main/examples/todomvc/tests/integration.spec.ts). + ### How is this all enforced? A GitHub app has been created with the `contents:write` permissions on this repo. Its App ID and private key are stored as secrets under the `restricted` environment. This environment is limited to the `main` and `release/v*` branches @@ -245,3 +257,4 @@ do gh api /repos/G-Research/fasttrackml-ui-aim/rulesets/$rule | jq '[{name: .name, target: .target, conditions: .conditions, rules: .rules, bypass_actors: .bypass_actors}]' done | jq -s add ``` + diff --git a/src/.gitignore b/src/.gitignore index 79829f37..37410fe9 100644 --- a/src/.gitignore +++ b/src/.gitignore @@ -25,3 +25,7 @@ yarn-debug.log* yarn-error.log* Desktop +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/src/e2e/Dashboard/DashboardContent.spec.ts b/src/e2e/Dashboard/DashboardContent.spec.ts new file mode 100644 index 00000000..189789eb --- /dev/null +++ b/src/e2e/Dashboard/DashboardContent.spec.ts @@ -0,0 +1,19 @@ +import { test, expect } from '@playwright/test'; + +const BASE_URL = 'http://localhost:3000'; + +test.describe('Dashboard', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL); + await page.waitForLoadState('networkidle'); + }); + + // Test that the total number of runs is two. This assumes that the + // test database has been seeded with two runs by running `k6 run k6_load.js` + test('has two runs', async ({ page }) => { + const textContent = await page.textContent( + 'p.ProjectStatistics__totalRuns', + ); + expect(textContent).toBe('Total runs: 2'); + }); +}); diff --git a/src/e2e/Dashboard/DashboardLogic.spec.ts b/src/e2e/Dashboard/DashboardLogic.spec.ts new file mode 100644 index 00000000..21b2f37e --- /dev/null +++ b/src/e2e/Dashboard/DashboardLogic.spec.ts @@ -0,0 +1,35 @@ +import { test, expect } from '@playwright/test'; + +const BASE_URL = 'http://localhost:3000'; + +test.describe('Dashboard', () => { + test.beforeEach(async ({ page }) => { + await page.goto(BASE_URL); + }); + + test('has title', async ({ page }) => { + await expect(page).toHaveTitle('FastTrackML (modern)'); + }); + + test('active runs link redirects correctly', async ({ page }) => { + await page.getByText('Active Runs').click({ force: true }); + + await page.getByRole('code', { name: 'runs.active == True' }); + }); + + test('archived runs link redirects correctly', async ({ page }) => { + await page.getByText('Archived Runs').click({ force: true }); + + await page.getByRole('code', { name: 'runs.active == False' }); + }); + + test("last week's runs link redirects correctly", async ({ page }) => { + await page.getByText("Last Week's Runs").click({ force: true }); + + // The text varies depending on the current date: + // Example: datetime(2024, 6, 3) <= run.created_at < datetime(2024, 6, 10) + await page.getByRole('code', { + name: /datetime\(\d+, \d+, \d+\) <= run\.created_at < datetime\(\d+, \d+, \d+\)/, + }); + }); +}); diff --git a/src/package-lock.json b/src/package-lock.json index f0d04c3a..ee510eee 100755 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -49,6 +49,7 @@ "memoize-one": "^5.2.1", "moment": "^2.29.4", "monaco-editor": "^0.33.0", + "playwright": "^1.44.1", "plotly.js": "^2.7.0", "prop-types": "^15.7.2", "prosemirror-tables": "^1.1.1", @@ -73,6 +74,7 @@ "zustand": "^4.1.1" }, "devDependencies": { + "@playwright/test": "^1.44.1", "@storybook/addon-actions": "^6.5.12", "@storybook/addon-essentials": "^6.5.12", "@storybook/addon-interactions": "^6.5.12", @@ -3618,6 +3620,21 @@ "node": ">=10" } }, + "node_modules/@playwright/test": { + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz", + "integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==", + "dev": true, + "dependencies": { + "playwright": "1.44.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@plotly/d3": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/@plotly/d3/-/d3-3.8.0.tgz", @@ -25409,6 +25426,34 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz", + "integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==", + "dependencies": { + "playwright-core": "1.44.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz", + "integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/please-upgrade-node": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", @@ -40022,6 +40067,15 @@ } } }, + "@playwright/test": { + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz", + "integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==", + "dev": true, + "requires": { + "playwright": "1.44.1" + } + }, "@plotly/d3": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/@plotly/d3/-/d3-3.8.0.tgz", @@ -56820,6 +56874,20 @@ } } }, + "playwright": { + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz", + "integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==", + "requires": { + "fsevents": "2.3.2", + "playwright-core": "1.44.1" + } + }, + "playwright-core": { + "version": "1.44.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz", + "integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==" + }, "please-upgrade-node": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz", diff --git a/src/package.json b/src/package.json index 13e2ede2..50e5b3e8 100644 --- a/src/package.json +++ b/src/package.json @@ -43,6 +43,7 @@ "memoize-one": "^5.2.1", "moment": "^2.29.4", "monaco-editor": "^0.33.0", + "playwright": "^1.44.1", "plotly.js": "^2.7.0", "prop-types": "^15.7.2", "prosemirror-tables": "^1.1.1", @@ -107,6 +108,7 @@ ] }, "devDependencies": { + "@playwright/test": "^1.44.1", "@storybook/addon-actions": "^6.5.12", "@storybook/addon-essentials": "^6.5.12", "@storybook/addon-interactions": "^6.5.12", diff --git a/src/playwright.config.ts b/src/playwright.config.ts new file mode 100644 index 00000000..bfe3e830 --- /dev/null +++ b/src/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/src/src/components/Grouping/Grouping.tsx b/src/src/components/Grouping/Grouping.tsx index c0331ef5..42fafb2e 100644 --- a/src/src/components/Grouping/Grouping.tsx +++ b/src/src/components/Grouping/Grouping.tsx @@ -16,6 +16,7 @@ import './Grouping.scss'; function Grouping({ groupingData, groupingSelectOptions, + conditionalGroupingOptions, onGroupingSelectChange, onGroupingModeChange, onGroupingPaletteChange, @@ -46,6 +47,7 @@ function Grouping({ groupName={groupName as GroupNameEnum} groupingData={groupingData} groupingSelectOptions={groupingSelectOptions} + conditionalGroupingOptions={conditionalGroupingOptions} onSelect={onGroupingSelectChange} onGroupingModeChange={onGroupingModeChange} isDisabled={isDisabled} @@ -65,6 +67,7 @@ function Grouping({ paletteIndex: groupingData?.paletteIndex, })} groupingSelectOptions={groupingSelectOptions} + conditionalGroupingOptions={conditionalGroupingOptions} onSelect={onGroupingSelectChange} onGroupingConditionsChange={onGroupingConditionsChange} /> diff --git a/src/src/components/GroupingItem/GroupingItem.tsx b/src/src/components/GroupingItem/GroupingItem.tsx index 3a5b17b3..3d31ed24 100644 --- a/src/src/components/GroupingItem/GroupingItem.tsx +++ b/src/src/components/GroupingItem/GroupingItem.tsx @@ -9,6 +9,8 @@ import { Icon } from 'components/kit'; import { IconName } from 'components/kit/Icon'; import ErrorBoundary from 'components/ErrorBoundary/ErrorBoundary'; +import { GroupNameEnum } from 'config/grouping/GroupingPopovers'; + import { IGroupingItemProps } from 'types/pages/components/GroupingItem/GroupingItem'; import './GroupingItem.scss'; @@ -29,6 +31,7 @@ function GroupingItem({ onSelect, onGroupingModeChange, groupingSelectOptions, + conditionalGroupingOptions, isDisabled, }: IGroupingItemProps): React.FunctionComponentElement { return ( @@ -50,7 +53,9 @@ function GroupingItem({
, ): Record => { - const defaultSuggestions = { + const metricNames = data?.metric ? Object.keys(data.metric) : []; + + const metricDict: Record = metricNames.reduce( + (acc: Record, metricName: string) => { + acc[metricName] = { last: 0 }; + return acc; + }, + {}, + ); + const defaultSuggestions: Record = { run: { active: false, hash: '', @@ -78,6 +87,7 @@ export const getSuggestionsByExplorer = ( created_at: 0, finalized_at: 0, duration: 0, + metrics: metricDict, ...(data?.params || {}), }, }; diff --git a/src/src/modules/core/utils/createResource.ts b/src/src/modules/core/utils/createResource.ts index 357032a4..45f4a33c 100644 --- a/src/src/modules/core/utils/createResource.ts +++ b/src/src/modules/core/utils/createResource.ts @@ -1,12 +1,21 @@ import { RequestOptions } from 'https'; import create from 'zustand'; +import { getPrefix } from 'config/config'; + +import { ErrorCode } from 'services/NetworkService'; + export interface IResourceState { data: T | null; loading: boolean; error: any; } +interface CreateResourceError { + error_code: string; + message: string; +} + const defaultState = { data: null, loading: true, @@ -18,8 +27,15 @@ function createResource(getter: any) { async function fetchData(args?: GetterArgs) { state.setState({ loading: true }); - const data = await getter(args); - state.setState({ data, loading: false }); + try { + const data = await getter(args); + state.setState({ data, loading: false }); + } catch (error: CreateResourceError | any) { + if (error?.error_code === ErrorCode.RESOURCE_DOES_NOT_EXIST) { + window.location.href = getPrefix(); + } + state.setState({ error, loading: false }); + } } function destroy() { state.destroy(); diff --git a/src/src/pages/Metrics/Metrics.tsx b/src/src/pages/Metrics/Metrics.tsx index 45f4652c..378ea1d0 100644 --- a/src/src/pages/Metrics/Metrics.tsx +++ b/src/src/pages/Metrics/Metrics.tsx @@ -133,6 +133,7 @@ function Metrics( isDisabled={isProgressBarVisible} groupingData={props.groupingData} groupingSelectOptions={props.groupingSelectOptions} + conditionalGroupingOptions={props.conditionalGroupingOptions} onGroupingSelectChange={props.onGroupingSelectChange} onGroupingModeChange={props.onGroupingModeChange} onGroupingPaletteChange={props.onGroupingPaletteChange} diff --git a/src/src/pages/Metrics/MetricsContainer.tsx b/src/src/pages/Metrics/MetricsContainer.tsx index 850b9187..fb9e8bc2 100644 --- a/src/src/pages/Metrics/MetricsContainer.tsx +++ b/src/src/pages/Metrics/MetricsContainer.tsx @@ -165,6 +165,7 @@ function MetricsContainer(): React.FunctionComponentElement { chartPanelOffsetHeight={chartPanelOffsetHeight} selectedRows={metricsData?.selectedRows!} groupingSelectOptions={metricsData?.groupingSelectOptions!} + conditionalGroupingOptions={metricsData?.conditionalGroupingOptions!} sortOptions={metricsData?.sortOptions!} resizeMode={metricsData?.config?.table?.resizeMode!} columnsWidths={metricsData?.config?.table?.columnsWidths!} diff --git a/src/src/pages/Metrics/components/ChartPopover/ChartPopoverAdvanced.scss b/src/src/pages/Metrics/components/ChartPopover/ChartPopoverAdvanced.scss index 36e81ca2..1bf458f8 100644 --- a/src/src/pages/Metrics/components/ChartPopover/ChartPopoverAdvanced.scss +++ b/src/src/pages/Metrics/components/ChartPopover/ChartPopoverAdvanced.scss @@ -14,6 +14,11 @@ &__conditionalFilter { padding: 1rem; border-bottom: $border-main; + + &__icon { + margin-left: 0.5rem; + color: $text-color; + } } &__conditionalFilter__p { diff --git a/src/src/pages/Metrics/components/ChartPopover/ChartPopoverAdvanced.tsx b/src/src/pages/Metrics/components/ChartPopover/ChartPopoverAdvanced.tsx index a48d2693..819ae4b3 100644 --- a/src/src/pages/Metrics/components/ChartPopover/ChartPopoverAdvanced.tsx +++ b/src/src/pages/Metrics/components/ChartPopover/ChartPopoverAdvanced.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; -import { Checkbox, TextField } from '@material-ui/core'; +import { Checkbox, TextField, Tooltip } from '@material-ui/core'; import { CheckBox as CheckBoxIcon, CheckBoxOutlineBlank, @@ -11,6 +11,8 @@ import { Button, Box } from '@material-ui/core'; import { Icon, Text } from 'components/kit'; import ErrorBoundary from 'components/ErrorBoundary'; +import { GroupNameEnum } from 'config/grouping/GroupingPopovers'; + import { IGroupingCondition, IGroupingSelectOption, @@ -31,7 +33,7 @@ export enum IOperator { function ChartPopoverAdvanced({ onGroupingConditionsChange, groupingData, - groupingSelectOptions, + conditionalGroupingOptions, }: IGroupingPopoverAdvancedProps): React.FunctionComponentElement { const [inputValue, setInputValue] = useState(''); const [selectedField, setSelectedField] = @@ -41,7 +43,7 @@ function ChartPopoverAdvanced({ ); const [selectedValue, setSelectedValue] = useState(''); const [conditions, setConditions] = useState( - groupingData?.conditions || [], + groupingData?.conditions?.chart || [], ); const onAddCondition = () => { @@ -60,7 +62,7 @@ function ChartPopoverAdvanced({ index === conditionIndex ? condition : c, ); setConditions(newConditions); - onGroupingConditionsChange?.(newConditions); + onGroupingConditionsChange?.(newConditions, GroupNameEnum.CHART); }; const onChangeField = (e: any, value: IGroupingSelectOption | null): void => { @@ -88,7 +90,7 @@ function ChartPopoverAdvanced({ }; const options = useMemo(() => { - const filteredOptions = groupingSelectOptions?.filter((item) => + const filteredOptions = conditionalGroupingOptions?.filter((item) => item.label.toLowerCase().includes(inputValue.toLowerCase()), ); return ( @@ -98,7 +100,7 @@ function ChartPopoverAdvanced({ b.label.toLowerCase().indexOf(inputValue.toLowerCase()), ) || [] ); - }, [groupingSelectOptions, inputValue]); + }, [conditionalGroupingOptions, inputValue]); return ( @@ -106,20 +108,32 @@ function ChartPopoverAdvanced({
group by condition + +
+ +
+
- Group charts by conditions such as{' '} + Group charts by conditions, e.g.{' '} run.epochs > 30 .
- {/* Add textbox to allow grouping by condition */} + {/* Textbox for selecting fields */} )} renderTags={() => null} // No tags for single selection @@ -212,7 +226,7 @@ function ChartPopoverAdvanced({ key={index} className='ChartPopoverAdvanced__conditionalFilter__box flex fac fjb' > - {/* Show condition and button in same line */} + {/* Show condition and Remove button in same line */} ' = '>', + '<' = '<', + '>=' = '>=', + '<=' = '<=', +} + function ColorPopoverAdvanced({ onPersistenceChange, onGroupingPaletteChange, onShuffleChange, + onGroupingConditionsChange, + groupingSelectOptions, persistence, paletteIndex, groupingData, }: IGroupingPopoverAdvancedProps): React.FunctionComponentElement { + const [inputValue, setInputValue] = useState(''); + const [selectedField, setSelectedField] = + useState(null); + const [selectedOperator, setSelectedOperator] = useState( + IOperator['=='], + ); + const [selectedValue, setSelectedValue] = useState(''); + const [conditions, setConditions] = useState( + groupingData?.conditions?.color || [], + ); + + const onAddCondition = () => { + const condition: IGroupingCondition = { + fieldName: selectedField?.label || '', + operator: selectedOperator ?? IOperator['=='], + value: selectedValue, + }; + const conditionIndex = conditions.findIndex( + (c) => c.fieldName === condition.fieldName, + ); + const newConditions = + conditionIndex === -1 + ? [...conditions, condition] + : conditions.map((c, index) => + index === conditionIndex ? condition : c, + ); + setConditions(newConditions); + onGroupingConditionsChange?.(newConditions, GroupNameEnum.COLOR); + }; + + const onChangeField = (e: any, value: IGroupingSelectOption | null): void => { + if (!e || e.code !== 'Backspace' || inputValue.length === 0) + handleSelectField(value); + }; + + const onChangeOperator = (e: any, value: IOperator): void => { + handleSelectOperator(value || IOperator['==']); + }; + + const handleSelectField = (value: IGroupingSelectOption | null) => { + const newSelectedField = + selectedField?.value === value?.value ? null : value; + setInputValue(newSelectedField?.label || ''); + setSelectedField(newSelectedField); + }; + + const handleSelectOperator = (value?: IOperator) => { + setSelectedOperator(value ?? IOperator['==']); + }; + + const handleSelectValue = (value: string) => { + setSelectedValue(value); + }; + + const options = useMemo(() => { + const filteredOptions = groupingSelectOptions?.filter((item) => + item.label.toLowerCase().includes(inputValue.toLowerCase()), + ); + return ( + filteredOptions?.sort( + (a, b) => + a.label.toLowerCase().indexOf(inputValue.toLowerCase()) - + b.label.toLowerCase().indexOf(inputValue.toLowerCase()), + ) || [] + ); + }, [groupingSelectOptions, inputValue]); + function onPaletteChange(e: React.ChangeEvent) { let { value } = e.target; if (onGroupingPaletteChange) { @@ -110,6 +198,144 @@ function ColorPopoverAdvanced({ ))}
+
+ + group by condition + + + Group charts by conditions such as{' '} + + run.epochs > 30 + + . + +
+ {/* Add textbox to allow grouping by condition */} + option.group} + getOptionLabel={(option) => option.label} + getOptionSelected={(option, value) => + option.value === selectedField?.value + } + renderInput={(params: any) => ( + { + setInputValue(e.target?.value); + }, + }} + className='TextField__OutLined__Small' + variant='outlined' + placeholder='Select fields' + /> + )} + renderTags={() => null} // No tags for single selection + renderOption={(option, { selected }) => ( +
onChangeField(null, option)} + > + } + checkedIcon={} + style={{ marginRight: 4 }} + checked={selected} + /> + + {option.label} + +
+ )} + /> + {/* Dropdown for operator */} + ( + + )} + /> + {/* Textbox for the condition value */} + handleSelectValue(e.target.value)} + /> +
+ +
+ {conditions.map((condition, index) => ( + + {/* Show condition and button in same line */} + + {condition.fieldName} {condition.operator} {condition.value} + + + + ))} +
+
); diff --git a/src/src/pages/Metrics/components/StrokePopover/StrokePopoverAdvanced.scss b/src/src/pages/Metrics/components/StrokePopover/StrokePopoverAdvanced.scss index 543ac865..93216f80 100644 --- a/src/src/pages/Metrics/components/StrokePopover/StrokePopoverAdvanced.scss +++ b/src/src/pages/Metrics/components/StrokePopover/StrokePopoverAdvanced.scss @@ -3,6 +3,7 @@ .StrokePopoverAdvanced { &__container { padding: 1rem; + border-bottom: $border-main; &__p { margin: 1rem 0; } diff --git a/src/src/pages/Metrics/components/StrokePopover/StrokePopoverAdvanced.tsx b/src/src/pages/Metrics/components/StrokePopover/StrokePopoverAdvanced.tsx index 18157cb3..a745e30a 100644 --- a/src/src/pages/Metrics/components/StrokePopover/StrokePopoverAdvanced.tsx +++ b/src/src/pages/Metrics/components/StrokePopover/StrokePopoverAdvanced.tsx @@ -1,18 +1,110 @@ -import React from 'react'; +import { useMemo, useState } from 'react'; -import { Button, Switcher, Text } from 'components/kit'; +import { Checkbox, TextField } from '@material-ui/core'; +import { + CheckBox as CheckBoxIcon, + CheckBoxOutlineBlank, +} from '@material-ui/icons'; +import Autocomplete from '@material-ui/lab/Autocomplete'; +import { Button, Box } from '@material-ui/core'; + +import { Icon, Switcher, Text } from 'components/kit'; import ErrorBoundary from 'components/ErrorBoundary/ErrorBoundary'; +import { GroupNameEnum } from 'config/grouping/GroupingPopovers'; + +import { + IGroupingCondition, + IGroupingSelectOption, +} from 'types/services/models/metrics/metricsAppModel'; import { IGroupingPopoverAdvancedProps } from 'types/components/GroupingPopover/GroupingPopover'; import './StrokePopoverAdvanced.scss'; +export enum IOperator { + '==' = '==', + '!=' = '!=', + '>' = '>', + '<' = '<', + '>=' = '>=', + '<=' = '<=', +} + function StrokePopoverAdvanced({ onPersistenceChange, onShuffleChange, persistence, groupingData, + onGroupingConditionsChange, + groupingSelectOptions, }: IGroupingPopoverAdvancedProps): React.FunctionComponentElement { + const [inputValue, setInputValue] = useState(''); + const [selectedField, setSelectedField] = + useState(null); + const [selectedOperator, setSelectedOperator] = useState( + IOperator['=='], + ); + const [selectedValue, setSelectedValue] = useState(''); + const [conditions, setConditions] = useState( + groupingData?.conditions?.stroke || [], + ); + + const onAddCondition = () => { + const condition: IGroupingCondition = { + fieldName: selectedField?.label || '', + operator: selectedOperator ?? IOperator['=='], + value: selectedValue, + }; + const conditionIndex = conditions.findIndex( + (c) => c.fieldName === condition.fieldName, + ); + const newConditions = + conditionIndex === -1 + ? [...conditions, condition] + : conditions.map((c, index) => + index === conditionIndex ? condition : c, + ); + setConditions(newConditions); + onGroupingConditionsChange?.(newConditions, GroupNameEnum.STROKE); + }; + + const onChangeField = (e: any, value: IGroupingSelectOption | null): void => { + if (!e || e.code !== 'Backspace' || inputValue.length === 0) + handleSelectField(value); + }; + + const onChangeOperator = (e: any, value: IOperator): void => { + handleSelectOperator(value || IOperator['==']); + }; + + const handleSelectField = (value: IGroupingSelectOption | null) => { + const newSelectedField = + selectedField?.value === value?.value ? null : value; + setInputValue(newSelectedField?.label || ''); + setSelectedField(newSelectedField); + }; + + const handleSelectOperator = (value?: IOperator) => { + setSelectedOperator(value ?? IOperator['==']); + }; + + const handleSelectValue = (value: string) => { + setSelectedValue(value); + }; + + const options = useMemo(() => { + const filteredOptions = groupingSelectOptions?.filter((item) => + item.label.toLowerCase().includes(inputValue.toLowerCase()), + ); + return ( + filteredOptions?.sort( + (a, b) => + a.label.toLowerCase().indexOf(inputValue.toLowerCase()) - + b.label.toLowerCase().indexOf(inputValue.toLowerCase()), + ) || [] + ); + }, [groupingSelectOptions, inputValue]); + function isShuffleDisabled(): boolean { //ToDo reverse mode // if (groupingData?.reverseMode.stroke || groupingData?.stroke.length) { @@ -60,6 +152,144 @@ function StrokePopoverAdvanced({ )} +
+ + group by condition + + + Group strokes by conditions such as{' '} + + run.epochs > 30 + + . + +
+ {/* Add textbox to allow grouping by condition */} + option.group} + getOptionLabel={(option) => option.label} + getOptionSelected={(option, value) => + option.value === selectedField?.value + } + renderInput={(params: any) => ( + { + setInputValue(e.target?.value); + }, + }} + className='TextField__OutLined__Small' + variant='outlined' + placeholder='Select fields' + /> + )} + renderTags={() => null} // No tags for single selection + renderOption={(option, { selected }) => ( +
onChangeField(null, option)} + > + } + checkedIcon={} + style={{ marginRight: 4 }} + checked={selected} + /> + + {option.label} + +
+ )} + /> + {/* Dropdown for operator */} + ( + + )} + /> + {/* Textbox for the condition value */} + handleSelectValue(e.target.value)} + /> +
+ +
+ {conditions.map((condition, index) => ( + + {/* Show condition and button in same line */} + + {condition.fieldName} {condition.operator} {condition.value} + + + + ))} +
+
); diff --git a/src/src/pages/Params/Params.tsx b/src/src/pages/Params/Params.tsx index 730a0364..9c8674cc 100644 --- a/src/src/pages/Params/Params.tsx +++ b/src/src/pages/Params/Params.tsx @@ -223,6 +223,7 @@ const Params = ({ isDisabled={isProgressBarVisible} groupingData={groupingData} groupingSelectOptions={groupingSelectOptions} + conditionalGroupingOptions={groupingSelectOptions} // jescalada: TODO: Implement conditional grouping (Params/Scatters) onGroupingSelectChange={onGroupingSelectChange} onGroupingModeChange={onGroupingModeChange} onGroupingPaletteChange={onGroupingPaletteChange} diff --git a/src/src/pages/RunDetail/RunDetail.tsx b/src/src/pages/RunDetail/RunDetail.tsx index 6eda999b..3212b9ca 100644 --- a/src/src/pages/RunDetail/RunDetail.tsx +++ b/src/src/pages/RunDetail/RunDetail.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import _ from 'lodash-es'; import classNames from 'classnames'; import moment from 'moment'; import { @@ -56,6 +57,9 @@ const RunDetailParamsTab = React.lazy( () => import(/* webpackChunkName: "RunDetailParamsTab" */ './RunDetailParamsTab'), ); +const RunLogsTab = React.lazy( + () => import(/* webpackChunkName: "RunLogsTab" */ './RunLogsTab'), +); const RunDetailSettingsTab = React.lazy( () => import( @@ -75,6 +79,7 @@ const RunOverviewTab = React.lazy( const tabs: Record = { overview: 'Overview', run_parameters: 'Run Params', + logs: 'Logs', metrics: 'Metrics', system: 'System', settings: 'Settings', @@ -126,6 +131,15 @@ function RunDetail(): React.FunctionComponentElement { isRunInfoLoading={runData?.isRunInfoLoading} /> ), + logs: ( + + ), metrics: ( { + return { + group: 'metric', + label: `metric.${metric?.config?.name}`, + value: `${metric?.config?.name}`, + }; + }), + 'value', + ), + ); const sortOptions = [ ...groupingSelectOptions, { @@ -1271,6 +1290,7 @@ function createAppModel(appConfig: IAppInitialConfig) { tableColumns, sameValueColumns: tableData.sameValueColumns, groupingSelectOptions, + conditionalGroupingOptions, sortOptions, selectedRows, }); @@ -1303,6 +1323,19 @@ function createAppModel(appConfig: IAppInitialConfig) { sequenceName: 'metric', }), ]; + // Conditional grouping allows grouping by regular select options and also metrics + const conditionalGroupingOptions = groupingSelectOptions.concat( + _.uniqBy( + data.map((metric) => { + return { + group: 'metric', + label: `metric.${metric?.config?.name}`, + value: `${metric?.config?.name}`, + }; + }), + 'value', + ), + ); const sortOptions = [ ...groupingSelectOptions, { @@ -1427,6 +1460,7 @@ function createAppModel(appConfig: IAppInitialConfig) { tableColumns: tableColumns, sameValueColumns: tableData.sameValueColumns, groupingSelectOptions, + conditionalGroupingOptions, sortOptions, selectedRows, }); @@ -1435,7 +1469,8 @@ function createAppModel(appConfig: IAppInitialConfig) { function alignData( data: IMetricsCollection[], type: AlignmentOptionsEnum = model.getState()!.config!.chart - ?.alignmentConfig.type, + ?.alignmentConfigs[0].type, + chartId: number = 0, ): IMetricsCollection[] { const alignmentObj: { [key: string]: Function } = { [AlignmentOptionsEnum.STEP]: alignByStep, @@ -1447,7 +1482,11 @@ function createAppModel(appConfig: IAppInitialConfig) { throw new Error('Unknown value for X axis alignment'); }, }; - const alignment = alignmentObj[type] || alignmentObj.default; + const alignmentConfig = + model.getState()!.config!.chart?.alignmentConfigs[chartId]; + const alignment = + alignmentObj[alignmentConfig.type] || alignmentObj.default; + return alignment(data, model); } @@ -1456,24 +1495,29 @@ function createAppModel(appConfig: IAppInitialConfig) { const grouping = configData!.grouping; const { paletteIndex = 0 } = grouping || {}; - const conditions: IGroupingCondition[] = grouping.conditions || []; - const conditionStrings = conditions.map( - (condition) => - `${condition.fieldName} ${condition.operator} ${condition.value}`, + const colorConditions = grouping.conditions?.color || []; + const colorConditionStrings = getConditionStrings(colorConditions); + const strokeConditions = grouping.conditions?.stroke || []; + const strokeConditionStrings = getConditionStrings(strokeConditions); + const chartConditions = grouping.conditions?.chart || []; + const chartConditionStrings = getConditionStrings(chartConditions); + const allConditions = colorConditions.concat( + strokeConditions, + chartConditions, ); + const groupByColor = getFilteredGroupingOptions({ groupName: GroupNameEnum.COLOR, model, - }); + }).concat(colorConditionStrings); const groupByStroke = getFilteredGroupingOptions({ groupName: GroupNameEnum.STROKE, model, - }); - + }).concat(strokeConditionStrings); const groupByChart = getFilteredGroupingOptions({ groupName: GroupNameEnum.CHART, model, - }).concat(conditionStrings); + }).concat(chartConditionStrings); if ( groupByColor.length === 0 && @@ -1491,63 +1535,13 @@ function createAppModel(appConfig: IAppInitialConfig) { ]); } - const groupValues: { - [key: string]: IMetricsCollection; - } = {}; - const groupingFields = _.uniq( groupByColor.concat(groupByStroke).concat(groupByChart), ); - for (let i = 0; i < data.length; i++) { - const groupValue: { [key: string]: any } = {}; - groupingFields.forEach((field) => { - groupValue[field] = getValue(data[i], field); - }); - - // Evaluate the conditions and update the row - conditionStrings.forEach((conditionString, j) => { - // Evaluate the condition - const condition = conditions[j]; - - // Get everything after the first dot in the field name - const fieldTypeAndName = condition.fieldName.split('.'); - const fieldType = fieldTypeAndName[0]; - const fieldName = fieldTypeAndName.slice(1).join('.'); - - // Flatten default run attributes and store them in a single object - const runAttributes = { - ...data[i].run.params, - ...data[i].run.props, - hash: data[i].run.hash, - name: - fieldType === 'metric' ? data[i].name : data[i].run.props.name, - tags: data[i].run.params.tags, - experiment: data[i].run.props.experiment?.name, - }; - - // Get the relevant attribute's value - const attributeValue = getValue(runAttributes, fieldName); - groupValue[conditionString] = evaluateCondition( - attributeValue, - condition, - ); - }); - - const groupKey = encode(groupValue); - if (groupValues.hasOwnProperty(groupKey)) { - groupValues[groupKey].data.push(data[i]); - } else { - groupValues[groupKey] = { - key: groupKey, - config: groupValue, - color: null, - dasharray: null, - chartIndex: 0, - data: [data[i]], - }; - } - } + const groupValues: { + [key: string]: IMetricsCollection; + } = generateGroupValues(data, allConditions, groupingFields); let colorIndex = 0; let dasharrayIndex = 0; @@ -2120,9 +2114,13 @@ function createAppModel(appConfig: IAppInitialConfig) { setAggregationEnabled, }); }, - onGroupingConditionsChange(conditions: IGroupingCondition[]): void { + onGroupingConditionsChange( + conditions: IGroupingCondition[], + groupName: GroupNameEnum, + ): void { onGroupingConditionsChange({ conditions, + groupName, model, appName, updateModelData, diff --git a/src/src/services/models/runs/runDetailAppModel.ts b/src/src/services/models/runs/runDetailAppModel.ts index 7aaf6c41..9e817f2c 100644 --- a/src/src/services/models/runs/runDetailAppModel.ts +++ b/src/src/services/models/runs/runDetailAppModel.ts @@ -64,6 +64,7 @@ function getExperimentsData() { } function getRunInfo(runHash: string): IApiRequest { + const DESCRIPTION_TAG = 'mlflow.note.content'; if (getRunsInfoRequestRef) { getRunsInfoRequestRef.abort(); } @@ -74,6 +75,10 @@ function getRunInfo(runHash: string): IApiRequest { const data = await getRunsInfoRequestRef.call((detail: any) => { exceptionHandler({ detail, model }); }); + data.props.description = + DESCRIPTION_TAG in data.params.tags + ? data.params.tags[DESCRIPTION_TAG] + : ''; model.setState({ runParams: data.params, runTraces: data.traces, @@ -332,7 +337,7 @@ function editRunNameAndDescription( description, }, }); - if (res.id) { + if (res.ID) { onNotificationAdd({ id: Date.now(), severity: 'success', diff --git a/src/src/types/components/GroupingPopover/GroupingPopover.d.ts b/src/src/types/components/GroupingPopover/GroupingPopover.d.ts index 7034b227..92b7a638 100644 --- a/src/src/types/components/GroupingPopover/GroupingPopover.d.ts +++ b/src/src/types/components/GroupingPopover/GroupingPopover.d.ts @@ -13,6 +13,7 @@ export interface IGroupingPopoverProps { groupingData: IGroupingConfig; advancedComponent?: React.FunctionComponentElement | null; groupingSelectOptions: IGroupingSelectOption[]; + conditionalGroupingOptions?: IGroupingSelectOption[]; onSelect: IMetricProps['onGroupingSelectChange']; onGroupingModeChange: IMetricProps['onGroupingModeChange']; inputLabel?: string; @@ -26,6 +27,7 @@ export interface IGroupingPopoverAdvancedProps { onGroupingPaletteChange?: IMetricProps['onGroupingPaletteChange']; onShuffleChange: IMetricProps['onShuffleChange']; groupingSelectOptions?: IGroupingSelectOption[]; + conditionalGroupingOptions?: IGroupingSelectOption[]; onSelect?: IMetricProps['onGroupingSelectChange']; inputLabel?: string; onGroupingConditionsChange?: IMetricProps['onGroupingConditionsChange']; diff --git a/src/src/types/pages/components/Grouping/Grouping.d.ts b/src/src/types/pages/components/Grouping/Grouping.d.ts index 3b924a08..d3f327b3 100644 --- a/src/src/types/pages/components/Grouping/Grouping.d.ts +++ b/src/src/types/pages/components/Grouping/Grouping.d.ts @@ -13,6 +13,7 @@ import { IMetricProps } from 'types/pages/metrics/Metrics'; export interface IGroupingProps { groupingData: IGroupingConfig; groupingSelectOptions: IGroupingSelectOption[]; + conditionalGroupingOptions?: IGroupingSelectOption[]; onGroupingSelectChange: IMetricProps['onGroupingSelectChange']; onGroupingModeChange: IMetricProps['onGroupingModeChange']; onGroupingPaletteChange: IMetricProps['onGroupingPaletteChange']; diff --git a/src/src/types/pages/components/GroupingItem/GroupingItem.d.ts b/src/src/types/pages/components/GroupingItem/GroupingItem.d.ts index e722cc12..7f9db3e7 100644 --- a/src/src/types/pages/components/GroupingItem/GroupingItem.d.ts +++ b/src/src/types/pages/components/GroupingItem/GroupingItem.d.ts @@ -14,6 +14,7 @@ export interface IGroupingItemProps extends IGroupingPopoverProps { groupingData: IGroupingConfig; advancedComponent?: React.FunctionComponentElement; groupingSelectOptions: IGroupingSelectOption[]; + conditionalGroupingOptions?: IGroupingSelectOption[]; onReset: () => void; onVisibilityChange: () => void; } diff --git a/src/src/types/pages/metrics/Metrics.d.ts b/src/src/types/pages/metrics/Metrics.d.ts index 757bfd5c..3c9adf52 100644 --- a/src/src/types/pages/metrics/Metrics.d.ts +++ b/src/src/types/pages/metrics/Metrics.d.ts @@ -1,7 +1,10 @@ import React from 'react'; import { RouteChildrenProps } from 'react-router-dom'; -import { RowHeightSize, UnselectedColumnState } from 'config/table/tableConfigs'; +import { + RowHeightSize, + UnselectedColumnState, +} from 'config/table/tableConfigs'; import { ResizeModeEnum } from 'config/enums/tableEnums'; import { DensityOptions } from 'config/enums/densityEnum'; import { RequestStatusEnum } from 'config/enums/requestStatusEnum'; @@ -90,6 +93,7 @@ export interface IMetricProps extends Partial { unselectedColumnState: UnselectedColumnState; sameValueColumns?: string[] | []; groupingSelectOptions: IGroupingSelectOption[]; + conditionalGroupingOptions: IGroupingSelectOption[]; sortOptions: IGroupingSelectOption[]; requestStatus: RequestStatusEnum; requestProgress: IRequestProgress; @@ -125,7 +129,10 @@ export interface IMetricProps extends Partial { onGroupingReset: (groupName: GroupNameEnum) => void; onGroupingApplyChange: (groupName: GroupNameEnum) => void; onGroupingPersistenceChange: (groupName: 'color' | 'stroke') => void; - onGroupingConditionsChange: (conditions: IGroupingCondition[]) => void; + onGroupingConditionsChange: ( + conditions: IGroupingCondition[], + groupName: GroupNameEnum, + ) => void; onBookmarkCreate: (params: IBookmarkFormState) => void; onBookmarkUpdate: (id: string) => void; onNotificationAdd: (notification: INotification) => void; diff --git a/src/src/types/services/models/explorer/createAppModel.d.ts b/src/src/types/services/models/explorer/createAppModel.d.ts index 03eef02f..49bb6499 100644 --- a/src/src/types/services/models/explorer/createAppModel.d.ts +++ b/src/src/types/services/models/explorer/createAppModel.d.ts @@ -1,5 +1,8 @@ import { ResizeModeEnum } from 'config/enums/tableEnums'; -import { RowHeightSize, UnselectedColumnState } from 'config/table/tableConfigs'; +import { + RowHeightSize, + UnselectedColumnState, +} from 'config/table/tableConfigs'; import { DensityOptions } from 'config/enums/densityEnum'; import { GroupNameEnum } from 'config/grouping/GroupingPopovers'; @@ -91,7 +94,11 @@ export interface IGroupingConfig { stroke: number; }; paletteIndex?: number; - conditions?: IGroupingCondition[]; + conditions?: { + color: IGroupingCondition[]; + stroke: IGroupingCondition[]; + chart: IGroupingCondition[]; + }; } export interface ISelectOption { diff --git a/src/src/types/services/models/metrics/metricModel.d.ts b/src/src/types/services/models/metrics/metricModel.d.ts index 74d5aa64..b687bd06 100644 --- a/src/src/types/services/models/metrics/metricModel.d.ts +++ b/src/src/types/services/models/metrics/metricModel.d.ts @@ -18,4 +18,5 @@ export interface IMetric { x_axis_iters?: Float64Array; x_axis_values?: Float64Array; isHidden: boolean; + lastValue?: number; } diff --git a/src/src/types/services/models/metrics/metricsAppModel.d.ts b/src/src/types/services/models/metrics/metricsAppModel.d.ts index cd373f75..639fa6f5 100644 --- a/src/src/types/services/models/metrics/metricsAppModel.d.ts +++ b/src/src/types/services/models/metrics/metricsAppModel.d.ts @@ -56,6 +56,7 @@ export interface IMetricAppModelState { params: string[]; notifyData: INotification[]; groupingSelectOptions: IGroupingSelectOption[]; + conditionalGroupingOptions: IGroupingSelectOption[]; sortOptions: IGroupingSelectOption[]; selectFormData?: { options: ISelectOption[]; diff --git a/src/src/utils/app/generateGroupValues.ts b/src/src/utils/app/generateGroupValues.ts new file mode 100644 index 00000000..5b4d4ee4 --- /dev/null +++ b/src/src/utils/app/generateGroupValues.ts @@ -0,0 +1,75 @@ +import { + IGroupingCondition, + IMetricsCollection, +} from 'types/services/models/metrics/metricsAppModel'; +import { IMetric } from 'types/services/models/metrics/metricModel'; + +import { getValue } from 'utils/helper'; +import { encode } from 'utils/encoder/encoder'; + +import evaluateCondition from './evaluateCondition'; +import { getConditionStrings } from './getConditionStrings'; + +export function generateGroupValues( + data: IMetric[], + allConditions: IGroupingCondition[], + groupingFields: string[], +) { + const groupValues: { + [key: string]: IMetricsCollection; + } = {}; + + const allConditionStrings = getConditionStrings(allConditions); + + for (let i = 0; i < data.length; i++) { + const groupValue: { [key: string]: any } = {}; + groupingFields.forEach((field) => { + groupValue[field] = getValue(data[i], field); + }); + + // Evaluate the conditions and update the row + allConditionStrings.forEach((conditionString, j) => { + // Evaluate the condition + const condition = allConditions[j]; + + // Get everything after the first dot in the field name + const fieldTypeAndName = condition.fieldName.split('.'); + const fieldType = fieldTypeAndName[0]; + const fieldName = fieldTypeAndName.slice(1).join('.'); + + // Flatten default run attributes and store them in a single object + const runAttributes = { + ...data[i].run.params, + ...data[i].run.props, + hash: data[i].run.hash, + name: fieldType === 'metric' ? data[i].name : data[i].run.props.name, + tags: data[i].run.params.tags, + experiment: data[i].run.props.experiment?.name, + context: data[i].context, + }; + + // Get the relevant attribute's value + const attributeValue = getValue(runAttributes, fieldName); + groupValue[conditionString] = evaluateCondition( + attributeValue, + condition, + ); + }); + + const groupKey = encode(groupValue); + if (groupValues.hasOwnProperty(groupKey)) { + groupValues[groupKey].data.push(data[i]); + } else { + groupValues[groupKey] = { + key: groupKey, + config: groupValue, + color: null, + dasharray: null, + chartIndex: 0, + data: [data[i]], + }; + } + } + + return groupValues; +} diff --git a/src/src/utils/app/getChartTitleData.ts b/src/src/utils/app/getChartTitleData.ts index 00dd84bf..fba8df98 100644 --- a/src/src/utils/app/getChartTitleData.ts +++ b/src/src/utils/app/getChartTitleData.ts @@ -31,7 +31,7 @@ export default function getChartTitleData({ let chartTitleData: IChartTitleData = {}; // Get the list of conditions as strings - const conditions: IGroupingCondition[] = groupData.conditions?.map( + const conditions: IGroupingCondition[] = groupData.conditions?.chart?.map( (condition: IGroupingCondition) => `${condition.fieldName} ${condition.operator} ${condition.value}`, ); diff --git a/src/src/utils/app/getConditionStrings.ts b/src/src/utils/app/getConditionStrings.ts new file mode 100644 index 00000000..0c51e685 --- /dev/null +++ b/src/src/utils/app/getConditionStrings.ts @@ -0,0 +1,12 @@ +import { IGroupingCondition } from 'types/services/models/metrics/metricsAppModel'; + +/** + * Get the list of conditions as strings + * @param conditions the list of IGroupingCondition + * @returns the list of conditions as strings + */ +export function getConditionStrings(conditions: IGroupingCondition[]) { + return conditions.map((condition) => { + return `${condition.fieldName} ${condition.operator} ${condition.value}`; + }); +} diff --git a/src/src/utils/app/getLegendsData.tsx b/src/src/utils/app/getLegendsData.tsx index e8f4c934..2ab836c9 100644 --- a/src/src/utils/app/getLegendsData.tsx +++ b/src/src/utils/app/getLegendsData.tsx @@ -39,10 +39,10 @@ function getLegendsData( const groupConfig = groupingConfig[groupName]; const groupedItemPropKeys = - groupName !== GroupNameEnum.CHART + groupName === GroupNameEnum.ROW ? groupConfig || [] : groupConfig?.concat( - groupingConfig.conditions?.map( + groupingConfig.conditions?.[groupName].map( (condition) => `${condition.fieldName} ${condition.operator} ${condition.value}`, ) || [], diff --git a/src/src/utils/app/onGroupingConditionsChange.ts b/src/src/utils/app/onGroupingConditionsChange.ts index ec65b3d7..947ba1ab 100644 --- a/src/src/utils/app/onGroupingConditionsChange.ts +++ b/src/src/utils/app/onGroupingConditionsChange.ts @@ -1,3 +1,5 @@ +import { GroupNameEnum } from 'config/grouping/GroupingPopovers'; + import * as analytics from 'services/analytics'; import { IModel, State } from 'types/services/models/model'; @@ -8,11 +10,13 @@ import resetChartZoom from './resetChartZoom'; export default function onGroupingConditionsChange({ conditions, + groupName, model, appName, updateModelData, }: { conditions: IGroupingCondition[]; + groupName: GroupNameEnum; model: IModel; appName: string; updateModelData: ( @@ -23,7 +27,10 @@ export default function onGroupingConditionsChange({ let configData = model.getState()?.config; if (configData?.grouping) { - configData.grouping = { ...configData.grouping, conditions }; + configData.grouping.conditions = { + ...configData.grouping.conditions, + [groupName]: conditions, + }; configData = resetChartZoom({ configData, appName }); updateModelData(configData, true); } diff --git a/src/src/utils/showAutocompletion.ts b/src/src/utils/showAutocompletion.ts index 6d99395f..616fed1d 100644 --- a/src/src/utils/showAutocompletion.ts +++ b/src/src/utils/showAutocompletion.ts @@ -126,12 +126,16 @@ function getSuggestions(monaco: Monaco, options: Record) { } // flatten strings of array of accessible options paths without example type const filteredOptions = getObjectPaths(options, options).map((option) => { + const remappedOption = option.replace( + /\.metrics\.([^."]+)(\.[^.]+)?$/, + '.metrics["$1"]$2', + ); const indexOf = - option.indexOf('.__example_type__') !== -1 || - option[option.length - 1] === '.' - ? option.indexOf('.__example_type__') - : option.length; - return option.slice(0, indexOf); + remappedOption.indexOf('.__example_type__') !== -1 || + remappedOption[option.length - 1] === '.' + ? remappedOption.indexOf('.__example_type__') + : remappedOption.length; + return remappedOption.slice(0, indexOf); }); // If the last character typed is a period then we need to look at member objects of the `options` object const isMember = activeTyping.charAt(activeTyping.length - 1) === '.'; @@ -168,13 +172,19 @@ function getSuggestions(monaco: Monaco, options: Record) { endColumn: word.endColumn, }; + // Check if the prefix ends with ".metrics" + const metricsContextRegex = /\.metrics.$/; + // Get all the child properties of the last token for (const prop in lastToken) { // Do not show properites that begin with "__" if (lastToken.hasOwnProperty(prop) && !prop.startsWith('__')) { // Create completion object - const key = !jsValidVariableRegex.test(prop) ? `["${prop}"]` : prop; + const key = + !jsValidVariableRegex.test(prop) || metricsContextRegex.test(prefix) + ? `["${prop}"]` + : prop; let detailType = getDetailType(getValue(options, prefix + key)); const completionItem = {