From 0609ecf62480aac0b7e6d01d82229fb740dc6086 Mon Sep 17 00:00:00 2001 From: Simeon Widdis Date: Wed, 9 Oct 2024 09:26:20 -0700 Subject: [PATCH 1/6] Switch OSD Optimizer to rely on file hashes instead of `mtime`s (#8472) * Hotswap getMtimes to compute sha1 hashes Signed-off-by: Simeon Widdis * Rename all the things Signed-off-by: Simeon Widdis * Changeset file for PR #8472 created/updated --------- Signed-off-by: Simeon Widdis Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8472.yml | 2 ++ .../osd-optimizer/src/common/bundle.test.ts | 2 +- packages/osd-optimizer/src/common/bundle.ts | 8 ++--- .../integration_tests/bundle_cache.test.ts | 22 ++++++------ packages/osd-optimizer/src/node/cache.ts | 16 ++++----- .../src/node/node_auto_tranpilation.ts | 6 ++-- .../src/optimizer/bundle_cache.ts | 6 ++-- .../src/optimizer/cache_keys.test.ts | 12 +++---- .../osd-optimizer/src/optimizer/cache_keys.ts | 14 ++++---- ...{get_mtimes.test.ts => get_hashes.test.ts} | 26 +++++++++----- .../{get_mtimes.ts => get_hashes.ts} | 17 ++++++---- .../osd-optimizer/src/worker/run_compilers.ts | 34 ++++++++----------- 12 files changed, 87 insertions(+), 78 deletions(-) create mode 100644 changelogs/fragments/8472.yml rename packages/osd-optimizer/src/optimizer/{get_mtimes.test.ts => get_hashes.test.ts} (69%) rename packages/osd-optimizer/src/optimizer/{get_mtimes.ts => get_hashes.ts} (72%) diff --git a/changelogs/fragments/8472.yml b/changelogs/fragments/8472.yml new file mode 100644 index 000000000000..ec7f1d92b11e --- /dev/null +++ b/changelogs/fragments/8472.yml @@ -0,0 +1,2 @@ +infra: +- Switch OSD build cache to rely on file hashes instead of `mtime`s ([#8472](/~https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8472)) \ No newline at end of file diff --git a/packages/osd-optimizer/src/common/bundle.test.ts b/packages/osd-optimizer/src/common/bundle.test.ts index ceb7eff4b1db..fab8043e8799 100644 --- a/packages/osd-optimizer/src/common/bundle.test.ts +++ b/packages/osd-optimizer/src/common/bundle.test.ts @@ -55,7 +55,7 @@ it('creates cache keys', () => { ) ).toMatchInlineSnapshot(` Object { - "mtimes": Object { + "hashes": Object { "/foo/bar/a": 123, "/foo/bar/c": 789, }, diff --git a/packages/osd-optimizer/src/common/bundle.ts b/packages/osd-optimizer/src/common/bundle.ts index fbc03c55b03a..2f5932726668 100644 --- a/packages/osd-optimizer/src/common/bundle.ts +++ b/packages/osd-optimizer/src/common/bundle.ts @@ -103,13 +103,13 @@ export class Bundle { /** * Calculate the cache key for this bundle based from current - * mtime values. + * hash values. */ - createCacheKey(files: string[], mtimes: Map): unknown { + createCacheKey(files: string[], hashes: Map): unknown { return { spec: this.toSpec(), - mtimes: entriesToObject( - files.map((p) => [p, mtimes.get(p)] as const).sort(ascending((e) => e[0])) + hashes: entriesToObject( + files.map((p) => [p, hashes.get(p)] as const).sort(ascending((e) => e[0])) ), }; } diff --git a/packages/osd-optimizer/src/integration_tests/bundle_cache.test.ts b/packages/osd-optimizer/src/integration_tests/bundle_cache.test.ts index 83a503a21e7e..21abccca17e1 100644 --- a/packages/osd-optimizer/src/integration_tests/bundle_cache.test.ts +++ b/packages/osd-optimizer/src/integration_tests/bundle_cache.test.ts @@ -34,7 +34,7 @@ import cpy from 'cpy'; import del from 'del'; import { createAbsolutePathSerializer } from '@osd/dev-utils'; -import { getMtimes } from '../optimizer/get_mtimes'; +import { getHashes } from '../optimizer/get_hashes'; import { OptimizerConfig } from '../optimizer/optimizer_config'; import { allValuesFrom, Bundle } from '../common'; import { getBundleCacheEvent$ } from '../optimizer/bundle_cache'; @@ -77,8 +77,8 @@ it('emits "bundle cached" event when everything is updated', async () => { Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), ]; - const mtimes = await getMtimes(files); - const cacheKey = bundle.createCacheKey(files, mtimes); + const hashes = await getHashes(files); + const cacheKey = bundle.createCacheKey(files, hashes); bundle.cache.set({ cacheKey, @@ -116,8 +116,8 @@ it('emits "bundle not cached" event when cacheKey is up to date but caching is d Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), ]; - const mtimes = await getMtimes(files); - const cacheKey = bundle.createCacheKey(files, mtimes); + const hashes = await getHashes(files); + const cacheKey = bundle.createCacheKey(files, hashes); bundle.cache.set({ cacheKey, @@ -155,8 +155,8 @@ it('emits "bundle not cached" event when optimizerCacheKey is missing', async () Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), ]; - const mtimes = await getMtimes(files); - const cacheKey = bundle.createCacheKey(files, mtimes); + const hashes = await getHashes(files); + const cacheKey = bundle.createCacheKey(files, hashes); bundle.cache.set({ cacheKey, @@ -194,8 +194,8 @@ it('emits "bundle not cached" event when optimizerCacheKey is outdated, includes Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), ]; - const mtimes = await getMtimes(files); - const cacheKey = bundle.createCacheKey(files, mtimes); + const hashes = await getHashes(files); + const cacheKey = bundle.createCacheKey(files, hashes); bundle.cache.set({ cacheKey, @@ -238,8 +238,8 @@ it('emits "bundle not cached" event when bundleRefExportIds is outdated, include Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/index.ts'), Path.resolve(MOCK_REPO_DIR, 'plugins/foo/public/lib.ts'), ]; - const mtimes = await getMtimes(files); - const cacheKey = bundle.createCacheKey(files, mtimes); + const hashes = await getHashes(files); + const cacheKey = bundle.createCacheKey(files, hashes); bundle.cache.set({ cacheKey, diff --git a/packages/osd-optimizer/src/node/cache.ts b/packages/osd-optimizer/src/node/cache.ts index a81c656267bf..c176a38b932f 100644 --- a/packages/osd-optimizer/src/node/cache.ts +++ b/packages/osd-optimizer/src/node/cache.ts @@ -47,7 +47,7 @@ const dbName = (db: LMDB.Database) => export class Cache { private readonly codes: LMDB.RootDatabase; private readonly atimes: LMDB.Database; - private readonly mtimes: LMDB.Database; + private readonly hashes: LMDB.Database; private readonly sourceMaps: LMDB.Database; private readonly pathRoots: string[]; private readonly prefix: string; @@ -82,8 +82,8 @@ export class Cache { encoding: 'string', }); - this.mtimes = this.codes.openDB('mtimes', { - name: 'mtimes', + this.hashes = this.codes.openDB('hashes', { + name: 'hashes', encoding: 'string', }); @@ -106,8 +106,8 @@ export class Cache { } } - getMtime(path: string) { - return this.safeGet(this.mtimes, this.getKey(path)); + getFileHash(path: string) { + return this.safeGet(this.hashes, this.getKey(path)); } getCode(path: string) { @@ -131,12 +131,12 @@ export class Cache { } } - async update(path: string, file: { mtime: string; code: string; map: any }) { + async update(path: string, file: { filehash: string; code: string; map: any }) { const key = this.getKey(path); await Promise.all([ this.safePut(this.atimes, key, GLOBAL_ATIME), - this.safePut(this.mtimes, key, file.mtime), + this.safePut(this.hashes, key, file.filehash), this.safePut(this.codes, key, file.code), this.safePut(this.sourceMaps, key, JSON.stringify(file.map)), ]); @@ -223,7 +223,7 @@ export class Cache { // if a future version starts returning independent promises so // this is just for some future-proofing promises.add(this.atimes.remove(k)); - promises.add(this.mtimes.remove(k)); + promises.add(this.hashes.remove(k)); promises.add(this.codes.remove(k)); promises.add(this.sourceMaps.remove(k)); } diff --git a/packages/osd-optimizer/src/node/node_auto_tranpilation.ts b/packages/osd-optimizer/src/node/node_auto_tranpilation.ts index 87fcde1ce9d3..8beeaca3e144 100644 --- a/packages/osd-optimizer/src/node/node_auto_tranpilation.ts +++ b/packages/osd-optimizer/src/node/node_auto_tranpilation.ts @@ -105,8 +105,8 @@ function determineCachePrefix() { function compile(cache: Cache, source: string, path: string) { try { - const mtime = `${Fs.statSync(path).mtimeMs}`; - if (cache.getMtime(path) === mtime) { + const filehash = Crypto.createHash('sha1').update(Fs.readFileSync(path)).digest('base64'); + if (cache.getFileHash(path) === filehash) { const code = cache.getCode(path); if (code) { // code *should* always be defined, but if it isn't for some reason rebuild it @@ -122,7 +122,7 @@ function compile(cache: Cache, source: string, path: string) { } cache.update(path, { - mtime, + filehash, map: result.map, code: result.code, }); diff --git a/packages/osd-optimizer/src/optimizer/bundle_cache.ts b/packages/osd-optimizer/src/optimizer/bundle_cache.ts index 4545eb8072c0..6976606e1c4b 100644 --- a/packages/osd-optimizer/src/optimizer/bundle_cache.ts +++ b/packages/osd-optimizer/src/optimizer/bundle_cache.ts @@ -34,7 +34,7 @@ import { mergeAll } from 'rxjs/operators'; import { Bundle, BundleRefs } from '../common'; import { OptimizerConfig } from './optimizer_config'; -import { getMtimes } from './get_mtimes'; +import { getHashes } from './get_hashes'; import { diffCacheKey } from './cache_keys'; export type BundleCacheEvent = BundleNotCachedEvent | BundleCachedEvent; @@ -136,7 +136,7 @@ export function getBundleCacheEvent$( eligibleBundles.push(bundle); } - const mtimes = await getMtimes( + const hashes = await getHashes( new Set( eligibleBundles.reduce( (acc: string[], bundle) => [...acc, ...(bundle.cache.getReferencedFiles() || [])], @@ -148,7 +148,7 @@ export function getBundleCacheEvent$( for (const bundle of eligibleBundles) { const diff = diffCacheKey( bundle.cache.getCacheKey(), - bundle.createCacheKey(bundle.cache.getReferencedFiles() || [], mtimes) + bundle.createCacheKey(bundle.cache.getReferencedFiles() || [], hashes) ); if (diff) { diff --git a/packages/osd-optimizer/src/optimizer/cache_keys.test.ts b/packages/osd-optimizer/src/optimizer/cache_keys.test.ts index 95cd4b4b1d32..84d87678fad8 100644 --- a/packages/osd-optimizer/src/optimizer/cache_keys.test.ts +++ b/packages/osd-optimizer/src/optimizer/cache_keys.test.ts @@ -45,8 +45,8 @@ jest.mock('./get_changes.ts', () => ({ ]), })); -jest.mock('./get_mtimes.ts', () => ({ - getMtimes: async (paths: string[]) => new Map(paths.map((path) => [path, 12345])), +jest.mock('./get_hashes.ts', () => ({ + getHashes: async (paths: string[]) => new Map(paths.map((path) => [path, ''])), })); jest.mock('execa'); @@ -88,11 +88,11 @@ describe('getOptimizerCacheKey()', () => { "deletedPaths": Array [ "/foo/bar/c", ], - "lastCommit": "", - "modifiedTimes": Object { - "/foo/bar/a": 12345, - "/foo/bar/b": 12345, + "fileHashes": Object { + "/foo/bar/a": "", + "/foo/bar/b": "", }, + "lastCommit": "", "workerConfig": Object { "browserslistEnv": "dev", "dist": false, diff --git a/packages/osd-optimizer/src/optimizer/cache_keys.ts b/packages/osd-optimizer/src/optimizer/cache_keys.ts index 16efdea4888b..d809052dec6b 100644 --- a/packages/osd-optimizer/src/optimizer/cache_keys.ts +++ b/packages/osd-optimizer/src/optimizer/cache_keys.ts @@ -40,7 +40,7 @@ import { diff } from 'jest-diff'; import jsonStable from 'json-stable-stringify'; import { ascending, CacheableWorkerConfig } from '../common'; -import { getMtimes } from './get_mtimes'; +import { getHashes } from './get_hashes'; import { getChanges } from './get_changes'; import { OptimizerConfig } from './optimizer_config'; @@ -138,7 +138,7 @@ export interface OptimizerCacheKey { readonly bootstrap: string | undefined; readonly workerConfig: CacheableWorkerConfig; readonly deletedPaths: string[]; - readonly modifiedTimes: Record; + readonly fileHashes: Record; } async function getLastCommit() { @@ -181,14 +181,14 @@ export async function getOptimizerCacheKey(config: OptimizerConfig) { lastCommit, bootstrap, deletedPaths, - modifiedTimes: {} as Record, + fileHashes: {} as Record, workerConfig: config.getCacheableWorkerConfig(), }; - const mtimes = await getMtimes(modifiedPaths); - for (const [path, mtime] of Array.from(mtimes.entries()).sort(ascending((e) => e[0]))) { - if (typeof mtime === 'number') { - cacheKeys.modifiedTimes[path] = mtime; + const hashes = await getHashes(modifiedPaths); + for (const [path, filehash] of Array.from(hashes.entries()).sort(ascending((e) => e[0]))) { + if (typeof filehash === 'string') { + cacheKeys.fileHashes[path] = filehash; } } diff --git a/packages/osd-optimizer/src/optimizer/get_mtimes.test.ts b/packages/osd-optimizer/src/optimizer/get_hashes.test.ts similarity index 69% rename from packages/osd-optimizer/src/optimizer/get_mtimes.test.ts rename to packages/osd-optimizer/src/optimizer/get_hashes.test.ts index d305ca59a1a0..1713d9de5ad7 100644 --- a/packages/osd-optimizer/src/optimizer/get_mtimes.test.ts +++ b/packages/osd-optimizer/src/optimizer/get_hashes.test.ts @@ -30,28 +30,36 @@ jest.mock('fs'); -import { getMtimes } from './get_mtimes'; +import { getHashes } from './get_hashes'; -const { stat }: { stat: jest.Mock } = jest.requireMock('fs'); +const { stat, readFile }: { stat: jest.Mock; readFile: jest.Mock } = jest.requireMock('fs'); -it('returns mtimes Map', async () => { +it('returns hashes Map', async () => { stat.mockImplementation((path, cb) => { if (path.includes('missing')) { const error = new Error('file not found'); (error as any).code = 'ENOENT'; cb(error); } else { - cb(null, { - mtimeMs: 1234, - }); + cb(null, {}); } }); - await expect(getMtimes(['/foo/bar', '/foo/missing', '/foo/baz', '/foo/bar'])).resolves + readFile.mockImplementation((path, cb) => { + if (path.includes('missing')) { + const error = new Error('file not found'); + (error as any).code = 'ENOENT'; + cb(error); + } else { + cb(null, `Content of ${path}`); + } + }); + + await expect(getHashes(['/foo/bar', '/foo/missing', '/foo/baz', '/foo/bar'])).resolves .toMatchInlineSnapshot(` Map { - "/foo/bar" => 1234, - "/foo/baz" => 1234, + "/foo/bar" => "OwCtruddjWkB6ROdbLRM0NnWOhs=", + "/foo/baz" => "mb6SFQi4VuH8jbwW3h6YoolklXc=", } `); }); diff --git a/packages/osd-optimizer/src/optimizer/get_mtimes.ts b/packages/osd-optimizer/src/optimizer/get_hashes.ts similarity index 72% rename from packages/osd-optimizer/src/optimizer/get_mtimes.ts rename to packages/osd-optimizer/src/optimizer/get_hashes.ts index fdb9d58c9d8d..b83128d54249 100644 --- a/packages/osd-optimizer/src/optimizer/get_mtimes.ts +++ b/packages/osd-optimizer/src/optimizer/get_hashes.ts @@ -32,23 +32,28 @@ import Fs from 'fs'; import * as Rx from 'rxjs'; import { mergeMap, map, catchError } from 'rxjs/operators'; +import Crypto from 'crypto'; import { allValuesFrom } from '../common'; -const stat$ = Rx.bindNodeCallback(Fs.stat); +// const stat$ = Rx.bindNodeCallback(Fs.stat); +const readFile$ = Rx.bindNodeCallback(Fs.readFile); /** - * get mtimes of referenced paths concurrently, limit concurrency to 100 + * Get content hashes of referenced paths concurrently, with at most 100 concurrent files */ -export async function getMtimes(paths: Iterable) { +export async function getHashes(paths: Iterable): Promise> { return new Map( await allValuesFrom( Rx.from(paths).pipe( - // map paths to [path, mtimeMs] entries with concurrency of + // map paths to [path, sha1Hash] entries with concurrency of // 100 at a time, ignoring missing paths mergeMap( (path) => - stat$(path).pipe( - map((stat) => [path, stat.mtimeMs] as const), + readFile$(path).pipe( + map( + (buffer) => + [path, Crypto.createHash('sha1').update(buffer).digest('base64')] as const + ), catchError((error: any) => error?.code === 'ENOENT' ? Rx.EMPTY : Rx.throwError(error) ) diff --git a/packages/osd-optimizer/src/worker/run_compilers.ts b/packages/osd-optimizer/src/worker/run_compilers.ts index 4cd55b5ad07c..03745eb4911e 100644 --- a/packages/osd-optimizer/src/worker/run_compilers.ts +++ b/packages/osd-optimizer/src/worker/run_compilers.ts @@ -58,6 +58,7 @@ import { isConcatenatedModule, getModulePath, } from './webpack_helpers'; +import { getHashes } from '../optimizer/get_hashes'; const PLUGIN_NAME = '@osd/optimizer'; @@ -178,28 +179,21 @@ const observeCompiler = ( } const files = Array.from(referencedFiles).sort(ascending((p) => p)); - const mtimes = new Map( - files.map((path): [string, number | undefined] => { - try { - return [path, compiler.inputFileSystem.statSync(path)?.mtimeMs]; - } catch (error) { - if (error?.code === 'ENOENT') { - return [path, undefined]; - } - throw error; - } + getHashes(files) + .then((hashes) => { + bundle.cache.set({ + bundleRefExportIds, + optimizerCacheKey: workerConfig.optimizerCacheKey, + cacheKey: bundle.createCacheKey(files, hashes), + moduleCount, + workUnits, + files, + }); }) - ); - - bundle.cache.set({ - bundleRefExportIds, - optimizerCacheKey: workerConfig.optimizerCacheKey, - cacheKey: bundle.createCacheKey(files, mtimes), - moduleCount, - workUnits, - files, - }); + .catch((_err) => { + // If cache fails to write, it's alright to ignore and reattempt next build + }); return compilerMsgs.compilerSuccess({ moduleCount, From 9eae14851672fad87d077856c2af47a8e181b671 Mon Sep 17 00:00:00 2001 From: "Yuanqi(Ella) Zhu" <53279298+zhyuanqi@users.noreply.github.com> Date: Wed, 9 Oct 2024 09:34:41 -0700 Subject: [PATCH 2/6] Encode searchId as it tends to be decoded after adds into url (#8530) * Encode searchId as it tends to be decoded after adds into url Signed-off-by: Yuanqi(Ella) Zhu <53279298+zhyuanqi@users.noreply.github.com> * Changeset file for PR #8530 created/updated * Changeset file for PR #8530 created/updated --------- Signed-off-by: Yuanqi(Ella) Zhu <53279298+zhyuanqi@users.noreply.github.com> Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8530.yml | 2 ++ src/plugins/visualizations/public/wizard/new_vis_modal.tsx | 1 + 2 files changed, 3 insertions(+) create mode 100644 changelogs/fragments/8530.yml diff --git a/changelogs/fragments/8530.yml b/changelogs/fragments/8530.yml new file mode 100644 index 000000000000..9cc0707cebdb --- /dev/null +++ b/changelogs/fragments/8530.yml @@ -0,0 +1,2 @@ +fix: +- Encode searchId as it tends to be decoded after adds into url. ([#8530](/~https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8530)) \ No newline at end of file diff --git a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx index 00a2253ea3ee..c2428174e5aa 100644 --- a/src/plugins/visualizations/public/wizard/new_vis_modal.tsx +++ b/src/plugins/visualizations/public/wizard/new_vis_modal.tsx @@ -170,6 +170,7 @@ class NewVisModal extends React.Component Date: Thu, 10 Oct 2024 10:20:04 +0800 Subject: [PATCH 3/6] feat: introducing workspace level ui settings and hide non-global ui settings from advance settings page (#8500) * feat: introducing workspace level ui settings and hide non-global ui settings from advance settings page + make defaultIndex pattern a workspace ui setting when workspace is on and make it a global setting when workspace is off Signed-off-by: Yulong Ruan * Changeset file for PR #8500 created/updated * fix: failed tests Signed-off-by: Yulong Ruan * fix: lint Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8500.yml | 2 + src/core/server/index.ts | 5 ++ src/core/server/internal_types.ts | 3 ++ src/core/server/legacy/legacy_service.test.ts | 2 + src/core/server/legacy/legacy_service.ts | 2 + src/core/server/mocks.ts | 5 ++ src/core/server/plugins/plugin_context.ts | 2 + src/core/server/server.ts | 7 +++ src/core/server/workspace/index.ts | 19 +++++++ src/core/server/workspace/mocks.ts | 43 +++++++++++++++ .../server/workspace/workspace_service.ts | 53 +++++++++++++++++++ src/core/types/ui_settings.ts | 1 + .../management_app/advanced_settings.tsx | 18 ++++++- src/plugins/data/server/plugin.ts | 2 +- src/plugins/data/server/ui_settings.ts | 11 +++- 15 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 changelogs/fragments/8500.yml create mode 100644 src/core/server/workspace/index.ts create mode 100644 src/core/server/workspace/mocks.ts create mode 100644 src/core/server/workspace/workspace_service.ts diff --git a/changelogs/fragments/8500.yml b/changelogs/fragments/8500.yml new file mode 100644 index 000000000000..63e3a509aed0 --- /dev/null +++ b/changelogs/fragments/8500.yml @@ -0,0 +1,2 @@ +feat: +- Introducing workspace level ui settings and hide non-global ui settings from advance settings page ([#8500](/~https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8500)) \ No newline at end of file diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 26dd30b8c6c4..984318556b4e 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -93,6 +93,7 @@ import { CoreEnvironmentUsageData, CoreServicesUsageData, } from './core_usage_data'; +import { WorkspaceSetup, WorkspaceStart } from './workspace'; export { CoreUsageData, CoreConfigUsageData, CoreEnvironmentUsageData, CoreServicesUsageData }; @@ -480,6 +481,8 @@ export interface CoreSetup = OsdServer as any; @@ -114,6 +115,7 @@ beforeEach(() => { metrics: metricsServiceMock.createInternalSetupContract(), security: securityServiceMock.createSetupContract(), dynamicConfig: dynamicConfigServiceMock.createInternalSetupContract(), + workspace: workspaceServiceMock.createInternalSetupContract(), }, plugins: { 'plugin-id': 'plugin-value' }, uiPlugins: { diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index 1dd9be84a663..60c3e628b4ed 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -239,6 +239,7 @@ export class LegacyService implements CoreService { getAsyncLocalStore: startDeps.core.dynamicConfig.getAsyncLocalStore, createStoreFromRequest: startDeps.core.dynamicConfig.createStoreFromRequest, }, + workspace: startDeps.core.workspace, }; const router = setupDeps.core.http.createRouter('', this.legacyId); @@ -308,6 +309,7 @@ export class LegacyService implements CoreService { auditTrail: setupDeps.core.auditTrail, getStartServices: () => Promise.resolve([coreStart, startDeps.plugins, {}]), security: setupDeps.core.security, + workspace: setupDeps.core.workspace, }; // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 823e778b469e..37d90bcfa93f 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -53,6 +53,7 @@ import { coreUsageDataServiceMock } from './core_usage_data/core_usage_data_serv import { securityServiceMock } from './security/security_service.mock'; import { crossCompatibilityServiceMock } from './cross_compatibility/cross_compatibility.mock'; import { dynamicConfigServiceMock } from './config/dynamic_config_service.mock'; +import { workspaceServiceMock } from './workspace/mocks'; export { configServiceMock } from './config/mocks'; export { dynamicConfigServiceMock } from './config/mocks'; @@ -170,6 +171,7 @@ function createCoreSetupMock({ .mockResolvedValue([createCoreStartMock(), pluginStartDeps, pluginStartContract]), security: securityServiceMock.createSetupContract(), dynamicConfigService: dynamicConfigServiceMock.createSetupContract(), + workspace: workspaceServiceMock.createSetupContract(), }; return mock; @@ -187,6 +189,7 @@ function createCoreStartMock() { coreUsageData: coreUsageDataServiceMock.createStartContract(), crossCompatibility: crossCompatibilityServiceMock.createStartContract(), dynamicConfig: dynamicConfigServiceMock.createStartContract(), + workspace: workspaceServiceMock.createStartContract(), }; return mock; @@ -209,6 +212,7 @@ function createInternalCoreSetupMock() { metrics: metricsServiceMock.createInternalSetupContract(), security: securityServiceMock.createSetupContract(), dynamicConfig: dynamicConfigServiceMock.createInternalSetupContract(), + workspace: workspaceServiceMock.createInternalSetupContract(), }; return setupDeps; } @@ -225,6 +229,7 @@ function createInternalCoreStartMock() { coreUsageData: coreUsageDataServiceMock.createStartContract(), crossCompatibility: crossCompatibilityServiceMock.createStartContract(), dynamicConfig: dynamicConfigServiceMock.createInternalStartContract(), + workspace: workspaceServiceMock.createInternalStartContract(), }; return startDeps; } diff --git a/src/core/server/plugins/plugin_context.ts b/src/core/server/plugins/plugin_context.ts index d9e79ec51a2b..28b7ec6337cf 100644 --- a/src/core/server/plugins/plugin_context.ts +++ b/src/core/server/plugins/plugin_context.ts @@ -226,6 +226,7 @@ export function createPluginSetupContext( registerAsyncLocalStoreRequestHeader: deps.dynamicConfig.registerAsyncLocalStoreRequestHeader, getStartService: deps.dynamicConfig.getStartService, }, + workspace: deps.workspace, }; } @@ -282,5 +283,6 @@ export function createPluginStartContext( getClient: deps.dynamicConfig.getClient, createStoreFromRequest: deps.dynamicConfig.createStoreFromRequest, }, + workspace: deps.workspace, }; } diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 1b2e5f05679e..ca8d7a7775fc 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -71,6 +71,7 @@ import { InternalCoreSetup, InternalCoreStart, ServiceConfigDescriptor } from '. import { CoreUsageDataService } from './core_usage_data'; import { CoreRouteHandlerContext } from './core_route_handler_context'; import { DynamicConfigService } from './config/dynamic_config_service'; +import { WorkspaceService } from './workspace/workspace_service'; const coreId = Symbol('core'); const rootConfigPath = ''; @@ -88,6 +89,7 @@ export class Server { private readonly plugins: PluginsService; private readonly savedObjects: SavedObjectsService; private readonly uiSettings: UiSettingsService; + private readonly workspace: WorkspaceService; private readonly environment: EnvironmentService; private readonly metrics: MetricsService; private readonly httpResources: HttpResourcesService; @@ -130,6 +132,7 @@ export class Server { this.opensearch = new OpenSearchService(core); this.savedObjects = new SavedObjectsService(core); this.uiSettings = new UiSettingsService(core); + this.workspace = new WorkspaceService(core); this.capabilities = new CapabilitiesService(core); this.environment = new EnvironmentService(core); this.metrics = new MetricsService(core); @@ -199,6 +202,7 @@ export class Server { http: httpSetup, savedObjects: savedObjectsSetup, }); + const workspaceSetup = await this.workspace.setup(); const metricsSetup = await this.metrics.setup({ http: httpSetup }); @@ -247,6 +251,7 @@ export class Server { metrics: metricsSetup, security: securitySetup, dynamicConfig: dynamicConfigServiceSetup, + workspace: workspaceSetup, }; const pluginsSetup = await this.plugins.setup(coreSetup); @@ -285,6 +290,7 @@ export class Server { soStartSpan?.end(); const capabilitiesStart = this.capabilities.start(); const uiSettingsStart = await this.uiSettings.start(); + const workspaceStart = await this.workspace.start(); const metricsStart = await this.metrics.start(); const httpStart = this.http.getStartContract(); const coreUsageDataStart = this.coreUsageData.start({ @@ -308,6 +314,7 @@ export class Server { coreUsageData: coreUsageDataStart, crossCompatibility: crossCompatibilityServiceStart, dynamicConfig: dynamicConfigServiceStart, + workspace: workspaceStart, }; const pluginsStart = await this.plugins.start(this.coreStart); diff --git a/src/core/server/workspace/index.ts b/src/core/server/workspace/index.ts new file mode 100644 index 000000000000..5fec378f0722 --- /dev/null +++ b/src/core/server/workspace/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PublicMethodsOf } from '@osd/utility-types'; + +import { + InternalWorkspaceServiceSetup, + InternalWorkspaceServiceStart, + WorkspaceService, +} from './workspace_service'; + +export { InternalWorkspaceServiceSetup, InternalWorkspaceServiceStart } from './workspace_service'; + +export type WorkspaceSetup = InternalWorkspaceServiceSetup; +export type WorkspaceStart = InternalWorkspaceServiceStart; + +export type IWorkspaceService = PublicMethodsOf; diff --git a/src/core/server/workspace/mocks.ts b/src/core/server/workspace/mocks.ts new file mode 100644 index 000000000000..d708885edc92 --- /dev/null +++ b/src/core/server/workspace/mocks.ts @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IWorkspaceService } from '.'; +import { InternalWorkspaceServiceSetup, InternalWorkspaceServiceStart } from './workspace_service'; + +const createWorkspaceServiceMock = () => { + const mocked: jest.Mocked = { + setup: jest.fn().mockReturnValue(createInternalSetupContractMock()), + start: jest.fn().mockReturnValue({}), + stop: jest.fn(), + }; + + return mocked; +}; + +const createInternalSetupContractMock = () => { + const mocked: jest.Mocked = { + isWorkspaceEnabled: jest.fn(), + }; + + return mocked; +}; +const createSetupContractMock = createInternalSetupContractMock; + +const createInternalStartContractMock = () => { + const mocked: jest.Mocked = { + isWorkspaceEnabled: jest.fn(), + }; + + return mocked; +}; +const createStartContractMock = createInternalStartContractMock; + +export const workspaceServiceMock = { + create: createWorkspaceServiceMock, + createInternalSetupContract: createInternalSetupContractMock, + createInternalStartContract: createInternalStartContractMock, + createSetupContract: createSetupContractMock, + createStartContract: createStartContractMock, +}; diff --git a/src/core/server/workspace/workspace_service.ts b/src/core/server/workspace/workspace_service.ts new file mode 100644 index 000000000000..9adeb130be96 --- /dev/null +++ b/src/core/server/workspace/workspace_service.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; + +import { CoreService } from '../../types'; +import { CoreContext } from '../core_context'; +import { Logger } from '../logging'; + +export interface InternalWorkspaceServiceSetup { + isWorkspaceEnabled: () => boolean; +} + +export interface InternalWorkspaceServiceStart { + isWorkspaceEnabled: () => boolean; +} + +/** @internal */ +export class WorkspaceService + implements CoreService { + private readonly log: Logger; + private readonly config$: Observable<{ enabled: boolean }>; + + constructor(private readonly coreContext: CoreContext) { + this.log = this.coreContext.logger.get('workspace-service'); + this.config$ = this.coreContext.configService.atPath<{ enabled: boolean }>('workspace'); + } + + public async setup(): Promise { + this.log.debug('Setting up workspace service'); + + const workspaceConfig = await this.config$.pipe(first()).toPromise(); + + return { + isWorkspaceEnabled: () => workspaceConfig.enabled, + }; + } + + public async start(): Promise { + this.log.debug('Starting workspace service'); + + const workspaceConfig = await this.config$.pipe(first()).toPromise(); + + return { + isWorkspaceEnabled: () => workspaceConfig.enabled, + }; + } + + public async stop() {} +} diff --git a/src/core/types/ui_settings.ts b/src/core/types/ui_settings.ts index 061c31c1d73a..6c0b90566d5f 100644 --- a/src/core/types/ui_settings.ts +++ b/src/core/types/ui_settings.ts @@ -63,6 +63,7 @@ export interface DeprecationSettings { export enum UiSettingScope { GLOBAL = 'global', USER = 'user', + WORKSPACE = 'workspace', } /** diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index 0ecca876e94d..bb5e107dc066 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -173,7 +173,23 @@ export class AdvancedSettingsComponent extends Component< const all = config.getAll(); const userSettingsEnabled = config.get('theme:enableUserControl'); return Object.entries(all) - .filter(([, setting]) => setting.scope !== UiSettingScope.USER) + .filter(([, setting]) => { + const scope = setting.scope; + // if scope is not defined, then it's a global ui setting + if (!scope) { + return true; + } + + if (typeof scope === 'string') { + return scope === UiSettingScope.GLOBAL; + } + + if (Array.isArray(scope)) { + return scope.includes(UiSettingScope.GLOBAL); + } + + return false; + }) .map((setting) => { return toEditableConfig({ def: setting[1], diff --git a/src/plugins/data/server/plugin.ts b/src/plugins/data/server/plugin.ts index a1e52fbb66a1..c9ca35a18cf6 100644 --- a/src/plugins/data/server/plugin.ts +++ b/src/plugins/data/server/plugin.ts @@ -106,7 +106,7 @@ export class DataServerPlugin this.autocompleteService.setup(core); this.dqlTelemetryService.setup(core, { usageCollection }); - core.uiSettings.register(getUiSettings()); + core.uiSettings.register(getUiSettings(core.workspace.isWorkspaceEnabled())); const searchSetup = await this.searchService.setup(core, { registerFunction: expressions.registerFunction, diff --git a/src/plugins/data/server/ui_settings.ts b/src/plugins/data/server/ui_settings.ts index 4cee0a9894dd..153361959e63 100644 --- a/src/plugins/data/server/ui_settings.ts +++ b/src/plugins/data/server/ui_settings.ts @@ -34,6 +34,10 @@ import { UiSettingsParams } from 'opensearch-dashboards/server'; // @ts-ignore untyped module import numeralLanguages from '@elastic/numeral/languages'; import { DEFAULT_QUERY_LANGUAGE, UI_SETTINGS } from '../common'; +// cannot import from core/server due to src/core/server/saved_objects/opensearch_query.js which +// export { opensearchKuery } from '../../../plugins/data/server'; +// eslint-disable-next-line @osd/eslint/no-restricted-paths +import { UiSettingScope } from '../../../core/server/ui_settings/types'; const luceneQueryLanguageLabel = i18n.translate('data.advancedSettings.searchQueryLanguageLucene', { defaultMessage: 'Lucene', @@ -79,7 +83,9 @@ const numeralLanguageIds = [ }), ]; -export function getUiSettings(): Record> { +export function getUiSettings( + workspaceEnabled: boolean +): Record> { return { [UI_SETTINGS.META_FIELDS]: { name: i18n.translate('data.advancedSettings.metaFieldsTitle', { @@ -200,6 +206,7 @@ export function getUiSettings(): Record> { defaultMessage: 'The index to access if no index is set', }), schema: schema.nullable(schema.string()), + scope: workspaceEnabled ? UiSettingScope.WORKSPACE : UiSettingScope.GLOBAL, }, [UI_SETTINGS.COURIER_IGNORE_FILTER_IF_FIELD_NOT_IN_INDEX]: { name: i18n.translate('data.advancedSettings.courier.ignoreFilterTitle', { @@ -757,7 +764,7 @@ export function getUiSettings(): Record> { }), value: ['none'], description: i18n.translate('data.advancedSettings.searchQueryLanguageBlocklistText', { - defaultMessage: `Additional languages that are blocked from being used in the query editor. + defaultMessage: `Additional languages that are blocked from being used in the query editor. Note: DQL and Lucene will not be blocked even if set.`, }), schema: schema.arrayOf(schema.string()), From 2ffb11f6c99bf8153bf5e5b9c8c36f10ccecce15 Mon Sep 17 00:00:00 2001 From: Miki Date: Thu, 10 Oct 2024 09:40:44 -0700 Subject: [PATCH 4/6] Allow hiding the TSVB axis for time series (#8504) * Allow hiding the TSVB axis for time series Also: * Compress non-OUI input fields * Allow setting scale of each axis for time series Signed-off-by: Miki * Changeset file for PR #8504 created/updated --------- Signed-off-by: Miki Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/8504.yml | 6 ++ .../application/components/aggs/_agg_row.scss | 4 - .../components/aggs/moving_average.js | 23 ++-- .../components/aggs/serial_diff.js | 23 ++-- .../application/components/aggs/top_hit.js | 21 ++-- .../components/panel_config/gauge.js | 25 +++-- .../components/panel_config/table.js | 23 ++-- .../components/panel_config/timeseries.js | 6 +- .../components/vis_types/timeseries/config.js | 102 +++++++++++++----- .../components/vis_types/timeseries/vis.js | 21 +++- .../visualizations/constants/chart.js | 6 ++ .../public/metrics_type.ts | 1 + 12 files changed, 175 insertions(+), 86 deletions(-) create mode 100644 changelogs/fragments/8504.yml diff --git a/changelogs/fragments/8504.yml b/changelogs/fragments/8504.yml new file mode 100644 index 000000000000..3ccc6590581d --- /dev/null +++ b/changelogs/fragments/8504.yml @@ -0,0 +1,6 @@ +feat: +- Allow hiding the TSVB axis for time series visualizations ([#8504](/~https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8504)) +- Allow setting scale of each axis for TSVB time series ([#8504](/~https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8504)) + +fix: +- Compress non-OUI input fields in TSVB visualizations ([#8504](/~https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8504)) \ No newline at end of file diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/_agg_row.scss b/src/plugins/vis_type_timeseries/public/application/components/aggs/_agg_row.scss index f021d0f1905a..13c874435d36 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/_agg_row.scss +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/_agg_row.scss @@ -17,7 +17,3 @@ .tvbAggRow__unavailable { margin-top: -$euiSizeXS; } - -.tvbAgg__input { - @include euiFormControlStyle($borderOnly: false, $includeStates: true, $includeSizes: false); -} diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/moving_average.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/moving_average.js index 22254d3d4a08..cdc80d0650a9 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/moving_average.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/moving_average.js @@ -45,6 +45,7 @@ import { EuiSpacer, EuiCompressedFormRow, EuiCompressedFieldNumber, + EuiFormControlLayout, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { MODEL_TYPES } from '../../../../common/model_options'; @@ -207,16 +208,18 @@ export const MovingAverageAgg = (props) => { }) } > - {/* - EUITODO: The following input couldn't be converted to EUI because of type mis-match. - Should it be text or number? - */} - + + {/* + EUITODO: The following input couldn't be converted to EUI because of type mis-match. + Should it be text or number? + */} + + diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js index b3fb682d7bb6..dfc8b9101605 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/serial_diff.js @@ -43,6 +43,7 @@ import { EuiFormLabel, EuiCompressedFormRow, EuiSpacer, + EuiFormControlLayout, } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; @@ -117,16 +118,18 @@ export const SerialDiffAgg = (props) => { /> } > - {/* - EUITODO: The following input couldn't be converted to EUI because of type mis-match. - Should it be text or number? - */} - + + {/* + EUITODO: The following input couldn't be converted to EUI because of type mis-match. + Should it be text or number? + */} + + diff --git a/src/plugins/vis_type_timeseries/public/application/components/aggs/top_hit.js b/src/plugins/vis_type_timeseries/public/application/components/aggs/top_hit.js index 7580c1c881bd..65ed49714a6e 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/aggs/top_hit.js +++ b/src/plugins/vis_type_timeseries/public/application/components/aggs/top_hit.js @@ -44,6 +44,7 @@ import { EuiCompressedComboBox, EuiSpacer, EuiCompressedFormRow, + EuiFormControlLayout, } from '@elastic/eui'; import { injectI18n, FormattedMessage } from '@osd/i18n/react'; import { OSD_FIELD_TYPES } from '../../../../../../plugins/data/public'; @@ -206,15 +207,17 @@ const TopHitAggUi = (props) => { } > - {/* - EUITODO: The following input couldn't be converted to EUI because of type mis-match. - Should it be text or number? - */} - + + {/* + EUITODO: The following input couldn't be converted to EUI because of type mis-match. + Should it be text or number? + */} + + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.js index d623915074a9..1d2b32a035d4 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.js +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/gauge.js @@ -52,6 +52,7 @@ import { EuiCompressedFieldNumber, EuiTitle, EuiHorizontalRule, + EuiFormControlLayout, } from '@elastic/eui'; import { injectI18n, FormattedMessage } from '@osd/i18n/react'; import { QueryBarWrapper } from '../query_bar_wrapper'; @@ -215,17 +216,19 @@ class GaugePanelConfigUi extends Component { /> } > - {/* - EUITODO: The following input couldn't be converted to EUI because of type mis-match. - It accepts a null value, but is passed a empty string. - */} - + + {/* + EUITODO: The following input couldn't be converted to EUI because of type mis-match. + It accepts a null value, but is passed a empty string. + */} + + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.js index e5ce1e9f9865..a7fef70aeaed 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.js +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/table.js @@ -52,6 +52,7 @@ import { EuiHorizontalRule, EuiCode, EuiText, + EuiFormControlLayout, } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; import { QueryBarWrapper } from '../query_bar_wrapper'; @@ -164,16 +165,18 @@ export class TablePanelConfig extends Component { /> } > - {/* - EUITODO: The following input couldn't be converted to EUI because of type mis-match. - Should it be number or string? - */} - + + {/* + EUITODO: The following input couldn't be converted to EUI because of type mis-match. + Should it be number or string? + */} + + diff --git a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js index ac95f5450df1..09c094178502 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js +++ b/src/plugins/vis_type_timeseries/public/application/components/panel_config/timeseries.js @@ -48,7 +48,7 @@ import { EuiCompressedFormRow, EuiFormLabel, EuiSpacer, - EuiCompressedFieldText, + EuiCompressedFieldNumber, EuiTitle, EuiHorizontalRule, } from '@elastic/eui'; @@ -271,7 +271,7 @@ class TimeseriesPanelConfigUi extends Component { /> } > - @@ -287,7 +287,7 @@ class TimeseriesPanelConfigUi extends Component { /> } > - diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js index d0a4af543610..41dd2fee0bc2 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/config.js @@ -47,13 +47,14 @@ import { EuiCompressedFieldNumber, EuiFormLabel, EuiSpacer, + EuiFormControlLayout, } from '@elastic/eui'; import { FormattedMessage, injectI18n } from '@osd/i18n/react'; import { getDefaultQueryLanguage } from '../../lib/get_default_query_language'; import { QueryBarWrapper } from '../../query_bar_wrapper'; import { isPercentDisabled } from '../../lib/stacked'; -import { STACKED_OPTIONS } from '../../../visualizations/constants/chart'; +import { STACKED_OPTIONS, AXIS_POSITION } from '../../../visualizations/constants/chart'; export const TimeseriesConfig = injectI18n(function (props) { const handleSelectChange = createSelectHandler(props.onChange); @@ -114,20 +115,48 @@ export const TimeseriesConfig = injectI18n(function (props) { id: 'visTypeTimeseries.timeSeries.rightLabel', defaultMessage: 'Right', }), - value: 'right', + value: AXIS_POSITION.RIGHT, }, { label: intl.formatMessage({ id: 'visTypeTimeseries.timeSeries.leftLabel', defaultMessage: 'Left', }), - value: 'left', + value: AXIS_POSITION.LEFT, + }, + { + label: intl.formatMessage({ + id: 'visTypeTimeseries.timeSeries.hiddenLabel', + defaultMessage: 'Hidden', + }), + value: AXIS_POSITION.HIDDEN, }, ]; + const selectedAxisPosOption = positionOptions.find((option) => { return model.axis_position === option.value; }); + const scaleOptions = [ + { + label: intl.formatMessage({ + id: 'visTypeTimeseries.timeseries.scaleOptions.normalLabel', + defaultMessage: 'Normal', + }), + value: 'normal', + }, + { + label: intl.formatMessage({ + id: 'visTypeTimeseries.timeseries.scaleOptions.logLabel', + defaultMessage: 'Log', + }), + value: 'log', + }, + ]; + const selectedAxisScaleOption = scaleOptions.find((option) => { + return model.axis_scale === option.value; + }); + const chartTypeOptions = [ { label: intl.formatMessage({ @@ -505,17 +534,19 @@ export const TimeseriesConfig = injectI18n(function (props) { /> } > - {/* - EUITODO: The following input couldn't be converted to EUI because of type mis-match. - It accepts a null value, but is passed a empty string. - */} - + + {/* + EUITODO: The following input couldn't be converted to EUI because of type mis-match. + It accepts a null value, but is passed a empty string. + */} + + @@ -528,16 +559,19 @@ export const TimeseriesConfig = injectI18n(function (props) { /> } > - {/* - EUITODO: The following input couldn't be converted to EUI because of type mis-match. - It accepts a null value, but is passed a empty string. - */} - + + {/* + EUITODO: The following input couldn't be converted to EUI because of type mis-match. + It accepts a null value, but is passed a empty string. + */} + + @@ -560,6 +594,26 @@ export const TimeseriesConfig = injectI18n(function (props) { /> + + + } + > + + + diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js index db0dc4de77a6..cbf293cb1781 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_types/timeseries/vis.js @@ -43,7 +43,7 @@ import { replaceVars } from '../../lib/replace_vars'; import { getAxisLabelString } from '../../lib/get_axis_label_string'; import { getInterval } from '../../lib/get_interval'; import { createXaxisFormatter } from '../../lib/create_xaxis_formatter'; -import { STACKED_OPTIONS } from '../../../visualizations/constants'; +import { STACKED_OPTIONS, AXIS_POSITION } from '../../../visualizations/constants'; import { getCoreStart } from '../../../../services'; export class TimeseriesVisualization extends Component { @@ -202,7 +202,11 @@ export class TimeseriesVisualization extends Component { seriesGroup.axisFormatter = 'percent'; seriesGroup.axis_min = seriesGroup.axis_min || 0; seriesGroup.axis_max = seriesGroup.axis_max || 1; - seriesGroup.axis_position = model.axis_position; + // If the axis is hidden, arbitrarily set it to left + seriesGroup.axis_position = + seriesGroup.axis_position === AXIS_POSITION.HIDDEN + ? AXIS_POSITION.LEFT + : model.axis_position; } series @@ -219,8 +223,11 @@ export class TimeseriesVisualization extends Component { domain, groupId, id: yAxisIdGenerator(seriesGroup.id), - position: seriesGroup.axis_position, - hide: isStackedWithinSeries, + position: + seriesGroup.axis_position === AXIS_POSITION.HIDDEN + ? AXIS_POSITION.LEFT + : seriesGroup.axis_position, + hide: isStackedWithinSeries || seriesGroup.axis_position === AXIS_POSITION.HIDDEN, tickFormatter: seriesGroup.stacked === STACKED_OPTIONS.PERCENT ? this.yAxisStackedByPercentFormatter @@ -231,7 +238,11 @@ export class TimeseriesVisualization extends Component { tickFormatter: allSeriesHaveSameFormatters ? seriesGroupTickFormatter : (val) => val, id: yAxisIdGenerator('main'), groupId: mainAxisGroupId, - position: model.axis_position, + position: + seriesGroup.axis_position === AXIS_POSITION.HIDDEN + ? AXIS_POSITION.LEFT + : model.axis_position, + hide: seriesGroup.axis_position === AXIS_POSITION.HIDDEN, domain: mainAxisDomain, }); diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/constants/chart.js b/src/plugins/vis_type_timeseries/public/application/visualizations/constants/chart.js index a9a82d5c2bef..a21111cad21d 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/constants/chart.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/constants/chart.js @@ -51,3 +51,9 @@ export const STACKED_OPTIONS = { STACKED: 'stacked', STACKED_WITHIN_SERIES: 'stacked_within_series', }; + +export const AXIS_POSITION = { + LEFT: 'left', + RIGHT: 'right', + HIDDEN: 'hidden', +}; diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index ed3ea21c25e2..337689e2f57d 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -65,6 +65,7 @@ export const metricsVisDefinition = { ], separate_axis: 0, axis_position: 'right', + axis_scale: 'normal', formatter: 'number', chart_type: 'line', line_width: 1, From 36debc8cff0bf4290c9a32550fb3be73a1a43f91 Mon Sep 17 00:00:00 2001 From: Suchit Sahoo <38322563+LDrago27@users.noreply.github.com> Date: Thu, 10 Oct 2024 13:44:24 -0700 Subject: [PATCH 5/6] Hide Doc links for Unsupported Langauges (#8548) Signed-off-by: Suchit Sahoo --- src/plugins/discover/public/plugin.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index 2e1f76aea6ad..2b17d334283b 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -206,9 +206,10 @@ export class DiscoverPlugin generateCb: (renderProps: any) => { const globalFilters: any = getServices().filterManager.getGlobalFilters(); const appFilters: any = getServices().filterManager.getAppFilters(); - const showDocLinks = getServices() - .data.query.queryString.getLanguageService() - .getUiOverrides().showDocLinks; + const queryString = getServices().data.query.queryString; + const showDocLinks = + queryString.getLanguageService().getLanguage(queryString.getQuery().language) + ?.showDocLinks ?? undefined; const hash = stringify( url.encodeQuery({ @@ -242,9 +243,10 @@ export class DiscoverPlugin defaultMessage: 'View single document', }), generateCb: (renderProps) => { - const showDocLinks = getServices() - .data.query.queryString.getLanguageService() - .getUiOverrides().showDocLinks; + const queryString = getServices().data.query.queryString; + const showDocLinks = + queryString.getLanguageService().getLanguage(queryString.getQuery().language) + ?.showDocLinks ?? undefined; const docUrl = `#/doc/${renderProps.indexPattern.id}/${ renderProps.hit._index From 615d7d48f8a34d89294ada1579f00cff1316476d Mon Sep 17 00:00:00 2001 From: Anan Zhuang Date: Thu, 10 Oct 2024 16:36:53 -0700 Subject: [PATCH 6/6] [VisBuilder-Next] Pie Chart Integration for VisBuilder (#7752) * [VisBuilder-Next] Pie Chart Integration for VisBuilder This PR integrates pie charts into VisBuilder using Vega rendering. Issue Resolve: /~https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7607 --------- Signed-off-by: Anan Zhuang --- .../application/components/data_tab/index.tsx | 13 ++ .../validations/validate_aggregations.test.ts | 76 +++++++++++ .../validations/validate_aggregations.ts | 16 ++- src/plugins/vis_builder/public/plugin.ts | 3 +- .../public/services/type_service/types.ts | 1 + .../public/visualizations/index.ts | 30 +++- .../vega/components/{ => mark}/mark.test.ts | 0 .../vega/components/{ => mark}/mark.ts | 4 +- .../vega/components/mark/mark_slices.test.ts | 56 ++++++++ .../vega/components/mark/mark_slices.ts | 121 +++++++++++++++++ .../vega/utils/expression_helper.test.ts | 11 +- .../vega/utils/expression_helper.ts | 6 +- .../visualizations/vega/utils/helpers.test.ts | 105 +++++++++++++- .../visualizations/vega/utils/helpers.ts | 87 ++++++++++-- .../public/visualizations/vega/utils/types.ts | 5 +- ... => vega_lite_spec_series_builder.test.ts} | 16 +-- ...er.ts => vega_lite_spec_series_builder.ts} | 4 +- .../visualizations/vega/vega_spec_factory.ts | 22 ++- ...st.ts => vega_spec_series_builder.test.ts} | 12 +- ...builder.ts => vega_spec_series_builder.ts} | 8 +- .../vega/vega_spec_slices_builder.test.ts | 75 ++++++++++ .../vega/vega_spec_slices_builder.ts | 89 ++++++++++++ .../visualizations/vislib/area/index.ts | 2 +- .../visualizations/vislib/histogram/index.ts | 2 +- .../public/visualizations/vislib/index.ts | 7 +- .../visualizations/vislib/line/index.ts | 2 +- .../vislib/pie/components/pie_vis_options.tsx | 54 ++++++++ .../public/visualizations/vislib/pie/index.ts | 6 + .../visualizations/vislib/pie/pie_vis_type.ts | 93 +++++++++++++ .../vislib/pie/to_expression.test.ts | 128 ++++++++++++++++++ .../vislib/pie/to_expression.ts | 82 +++++++++++ src/plugins/vis_builder/server/plugin.ts | 1 + 32 files changed, 1084 insertions(+), 53 deletions(-) rename src/plugins/vis_builder/public/visualizations/vega/components/{ => mark}/mark.test.ts (100%) rename src/plugins/vis_builder/public/visualizations/vega/components/{ => mark}/mark.ts (98%) create mode 100644 src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.test.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.ts rename src/plugins/vis_builder/public/visualizations/vega/{vega_lite_spec_builder.test.ts => vega_lite_spec_series_builder.test.ts} (81%) rename src/plugins/vis_builder/public/visualizations/vega/{vega_lite_spec_builder.ts => vega_lite_spec_series_builder.ts} (97%) rename src/plugins/vis_builder/public/visualizations/vega/{vega_spec_builder.test.ts => vega_spec_series_builder.test.ts} (77%) rename src/plugins/vis_builder/public/visualizations/vega/{vega_spec_builder.ts => vega_spec_series_builder.ts} (96%) create mode 100644 src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.test.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vislib/pie/components/pie_vis_options.tsx create mode 100644 src/plugins/vis_builder/public/visualizations/vislib/pie/index.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vislib/pie/pie_vis_type.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.test.ts create mode 100644 src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.ts diff --git a/src/plugins/vis_builder/public/application/components/data_tab/index.tsx b/src/plugins/vis_builder/public/application/components/data_tab/index.tsx index 51a69950c719..1764b62a4454 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/index.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/index.tsx @@ -20,6 +20,7 @@ import { addFieldToConfiguration } from './drag_drop/add_field_to_configuration' import { replaceFieldInConfiguration } from './drag_drop/replace_field_in_configuration'; import { reorderFieldsWithinSchema } from './drag_drop/reorder_fields_within_schema'; import { moveFieldBetweenSchemas } from './drag_drop/move_field_between_schemas'; +import { validateAggregations } from '../../utils/validations'; export const DATA_TAB_ID = 'data_tab'; @@ -39,6 +40,7 @@ export const DataTab = () => { data: { search: { aggs: aggService }, }, + notifications: { toasts }, }, } = useOpenSearchDashboards(); @@ -77,6 +79,17 @@ export const DataTab = () => { } const panelGroups = Array.from(schemas.all.map((schema) => schema.name)); + // Check schema order + if (destinationSchemaName === 'split') { + const validationResult = validateAggregations(aggProps.aggs, schemas.all); + if (!validationResult.valid) { + toasts.addWarning({ + title: 'vb_invalid_schema', + text: validationResult.errorMsg, + }); + return; + } + } if (destinationSchemaName.startsWith(ADD_PANEL_PREFIX)) { const updatedDestinationSchemaName = destinationSchemaName.split(ADD_PANEL_PREFIX)[1]; diff --git a/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.test.ts b/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.test.ts index bec1ae506928..a0b8c7be24f9 100644 --- a/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.test.ts +++ b/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.test.ts @@ -96,4 +96,80 @@ describe('validateAggregations', () => { expect(valid).toBe(true); expect(errorMsg).not.toBeDefined(); }); + + test('Split chart should be first in the configuration', () => { + const aggConfigs = dataStart.search.aggs.createAggConfigs(indexPattern as IndexPattern, [ + { + id: '0', + enabled: true, + type: BUCKET_TYPES.TERMS, + schema: 'group', + params: {}, + }, + { + id: '1', + enabled: true, + type: BUCKET_TYPES.TERMS, + schema: 'split', + params: {}, + }, + ]); + + const schemas = [{ name: 'split', mustBeFirst: true }, { name: 'group' }]; + + const { valid, errorMsg } = validateAggregations(aggConfigs.aggs, schemas); + + expect(valid).toBe(false); + expect(errorMsg).toMatchInlineSnapshot(`"Split chart must be first in the configuration."`); + }); + + test('Valid configuration with split chart first', () => { + const aggConfigs = dataStart.search.aggs.createAggConfigs(indexPattern as IndexPattern, [ + { + id: '0', + enabled: true, + type: BUCKET_TYPES.TERMS, + schema: 'split', + params: {}, + }, + { + id: '2', + enabled: true, + type: METRIC_TYPES.COUNT, + schema: 'metric', + params: {}, + }, + ]); + + const schemas = [{ name: 'split', mustBeFirst: true }, { name: 'metric' }]; + + const { valid, errorMsg } = validateAggregations(aggConfigs.aggs, schemas); + + expect(valid).toBe(true); + expect(errorMsg).toBeUndefined(); + }); + + test('Valid configuration when schemas are not provided', () => { + const aggConfigs = dataStart.search.aggs.createAggConfigs(indexPattern as IndexPattern, [ + { + id: '0', + enabled: true, + type: BUCKET_TYPES.TERMS, + schema: 'group', + params: {}, + }, + { + id: '1', + enabled: true, + type: BUCKET_TYPES.TERMS, + schema: 'split', + params: {}, + }, + ]); + + const { valid, errorMsg } = validateAggregations(aggConfigs.aggs); + + expect(valid).toBe(true); + expect(errorMsg).not.toBeDefined(); + }); }); diff --git a/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.ts b/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.ts index 470c83e96895..37dde685a14c 100644 --- a/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.ts +++ b/src/plugins/vis_builder/public/application/utils/validations/validate_aggregations.ts @@ -12,9 +12,10 @@ import { ValidationResult } from './types'; /** * Validate if the aggregations to perform are possible * @param aggs Aggregations to be performed + * @param schemas Optional. All available schemas * @returns ValidationResult */ -export const validateAggregations = (aggs: AggConfig[]): ValidationResult => { +export const validateAggregations = (aggs: AggConfig[], schemas?: any[]): ValidationResult => { // Pipeline aggs should have a valid bucket agg const metricAggs = aggs.filter((agg) => agg.schema === 'metric'); const lastParentPipelineAgg = findLast( @@ -50,5 +51,18 @@ export const validateAggregations = (aggs: AggConfig[]): ValidationResult => { }; } + const splitSchema = schemas?.find((s) => s.name === 'split'); + if (splitSchema?.mustBeFirst) { + const firstGroupSchemaIndex = aggs.findIndex((item) => item.schema === 'group'); + if (firstGroupSchemaIndex !== -1) { + return { + valid: false, + errorMsg: i18n.translate('visBuilder.aggregation.splitChartOrderErrorMessage', { + defaultMessage: 'Split chart must be first in the configuration.', + }), + }; + } + } + return { valid: true }; }; diff --git a/src/plugins/vis_builder/public/plugin.ts b/src/plugins/vis_builder/public/plugin.ts index 20b13281e53b..216feafe187a 100644 --- a/src/plugins/vis_builder/public/plugin.ts +++ b/src/plugins/vis_builder/public/plugin.ts @@ -57,6 +57,7 @@ import { } from '../../opensearch_dashboards_utils/public'; import { opensearchFilters } from '../../data/public'; import { createRawDataVisFn } from './visualizations/vega/utils/expression_helper'; +import { VISBUILDER_ENABLE_VEGA_SETTING } from '../common/constants'; export class VisBuilderPlugin implements @@ -107,7 +108,7 @@ export class VisBuilderPlugin // Register Default Visualizations const typeService = this.typeService; - registerDefaultTypes(typeService.setup()); + registerDefaultTypes(typeService.setup(), core.uiSettings.get(VISBUILDER_ENABLE_VEGA_SETTING)); exp.registerFunction(createRawDataVisFn()); // Register the plugin to core diff --git a/src/plugins/vis_builder/public/services/type_service/types.ts b/src/plugins/vis_builder/public/services/type_service/types.ts index 0c232829431c..7f2a132fa752 100644 --- a/src/plugins/vis_builder/public/services/type_service/types.ts +++ b/src/plugins/vis_builder/public/services/type_service/types.ts @@ -34,4 +34,5 @@ export interface VisualizationTypeOptions { searchContext: IExpressionLoaderParams['searchContext'], useVega: boolean ) => Promise; + readonly hierarchicalData?: boolean | ((vis: { params: T }) => boolean); } diff --git a/src/plugins/vis_builder/public/visualizations/index.ts b/src/plugins/vis_builder/public/visualizations/index.ts index c867e570143e..6efa77dbf869 100644 --- a/src/plugins/vis_builder/public/visualizations/index.ts +++ b/src/plugins/vis_builder/public/visualizations/index.ts @@ -6,10 +6,32 @@ import type { TypeServiceSetup } from '../services/type_service'; import { createMetricConfig } from './metric'; import { createTableConfig } from './table'; -import { createHistogramConfig, createLineConfig, createAreaConfig } from './vislib'; +import { + createHistogramConfig, + createLineConfig, + createAreaConfig, + createPieConfig, +} from './vislib'; +import { VisualizationTypeOptions } from '../services/type_service'; +import { + HistogramOptionsDefaults, + LineOptionsDefaults, + AreaOptionsDefaults, + PieOptionsDefaults, +} from './vislib'; +import { MetricOptionsDefaults } from './metric/metric_viz_type'; +import { TableOptionsDefaults } from './table/table_viz_type'; -export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup) { - const visualizationTypes = [ +type VisualizationConfigFunction = + | (() => VisualizationTypeOptions) + | (() => VisualizationTypeOptions) + | (() => VisualizationTypeOptions) + | (() => VisualizationTypeOptions) + | (() => VisualizationTypeOptions) + | (() => VisualizationTypeOptions); + +export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup, useVega: boolean) { + const visualizationTypes: VisualizationConfigFunction[] = [ createHistogramConfig, createLineConfig, createAreaConfig, @@ -17,6 +39,8 @@ export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup) { createTableConfig, ]; + if (useVega) visualizationTypes.push(createPieConfig); + visualizationTypes.forEach((createTypeConfig) => { typeServiceSetup.createVisualizationType(createTypeConfig()); }); diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/mark.test.ts b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark.test.ts similarity index 100% rename from src/plugins/vis_builder/public/visualizations/vega/components/mark.test.ts rename to src/plugins/vis_builder/public/visualizations/vega/components/mark/mark.test.ts diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/mark.ts b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark.ts similarity index 98% rename from src/plugins/vis_builder/public/visualizations/vega/components/mark.ts rename to src/plugins/vis_builder/public/visualizations/vega/components/mark/mark.ts index dd85544dd839..6e717960e7f2 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/components/mark.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark.ts @@ -4,7 +4,7 @@ */ import { AxisFormats } from '../utils/types'; -import { buildAxes } from './axes'; +import { buildAxes } from '../axes'; export type VegaMarkType = | 'line' @@ -87,7 +87,7 @@ export const buildMarkForVegaLite = (vegaType: string): VegaLiteMark => { }; /** - * Builds a mark configuration for Vega based on the chart type. + * Builds a mark configuration for Vega useing series data based on the chart type. * * @param {VegaMarkType} chartType - The type of chart to build the mark for. * @param {any} dimensions - The dimensions of the data. diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.test.ts b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.test.ts new file mode 100644 index 000000000000..b14fe365a14e --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.test.ts @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildSlicesMarkForVega, buildPieMarks, buildArcMarks } from './mark_slices'; + +describe('buildSlicesMarkForVega', () => { + it('should return a group mark with correct properties', () => { + const result = buildSlicesMarkForVega(['level1', 'level2'], true, true); + expect(result.type).toBe('group'); + expect(result.from).toEqual({ data: 'splits' }); + expect(result.encode.enter.width).toEqual({ signal: 'chartWidth' }); + expect(result.title).toBeDefined(); + expect(result.data).toBeDefined(); + expect(result.marks).toBeDefined(); + }); + + it('should handle non-split case correctly', () => { + const result = buildSlicesMarkForVega(['level1'], false, true); + expect(result.from).toBeNull(); + expect(result.encode.enter.width).toEqual({ signal: 'width' }); + expect(result.title).toBeNull(); + }); +}); + +describe('buildPieMarks', () => { + it('should create correct number of marks', () => { + const result = buildPieMarks(['level1', 'level2'], true); + expect(result).toHaveLength(2); + }); + + it('should create correct transformations', () => { + const result = buildPieMarks(['level1'], true); + expect(result[0].transform).toHaveLength(3); + expect(result[0].transform[0].type).toBe('filter'); + expect(result[0].transform[1].type).toBe('aggregate'); + expect(result[0].transform[2].type).toBe('pie'); + }); +}); + +describe('buildArcMarks', () => { + it('should create correct number of arc marks', () => { + const result = buildArcMarks(['level1', 'level2'], false); + expect(result).toHaveLength(2); + expect(result[0].encode.update.tooltip).toBeUndefined(); + }); + + it('should create arc marks with correct properties', () => { + const result = buildArcMarks(['level1'], true); + expect(result[0].type).toBe('arc'); + expect(result[0].encode.enter.fill).toBeDefined(); + expect(result[0].encode.update.startAngle).toBeDefined(); + expect(result[0].encode.update.tooltip).toBeDefined(); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.ts b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.ts new file mode 100644 index 000000000000..0fba717a2ce2 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/components/mark/mark_slices.ts @@ -0,0 +1,121 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Builds a mark configuration for Vega using slices data. + * + * @param {string[]} levels - The array of hierarchy levels. + * @param {boolean} hasSplit - Indicates whether we have split data. + * @returns {Object} An object containing a single group mark configuration. + */ +export const buildSlicesMarkForVega = (levels: string[], hasSplit: boolean, addTooltip) => { + return { + type: 'group', + // If we have splits, use the 'splits' data, otherwise no specific data source + from: hasSplit ? { data: 'splits' } : null, + encode: { + enter: { + // Set width based on whether we have splits or not + width: { signal: hasSplit ? 'chartWidth' : 'width' }, + height: { signal: 'chartHeight' }, + }, + }, + // Define signals for facet dimensions + signals: [ + { name: 'facetWidth', update: hasSplit ? 'chartWidth' : 'width' }, + { name: 'facetHeight', update: 'height' }, + ], + // Add a title if have splits + title: hasSplit + ? { + text: { signal: 'parent.split' }, + frame: 'group', + baseline: 'bottom', // Align the text to the bottom + orient: 'bottom', // Position the title at the bottom + offset: 20, + limit: { signal: 'chartWidth' }, // This limits the title width + ellipsis: '...', // Add ellipsis for truncated text + } + : null, + // Build the data for each level of the pie + data: buildPieMarks(levels, hasSplit), + // Build the arc marks for each level of the pie + marks: buildArcMarks(levels, addTooltip), + }; +}; + +/** + * Builds the data transformations for each level of the pie chart. + * + * @param {string[]} levels - The array of hierarchy levels. + * @param {boolean} hasSplit - Indicates whether we have split data. + * @returns {Object[]} An array of data transformation configurations for each level. + */ +export const buildPieMarks = (levels: string[], hasSplit: boolean) => { + return levels.map((level, index) => ({ + name: `facet_${level}`, + source: 'table', + transform: [ + // Filter data if we have splits + { + type: 'filter', + expr: hasSplit ? `datum.split === parent.split` : 'true', + }, + // Aggregate data for this level + { + type: 'aggregate', + groupby: levels.slice(0, index + 1), + fields: ['value'], + ops: ['sum'], + as: ['sum_value'], + }, + // Create pie layout + { type: 'pie', field: 'sum_value' }, + ], + })); +}; + +/** + * Builds the arc marks for each level of the pie chart. + * + * @param {string[]} levels - The array of hierarchy levels. + * @returns {Object[]} An array of arc mark configurations for each level. + */ +export const buildArcMarks = (levels: string[], addTooltip) => { + return levels.map((level, index) => ({ + type: 'arc', + from: { data: `facet_${level}` }, + encode: { + enter: { + // Set fill color based on the current level + fill: { scale: 'color', field: level }, + // Center the arc + x: { signal: 'facetWidth / 2' }, + y: { signal: 'facetHeight / 2' }, + }, + update: { + // Set arc angles and dimensions + startAngle: { field: 'startAngle' }, + endAngle: { field: 'endAngle' }, + padAngle: { value: 0.01 }, + innerRadius: { signal: `innerRadius + thickness * ${index}` }, + outerRadius: { signal: `innerRadius + thickness * (${index} + 1)` }, + stroke: { value: 'white' }, + strokeWidth: { value: 2 }, + // Create tooltip with all relevant level data + ...(addTooltip + ? { + tooltip: { + signal: `{${levels + .slice(0, index + 1) + .map((l) => `'${l}': datum.${l}`) + .join(', ')}, 'Value': datum.sum_value}`, + }, + } + : {}), + }, + }, + })); +}; diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.test.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.test.ts index dd26df5219c2..302de1ad727e 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.test.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.test.ts @@ -5,6 +5,7 @@ import { createRawDataVisFn, executeExpression } from './expression_helper'; import { getExpressionsService } from '../../../../../expressions/public'; +import { ExecutionContext, OpenSearchDashboardsDatatable } from '../../../../../expressions/public'; jest.mock('../../../../../expressions/public', () => ({ getExpressionsService: jest.fn(), @@ -22,7 +23,15 @@ describe('expression_helper.ts', () => { it('should return the input context unchanged', () => { const result = createRawDataVisFn(); const context = { some: 'data' }; - expect(result.fn(context as any)).toBe(context); + const mockArgs = {}; + const mockHandlers: ExecutionContext = { + getInitialInput: jest.fn(), + variables: {}, + types: {}, + abortSignal: new AbortController().signal, + inspectorAdapters: {}, + }; + expect(result.fn(context as any, mockArgs, mockHandlers)).toBe(context); }); }); diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.ts index 2967fa177983..9b71ce408a0c 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/expression_helper.ts @@ -27,7 +27,11 @@ export const createRawDataVisFn = (): ExpressionFunctionDefinition< inputTypes: ['opensearch_dashboards_datatable'], help: 'Returns raw data from opensearchaggs without modification', args: {}, - fn(context: OpenSearchDashboardsDatatable): OpenSearchDashboardsDatatable { + fn( + context: OpenSearchDashboardsDatatable, + args?: {}, + handlers?: {} + ): OpenSearchDashboardsDatatable { // Simply return the input context, which should be the opensearchaggs result return context; }, diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.test.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.test.ts index 92576d1a608b..4b210ea9b497 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.test.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.test.ts @@ -5,7 +5,7 @@ import { flattenDataHandler, mapFieldTypeToVegaType, mapChartTypeToVegaType } from './helpers'; -// Mock the vislibSeriesResponseHandler +// Mock the vislibSeriesResponseHandler and vislibSlicesResponseHandler jest.mock('../../../../../vis_type_vislib/public', () => ({ vislibSeriesResponseHandler: jest.fn((context, dimensions) => { if (dimensions.splitRow || dimensions.splitColumn) { @@ -18,6 +18,11 @@ jest.mock('../../../../../vis_type_vislib/public', () => ({ }; } }), + vislibSlicesResponseHandler: jest.fn((context) => { + return { + slices: context.slices, + }; + }), })); describe('helpers.ts', () => { @@ -66,13 +71,108 @@ describe('helpers.ts', () => { expect(result.series[0]).toEqual({ x: 1, y: 10, series: 'Series 1' }); expect(result.series[1]).toEqual({ x: 2, y: 20, series: 'Series 1' }); }); + + it('should flatten slice data correctly', () => { + const context = { + slices: { + children: [ + { + name: 'Category A', + children: [ + { name: 'Subcategory 1', size: 10 }, + { name: 'Subcategory 2', size: 20 }, + ], + }, + ], + }, + }; + const dimensions = {}; + const result = flattenDataHandler(context, dimensions, 'slices'); + + expect(result.slices).toHaveLength(2); + expect(result.slices[0]).toEqual({ + level1: 'Category A', + level2: 'Subcategory 1', + value: 10, + }); + expect(result.slices[1]).toEqual({ + level1: 'Category A', + level2: 'Subcategory 2', + value: 20, + }); + expect(result.levels).toEqual(['level1', 'level2']); + }); + + it('should handle slice data with splits', () => { + const context = { + slices: { + children: [ + { + name: 'Category A', + children: [{ name: 'Subcategory 1', size: 10 }], + }, + ], + }, + }; + const dimensions = { splitRow: [{}] }; + const result = flattenDataHandler(context, dimensions, 'slices'); + + expect(result.slices).toHaveLength(1); + expect(result.slices[0]).toEqual({ + level1: 'Category A', + level2: 'Subcategory 1', + value: 10, + split: undefined, + }); + }); + + it('should handle z-values in series data', () => { + const context = { + series: [ + { + label: 'Series 1', + values: [ + { x: 1, y: 10, z: 5 }, + { x: 2, y: 20, z: 10 }, + ], + }, + ], + }; + const dimensions = {}; + const result = flattenDataHandler(context, dimensions); + + expect(result.series).toHaveLength(2); + expect(result.series[0]).toEqual({ x: 1, y: 10, z: 5, series: 'Series 1' }); + expect(result.series[1]).toEqual({ x: 2, y: 20, z: 10, series: 'Series 1' }); + }); + + it('should throw an error if series values are not an array', () => { + const context = { + series: [ + { + label: 'Series 1', + values: 'not an array', + }, + ], + }; + const dimensions = {}; + + expect(() => flattenDataHandler(context, dimensions)).toThrow( + 'Each series must have a "values" array' + ); + }); }); describe('mapFieldTypeToVegaType', () => { it('should map OpenSearch field types to Vega data types', () => { expect(mapFieldTypeToVegaType('number')).toBe('quantitative'); expect(mapFieldTypeToVegaType('date')).toBe('temporal'); + expect(mapFieldTypeToVegaType('time')).toBe('temporal'); + expect(mapFieldTypeToVegaType('terms')).toBe('nominal'); expect(mapFieldTypeToVegaType('keyword')).toBe('nominal'); + expect(mapFieldTypeToVegaType('ip')).toBe('nominal'); + expect(mapFieldTypeToVegaType('boolean')).toBe('nominal'); + expect(mapFieldTypeToVegaType('histogram')).toBe('quantitative'); expect(mapFieldTypeToVegaType('unknown')).toBe('nominal'); }); }); @@ -81,6 +181,9 @@ describe('helpers.ts', () => { it('should map chart types to Vega mark types', () => { expect(mapChartTypeToVegaType('histogram')).toBe('bar'); expect(mapChartTypeToVegaType('line')).toBe('line'); + expect(mapChartTypeToVegaType('area')).toBe('area'); + expect(mapChartTypeToVegaType('bar')).toBe('bar'); + expect(mapChartTypeToVegaType('pie')).toBe('pie'); }); }); }); diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.ts index e15edf1ac795..e77e1460e7ae 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/helpers.ts @@ -37,7 +37,7 @@ interface Series { values: SeriesValue[]; } -interface FlattenedSeriesItem extends SeriesValue { +export interface FlattenedSeriesItem extends SeriesValue { series: string; split?: string; } @@ -79,30 +79,93 @@ const flattenSeries = ( }); }; +export interface FlattenedSliceItem { + [key: string]: any; + value: number; + split?: string; +} + +export interface FlattenHierarchyResult { + flattenedData: FlattenedSliceItem[]; + levels: string[]; +} + +/** + * Flattens hierarchical slice data into a single array of data points + * @param {any} data - The hierarchical data to flatten + * @param {any[]} group - The group data (rows or columns) if split dimensions exist + * @returns {FlattenedSliceItem[]} Flattened array of data points + */ +const flattenHierarchy = (data, group): FlattenHierarchyResult => { + const flattenedData: FlattenedSliceItem[] = []; + const levelSet = new Set(); + + const flattenSlices = ( + slices: any, + split?: string, + level = 1, + parentLabels: { [key: string]: string } = {} + ) => { + slices.children.forEach((child: any) => { + const currentLabels = { ...parentLabels, [`level${level}`]: child.name }; + levelSet.add(`level${level}`); + + if (Array.isArray(child.children) && child.children.length > 0) { + flattenSlices(child, split, level + 1, currentLabels); + } else { + const dataPoint: FlattenedSliceItem = { + ...currentLabels, + value: child.size !== undefined ? child.size : null, + }; + if (split !== undefined) { + dataPoint.split = split; + } + flattenedData.push(dataPoint); + } + }); + }; + + if (Array.isArray(group) && group.length !== 0) { + group.forEach((splitData) => { + flattenSlices(splitData.slices, splitData.label); + }); + } else { + flattenSlices(data.slices, undefined); + } + + return { flattenedData, levels: Array.from(levelSet) }; +}; + +/** + * Handles the flattening of data for different chart types + * @param {any} context - The context object containing the data + * @param {any} dimensions - The dimensions object defining the chart structure + * @param {'series' | 'slices'} handlerType - The type of chart data to handle + * @returns {any} Converted and flattened data suitable for visualization + */ export const flattenDataHandler = (context, dimensions, handlerType = 'series') => { - // Currently, our vislib only supports 'series' or 'slices' response types. - // This will need to be updated if more types are added in the future. + // TODO: Update this func if more types are added in the future. const handler = handlerType === 'series' ? vislibSeriesResponseHandler : vislibSlicesResponseHandler; const converted = handler(context, dimensions); + const group = dimensions.splitRow + ? converted.rows + : dimensions.splitColumn + ? converted.columns + : []; if (handlerType === 'series') { // Determine the group based on split dimensions - const group = dimensions.splitRow - ? converted.rows - : dimensions.splitColumn - ? converted.columns - : []; - - if (group && group.length !== 0) { + if (Array.isArray(group) && group.length !== 0) { converted.series = group.flatMap((split) => flattenSeries(split.series, split.label)); setAxisProperties(converted, group); } else { converted.series = flattenSeries(converted.series); } } else if (handlerType === 'slices') { - // TODO: Handle slices data, such as pie charts - // This section should be implemented when support for slice-based charts is added + const { flattenedData, levels } = flattenHierarchy(converted, group); + converted.slices = flattenedData; + converted.levels = levels; } return converted; diff --git a/src/plugins/vis_builder/public/visualizations/vega/utils/types.ts b/src/plugins/vis_builder/public/visualizations/vega/utils/types.ts index 855d88fd145e..1b5c630788f2 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/utils/types.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/utils/types.ts @@ -4,7 +4,8 @@ */ import { VegaEncoding } from '../components/encoding'; -import { VegaLiteMark } from '../components/mark'; +import { VegaLiteMark } from '../components/mark/mark'; + export interface AxisFormat { id: string; } @@ -76,7 +77,7 @@ export interface VegaSpec { }>; layout?: { [key: string]: any; - }; + } | null; marks: Array<{ type: string; from?: any; diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.test.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.test.ts similarity index 81% rename from src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.test.ts rename to src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.test.ts index 0f1f25b1e762..e5c0a2a93fce 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.test.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.test.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { generateVegaLiteSpec } from './vega_lite_spec_builder'; +import { generateVegaLiteSpecForSeries } from './vega_lite_spec_series_builder'; -describe('generateVegaLiteSpec', () => { +describe('generateVegaLiteSpecForSeries', () => { it('should generate a basic Vega-Lite specification', () => { const data = { xAxisFormat: { id: 'date' }, @@ -22,7 +22,7 @@ describe('generateVegaLiteSpec', () => { }; const style = { type: 'line' }; - const result = generateVegaLiteSpec(data, visConfig, style); + const result = generateVegaLiteSpecForSeries(data, visConfig, style); expect(result.$schema).toBe('https://vega.github.io/schema/vega-lite/v5.json'); expect(result.data).toBeDefined(); @@ -45,10 +45,10 @@ describe('generateVegaLiteSpec', () => { addTooltip: true, }; - const lineResult = generateVegaLiteSpec(data, visConfig, { type: 'line' }); + const lineResult = generateVegaLiteSpecForSeries(data, visConfig, { type: 'line' }); expect(lineResult.mark).toEqual({ type: 'line', point: true, tooltip: true }); - const areaResult = generateVegaLiteSpec(data, visConfig, { type: 'area' }); + const areaResult = generateVegaLiteSpecForSeries(data, visConfig, { type: 'area' }); expect(areaResult.mark).toEqual({ type: 'area', line: true, @@ -58,7 +58,7 @@ describe('generateVegaLiteSpec', () => { baseline: 0, }); - const barResult = generateVegaLiteSpec(data, visConfig, { type: 'bar' }); + const barResult = generateVegaLiteSpecForSeries(data, visConfig, { type: 'bar' }); expect(barResult.mark).toEqual({ type: 'bar', tooltip: true }); }); @@ -78,7 +78,7 @@ describe('generateVegaLiteSpec', () => { }; const style = { type: 'line' }; - const result = generateVegaLiteSpec(data, visConfig, style); + const result = generateVegaLiteSpecForSeries(data, visConfig, style); expect(result.config).toBeDefined(); expect(result.config!.legend).toBeDefined(); @@ -101,7 +101,7 @@ describe('generateVegaLiteSpec', () => { }; const style = { type: 'line' }; - const result = generateVegaLiteSpec(data, visConfig, style); + const result = generateVegaLiteSpecForSeries(data, visConfig, style); expect(result.encoding!.tooltip).toBeDefined(); expect(result.mark).toHaveProperty('tooltip', true); diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.ts similarity index 97% rename from src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.ts rename to src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.ts index 6110b3f75ed9..347f3a5299ba 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_builder.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_lite_spec_series_builder.ts @@ -4,7 +4,7 @@ */ import { buildVegaLiteEncoding } from './components/encoding'; -import { buildMarkForVegaLite, VegaMarkType } from './components/mark'; +import { buildMarkForVegaLite, VegaMarkType } from './components/mark/mark'; import { buildTooltip } from './components/tooltip'; import { buildLegend } from './components/legend'; import { StyleState } from '../../application/utils/state_management'; @@ -19,7 +19,7 @@ import { mapChartTypeToVegaType } from './utils/helpers'; * @param {StyleState} style - The StyleState defined in style slice. * @returns {VegaLiteSpec} The complete Vega-Lite specification. */ -export const generateVegaLiteSpec = ( +export const generateVegaLiteSpecForSeries = ( data: any, visConfig: any, style: StyleState diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_factory.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_factory.ts index b1ea168e8279..ba01c48e4ad5 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_factory.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_factory.ts @@ -5,8 +5,9 @@ import { StyleState } from '../../application/utils/state_management'; import { flattenDataHandler } from './utils/helpers'; -import { generateVegaLiteSpec } from './vega_lite_spec_builder'; -import { generateVegaSpec } from './vega_spec_builder'; +import { generateVegaLiteSpecForSeries } from './vega_lite_spec_series_builder'; +import { generateVegaSpecForSeries } from './vega_spec_series_builder'; +import { generateVegaSpecForSlices } from './vega_spec_slices_builder'; import { VegaLiteSpec, VegaSpec } from './utils/types'; /** @@ -25,15 +26,26 @@ export const createVegaSpec = ( const { dimensions } = visConfig; // Transform the data using the flattenDataHandler - const transformedData = flattenDataHandler(context, dimensions, 'series'); + const handler = style.type !== 'pie' ? 'series' : 'slices'; + const transformedData = flattenDataHandler(context, dimensions, handler); + return handler === 'series' + ? createVegaSpecForSeriesData(dimensions, transformedData, visConfig, style) + : createVegaSpecForSlicesData(dimensions, transformedData, visConfig, style); +}; + +const createVegaSpecForSeriesData = (dimensions, transformedData, visConfig, style) => { // Determine whether to use Vega or Vega-Lite based on the presence of split dimensions // TODO: Summarize the cases to use Vega. Change this to a better determine function. if (dimensions.splitRow || dimensions.splitColumn) { // Use Vega for more complex, split visualizations - return generateVegaSpec(transformedData, visConfig, style); + return generateVegaSpecForSeries(transformedData, visConfig, style); } else { // Use Vega-Lite for simpler visualizations - return generateVegaLiteSpec(transformedData, visConfig, style); + return generateVegaLiteSpecForSeries(transformedData, visConfig, style); } }; + +const createVegaSpecForSlicesData = (dimensions, transformedData, visConfig, style) => { + return generateVegaSpecForSlices(transformedData, visConfig, style); +}; diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.test.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.test.ts similarity index 77% rename from src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.test.ts rename to src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.test.ts index 3197e28ae40a..9b2cc424ea84 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.test.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.test.ts @@ -3,9 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { generateVegaSpec } from './vega_spec_builder'; +import { generateVegaSpecForSeries } from './vega_spec_series_builder'; -describe('generateVegaSpec', () => { +describe('generateVegaSpecForSeries', () => { const baseData = { xAxisFormat: { id: 'date' }, xAxisLabel: 'Date', @@ -26,7 +26,7 @@ describe('generateVegaSpec', () => { it('should generate a basic Vega specification', () => { const style = { type: 'line' }; - const result = generateVegaSpec(baseData, baseVisConfig, style); + const result = generateVegaSpecForSeries(baseData, baseVisConfig, style); expect(result.$schema).toBe('https://vega.github.io/schema/vega/v5.json'); expect(result.data).toBeDefined(); @@ -38,7 +38,7 @@ describe('generateVegaSpec', () => { it('should handle area charts', () => { const style = { type: 'area' }; - const result = generateVegaSpec(baseData, baseVisConfig, style); + const result = generateVegaSpecForSeries(baseData, baseVisConfig, style); expect(result.data).toBeDefined(); expect(result.data?.some((d) => d.name === 'stacked')).toBe(true); @@ -48,7 +48,7 @@ describe('generateVegaSpec', () => { it('should add legend when specified', () => { const style = { type: 'line' }; - const result = generateVegaSpec(baseData, baseVisConfig, style); + const result = generateVegaSpecForSeries(baseData, baseVisConfig, style); expect(result.legends).toBeDefined(); expect(result.legends?.[0]?.orient).toBe('right'); @@ -61,7 +61,7 @@ describe('generateVegaSpec', () => { }; const style = { type: 'line' }; - const result = generateVegaSpec(baseData, visConfigNoLegend, style); + const result = generateVegaSpecForSeries(baseData, visConfigNoLegend, style); expect(result.legends).toBeUndefined(); }); diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.ts similarity index 96% rename from src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.ts rename to src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.ts index 90181a8e5513..7f77cf85d553 100644 --- a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_builder.ts +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_series_builder.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { buildMarkForVega, VegaMarkType } from './components/mark'; +import { buildMarkForVega, VegaMarkType } from './components/mark/mark'; import { buildLegend } from './components/legend'; import { VegaSpec, AxisFormats } from './utils/types'; import { StyleState } from '../../application/utils/state_management'; @@ -17,7 +17,11 @@ import { mapChartTypeToVegaType } from './utils/helpers'; * @param {StyleState} style - The style configuration for the visualization. * @returns {VegaSpec} The complete Vega specification. */ -export const generateVegaSpec = (data: any, visConfig: any, style: StyleState): VegaSpec => { +export const generateVegaSpecForSeries = ( + data: any, + visConfig: any, + style: StyleState +): VegaSpec => { const { dimensions, addLegend, legendPosition } = visConfig; const { type } = style; const vegaType = mapChartTypeToVegaType(type) as VegaMarkType; diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.test.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.test.ts new file mode 100644 index 000000000000..1f4529e5cd27 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { generateVegaSpecForSlices } from './vega_spec_slices_builder'; + +// Mock the imported functions +jest.mock('./components/mark/mark_slices', () => ({ + buildSlicesMarkForVega: jest.fn(() => ({ type: 'mock-slices-mark' })), +})); + +jest.mock('./components/legend', () => ({ + buildLegend: jest.fn(() => ({ type: 'mock-legend' })), +})); + +describe('generateVegaSpecForSlices', () => { + const mockData = { + slices: [{ value: 10 }, { value: 20 }], + levels: ['level1', 'level2'], + }; + + const mockVisConfig = { + dimensions: {}, + addLegend: true, + legendPosition: 'right', + }; + + const mockStyle = { + colorSchema: 'custom-schema', + }; + + it('should generate a valid Vega spec for slices', () => { + const result = generateVegaSpecForSlices(mockData, mockVisConfig, mockStyle); + + expect(result.$schema).toBe('https://vega.github.io/schema/vega/v5.json'); + expect(result.padding).toBe(5); + expect(result.signals).toHaveLength(8); + expect(result.data).toHaveLength(2); + expect(result.scales).toHaveLength(1); + expect(result.scales?.[0].range.scheme).toBe('category20'); + expect(result.marks).toEqual([{ type: 'mock-slices-mark' }]); + expect(result.legends).toEqual([{ type: 'mock-legend' }]); + }); + + it('should handle split data correctly', () => { + const splitVisConfig = { + ...mockVisConfig, + dimensions: { splitRow: true }, + }; + + const result = generateVegaSpecForSlices(mockData, splitVisConfig, mockStyle); + + expect(result.signals?.[0].update).toBe("length(data('splits'))"); + expect(result.layout).toBeDefined(); + if (result.layout) expect(result.layout.columns).toEqual({ signal: 'splitCount' }); + }); + + it('should not add legend when addLegend is false', () => { + const noLegendVisConfig = { + ...mockVisConfig, + addLegend: false, + }; + + const result = generateVegaSpecForSlices(mockData, noLegendVisConfig, mockStyle); + + expect(result.legends).toBeUndefined(); + }); + + it('should use default color schema when not provided in style', () => { + const result = generateVegaSpecForSlices(mockData, mockVisConfig, {}); + + expect(result.scales?.[0].range.scheme).toBe('category20'); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.ts b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.ts new file mode 100644 index 000000000000..1651bf2882e6 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vega/vega_spec_slices_builder.ts @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VegaSpec } from './utils/types'; +import { buildLegend } from './components/legend'; +import { StyleState } from '../../application/utils/state_management'; +import { buildSlicesMarkForVega } from './components/mark/mark_slices'; + +/** + * Generates a Vega specification for a sliced chart (pie/donut chart). + * + * @param {any} data - The data object containing slices and levels information. + * @param {any} visConfig - The visualization configuration object. + * @param {StyleState} style - The style state object. + * @returns {VegaSpec} A Vega specification object for the sliced chart. + */ +export const generateVegaSpecForSlices = ( + data: any, + visConfig: any, + style: StyleState +): VegaSpec => { + const { dimensions, addLegend, addTooltip, legendPosition, isDonut } = visConfig; + const { slices, levels } = data; + const hasSplit = dimensions.splitRow || dimensions.splitColumn; + + const spec: VegaSpec = { + $schema: 'https://vega.github.io/schema/vega/v5.json', + padding: 5, + + signals: [ + { name: 'splitCount', update: hasSplit ? `length(data('splits'))` : '1' }, + { name: 'levelCount', update: levels.length.toString() }, + { name: 'chartWidth', update: hasSplit ? 'width / splitCount - 10' : 'width' }, + { name: 'chartHeight', update: 'height' }, + { name: 'radius', update: 'min(chartWidth, chartHeight) / 2' }, + { + name: 'innerRadiusRatio', + update: isDonut ? 'max(0.1, 0.4 - (levelCount - 1) * 0.05)' : '0', + }, + { name: 'innerRadius', update: 'radius * innerRadiusRatio' }, + { name: 'thickness', update: '(radius - innerRadius) / levelCount' }, + ], + + data: [ + { + name: 'table', + values: slices, + transform: [{ type: 'filter', expr: 'datum.value != null' }], + }, + { + name: 'splits', + source: 'table', + transform: [ + { + type: 'aggregate', + groupby: hasSplit ? ['split'] : [], + }, + ], + }, + ], + + scales: [ + { + name: 'color', + type: 'ordinal', + domain: { data: 'table', fields: levels }, + range: { scheme: 'category20' }, + }, + ], + + layout: hasSplit + ? { + columns: { signal: 'splitCount' }, + padding: { row: 40, column: 20 }, + } + : null, + + marks: [buildSlicesMarkForVega(levels, hasSplit, addTooltip)], + }; + + // Add legend if specified + if (addLegend) { + spec.legends = [buildLegend(legendPosition, true)]; + } + + return spec; +}; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/area/index.ts b/src/plugins/vis_builder/public/visualizations/vislib/area/index.ts index 7ec1f37a601d..b71df534272c 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/area/index.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/area/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { createAreaConfig } from './area_vis_type'; +export { createAreaConfig, AreaOptionsDefaults } from './area_vis_type'; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/histogram/index.ts b/src/plugins/vis_builder/public/visualizations/vislib/histogram/index.ts index bba280de2d77..ad52c3072814 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/histogram/index.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/histogram/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { createHistogramConfig } from './histogram_vis_type'; +export { createHistogramConfig, HistogramOptionsDefaults } from './histogram_vis_type'; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/index.ts b/src/plugins/vis_builder/public/visualizations/vislib/index.ts index 84dc3e346ef5..4f02715d07e8 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/index.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/index.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { createHistogramConfig } from './histogram'; -export { createLineConfig } from './line'; -export { createAreaConfig } from './area'; +export { createHistogramConfig, HistogramOptionsDefaults } from './histogram'; +export { createLineConfig, LineOptionsDefaults } from './line'; +export { createAreaConfig, AreaOptionsDefaults } from './area'; +export { createPieConfig, PieOptionsDefaults } from './pie'; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/line/index.ts b/src/plugins/vis_builder/public/visualizations/vislib/line/index.ts index 721ec7858a7a..ea3448e7a514 100644 --- a/src/plugins/vis_builder/public/visualizations/vislib/line/index.ts +++ b/src/plugins/vis_builder/public/visualizations/vislib/line/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { createLineConfig } from './line_vis_type'; +export { createLineConfig, LineOptionsDefaults } from './line_vis_type'; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/pie/components/pie_vis_options.tsx b/src/plugins/vis_builder/public/visualizations/vislib/pie/components/pie_vis_options.tsx new file mode 100644 index 000000000000..0de536b6e8fd --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/pie/components/pie_vis_options.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { i18n } from '@osd/i18n'; +import produce, { Draft } from 'immer'; +import { useTypedDispatch, useTypedSelector } from '../../../../application/utils/state_management'; +import { PieOptionsDefaults } from '../pie_vis_type'; +import { setState } from '../../../../application/utils/state_management/style_slice'; +import { Option } from '../../../../application/app'; +import { BasicVisOptions } from '../../common/basic_vis_options'; +import { SwitchOption } from '../../../../../../charts/public'; + +function PieVisOptions() { + const styleState = useTypedSelector((state) => state.style) as PieOptionsDefaults; + const dispatch = useTypedDispatch(); + + const setOption = useCallback( + (callback: (draft: Draft) => void) => { + const newState = produce(styleState, callback); + dispatch(setState(newState)); + }, + [dispatch, styleState] + ); + + return ( + <> + + + ); +} + +export { PieVisOptions }; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/pie/index.ts b/src/plugins/vis_builder/public/visualizations/vislib/pie/index.ts new file mode 100644 index 000000000000..d05188e1316e --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/pie/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { createPieConfig, PieOptionsDefaults } from './pie_vis_type'; diff --git a/src/plugins/vis_builder/public/visualizations/vislib/pie/pie_vis_type.ts b/src/plugins/vis_builder/public/visualizations/vislib/pie/pie_vis_type.ts new file mode 100644 index 000000000000..bdaa3abc4dac --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/pie/pie_vis_type.ts @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Schemas } from '../../../../../vis_default_editor/public'; +import { Positions } from '../../../../../vis_type_vislib/public'; +import { AggGroupNames } from '../../../../../data/public'; +import { PieVisOptions } from './components/pie_vis_options'; +import { VisualizationTypeOptions } from '../../../services/type_service'; +import { toExpression } from './to_expression'; +import { BasicOptionsDefaults } from '../common/types'; + +export interface PieOptionsDefaults extends BasicOptionsDefaults { + type: 'pie'; + isDonut: boolean; + showMetricsAtAllLevels: boolean; + labels: { + show: boolean; + values: boolean; + last_level: boolean; + truncate: number; + }; +} + +export const createPieConfig = (): VisualizationTypeOptions => ({ + name: 'pie', + title: 'Pie', + icon: 'visPie', + description: 'Display pie chart', + toExpression, + ui: { + containerConfig: { + data: { + schemas: new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visBuilder.pie.metricTitle', { + defaultMessage: 'Slice size', + }), + min: 1, + max: 1, + aggFilter: ['sum', 'count', 'cardinality', 'top_hits'], + defaults: [{ schema: 'metric', type: 'count' }], + }, + { + group: AggGroupNames.Buckets, + name: 'group', + title: i18n.translate('visBuilder.pie.groupTitle', { + defaultMessage: 'Split slices', + }), + min: 0, + max: Infinity, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + defaults: { aggTypes: ['terms'] }, + }, + { + group: AggGroupNames.Buckets, + name: 'split', + title: i18n.translate('visBuilder.pie.splitTitle', { + defaultMessage: 'Split chart', + }), + mustBeFirst: true, + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + defaults: { aggTypes: ['terms'] }, + }, + ]), + }, + style: { + defaults: { + addTooltip: true, + addLegend: true, + legendPosition: Positions.RIGHT, + isDonut: true, + showMetricsAtAllLevels: true, + type: 'pie', + labels: { + show: false, + values: true, + last_level: true, + truncate: 100, + }, + }, + render: PieVisOptions, + }, + }, + }, + hierarchicalData: true, +}); diff --git a/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.test.ts b/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.test.ts new file mode 100644 index 000000000000..ca70c19478a7 --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.test.ts @@ -0,0 +1,128 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { toExpression } from './to_expression'; +import * as expressionHelpers from '../../common/expression_helpers'; +import * as vegaSpecFactory from '../../vega/vega_spec_factory'; +import * as expressionHelper from '../../vega/utils/expression_helper'; +import * as createVis from '../common/create_vis'; +import * as visualizationsPublic from '../../../../../visualizations/public'; +import * as expressionsPublic from '../../../../../expressions/public'; + +jest.mock('../../common/expression_helpers'); +jest.mock('../../vega/vega_spec_factory'); +jest.mock('../../vega/utils/expression_helper'); +jest.mock('../common/create_vis'); +jest.mock('../../../../../visualizations/public'); +jest.mock('../../../../../expressions/public'); + +jest.mock('../../../plugin_services', () => ({ + getSearchService: jest.fn(() => ({ + aggs: { + createAggConfigs: jest.fn(), + }, + })), + getTimeFilter: jest.fn(() => ({ + getTime: jest.fn(() => ({ from: 'now-7d', to: 'now' })), + })), +})); + +describe('pie/to_expression.ts', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should generate vega expression for pie chart', async () => { + const mockState = { + style: { + addLegend: true, + addTooltip: true, + showMetricsAtAllLevels: true, + isDonut: false, + legendPosition: 'right', + type: 'pie', + }, + visualization: { someConfig: 'value' }, + }; + const mockSearchContext = { someContext: 'value' }; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockResolvedValue({ + expressionFns: ['mockFn1', 'mockFn2'], + aggConfigs: {}, + indexPattern: {}, + }); + + (createVis.createVis as jest.Mock).mockResolvedValue({ + data: { + aggs: { + getResponseAggs: jest.fn().mockReturnValue([]), + }, + }, + }); + + (visualizationsPublic.getVisSchemas as jest.Mock).mockReturnValue({ + metric: [{ label: 'Metric' }], + group: [{ label: 'Group' }], + split_row: [{ label: 'Split Row' }], + split_column: [{ label: 'Split Column' }], + }); + + (expressionsPublic.buildExpression as jest.Mock).mockReturnValue({ + toString: jest.fn().mockReturnValue('rawData | mockFn1 | mockFn2'), + }); + (expressionsPublic.buildExpressionFunction as jest.Mock).mockReturnValue({}); + + (expressionHelper.executeExpression as jest.Mock).mockResolvedValue({ someData: 'value' }); + (vegaSpecFactory.createVegaSpec as jest.Mock).mockReturnValue({ spec: 'mockVegaSpec' }); + + (visualizationsPublic.buildPipeline as jest.Mock).mockResolvedValue('vega | mockVegaSpec'); + + const result = await toExpression(mockState as any, mockSearchContext as any); + + expect(result).toBe('vega | mockVegaSpec'); + expect(expressionHelpers.getAggExpressionFunctions).toHaveBeenCalledWith( + mockState.visualization, + mockState.style, + true, + mockSearchContext + ); + expect(createVis.createVis).toHaveBeenCalledWith('pie', {}, {}, mockSearchContext); + expect(visualizationsPublic.getVisSchemas).toHaveBeenCalled(); + expect(expressionsPublic.buildExpressionFunction).toHaveBeenCalledWith('rawData', {}); + expect(expressionHelper.executeExpression).toHaveBeenCalledWith( + 'rawData | mockFn1 | mockFn2', + mockSearchContext + ); + expect(vegaSpecFactory.createVegaSpec).toHaveBeenCalledWith( + { someData: 'value' }, + expect.objectContaining({ + addLegend: true, + addTooltip: true, + isDonut: false, + legendPosition: 'right', + dimensions: expect.any(Object), + showMetricsAtAllLevels: true, + }), + mockState.style + ); + expect(visualizationsPublic.buildPipeline).toHaveBeenCalled(); + }); + + it('should handle errors gracefully', async () => { + const mockState = { + style: { type: 'pie' }, + visualization: {}, + }; + const mockSearchContext = {}; + + (expressionHelpers.getAggExpressionFunctions as jest.Mock).mockRejectedValue( + new Error('Mock error') + ); + + await expect(toExpression(mockState as any, mockSearchContext as any)).rejects.toThrow( + 'Mock error' + ); + }); +}); diff --git a/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.ts b/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.ts new file mode 100644 index 000000000000..4f70e2cce76b --- /dev/null +++ b/src/plugins/vis_builder/public/visualizations/vislib/pie/to_expression.ts @@ -0,0 +1,82 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildVislibDimensions, getVisSchemas } from '../../../../../visualizations/public'; +import { + buildExpression, + buildExpressionFunction, + IExpressionLoaderParams, +} from '../../../../../expressions/public'; +import { PieOptionsDefaults } from './pie_vis_type'; +import { getAggExpressionFunctions } from '../../common/expression_helpers'; +import { VislibRootState, getPipelineParams } from '../common'; +import { createVis } from '../common/create_vis'; +import { buildPipeline } from '../../../../../visualizations/public'; +import { createVegaSpec } from '../../vega/vega_spec_factory'; +import { executeExpression } from '../../vega/utils/expression_helper'; + +export const toExpression = async ( + { style: styleState, visualization }: VislibRootState, + searchContext: IExpressionLoaderParams['searchContext'] +) => { + const { expressionFns, aggConfigs, indexPattern } = await getAggExpressionFunctions( + visualization, + styleState, + true, + searchContext + ); + const { + addLegend, + addTooltip, + showMetricsAtAllLevels, + isDonut, + legendPosition, + type, + } = styleState; + const vis = await createVis(type, aggConfigs, indexPattern, searchContext); + const params = getPipelineParams(); + const schemas = getVisSchemas(vis, { + timeRange: params.timeRange, + timefilter: params.timefilter, + }); + + const dimensions = { + metric: schemas.metric[0], + buckets: schemas.group, + splitRow: schemas.split_row, + splitColumn: schemas.split_column, + }; + + const visConfig = { + addLegend, + addTooltip, + isDonut, + legendPosition, + dimensions, + showMetricsAtAllLevels, + }; + + const rawDataFn = buildExpressionFunction('rawData', {}); + const dataExpression = buildExpression([...expressionFns, rawDataFn]).toString(); + // Execute the expression to get the raw data + const rawData = await executeExpression(dataExpression, searchContext); + + const vegaSpec = createVegaSpec(rawData, visConfig, styleState); + + const visVega = await createVis('vega', aggConfigs, indexPattern, searchContext); + visVega.params = { + spec: JSON.stringify(vegaSpec), + }; + + const vegaExpression = await buildPipeline(visVega, { + timefilter: params.timefilter, + timeRange: params.timeRange, + abortSignal: undefined, + visLayers: undefined, + visAugmenterConfig: undefined, + }); + + return vegaExpression; +}; diff --git a/src/plugins/vis_builder/server/plugin.ts b/src/plugins/vis_builder/server/plugin.ts index 35d3f4b89c20..a9e48d1bc2b7 100644 --- a/src/plugins/vis_builder/server/plugin.ts +++ b/src/plugins/vis_builder/server/plugin.ts @@ -44,6 +44,7 @@ export class VisBuilderPlugin implements Plugin