From 549c06d321f8b11a8a573ece87ed70e87bbd6ebd Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Tue, 6 Oct 2026 21:52:17 +0530 Subject: [PATCH 01/57] review feedback for Spotter Analyst --- modules/ROOT/pages/embed-spotter-analyst.adoc | 40 +++---------------- 1 file changed, 6 insertions(+), 34 deletions(-) diff --git a/modules/ROOT/pages/embed-spotter-analyst.adoc b/modules/ROOT/pages/embed-spotter-analyst.adoc index ad3f5772e..371b9e0ed 100644 --- a/modules/ROOT/pages/embed-spotter-analyst.adoc +++ b/modules/ROOT/pages/embed-spotter-analyst.adoc @@ -14,9 +14,8 @@ This page shows how to embed one Analyst using the `SpotterEmbed` component. Before you embed an Analyst, make sure that you have the following: -* Your ThoughtSpot instance is on version 26.10.0.cl or later. Embedding a Spotter Analyst also requires Visual Embed SDK version 1.53.0 or later. -* GUID of the Analyst to embed. You can copy the GUID from the URL of the Spotter Analyst page or find the GUID using the xref:spotter-analyst-api.adoc#search-analysts[search Analysts] API endpoint. -* GUID of the data model to use for queries. You can use the model that the Analyst is already configured with. If the Analyst spans multiple models, you need the GUID of each model. +* A ThoughtSpot instance on version 26.10.0.cl or later. Embedding a Spotter Analyst also requires Visual Embed SDK version 1.53.0 or later. +* The GUID of the Analyst to embed. You can copy the GUID from the URL of the Spotter Analyst page or find the GUID using the xref:spotter-analyst-api.adoc#search-analysts[search Analysts] API endpoint. * Access for the users who see the embed. Users must have access to the Analyst and its data sources. To grant access, share the Analyst with the users or groups. To share an Analyst programmatically, see xref:spotter-analyst-api.adoc#share-analyst[Share an Analyst]. * Your embedding app's domain in the Content Security Policy (CSP) Visual Embed hosts and Cross-Origin Resource Sharing (CORS) allowlists. For more information, see xref:security-settings.adoc[Security settings]. @@ -68,41 +67,18 @@ init({ }); ---- -== Specify the Analyst and data source to embed +== Specify the Analyst to embed Create an instance of the `SpotterEmbed` object, and pin it to one Analyst by using `spotterAnalystConfig.analystId`. -The `analystId` is the GUID of the Analyst to embed. When you specify it, the embed opens with this Analyst and doesn't show the default Spotter or the *Show all* Analysts list. +The `analystId` is the GUID of the Analyst to embed. When you specify it, the embed opens with this Analyst and doesn't show the default Spotter or the *Show all* Analysts list. Ensure that the Analyst is already configured with its data sources. -Optionally, specify the data source that Spotter queries: - -* `worksheetId` + -GUID of the model to query. Include it alongside `analystId`. -* `dataSources` + -Array that contains one GUID for each model, for an Analyst that spans multiple models. If you set both `dataSources` and `worksheetId`, `dataSources` takes precedence. - -The following example embeds one Analyst that uses a single model: - -[source,JavaScript] ----- -const spotterEmbed = new SpotterEmbed(document.getElementById('ts-embed'), { - frameParams: { width: '100%', height: '100%' }, - worksheetId: '{model-guid}', - - // Specify the Analyst ID - spotterAnalystConfig: { - analystId: '{analyst-guid}', - }, -}); ----- - -If the Analyst spans multiple models, use `dataSources` instead of `worksheetId`: +The following example embeds one Analyst: [source,JavaScript] ---- const spotterEmbed = new SpotterEmbed(document.getElementById('ts-embed'), { frameParams: { width: '100%', height: '100%' }, - dataSources: ['{model-guid-1}', '{model-guid-2}'], // Specify the Analyst ID spotterAnalystConfig: { @@ -226,7 +202,7 @@ If you want to show only the chat interface, without extra options such as start ==== Hiding the data source selector -To keep users on the data source that you specify, set `hideSourceSelection` to `true`. This hides the data source selector. To show the selected data source but prevent users from changing it, set `disableSourceSelection` to `true` instead. +To keep users on the data source configured for the Analyst, set `hideSourceSelection` to `true`. This hides the data source selector. To show the selected data source but prevent users from changing it, set `disableSourceSelection` to `true` instead. [source,JavaScript] ---- @@ -330,10 +306,6 @@ if (container) { width: '100%', // Set the width of the embedded frame }, - // ID of the model to query. For an Analyst that spans multiple models, - // use dataSources: ['{model-guid-1}', '{model-guid-2}'] instead. - worksheetId: '{model-guid}', - // Specify the Analyst ID spotterAnalystConfig: { analystId: '{analyst-guid}' }, From 9d323d7c80654bd8f973438dc346e1b5b601698c Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Wed, 7 Oct 2026 14:04:49 +0530 Subject: [PATCH 02/57] fix: hide SpotterCode widget inside nested version iframe Version wrapper pages load the frozen site in an iframe (?_iframe=1) and already mount their own widget, so the nested copy rendered a duplicate. Co-Authored-By: Claude Sonnet 5.5 --- src/components/FloatingAssistant/index.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/components/FloatingAssistant/index.tsx b/src/components/FloatingAssistant/index.tsx index 658f36daa..a3d6b683f 100644 --- a/src/components/FloatingAssistant/index.tsx +++ b/src/components/FloatingAssistant/index.tsx @@ -50,6 +50,14 @@ const FloatingAssistant: React.FC = () => { const [isTutorialsPage, setIsTutorialsPage] = useState( () => typeof window !== 'undefined' && isTutorialsPath(window.location.pathname), ); + // Older-version wrapper pages (e.g. /docs/26.9.0.cl) load the frozen version's site in a + // nested iframe (VersionIframe, src carries ?_iframe=1). The wrapper already mounts its own + // assistant, so the nested copy must not render a second one. Read once at load: the flag + // is on the iframe's initial URL, and in-page navigation inside it may drop the param. + const [isNestedIframe] = useState( + () => typeof window !== 'undefined' + && new URLSearchParams(window.location.search).get('_iframe') === '1', + ); // The 404 page has no fixed pathname to match against (it renders for // whatever bogus URL the user hit), so detect it via document.title // instead. NOTE: a DOM marker (id) doesn't work here — in production, @@ -495,6 +503,7 @@ const FloatingAssistant: React.FC = () => { if (pageId === CUSTOM_PAGE_ID.API_PLAYGROUND) return null; if (isTutorialsPage) return null; if (isNotFoundPage) return null; + if (isNestedIframe) return null; return ( <> From 4bbf6892842990e8021b60bbcc4d27f9acd9ec10 Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Wed, 7 Oct 2026 16:06:50 +0530 Subject: [PATCH 03/57] feat: show hero gradient in nested version site under the public wrapper The nested version site always runs in embedded mode, which hides the radiance blobs and uses the flat in-product banner. VersionIframe now passes _public=1 when the wrapper is the public docs site, and the nested site keeps the public hero for _iframe=1&_public=1. In-product help is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/assets/styles/index.scss | 16 ++++++++++++++++ src/components/DevDocTemplate/index.tsx | 14 +++++++++++++- src/components/VersionIframe/index.tsx | 6 ++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss index 32e85f14d..9a3572d6d 100644 --- a/src/assets/styles/index.scss +++ b/src/assets/styles/index.scss @@ -1546,6 +1546,22 @@ a.anchor { color: #fff; } } + + // Nested version site shown inside the public docs wrapper (?_iframe=1&_public=1): keep the + // public hero, i.e. undo the in-product banner overrides above. + &.embedded-public-hero { + .radiance-bg { + display: block; + } + + .header-banner { + background: none; + + .header-banner-text { + color: var(--primary-color); + } + } + } } .introCard, diff --git a/src/components/DevDocTemplate/index.tsx b/src/components/DevDocTemplate/index.tsx index e3760503f..74f0a41c2 100644 --- a/src/components/DevDocTemplate/index.tsx +++ b/src/components/DevDocTemplate/index.tsx @@ -663,8 +663,20 @@ if (isVersionedIframe) { return cName; }; + // Nested version site (?_iframe=1) whose wrapper is the public docs site (VersionIframe adds + // ?_public=1), as opposed to a wrapper embedded in the product. It is still "embedded" for + // layout/nav, but keeps the public hero (gradient) instead of the flat in-product banner. + const isPublicWrapperIframe = () => { + if (!isBrowser()) return false; + const urlParams = new URLSearchParams(location.search); + return urlParams.get('_iframe') === '1' && urlParams.get('_public') === '1'; + }; + const getWrapperClassName = () => { - return isEmbeddedContext() ? 'embedded-mode' : ''; + if (!isEmbeddedContext()) return ''; + return isPublicWrapperIframe() + ? 'embedded-mode embedded-public-hero' + : 'embedded-mode'; }; const getCloudLatestVersion = () => { diff --git a/src/components/VersionIframe/index.tsx b/src/components/VersionIframe/index.tsx index e6e23eaa3..c90de598c 100644 --- a/src/components/VersionIframe/index.tsx +++ b/src/components/VersionIframe/index.tsx @@ -1,4 +1,5 @@ import React, { useMemo } from 'react'; +import { isPublicSite } from '../../utils/app-utils'; import './index.scss'; interface VersionIframeProps { @@ -38,6 +39,11 @@ const VersionIframe: React.FC = ({ url.hash = location?.hash; } url.searchParams.set('_iframe', '1'); + // Tell the nested site its wrapper is the public docs site (not the product), so it + // can show the public hero gradient instead of the in-product banner. + if (isPublicSite(window.location.search)) { + url.searchParams.set('_public', '1'); + } } return url.toString(); From bdf567b25b35343cbdea1686059b8950c0ecf3f5 Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Wed, 7 Oct 2026 16:09:32 +0530 Subject: [PATCH 04/57] version fix --- src/configs/doc-configs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/configs/doc-configs.js b/src/configs/doc-configs.js index 99c3cd8e7..d6fa1f9f7 100644 --- a/src/configs/doc-configs.js +++ b/src/configs/doc-configs.js @@ -56,7 +56,7 @@ module.exports = { { label: '26.9.0.cl', link: '26.9.0.cl', - subLabel: 'Cloud (Latest)', + subLabel: 'Cloud', iframeUrl: 'https://developer-docs-26-10-0-cl.vercel.app/docs/', }, { From 060c6628640013d1de122505aac8786529082da4 Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Thu, 8 Oct 2026 10:36:30 +0530 Subject: [PATCH 05/57] title text fix --- modules/ROOT/pages/onboarding-guide.adoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ROOT/pages/onboarding-guide.adoc b/modules/ROOT/pages/onboarding-guide.adoc index 73d3fa9b9..bf000736f 100644 --- a/modules/ROOT/pages/onboarding-guide.adoc +++ b/modules/ROOT/pages/onboarding-guide.adoc @@ -32,7 +32,7 @@ If you’re using the Free Trial instance, note that it is already connected to To learn more about data connections, data models and objects, and how to search data and create Liveboards, see link:https://docs.thoughtspot.com/cloud/latest/ts-cloud-getting-started[ThoughtSpot product documentation, window=_blank]. -== Embed ThoughtSpotcontent +== Embed ThoughtSpot content This section provides a set of steps to quickly embed a xref:faqs.adoc#lbDef[Liveboard] from the link:https://www.thoughtspot.com/trial?tsiref=web-devsite[Free Trial, window=_blank] or your own ThoughtSpot application instance. The following figure shows a sample Liveboard with charts and a table: From 526d4c7a17f9d2d3ae74c2a3dfd52db3f7526142 Mon Sep 17 00:00:00 2001 From: ShashiSubramanya <76986173+ShashiSubramanya@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:13:32 +0530 Subject: [PATCH 06/57] Update doc-configs.js --- src/configs/doc-configs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/configs/doc-configs.js b/src/configs/doc-configs.js index d6fa1f9f7..0e414264c 100644 --- a/src/configs/doc-configs.js +++ b/src/configs/doc-configs.js @@ -57,7 +57,7 @@ module.exports = { label: '26.9.0.cl', link: '26.9.0.cl', subLabel: 'Cloud', - iframeUrl: 'https://developer-docs-26-10-0-cl.vercel.app/docs/', + iframeUrl: 'https://developer-docs-26-9-0-cl.vercel.app/docs/', }, { label: '26.8.0.cl', From 6fd125320b0e896fcad7e8d6a8531b63b36938b5 Mon Sep 17 00:00:00 2001 From: Rani Gangwar Date: Tue, 6 Oct 2026 09:39:45 +0530 Subject: [PATCH 07/57] lb embed split --- .idea/.gitignore | 3 + .idea/developer-docs.iml | 9 + .idea/misc.xml | 6 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + modules/ROOT/pages/api-changelog.adoc | 10 +- modules/ROOT/pages/common/nav-embedding.adoc | 3 + .../pages/common/nav-in-product-help.adoc | 3 + .../pages/customize-liveboard-filters.adoc | 134 +++++ .../pages/customize-liveboard-layout.adoc | 246 +++++++++ modules/ROOT/pages/data-security.adoc | 2 +- modules/ROOT/pages/embed-pinboard.adoc | 501 +----------------- modules/ROOT/pages/filters_overview.adoc | 4 +- .../ROOT/pages/liveboard-data-downloads.adoc | 172 ++++++ .../ROOT/pages/whats-new-prev-history.adoc | 10 +- modules/ROOT/pages/whats-new.adoc | 12 +- src/configs/doc-configs.js | 1 + 17 files changed, 620 insertions(+), 510 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/developer-docs.iml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 modules/ROOT/pages/customize-liveboard-filters.adoc create mode 100644 modules/ROOT/pages/customize-liveboard-layout.adoc create mode 100644 modules/ROOT/pages/liveboard-data-downloads.adoc diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 000000000..26d33521a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/developer-docs.iml b/.idea/developer-docs.iml new file mode 100644 index 000000000..d6ebd4805 --- /dev/null +++ b/.idea/developer-docs.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 000000000..639900d13 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 000000000..aff6e430d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 000000000..35eb1ddfb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/modules/ROOT/pages/api-changelog.adoc b/modules/ROOT/pages/api-changelog.adoc index 95e141cfb..407da9247 100644 --- a/modules/ROOT/pages/api-changelog.adoc +++ b/modules/ROOT/pages/api-changelog.adoc @@ -263,11 +263,11 @@ The SDK includes the following new features and enhancements in Liveboard embedd `liveboardGutter` embed config property:: A new `liveboardGutter` property is available on `LiveboardViewConfig`. Use this property to set the gutter spacing between tiles on an embedded Liveboard. -For more information, see xref:embed-pinboard.adoc#liveboard-gutter[Set Liveboard tile gutter] +For more information, see #xref:customize-liveboard-layout.adoc#liveboard-gutter[Set Liveboard tile gutter]# 12-column layout for Liveboard tile positioning:: A new embed config property is available on `LiveboardViewConfig` to always use a 12-column grid layout for Liveboard tile positioning. When enabled, tile placement and sizing follow a fixed 12-column grid regardless of the Liveboard's default layout settings. -For more information, see xref:embed-pinboard.adoc#force-12col-layout[12-column layout in embedded Liveboards] +For more information, see #xref:customize-liveboard-layout.adoc#force-12col-layout[12-column layout in embedded Liveboards]# New host events for the Liveboard edit page:: * `HostEvent.OpenAddFilterModal` + @@ -316,7 +316,7 @@ Enables compact header layout in embedded Liveboards. * `coverAndFilterOptionInPDF` + Enables the *Include cover page* and *Include filter page(s)* checkboxes in the Liveboard download modal. * `isLiveboardMasterpiecesEnabled` + -Enables the xref:embed-pinboard.adoc#_liveboard_grouping_and_styling[Liveboard styling and grouping] feature. +Enables the #xref:customize-liveboard-layout.adoc#liveboard-grouping-styling[Liveboard styling and grouping]# feature. * `isEnhancedFilterInteractivityEnabled` + Enables interactive filter chips that allow users to add, update, or remove filters in an embedded Liveboard. @@ -387,7 +387,7 @@ Trigger a manual cache refresh from the host application. * `Action.RefreshLiveboardBrowserCache` + Action ID to show or hide the cache refresh button. -For more information, see xref:embed-pinboard.adoc#liveboard-data-cache[Enable Liveboard refresh]. +For more information, see #xref:liveboard-data-downloads.adoc#liveboard-data-cache[Enable Liveboard refresh]#. |[tag greenBackground]#NEW# a| [discrete] @@ -1731,7 +1731,7 @@ For more information, see xref:prefetch-and-cache.adoc[Prefetch static resources |[tag greenBackground]#NEW#| For embedded instances with the new Liveboard experience, the Visual Embed SDK provides the `activeTabId` attribute, using which you can set a Liveboard tab as an active tab. -For more information, see xref:embed-pinboard.adoc#_liveboard_tabs[Customize Liveboard tabs]. +For more information, see #xref:customize-liveboard-layout.adoc#liveboard-tabs[Customize Liveboard tabs]#. |[tag greenBackground]#NEW# a|The new version of the SDK supports firing events for Liveboard menu actions from the host application. The SDK introduces the following host event enumeration members for Liveboard objects: diff --git a/modules/ROOT/pages/common/nav-embedding.adoc b/modules/ROOT/pages/common/nav-embedding.adoc index e19841f8a..35d18dcd2 100644 --- a/modules/ROOT/pages/common/nav-embedding.adoc +++ b/modules/ROOT/pages/common/nav-embedding.adoc @@ -20,6 +20,9 @@ Embed ThoughtSpot in a web app ** link:{{navprefix}}/embed-spotter-agent[Embed Spotter without ThoughtSpot UI] * Embed charts and Liveboards ** link:{{navprefix}}/embed-liveboard[Embed a Liveboard] +*** link:{{navprefix}}/customize-liveboard-layout[Customize Liveboard layout and UI] +*** link:{{navprefix}}/customize-liveboard-filters[Customize Liveboard filters] +*** link:{{navprefix}}/liveboard-data-downloads[Liveboard data refresh and downloads] ** link:{{navprefix}}/spotterViz-agent[SpotterViz AI agent in Liveboards] ** link:{{navprefix}}/embed-a-viz[Embed a visualization] * link:{{navprefix}}/full-embed[Embed full application] diff --git a/modules/ROOT/pages/common/nav-in-product-help.adoc b/modules/ROOT/pages/common/nav-in-product-help.adoc index e2dfb49e3..a7173bacf 100644 --- a/modules/ROOT/pages/common/nav-in-product-help.adoc +++ b/modules/ROOT/pages/common/nav-in-product-help.adoc @@ -39,6 +39,9 @@ Embed ThoughtSpot in a web app ** link:{{navprefix}}/embed-spotter-agent[Embed Spotter AI without ThoughtSpot UI] * link:{{navprefix}}/embed-liveboard[Embed live analytics] ** link:{{navprefix}}/embed-liveboard[Embed a Liveboard] +*** link:{{navprefix}}/customize-liveboard-layout[Customize Liveboard layout and UI] +*** link:{{navprefix}}/customize-liveboard-filters[Customize Liveboard filters] +*** link:{{navprefix}}/liveboard-data-downloads[Liveboard data refresh and downloads] ** link:{{navprefix}}/spotterViz-agent[SpotterViz AI agent in Liveboards] ** link:{{navprefix}}/embed-a-viz[Embed a visualization] * link:{{navprefix}}/full-embed[Embed full application] diff --git a/modules/ROOT/pages/customize-liveboard-filters.adoc b/modules/ROOT/pages/customize-liveboard-filters.adoc new file mode 100644 index 000000000..27d7b95a4 --- /dev/null +++ b/modules/ROOT/pages/customize-liveboard-filters.adoc @@ -0,0 +1,134 @@ += Customize Liveboard filters +:toc: true +:toclevels: 2 + +:page-title: Customize Liveboard filters +:page-pageid: customize-liveboard-filters +:page-description: Use the Visual Embed SDK to control filter visibility, apply filters at load time, scope filters to tabs and groups, and update filters on an embedded Liveboard. + +To view specific data across the tables and charts on an embedded Liveboard, users can use Liveboard filter options. #The Visual Embed SDK provides properties and host events to control which filter chips are visible, apply filters at load time, scope filters to specific tabs or groups, and update filters programmatically.# + +#Before you begin, make sure you have xref:embed-pinboard.adoc[embedded a Liveboard] using the `LiveboardEmbed` component.# + +[#filter-visibility] +== Customizing filter visibility +To hide the entire header area, including filters and the Liveboard menu, set `hideLiveboardHeader` to `true`. You can then trigger filter interactions programmatically using host events. #For more information, see xref:customize-liveboard-layout.adoc#show-hide-ui-elements[Show/hide large UI elements].# + +If the Liveboard has multiple tabs and the compact header is enabled, you can set `hideIrrelevantChipsInLiveboardTabs` to `true` to hide filters that are not relevant to the current tab in an embedded Liveboard. Note that `hideIrrelevantChipsInLiveboardTabs` is set to `true` by default on all ThoughtSpot embedded instances using Visual Embed SDK v1.51.0 or later. + +[source,JavaScript] +---- +const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { + //... other embed config properties + isLiveboardCompactHeaderEnabled: true, + hideIrrelevantChipsInLiveboardTabs: true, +}); +---- + +When `hideIrrelevantChipsInLiveboardTabs` is `true`: + +* Filter chips backed by data sources that are *not used by any visualization on the active tab* are hidden automatically when the user switches tabs. +* A *Show irrelevant filters* toggle button appears in the filter row when at least one chip is hidden, allowing users to temporarily reveal all chips. +* When the user clicks *Show irrelevant filters*, a complementary *Hide irrelevant filters* button appears to restore the filtered view. + +#For more information about the compact header, see xref:customize-liveboard-layout.adoc#compact-header[Enable compact Liveboard header].# + +[#masked-filter-chips] +=== #Masked filter chips# +#If a Liveboard includes filters on columns that a user cannot access because of column-level security, the filter values are masked for that user. Use the `showMaskedFilterChip` property to control whether these masked filter chips are visible:# + +* #`true`: the filter chips for inaccessible columns are displayed as masked.# +* #`false`: the filter chips for inaccessible columns are hidden.# + +[source,JavaScript] +---- +const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { + //... other embed config properties + showMaskedFilterChip: true, +}); +---- + +#The `showMaskedFilterChip` property is also available in full application embedding. For more information, see xref:data-security.adoc#csr-liveboards[Column security rules on Liveboards].# + +[#filters-at-load] +== #Apply filters at load time# +By default, Liveboard filters cannot be applied at load. You can either embed a Liveboard that already includes filters or use the xref:runtime-filters.adoc[runtime filters] feature in the Visual Embed SDK to apply filters at load time. + +[IMPORTANT] +==== +Runtime filter limits vary by input path. URL-based runtime filters are capped at 50. +When using the Visual Embed SDK `runtimeFilters` property, the limit is configurable by a +ThoughtSpot administrator, with a default and maximum of 1,000 runtime filters. Filters beyond the enforced +limit are silently dropped without an error or warning. For more information, see +xref:runtime-filters.adoc#_maximum_filter_count[Runtime filter limit]. +==== + +[#contextual-liveboard-filtering] +== Contextual filtering in Liveboards [earlyAccess eaBackground]#Early Access# +Starting with ThoughtSpot Cloud 26.10.0.cl release, Liveboard filters and parameters can be scoped at three levels: + +* *Liveboard*: applies to all visualizations on the Liveboard. +* *Tab*: applies only to visualizations on a specific tab. +* *Group*: applies only to visualizations in a specific group on a tab. + +With contextual filtering, you can add the same filter at more than one level on a Liveboard, but not at a level directly below it in the same hierarchy. For example: + +* If a `Region` filter is set at the Liveboard level, it cannot be added again on any tab or group on that Liveboard. +* If a `City` filter is set on Tab 1, it cannot be added to a group on Tab 1. However, it can be added to a group on another tab, such as Tab 2, because that group is not in the Tab 1 hierarchy. + +[NOTE] +==== +Contextual filtering in Liveboards is an Early Access feature and is disabled by default on ThoughtSpot instances. To enable this feature on your instance, set `isScopedLiveboardFilteringEnabled` to `true`. +==== + + +[source,JavaScript] +---- +const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { + //... other embed config properties + liveboardId: "d7a5a08e-a1f7-4850-aeb7-0764692855b8", + isScopedLiveboardFilteringEnabled: true, +}); +---- + +The `isScopedLiveboardFilteringEnabled` property is also available in `AppViewConfig` for full application embedding. + +To get the groups on a Liveboard, use `HostEvent.GetGroups`. To scope a filter or parameter update to a specific tab or group, pass the `applicability` attribute with `HostEvent.UpdateFilters` or `HostEvent.UpdateParameters`: + +[source,javascript] +---- +liveboardEmbed.trigger(HostEvent.UpdateFilters, { + filters: [ + { + column: 'Region', + oper: 'IN', + values: ['West'], + applicability: { + level: 'GROUP', + targetId: '{group-id}', + }, + }, + ], +}); +---- + +To listen for filter and parameter changes, use `EmbedEvent.FilterChanged` and `EmbedEvent.ParameterChanged`. Starting with SDK 1.53.0, both events include an optional `applicability` field in their response payload that indicates the scope of the change. + +For more information, see xref:events-hostEvents.adoc#liveboard-group-events[Liveboard group and parameter events] and xref:events-hostEvents.adoc#applicability-host-events[Scoped filter and parameter host events]. + +[#update-filters] +== Updating filters +Use the following host events in the Visual Embed SDK to update filters: + +* link:https://developers.thoughtspot.com/docs/Enumeration_HostEvent#_updatefilters[`HostEvent.UpdateFilters`] to update Liveboard filters +* link:https://developers.thoughtspot.com/docs/Enumeration_HostEvent#_updateruntimefilters[`HostEvent.UpdateRuntimeFilters`] to update runtime filters +* link:https://developers.thoughtspot.com/docs/Enumeration_HostEvent#_updatecrossfilter[`HostEvent.UpdateCrossFilter`] + +For more information and examples, see xref:filters_overview.adoc[Filter types and application layers]. + +== #Related information# +* #xref:embed-pinboard.adoc[Embed a Liveboard]# +* #xref:customize-liveboard-layout.adoc[Customize Liveboard layout and UI]# +* #xref:liveboard-data-downloads.adoc[Liveboard data refresh and downloads]# +* #xref:runtime-filters.adoc[Runtime filters]# +* #xref:runtime-parameters.adoc[Runtime Parameter overrides]# diff --git a/modules/ROOT/pages/customize-liveboard-layout.adoc b/modules/ROOT/pages/customize-liveboard-layout.adoc new file mode 100644 index 000000000..adc1a7835 --- /dev/null +++ b/modules/ROOT/pages/customize-liveboard-layout.adoc @@ -0,0 +1,246 @@ += Customize Liveboard layout and UI +:toc: true +:toclevels: 2 + +:page-title: Customize Liveboard layout and UI +:page-pageid: customize-liveboard-layout +:page-description: Use the LiveboardViewConfig properties in the Visual Embed SDK to customize the header, tabs, tiles, and layout of an embedded Liveboard. + +#The xref:LiveboardViewConfig.adoc[LiveboardViewConfig] object includes several properties that control the look and layout of an embedded Liveboard, such as the header, tabs, tile spacing, responsive layout, and grouping and styling of tiles.# + +#Before you begin, make sure you have xref:embed-pinboard.adoc[embedded a Liveboard] using the `LiveboardEmbed` component.# + +[#show-hide-ui-elements] +== Show/hide large UI elements +Parameters such as `hideLiveboardHeader`, `hideTabPanel`, `isLiveboardHeaderSticky`, `showLiveboardTitle`, and `showLiveboardDescription` control various aspects of the standard embedded Liveboard experience. Note the phrasing of the property name and the description in the documentation to understand whether `true` enables or disables the particular feature. + +//Along with xref:Action.adoc[Actions], there is very fine-grained control of the display of the `LiveboardEmbed` component, which can vary for each user or content object displayed depending on the desires of the app development team. + +[.widthAuto] +image::./images/liveboard_view_config_callouts_2.png[LiveboardViewConfig parameters] + +The `hideLiveboardHeader` property removes the entire header area above the Liveboard, including filters and the overall Liveboard menu, which is a common pattern for "read-only" use cases or for rebuilding your own menus and buttons using xref:embed-events.adoc[HostEvents]. + +[source,JavaScript] +---- +const embed = new LiveboardEmbed('#embed', { + ... // other liveboard view config + hideLiveboardHeader:true, +}); +---- + +#When the header is hidden, you can trigger filter interactions programmatically using host events. For more information, see xref:customize-liveboard-filters.adoc#update-filters[Updating filters].# + +The following constructor disables the __sticky header__ and shows the Liveboard title, which is hidden by default: + +[source,JavaScript] +---- +const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { + frameParams: { + width: '100%', + height: '100%', + }, + isLiveboardHeaderSticky : false, + showLiveboardTitle: true, + liveboardId: '<%=liveboardGUID%>', +}); +---- + +[NOTE] +==== +When `fullHeight` is set to `true`, the SDK ignores the `isLiveboardHeaderSticky:true` setting, and the Liveboard header is not sticky. +==== + +[#compact-header] +== Enable compact Liveboard header +By default, embedded Liveboards display a full-height header bar that contains the Liveboard title, description, filter chips, tab navigation, and action menu. You can switch to a more space-efficient layout by enabling the compact header. + +The compact header reduces the vertical footprint of the header area, integrates tabs and filter chips into a single condensed row, and moves the *Edit* button into the header's overflow action menu. + +To enable the compact header, set `isLiveboardCompactHeaderEnabled` to `true` in the `LiveboardViewConfig` object. Note that the `isLiveboardCompactHeaderEnabled` attribute is set to `true` by default on all ThoughtSpot embedded instances using Visual Embed SDK v1.51.0 or later. + +[source,JavaScript] +---- +const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { + //... other embed config properties + isLiveboardCompactHeaderEnabled: true, +}); +---- + +When `isLiveboardCompactHeaderEnabled` is `true`, the following UI changes apply compared to the standard header: + +[width="100%",cols="2,4",options="header"] +|==== +|Area |Change +|Header height |The header renders in a condensed, shorter height to maximize the visible content area. +|Edit button |The *Edit* button is removed from the top-level header and is accessible from the header overflow (*...*) menu. +|Filter chips |Filter chips are rendered inside the compact header's integrated filter row, replacing the separate floating filter panel. +|Tabs |The tab bar is incorporated into the compact header row, rather than appearing as a separate element below the header. +|Sticky scroll behavior |The scroll threshold at which the sticky header attaches to the top of the viewport is recalculated for the compact header's shorter height. This may affect the scroll position at which the header becomes sticky compared to the standard header. +|| +|==== + +=== Hiding the Liveboard title in compact header +In the compact header, you can hide the Liveboard title by setting `showLiveboardTitle` to `false` in the `LiveboardViewConfig` object: + +[source,JavaScript] +---- +const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { + //... other embed config properties + isLiveboardCompactHeaderEnabled: true, + showLiveboardTitle: false, +}); +---- + +#To hide filter chips that are not relevant to the active tab in the compact header, see xref:customize-liveboard-filters.adoc#filter-visibility[Customizing filter visibility].# + +[#liveboard-tabs] +== Customize Liveboard tabs +By default, the first tab created on a Liveboard is set as the home tab. You can set any tab as an active tab using the `activeTabId` property in the Visual Embed SDK as shown in the example here: + +[source,JavaScript] +---- +const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { + frameParams: { + width: '100%', + height: '100%', + }, + liveboardId: "d7a5a08e-a1f7-4850-aeb7-0764692855b8", + activeTabId: "05406350-44ce-488e-abc5-5e8cdd65cf3c", +}); +---- + +[NOTE] +==== +The `activeTabId` property is available only in the `LiveboardEmbed` package and is not supported in the full application embed mode. +==== + +[#visible-tabs-vizs] +== Reduce visible tabs and visualizations +`visibleVizs` and `visibleTabs` allow you to limit the experience for certain users on a Liveboard with many more elements. + +For example, a template Liveboard with many different key performance indicators (KPIs) could be reduced to a smaller set by giving a user an interface to select the particular visualizations to show, storing their selections, and using that saved set of visualization globally unique identifiers (GUIDs) as the array for `visibleVizs` on page load (there is an equivalent xref:embed-events.adoc[HostEvent] called `SetVisibleVizs` to make an update after the Liveboard has loaded). + +[#liveboard-gutter] +== Set Liveboard tile gutter + +The `liveboardGutter` property lets you control the spacing between Liveboard tiles and groups, and the grid's outer layout padding, directly from your embed configuration. The value you set overrides the Liveboard's saved styling setting. + +Use this property when you need to enforce consistent tile spacing across embedded Liveboards, regardless of how individual Liveboards are styled in ThoughtSpot. + +[source,JavaScript] +---- +const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { + frameParams: { + width: '100%', + height: '100%', + }, + liveboardId: '<%=liveboardGUID%>', + liveboardGutter: 16, +}); +liveboardEmbed.render(); +---- + +The following rules apply to the `liveboardGutter` value: + +* Accepts non-negative integers (0 or greater). +* A value of `0` removes all gutter space between tiles and the grid's outer layout padding. +* Negative numbers, decimal values, and non-numeric strings are ignored. The Liveboard falls back to its saved styling value. + +[NOTE] +==== +`liveboardGutter` controls the spacing between tiles and groups, not the spacing within a group. + +The Liveboard header's horizontal margin is controlled separately by the `--ts-var-liveboard-header-horizontal-margin` CSS variable. To align the Liveboard header and tab/filter section with the tile grid, set both `liveboardGutter` and `--ts-var-liveboard-header-horizontal-margin` to matching values. For more information, see xref:customize-css-styles.adoc#lb-header-margin[Liveboard CSS variables]. +==== + +[#noteTiles] +== Add Note tiles +You can add a link:https://docs.thoughtspot.com/cloud/latest/liveboard-notes[Liveboard Note tile, window=_blank] with custom text, images, and links on an embedded Liveboard. + +* Only users with edit access to a Liveboard can add a Note tile. +* Users require `Can upload data` privilege to upload an image to the note tile. +* If you are adding links and images from an external site, or embedding multimedia or a web page in an iFrame, make sure the URLs are added to CORS and CSP allowlists. For more information, see xref:security-settings.adoc[Security settings]. + +[#liveboard-breakpoints] +== Redefine Liveboard breakpoint widths +The `enable2ColumnLayout` property, when enabled, allows you to customize the Liveboard breakpoint width for embedded users. +The current 12 column layout changes to 2 columns per row at 1024px, and to 1 column per row layout at 630px in the new Liveboard experience. Once enabled, these breakpoints apply to all Liveboards in the ThoughtSpot instance, and cannot be set only for individual Liveboards. + +These breakpoint widths are customizable for the embedded customers. Contact ThoughtSpot support for assistance with customization. + +[#force-12col-layout] +== 12-column layout in embedded Liveboards +By default, embedded Liveboards use a responsive layout that collapses from 12 columns to +2 columns at container widths of 1024px or less, and to a single column at 630px or less. + +Use the `isLiveboardAlwaysOn12ColLayout` property in the SDK to prevent this collapse and keep the +12-column layout at all container widths. + +To force the 12-column layout, set `isLiveboardAlwaysOn12ColLayout` to `true` in the +`LiveboardViewConfig` object: + +[source,JavaScript] +---- +const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { + frameParams: { + width: '100%', + height: '100%', + }, + liveboardId: '<%=liveboardGUID%>', + isLiveboardAlwaysOn12ColLayout: true, +}); +---- + +The property is also supported on `AppEmbed` via `AppViewConfig`: + +[source,JavaScript] +---- +const appEmbed = new AppEmbed(document.getElementById('ts-embed'), { + frameParams: { + width: '100%', + height: '100%', + }, + isLiveboardAlwaysOn12ColLayout: true, +}); +---- + +[NOTE] +==== +The CSS variables `--ts-var-liveboard-dual-column-breakpoint` and +`--ts-var-liveboard-single-column-breakpoint` control the pixel thresholds at which the +responsive layout collapses. +Use `isLiveboardAlwaysOn12ColLayout: true` when you want to prevent layout collapse +entirely, rather than adjust the breakpoint thresholds. +For more information about the CSS layout variables available for Liveboard layout control, +see xref:customize-css-styles.adoc#liveboard-layout-vars[Liveboard layout CSS variables]. +==== + +[#liveboard-grouping-styling] +== Liveboard grouping and styling + +You can now create a visual group of Answers and note tiles together in the Liveboard. You can select multiple Answers and notes in the Liveboard editor. You can also style parts of the Liveboard, groups and Answers with the new styling panel. +To enable this feature, set `isLiveboardMasterpiecesEnabled` to `true`. +Note the following changes that occur in the Liveboard UI and layout when this feature is enabled. + +* All tiles on the Liveboard now have a default border and an increased border-radius, resulting in more pronounced curved corners. This is part of the broader visualization tile customization options to enhance the visual appearance of charts and tables. +* For Note tiles, the default scrollbar is now hidden for long content. Users must scroll within the tile area itself, and if the tile is not sized appropriately for its content, it may appear clipped due to the new container styles. This change emphasizes the importance of sizing Note tiles correctly to avoid content being visually cut off. +* If a Liveboard is saved with Groups in the ThoughtSpot interface, but the embedding code does not have the grouping and styling feature enabled, the Liveboard fails to load and returns an error. ++ +[.bordered] +[.widthAuto] +-- +image::./images/lb-grp-styling-error.png[Liveboard with groups styling error] +-- ++ +To embed a Liveboard that uses Groups, you must set `isLiveboardMasterpiecesEnabled` to `true` in your embedding configuration. This is required for compatibility with the new grouping and styling features; otherwise, the embedded Liveboard does not render correctly. + +For more information, see link:https://docs.thoughtspot.com/cloud/latest/liveboard-grouping-styling[Liveboard grouping and styling, window=_blank]. + +For information about the CSS variables for this feature, see xref:customize-css-styles.adoc#grp-style[CSS variables reference]. + +== #Related information# +* #xref:embed-pinboard.adoc[Embed a Liveboard]# +* #xref:customize-liveboard-filters.adoc[Customize Liveboard filters]# +* #xref:liveboard-data-downloads.adoc[Liveboard data refresh and downloads]# +* #xref:LiveboardViewConfig.adoc[LiveboardViewConfig]# diff --git a/modules/ROOT/pages/data-security.adoc b/modules/ROOT/pages/data-security.adoc index e8f25e89b..d1d997f7c 100644 --- a/modules/ROOT/pages/data-security.adoc +++ b/modules/ROOT/pages/data-security.adoc @@ -34,6 +34,6 @@ Starting with ThoughtSpot Cloud 26.10.0.cl, Liveboards that include columns rest * Filter chips from columns that the user cannot access are masked instead of blocking the Liveboard. Only one masked filter chip is shown, at the end of the filter bar, to indicate that one or more filters are inaccessible. For example, if five filters are inaccessible, the Liveboard still shows a single masked filter chip. * Scheduled Liveboard deliveries apply CSR separately for each recipient. -To control whether masked filter chips are visible in an embedded Liveboard, use the `showMaskedFilterChip` SDK property. For more information, see xref:embed-pinboard.adoc#masked-filter-chips[Masked filter chips]. +To control whether masked filter chips are visible in an embedded Liveboard, use the `showMaskedFilterChip` SDK property. For more information, see #xref:customize-liveboard-filters.adoc#masked-filter-chips[Masked filter chips]#. For more information, see link:https://docs.thoughtspot.com/cloud/latest/security-data-object#csr-liveboard[Column security rules on Liveboards, window=_blank]. diff --git a/modules/ROOT/pages/embed-pinboard.adoc b/modules/ROOT/pages/embed-pinboard.adoc index bfe4aabb6..54067ac77 100644 --- a/modules/ROOT/pages/embed-pinboard.adoc +++ b/modules/ROOT/pages/embed-pinboard.adoc @@ -67,7 +67,7 @@ For more information about the Liveboard embed object, classes, methods, interfa The `LiveboardEmbed` component includes the `xref:LiveboardViewConfig.adoc[LiveboardViewConfig]` object. This object provides various attributes and properties to xref:css-customization.adoc[customize the look and feel of the Liveboard page], xref:embed-actions.adoc[control the visibility of menu actions] and features, and xref:embed-events.adoc[manage interactions between the host and embedded app]. -The most common customization is controlling the visibility of menu items in the embedded view by configuring the `disabledActions`, `hiddenActions`, and `visibleActions` properties with an array of xref:Action.adoc[Action] IDs. For information about the other frequently used properties, see xref:embed-pinboard.adoc#common-customizations[common customization options]. +The most common customization is controlling the visibility of menu items in the embedded view by configuring the `disabledActions`, `hiddenActions`, and `visibleActions` properties with an array of xref:Action.adoc[Action] IDs. For information about the other frequently used properties, see #<>#. == Register, handle, and trigger events @@ -106,505 +106,24 @@ image::./images/embed-lb.png[Liveboard embed] == Common customization options The *xref:LiveboardViewConfig.adoc[LiveboardViewConfig]* object includes several properties and attributes that allow fine-grained control of the embedded experience. You can specify settings that enable or disable a specific feature, control visible or disabled menu elements and actions, set tabs and layout preferences, manage filters, hide the header, and more. -=== Show/hide large UI elements -Parameters such as `hideLiveboardHeader`, `hideTabPanel`, `isLiveboardHeaderSticky`, `showLiveboardTitle`, and `showLiveboardDescription` control various aspects of the standard embedded Liveboard experience. Note the phrasing of the property name and the description in the documentation to understand whether `true` enables or disables the particular feature. - -//Along with xref:Action.adoc[Actions], there is very fine-grained control of the display of the `LiveboardEmbed` component, which can vary for each user or content object displayed depending on the desires of the app development team. - -[.widthAuto] -image::./images/liveboard_view_config_callouts_2.png[LiveboardViewConfig parameters] - -The `hideLiveboardHeader` property removes the entire header area above the Liveboard, including filters and the overall Liveboard menu, which is a common pattern for "read-only" use cases or for rebuilding your own menus and buttons using xref:embed-events.adoc[HostEvents]. - -The following constructor disables the __sticky header__ and shows the Liveboard title, which is hidden by default: - -[source,JavaScript] ----- -const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { - frameParams: { - width: '100%', - height: '100%', - }, - isLiveboardHeaderSticky : false, - showLiveboardTitle: true, - liveboardId: '<%=liveboardGUID%>', -}); ----- - -[NOTE] -==== -When `fullHeight` is set to `true`, the SDK ignores the `isLiveboardHeaderSticky:true` setting, and the Liveboard header is not sticky. -==== - -[#compact-header] -=== Enable compact Liveboard header -By default, embedded Liveboards display a full-height header bar that contains the Liveboard title, description, filter chips, tab navigation, and action menu. You can switch to a more space-efficient layout by enabling the compact header. - -The compact header reduces the vertical footprint of the header area, integrates tabs and filter chips into a single condensed row, and moves the *Edit* button into the header's overflow action menu. - -To enable the compact header, set `isLiveboardCompactHeaderEnabled` to `true` in the `LiveboardViewConfig` object. Note that the `isLiveboardCompactHeaderEnabled` attribute is set to `true` by default on all ThoughtSpot embedded instances using Visual Embed SDK v1.51.0 or later. - -[source,JavaScript] ----- -const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { - //... other embed config properties - isLiveboardCompactHeaderEnabled: true, -}); ----- - -When `isLiveboardCompactHeaderEnabled` is `true`, the following UI changes apply compared to the standard header: +#The customization options are grouped into the following pages:# [width="100%",cols="2,4",options="header"] |==== -|Area |Change -|Header height |The header renders in a condensed, shorter height to maximize the visible content area. -|Edit button |The *Edit* button is removed from the top-level header and is accessible from the header overflow (*...*) menu. -|Filter chips |Filter chips are rendered inside the compact header's integrated filter row, replacing the separate floating filter panel. -|Tabs |The tab bar is incorporated into the compact header row, rather than appearing as a separate element below the header. -|Sticky scroll behavior |The scroll threshold at which the sticky header attaches to the top of the viewport is recalculated for the compact header's shorter height. This may affect the scroll position at which the header becomes sticky compared to the standard header. -|| -|==== - -==== Hiding the Liveboard title in compact header -In the compact header, you can hide the Liveboard title by setting `showLiveboardTitle` to `false` in the `LiveboardViewConfig` object: - -[source,JavaScript] ----- -const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { - //... other embed config properties - isLiveboardCompactHeaderEnabled: true, - showLiveboardTitle: false, -}); ----- - - -[#liveboard-gutter] -=== Set Liveboard tile gutter - -The `liveboardGutter` property lets you control the spacing between Liveboard tiles and groups, and the grid's outer layout padding, directly from your embed configuration. The value you set overrides the Liveboard's saved styling setting. - -Use this property when you need to enforce consistent tile spacing across embedded Liveboards, regardless of how individual Liveboards are styled in ThoughtSpot. - -[source,JavaScript] ----- -const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { - frameParams: { - width: '100%', - height: '100%', - }, - liveboardId: '<%=liveboardGUID%>', - liveboardGutter: 16, -}); -liveboardEmbed.render(); ----- - -The following rules apply to the `liveboardGutter` value: - -* Accepts non-negative integers (0 or greater). -* A value of `0` removes all gutter space between tiles and the grid's outer layout padding. -* Negative numbers, decimal values, and non-numeric strings are ignored. The Liveboard falls back to its saved styling value. - -[NOTE] -==== -`liveboardGutter` controls the spacing between tiles and groups, not the spacing within a group. - -The Liveboard header's horizontal margin is controlled separately by the `--ts-var-liveboard-header-horizontal-margin` CSS variable. To align the Liveboard header and tab/filter section with the tile grid, set both `liveboardGutter` and `--ts-var-liveboard-header-horizontal-margin` to matching values. For more information, see xref:customize-css-styles.adoc#lb-header-margin[Liveboard CSS variables]. -==== - -=== Customize Liveboard tabs -By default, the first tab created on a Liveboard is set as the home tab. You can set any tab as an active tab using the `activeTabId` property in the Visual Embed SDK as shown in the example here: - -[source,JavaScript] ----- -const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { - frameParams: { - width: '100%', - height: '100%', - }, - liveboardId: "d7a5a08e-a1f7-4850-aeb7-0764692855b8", - activeTabId: "05406350-44ce-488e-abc5-5e8cdd65cf3c", -}); ----- - -[NOTE] -==== -The `activeTabId` property is available only in the `LiveboardEmbed` package and is not supported in the full application embed mode. -==== - -=== Reduce visible tabs and visualizations -`visibleVizs` and `visibleTabs` allow you to limit the experience for certain users on a Liveboard with many more elements. - -For example, a template Liveboard with many different key performance indicators (KPIs) could be reduced to a smaller set by giving a user an interface to select the particular visualizations to show, storing their selections, and using that saved set of visualization globally unique identifiers (GUIDs) as the array for `visibleVizs` on page load (there is an equivalent xref:embed-events.adoc[HostEvent] called `SetVisibleVizs` to make an update after the Liveboard has loaded). - -=== Customizing filter visibility -To hide the entire header area, including filters and the Liveboard menu, set `hideLiveboardHeader` to `true`. You can then trigger filter interactions programmatically using host events. - -[source,JavaScript] ----- -const embed = new LiveboardEmbed('#embed', { - ... // other liveboard view config - hideLiveboardHeader:true, -}); ----- - -If the Liveboard has multiple tabs and the compact header is enabled, you can set `hideIrrelevantChipsInLiveboardTabs` to `true` to hide filters that are not relevant to the current tab in an embedded Liveboard. Note that `hideIrrelevantChipsInLiveboardTabs` is set to `true` by default on all ThoughtSpot embedded instances using Visual Embed SDK v1.51.0 or later. - -[source,JavaScript] ----- -const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { - //... other embed config properties - isLiveboardCompactHeaderEnabled: true, - hideIrrelevantChipsInLiveboardTabs: true, -}); ----- - -When `hideIrrelevantChipsInLiveboardTabs` is `true`: - -* Filter chips backed by data sources that are *not used by any visualization on the active tab* are hidden automatically when the user switches tabs. -* A *Show irrelevant filters* toggle button appears in the filter row when at least one chip is hidden, allowing users to temporarily reveal all chips. -* When the user clicks *Show irrelevant filters*, a complementary *Hide irrelevant filters* button appears to restore the filtered view. - -[#masked-filter-chips] -==== #Masked filter chips# -#If a Liveboard includes filters on columns that a user cannot access because of column-level security, the filter values are masked for that user. Use the `showMaskedFilterChip` property to control whether these masked filter chips are visible:# - -* #`true`: the filter chips for inaccessible columns are displayed as masked.# -* #`false`: the filter chips for inaccessible columns are hidden.# - -[source,JavaScript] ----- -const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { - //... other embed config properties - showMaskedFilterChip: true, -}); ----- - -#The `showMaskedFilterChip` property is also available in full application embedding. For more information, see xref:data-security.adoc#csr-liveboards[Column security rules on Liveboards].# - -[#noteTiles] -=== Add Note tiles -You can add a link:https://docs.thoughtspot.com/cloud/latest/liveboard-notes[Liveboard Note tile, window=_blank] with custom text, images, and links on an embedded Liveboard. - -* Only users with edit access to a Liveboard can add a Note tile. -* Users require `Can upload data` privilege to upload an image to the note tile. -* If you are adding links and images from an external site, or embedding multimedia or a web page in an iFrame, make sure the URLs are added to CORS and CSP allowlists. For more information, see xref:security-settings.adoc[Security settings]. - -=== Redefine Liveboard breakpoint widths -The `enable2ColumnLayout` property, when enabled, allows you to customize the Liveboard breakpoint width for embedded users. -The current 12 column layout changes to 2 columns per row at 1024px, and to 1 column per row layout at 630px in the new Liveboard experience. Once enabled, these breakpoints apply to all Liveboards in the ThoughtSpot instance, and cannot be set only for individual Liveboards. - -These breakpoint widths are customizable for the embedded customers. Contact ThoughtSpot support for assistance with customization. - -[#force-12col-layout] -=== 12-column layout in embedded Liveboards -By default, embedded Liveboards use a responsive layout that collapses from 12 columns to -2 columns at container widths of 1024px or less, and to a single column at 630px or less. - -Use the `isLiveboardAlwaysOn12ColLayout` property in the SDK to prevent this collapse and keep the -12-column layout at all container widths. - -To force the 12-column layout, set `isLiveboardAlwaysOn12ColLayout` to `true` in the -`LiveboardViewConfig` object: - -[source,JavaScript] ----- -const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { - frameParams: { - width: '100%', - height: '100%', - }, - liveboardId: '<%=liveboardGUID%>', - isLiveboardAlwaysOn12ColLayout: true, -}); ----- - -The property is also supported on `AppEmbed` via `AppViewConfig`: - -[source,JavaScript] ----- -const appEmbed = new AppEmbed(document.getElementById('ts-embed'), { - frameParams: { - width: '100%', - height: '100%', - }, - isLiveboardAlwaysOn12ColLayout: true, -}); ----- - -[NOTE] -==== -The CSS variables `--ts-var-liveboard-dual-column-breakpoint` and -`--ts-var-liveboard-single-column-breakpoint` control the pixel thresholds at which the -responsive layout collapses. -Use `isLiveboardAlwaysOn12ColLayout: true` when you want to prevent layout collapse -entirely, rather than adjust the breakpoint thresholds. -For more information about the CSS layout variables available for Liveboard layout control, -see xref:customize-css-styles.adoc#liveboard-layout-vars[Liveboard layout CSS variables]. -==== - -[#liveboard-data-cache] -=== Liveboard browser cache and refresh -ThoughtSpot supports browser-side data caching for Liveboards to improve load performance for users who revisit the same Liveboard within a session. Users can clear cache by clicking the refresh icon in the Liveboard header. - -[NOTE] -==== -The CSS variables `--ts-var-liveboard-dual-column-breakpoint` and -`--ts-var-liveboard-single-column-breakpoint` control the pixel thresholds at which the -responsive layout collapses. -Use `isLiveboardAlwaysOn12ColLayout: true` when you want to prevent layout collapse -entirely, rather than adjust the breakpoint thresholds. -For more information about the CSS layout variables available for Liveboard layout control, -see xref:customize-css-styles.adoc#liveboard-layout-vars[Liveboard layout CSS variables]. -==== - -[#liveboard-data-cache] -=== Liveboard browser cache and refresh -ThoughtSpot supports browser-side data caching for Liveboards to improve load performance for users who revisit the same Liveboard within a session. Users can clear cache by clicking the refresh icon in the Liveboard header. - -To enable Liveboard data caching, set `enableLiveboardDataCache` to `true`. - -[source,javascript] ----- -const liveboardEmbed = new LiveboardEmbed('#embed-container', { - frameParams: { - width: '100%', - height: '100%', - }, - liveboardId: '', // - enableLiveboardDataCache: true, // -}); ----- - -When data caching is enabled, the refresh icon appears in the Liveboard header. Users can click this button to clear the browser cache and fetch fresh data. - -[.bordered] -[.widthAuto] --- -image::./images/liveboard-refresh.png[Liveboard refresh] --- - -The Visual Embed SDK also provides the following action IDs and events to customize the cache refresh visibility and workflow. - -[width="100%",cols="2,4"] -|==== -|ID | Description - -|xref:Action.adoc#_refreshliveboardbrowsercache[Action.RefreshLiveboardBrowserCache] -|Action ID to show, hide, or disable the *Refresh* button in the Liveboard header. - -|xref:EmbedEvent.adoc#_refreshliveboardbrowsercache[EmbedEvent.RefreshLiveboardBrowserCache] -|Emitted when a user clicks the *Refresh* button in the Liveboard header. - -|xref:HostEvent.adoc#_refreshliveboardbrowsercache[HostEvent.RefreshLiveboardBrowserCache] -|Triggers a browser cache refresh programmatically for all visualization containers on the embedded Liveboard. +|#Page# |#Covers# +|#xref:customize-liveboard-layout.adoc[Customize Liveboard layout and UI]# +|#Header visibility and compact header, tabs, visible tabs and visualizations, tile gutter, Note tiles, responsive breakpoints and 12-column layout, and grouping and styling.# +|#xref:customize-liveboard-filters.adoc[Customize Liveboard filters]# +|#Filter chip visibility, masked filter chips, filters at load time, contextual filtering at the Liveboard, tab, and group levels, and updating filters with host events.# +|#xref:liveboard-data-downloads.adoc[Liveboard data refresh and downloads]# +|#Browser data cache and refresh, PDF, XLSX, and CSV downloads, download action IDs and host events, and scheduled exports.# |==== -=== Customize filters -To view specific data across the tables and charts on an embedded Liveboard, users can use Liveboard filter options. By default, Liveboard filters cannot be applied at load. You can either embed a Liveboard that already includes filters or use the xref:runtime-filters.adoc[runtime filters] feature in the Visual Embed SDK to apply filters at load time. - [IMPORTANT] ==== -Runtime filter limits vary by input path. URL-based runtime filters are capped at 50. -When using the Visual Embed SDK `runtimeFilters` property, the limit is configurable by a -ThoughtSpot administrator, with a default and maximum of 1,000 runtime filters. Filters beyond the enforced -limit are silently dropped without an error or warning. For more information, see -xref:runtime-filters.adoc#_maximum_filter_count[Runtime filter limit]. +#If a Liveboard is saved with Groups in the ThoughtSpot interface, you must set `isLiveboardMasterpiecesEnabled` to `true` in your embed configuration. Otherwise, the embedded Liveboard fails to load. For more information, see xref:customize-liveboard-layout.adoc#liveboard-grouping-styling[Liveboard grouping and styling].# ==== -[#contextual-liveboard-filtering] -==== Contextual filtering in Liveboards [earlyAccess eaBackground]#Early Access# -Starting with ThoughtSpot Cloud 26.10.0.cl release, Liveboard filters and parameters can be scoped at three levels: - -* *Liveboard*: applies to all visualizations on the Liveboard. -* *Tab*: applies only to visualizations on a specific tab. -* *Group*: applies only to visualizations in a specific group on a tab. - -With contextual filtering, you can add the same filter at more than one level on a Liveboard, but not at a level directly below it in the same hierarchy. For example: - -* If a `Region` filter is set at the Liveboard level, it cannot be added again on any tab or group on that Liveboard. -* If a `City` filter is set on Tab 1, it cannot be added to a group on Tab 1. However, it can be added to a group on another tab, such as Tab 2, because that group is not in the Tab 1 hierarchy. - -[NOTE] -==== -Contextual filtering in Liveboards is an Early Access feature and is disabled by default on ThoughtSpot instances. To enable this feature on your instance, set `isScopedLiveboardFilteringEnabled` to `true`. -==== - - -[source,JavaScript] ----- -const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { - //... other embed config properties - liveboardId: "d7a5a08e-a1f7-4850-aeb7-0764692855b8", - isScopedLiveboardFilteringEnabled: true, -}); ----- - -The `isScopedLiveboardFilteringEnabled` property is also available in `AppViewConfig` for full application embedding. - -To get the groups on a Liveboard, use `HostEvent.GetGroups`. To scope a filter or parameter update to a specific tab or group, pass the `applicability` attribute with `HostEvent.UpdateFilters` or `HostEvent.UpdateParameters`: - -[source,javascript] ----- -liveboardEmbed.trigger(HostEvent.UpdateFilters, { - filters: [ - { - column: 'Region', - oper: 'IN', - values: ['West'], - applicability: { - level: 'GROUP', - targetId: '{group-id}', - }, - }, - ], -}); ----- - -To listen for filter and parameter changes, use `EmbedEvent.FilterChanged` and `EmbedEvent.ParameterChanged`. Starting with SDK 1.53.0, both events include an optional `applicability` field in their response payload that indicates the scope of the change. - -For more information, see xref:events-hostEvents.adoc#liveboard-group-events[Liveboard group and parameter events] and xref:events-hostEvents.adoc#applicability-host-events[Scoped filter and parameter host events]. - -==== Updating filters -Use the following host events in the Visual Embed SDK to update filters: - -* link:https://developers.thoughtspot.com/docs/Enumeration_HostEvent#_updatefilters[`HostEvent.UpdateFilters`] to update Liveboard filters -* link:https://developers.thoughtspot.com/docs/Enumeration_HostEvent#_updateruntimefilters[`HostEvent.UpdateRuntimeFilters`] to update runtime filters -* link:https://developers.thoughtspot.com/docs/Enumeration_HostEvent#_updatecrossfilter[`HostEvent.UpdateCrossFilter`] - -For more information and examples, see xref:filters_overview.adoc[Filter types and application layers]. - -=== Liveboard download options - -Embedding application users can download Liveboards in the PDF, XLSX, and CSV formats. If the `isContinuousLiveboardPDFEnabled` is set to `true`, users can download the PDF with a continuous layout as seen in the UI, with each tab on a single page. When this parameter is set to `false`, users can download the Liveboard PDF in the A4 format with a paginated view. - -[NOTE] -==== -Starting with ThoughtSpot Cloud version 26.5.0.cl and Visual Embed SDK version 1.48.x, the new multi-format download experience is available on embedded Liveboards and can be enabled using the `isContinuousLiveboardPDFEnabled` and `isLiveboardXLSXCSVDownloadEnabled` parameters. The `Download` action in the Liveboard's more options menu is controlled by `Action.DownloadLiveboard` instead of `Action.Download` and `Action.DownloadAsPdf`. `Action.DownloadAsPdf` remains valid for controlling PDF download on individual visualizations (chart and table tiles) within a Liveboard. -==== - -==== XLSX and CSV in Liveboard scheduled exports -To enable Liveboard scheduled exports in XLSX and CSV format from the embed view, ensure that `isGranularXLSXCSVSchedulesEnabled` is set to `true`. - -[source,javascript] ----- -const embed = new LiveboardEmbed('#tsEmbed', { - //... - isGranularXLSXCSVSchedulesEnabled: true, // enable scope selection for XLSX and CSV Liveboard scheduled jobs. -}); ----- - -==== Download actions and behavior - -[width=100%, cols="2,2,4"] -[options="header"] -|===== -| Download actions -| SDK parameter -| Action ID for show/hide control -| **Download** menu action -| Enabled by default. -a| Use the `Action.DownloadLiveboard` to show, hide, or disable the Download action in the menu. -| Continuous PDF -|`isContinuousLiveboardPDFEnabled` + -Enables or disables the continuous PDF option in Liveboard downloads -a| Use the `Action.DownloadLiveboardAsContinuousPDF` action ID to show or hide the **PDF (Continuous)** option in the **Download** modal. - -| XLSX download -| `isLiveboardXLSXCSVDownloadEnabled` + -Enables the XLSX and CSV download options in the Download modal. -a|`Action.DownloadLiveboardAsXlsx` - action ID to show or hide the XLSX option in the **Download** modal. - -| CSV download -a| `isLiveboardXLSXCSVDownloadEnabled` + -Enables the XLSX and CSV download options in the Download modal. -a|`Action.DownloadLiveboardAsCsv` - action ID to show, hide, or disable the CSV option in the **Download** modal. - -| PDF cover and filter pages -| None. UI options in PDF modal only. -a| Use `Action.CoverAndFilterOptionInPDF` to hide the checkboxes for including or excluding cover and filter pages in the Liveboard PDF download dialog. -|===== - -==== Example - -The following example enables the **PDF (Continuous)**, **XLSX**, and **CSV** options in the Liveboard **Download** modal. - -[source,javascript] ----- -const embed = new LiveboardEmbed('#tsEmbed', { - isContinuousLiveboardPDFEnabled: true, - isLiveboardXLSXCSVDownloadEnabled: true, -}); ----- - -To hide or disable the download options, specify the following action IDs: - -[source,javascript] ----- -const embed = new LiveboardEmbed('#tsEmbed', { - //... - hiddenActions: [ - Action.DownloadLiveboard, // Hides the Download parent action from the more options menu - Action.DownloadLiveboardAsContinuousPDF, // Hides the Continuous PDF download option from the Download modal - Action.DownloadLiveboardAsCsv, // Hides the CSV download option from the Download modal - Action.DownloadLiveboardAsXlsx // Hides the XLSX download option from the Download modal - ], - // Use either `hiddenActions` or visibleActions, not both, to avoid errors - //visibleActions: [ - //Action.DownloadLiveboard, // Shows the Download parent action in the more options menu - //Action.DownloadLiveboardAsContinuousPDF, // Shows the Continuous PDF download option - //Action.DownloadLiveboardAsCsv, // Shows the CSV download option - //Action.DownloadLiveboardAsXlsx // Shows the XLSX download option - //] -}); ----- - -[source,javascript] ----- -const embed = new LiveboardEmbed('#tsEmbed', { - //... - disabledActions: [ - Action.DownloadLiveboard,// Disables the Download parent action in the more options menu - Action.DownloadLiveboardAsContinuousPDF, // Disables the Continuous PDF download option in the Download modal - Action.DownloadLiveboardAsCsv, // Disables the CSV download option in the Download modal - Action.DownloadLiveboardAsXlsx, // Disables the XLSX download option in the Download modal - ], -}); ----- - -==== HostEvent workflow for the download actions -Use the HostEvent IDs to trigger download actions on an embedded Liveboard. - -* `HostEvent.DownloadLiveboardAsContinuousPDF` + -To open the **Download** modal with the continuous PDF download option selected. -* `HostEvent.DownloadAsPdf` + -To trigger the standard PDF (A4) download action. To download a specific Liveboard visualization, specify the GUID of the visualization in the `vizId` key. -* `HostEvent.DownloadAsXlsx` + -To open the **Download** modal with the XLSX option selected when the `isLiveboardXLSXCSVDownloadEnabled` is enabled in the embed view. -* `HostEvent.DownloadAsCsv` + -To open the **Download** modal with the CSV option selected when `isLiveboardXLSXCSVDownloadEnabled` parameter is enabled in the embed view. - -=== Liveboard grouping and styling - -You can now create a visual group of Answers and note tiles together in the Liveboard. You can select multiple Answers and notes in the Liveboard editor. You can also style parts of the Liveboard, groups and Answers with the new styling panel. -To enable this feature, set `isLiveboardMasterpiecesEnabled` to `true`. -Note the following changes that occur in the Liveboard UI and layout when this feature is enabled. - -* All tiles on the Liveboard now have a default border and an increased border-radius, resulting in more pronounced curved corners. This is part of the broader visualization tile customization options to enhance the visual appearance of charts and tables. -* For Note tiles, the default scrollbar is now hidden for long content. Users must scroll within the tile area itself, and if the tile is not sized appropriately for its content, it may appear clipped due to the new container styles. This change emphasizes the importance of sizing Note tiles correctly to avoid content being visually cut off. -* If a Liveboard is saved with Groups in the ThoughtSpot interface, but the embedding code does not have the grouping and styling feature enabled, the Liveboard fails to load and returns an error. -+ -[.bordered] -[.widthAuto] --- -image::./images/lb-grp-styling-error.png[Liveboard with groups styling error] --- -+ -To embed a Liveboard that uses Groups, you must set `isLiveboardMasterpiecesEnabled` to `true` in your embedding configuration. This is required for compatibility with the new grouping and styling features; otherwise, the embedded Liveboard does not render correctly. - -For more information, see link:https://docs.thoughtspot.com/cloud/latest/liveboard-grouping-styling[Liveboard grouping and styling, window=_blank]. - -For information about the CSS variables for this feature, see xref:customize-css-styles.adoc#grp-style[CSS variables reference]. - == Additional resources * For information about runtime overrides, see xref:runtime-filters.adoc[Runtime filters] and xref:runtime-parameters.adoc[Runtime Parameter overrides]. * For code examples, see link:https://github.com/thoughtspot/developer-examples/tree/main/visual-embed/liveboard[Developer examples^]. diff --git a/modules/ROOT/pages/filters_overview.adoc b/modules/ROOT/pages/filters_overview.adoc index 98d1a6ea3..3e9bd0c5a 100644 --- a/modules/ROOT/pages/filters_overview.adoc +++ b/modules/ROOT/pages/filters_overview.adoc @@ -230,7 +230,7 @@ Starting with ThoughtSpot Cloud 26.10.0.cl release, Liveboard filters and parame * *Tab*: applies only to visualizations on a specific tab. * *Group*: applies only to visualizations in a specific group on a tab. -To enable group-level scoping in an embedded Liveboard, set `isScopedLiveboardFilteringEnabled` to `true` in the embed configuration. For more information, see xref:embed-pinboard.adoc#scoped-liveboard-filtering[Scoped Liveboard filtering]. +#To enable group-level scoping in an embedded Liveboard, set `isScopedLiveboardFilteringEnabled` to `true` in the embed configuration. For more information, see xref:customize-liveboard-filters.adoc#contextual-liveboard-filtering[Scoped Liveboard filtering].# To scope an update to a tab or group, pass the `applicability` attribute with `HostEvent.UpdateFilters` or `HostEvent.UpdateParameters`. Set `level` to `TAB` or `GROUP`, and set `targetId` to the ID of the tab or group. To get the group IDs on a Liveboard, use `HostEvent.GetGroups`. @@ -977,4 +977,4 @@ There is no specific event to update `search_query filters` in the `SearchEmbed` You can set your app to listen to link:https://developers.thoughtspot.com/docs/Enumeration_EmbedEvent#_querychanged[EmbedEvent.QueryChanged] and trigger the link:https://developers.thoughtspot.com/docs/Enumeration_HostEvent#_gettml[HostEvent.GetTML] event to get a new TML generated for the `search_query` string after an update. -//// \ No newline at end of file +//// diff --git a/modules/ROOT/pages/liveboard-data-downloads.adoc b/modules/ROOT/pages/liveboard-data-downloads.adoc new file mode 100644 index 000000000..dc5697326 --- /dev/null +++ b/modules/ROOT/pages/liveboard-data-downloads.adoc @@ -0,0 +1,172 @@ += Liveboard data refresh and downloads +:toc: true +:toclevels: 2 + +:page-title: Liveboard data refresh and downloads +:page-pageid: liveboard-data-downloads +:page-description: Use the Visual Embed SDK to enable browser data caching and refresh, and to configure PDF, XLSX, and CSV downloads and scheduled exports on an embedded Liveboard. + +#The Visual Embed SDK provides properties, action IDs, and events to control how users refresh Liveboard data and download or export Liveboards from the embedded view.# + +#Before you begin, make sure you have xref:embed-pinboard.adoc[embedded a Liveboard] using the `LiveboardEmbed` component.# + +[#liveboard-data-cache] +== Liveboard browser cache and refresh +ThoughtSpot supports browser-side data caching for Liveboards to improve load performance for users who revisit the same Liveboard within a session. Users can clear cache by clicking the refresh icon in the Liveboard header. + +To enable Liveboard data caching, set `enableLiveboardDataCache` to `true`. + +[source,javascript] +---- +const liveboardEmbed = new LiveboardEmbed('#embed-container', { + frameParams: { + width: '100%', + height: '100%', + }, + liveboardId: '', // + enableLiveboardDataCache: true, // +}); +---- + +When data caching is enabled, the refresh icon appears in the Liveboard header. Users can click this button to clear the browser cache and fetch fresh data. + +[.bordered] +[.widthAuto] +-- +image::./images/liveboard-refresh.png[Liveboard refresh] +-- + +The Visual Embed SDK also provides the following action IDs and events to customize the cache refresh visibility and workflow. + +[width="100%",cols="2,4"] +|==== +|ID | Description + +|xref:Action.adoc#_refreshliveboardbrowsercache[Action.RefreshLiveboardBrowserCache] +|Action ID to show, hide, or disable the *Refresh* button in the Liveboard header. + +|xref:EmbedEvent.adoc#_refreshliveboardbrowsercache[EmbedEvent.RefreshLiveboardBrowserCache] +|Emitted when a user clicks the *Refresh* button in the Liveboard header. + +|xref:HostEvent.adoc#_refreshliveboardbrowsercache[HostEvent.RefreshLiveboardBrowserCache] +|Triggers a browser cache refresh programmatically for all visualization containers on the embedded Liveboard. +|==== + +[#liveboard-download-options] +== Liveboard download options + +Embedding application users can download Liveboards in the PDF, XLSX, and CSV formats. If the `isContinuousLiveboardPDFEnabled` is set to `true`, users can download the PDF with a continuous layout as seen in the UI, with each tab on a single page. When this parameter is set to `false`, users can download the Liveboard PDF in the A4 format with a paginated view. + +[NOTE] +==== +Starting with ThoughtSpot Cloud version 26.5.0.cl and Visual Embed SDK version 1.48.x, the new multi-format download experience is available on embedded Liveboards and can be enabled using the `isContinuousLiveboardPDFEnabled` and `isLiveboardXLSXCSVDownloadEnabled` parameters. The `Download` action in the Liveboard's more options menu is controlled by `Action.DownloadLiveboard` instead of `Action.Download` and `Action.DownloadAsPdf`. `Action.DownloadAsPdf` remains valid for controlling PDF download on individual visualizations (chart and table tiles) within a Liveboard. +==== + +=== Download actions and behavior + +[width=100%, cols="2,2,4"] +[options="header"] +|===== +| Download actions +| SDK parameter +| Action ID for show/hide control +| **Download** menu action +| Enabled by default. +a| Use the `Action.DownloadLiveboard` to show, hide, or disable the Download action in the menu. +| Continuous PDF +|`isContinuousLiveboardPDFEnabled` + +Enables or disables the continuous PDF option in Liveboard downloads +a| Use the `Action.DownloadLiveboardAsContinuousPDF` action ID to show or hide the **PDF (Continuous)** option in the **Download** modal. + +| XLSX download +| `isLiveboardXLSXCSVDownloadEnabled` + +Enables the XLSX and CSV download options in the Download modal. +a|`Action.DownloadLiveboardAsXlsx` - action ID to show or hide the XLSX option in the **Download** modal. + +| CSV download +a| `isLiveboardXLSXCSVDownloadEnabled` + +Enables the XLSX and CSV download options in the Download modal. +a|`Action.DownloadLiveboardAsCsv` - action ID to show, hide, or disable the CSV option in the **Download** modal. + +| PDF cover and filter pages +| None. UI options in PDF modal only. +a| Use `Action.CoverAndFilterOptionInPDF` to hide the checkboxes for including or excluding cover and filter pages in the Liveboard PDF download dialog. +|===== + +=== Example + +The following example enables the **PDF (Continuous)**, **XLSX**, and **CSV** options in the Liveboard **Download** modal. + +[source,javascript] +---- +const embed = new LiveboardEmbed('#tsEmbed', { + isContinuousLiveboardPDFEnabled: true, + isLiveboardXLSXCSVDownloadEnabled: true, +}); +---- + +To hide or disable the download options, specify the following action IDs: + +[source,javascript] +---- +const embed = new LiveboardEmbed('#tsEmbed', { + //... + hiddenActions: [ + Action.DownloadLiveboard, // Hides the Download parent action from the more options menu + Action.DownloadLiveboardAsContinuousPDF, // Hides the Continuous PDF download option from the Download modal + Action.DownloadLiveboardAsCsv, // Hides the CSV download option from the Download modal + Action.DownloadLiveboardAsXlsx // Hides the XLSX download option from the Download modal + ], + // Use either `hiddenActions` or visibleActions, not both, to avoid errors + //visibleActions: [ + //Action.DownloadLiveboard, // Shows the Download parent action in the more options menu + //Action.DownloadLiveboardAsContinuousPDF, // Shows the Continuous PDF download option + //Action.DownloadLiveboardAsCsv, // Shows the CSV download option + //Action.DownloadLiveboardAsXlsx // Shows the XLSX download option + //] +}); +---- + +[source,javascript] +---- +const embed = new LiveboardEmbed('#tsEmbed', { + //... + disabledActions: [ + Action.DownloadLiveboard,// Disables the Download parent action in the more options menu + Action.DownloadLiveboardAsContinuousPDF, // Disables the Continuous PDF download option in the Download modal + Action.DownloadLiveboardAsCsv, // Disables the CSV download option in the Download modal + Action.DownloadLiveboardAsXlsx, // Disables the XLSX download option in the Download modal + ], +}); +---- + +=== HostEvent workflow for the download actions +Use the HostEvent IDs to trigger download actions on an embedded Liveboard. + +* `HostEvent.DownloadLiveboardAsContinuousPDF` + +To open the **Download** modal with the continuous PDF download option selected. +* `HostEvent.DownloadAsPdf` + +To trigger the standard PDF (A4) download action. To download a specific Liveboard visualization, specify the GUID of the visualization in the `vizId` key. +* `HostEvent.DownloadAsXlsx` + +To open the **Download** modal with the XLSX option selected when the `isLiveboardXLSXCSVDownloadEnabled` is enabled in the embed view. +* `HostEvent.DownloadAsCsv` + +To open the **Download** modal with the CSV option selected when `isLiveboardXLSXCSVDownloadEnabled` parameter is enabled in the embed view. + +[#scheduled-exports] +== XLSX and CSV in Liveboard scheduled exports +To enable Liveboard scheduled exports in XLSX and CSV format from the embed view, ensure that `isGranularXLSXCSVSchedulesEnabled` is set to `true`. + +[source,javascript] +---- +const embed = new LiveboardEmbed('#tsEmbed', { + //... + isGranularXLSXCSVSchedulesEnabled: true, // enable scope selection for XLSX and CSV Liveboard scheduled jobs. +}); +---- + +== #Related information# +* #xref:embed-pinboard.adoc[Embed a Liveboard]# +* #xref:customize-liveboard-layout.adoc[Customize Liveboard layout and UI]# +* #xref:customize-liveboard-filters.adoc[Customize Liveboard filters]# +* #xref:Action.adoc[Actions]# +* #xref:HostEvent.adoc[HostEvent]# diff --git a/modules/ROOT/pages/whats-new-prev-history.adoc b/modules/ROOT/pages/whats-new-prev-history.adoc index 0de0de67b..eefffcd92 100644 --- a/modules/ROOT/pages/whats-new-prev-history.adoc +++ b/modules/ROOT/pages/whats-new-prev-history.adoc @@ -736,7 +736,7 @@ For more information, see xref:selective-user-access.adoc[User access to non-emb ==== You can now customize the width of the Liveboard breakpoint on an embedded instance. To enable it, you must set the `enable2ColumnLayout` property to `true`. -For more information, see xref:embed-pinboard.adoc#_redefine_liveboard_breakpoint_widths[Redefine Liveboard breakpoint widths]. +For more information, see #xref:customize-liveboard-layout.adoc#liveboard-breakpoints[Redefine Liveboard breakpoint widths]#. ==== .Visual Embed SDK @@ -1389,7 +1389,7 @@ We recommend using a development environment to test your customizations before ==== In the new Liveboard experience mode, you can now add Note tiles with custom text, images, and links. This feature is turned off by default and can be enabled by ThoughtSpot administrators. -For more information, see xref:embed-pinboard.adoc#noteTiles[Note tiles]. +For more information, see #xref:customize-liveboard-layout.adoc#noteTiles[Note tiles]#. ==== .Visual Embed SDK [%collapsible] @@ -1407,7 +1407,7 @@ For information about REST API enhancements, see xref:rest-apiv1-changelog.adoc[ .New Liveboard experience [%collapsible] ==== -The xref:embed-pinboard.adoc#lbv2[new Liveboard experience] is now Generally Available (GA) on embedded instances and is enabled by default. +The #xref:embed-pinboard.adoc[new Liveboard experience]# is now Generally Available (GA) on embedded instances and is enabled by default. ==== .New CSS variables for custom styling @@ -1605,7 +1605,7 @@ For more information, see xref:fixed-issues.adoc[Fixed issues]. ==== The new Liveboard experience[beta betaBackground]^Beta^ now supports organizing visualizations in tabs. Users with edit access to a Liveboard can add, edit, and move visualizations to tabs on a Liveboard. On embedded ThoughtSpot instances, developers can set a specific tab as an active tab using the Visual Embed SDK. -This feature is available only on deployments that have the new Liveboard experience enabled. For more information, see xref:embed-pinboard.adoc#_liveboard_tabs[Customize Liveboard tabs]. +This feature is available only on deployments that have the new Liveboard experience enabled. For more information, see #xref:customize-liveboard-layout.adoc#liveboard-tabs[Customize Liveboard tabs]#. [IMPORTANT] The new Liveboard experience is in BETA on embedded ThoughtSpot instances. This feature is turned off by default on embedded ThoughtSpot instances. @@ -1665,7 +1665,7 @@ The new Liveboard experience is currently available in BETA on embedded ThoughtS In ThoughtSpot Cloud 8.6.0.cl:: -The new Liveboard experience is turned off by default on embedded ThoughtSpot instances. If you are using the Visual Embed SDK to embed ThoughtSpot, you can xref:embed-pinboard.adoc#lbv2[set the `liveboardV2` parameter] in the SDK package to `true` and enable the new experience globally for all users on your instance. + +The new Liveboard experience is turned off by default on embedded ThoughtSpot instances. If you are using the Visual Embed SDK to embed ThoughtSpot, you can #xref:embed-pinboard.adoc[set the `liveboardV2` parameter]# in the SDK package to `true` and enable the new experience globally for all users on your instance. + + ThoughtSpot users with administrator privileges can also link:https://docs.thoughtspot.com/cloud/latest/liveboard-experience-new[turn on the new Liveboard experience, window=_blank] at the cluster level. diff --git a/modules/ROOT/pages/whats-new.adoc b/modules/ROOT/pages/whats-new.adoc index e71d52f10..bc29e0157 100644 --- a/modules/ROOT/pages/whats-new.adoc +++ b/modules/ROOT/pages/whats-new.adoc @@ -64,7 +64,7 @@ ThoughtSpot now supports programmatic feature management with new REST API v2 en ==== Liveboard enhancements * *Contextual filtering in Liveboards* [earlyAccess eaBackground]#Early Access# + -Liveboard filters now have a three-tier hierarchy: Liveboard level, tab level, and group level. You can enable this feature in embedded Liveboards using the `isScopedLiveboardFilteringEnabled` parameter in the SDK. For more information, see xref:embed-pinboard.adoc#contextual-liveboard-filtering[Embed a Liveboard]. +Liveboard filters now have a three-tier filter hierarchy : Liveboard level, tab level, and group level. You can enable this feature in embedded Liveboards using the `isScopedLiveboardFilteringEnabled` parameter in the SDK. For more information, see #xref:customize-liveboard-filters.adoc#contextual-liveboard-filtering[Contextual filtering in Liveboards]#. * *Host Events* + @@ -227,13 +227,13 @@ Embedded Spotter interface supports onboarding starter prompts to guide first-ti The following features, previously in Early Access, are now generally available and enabled by default on ThoughtSpot Embedded instances: * Hide irrelevant filters (`hideIrrelevantChipsInLiveboardTabs`) + -xref:embed-pinboard.adoc#_customizing_filter_visibility[Hides filters] that are not relevant to the displayed visualization in a tab. +#xref:customize-liveboard-filters.adoc#filter-visibility[Hides filters]# that are not relevant to the displayed visualization in a tab. * Compact header (`isLiveboardCompactHeaderEnabled`) + -Enables in compact header in embedded Liveboards. For information about breaking changes and the affected elements, see xref:embed-pinboard.adoc#compact-header[compact Liveboard header]. +Enables in compact header in embedded Liveboards. For information about breaking changes and the affected elements, see #xref:customize-liveboard-layout.adoc#compact-header[compact Liveboard header]#. * Cover page filtering options (`coverAndFilterOptionInPDF`) + Enables the *Include cover page* and *Include filter page(s)* checkboxes in the Liveboard download modal. * Liveboard styling and grouping (isLiveboardMasterpiecesEnabled) + -Enables the xref:embed-pinboard.adoc#_liveboard_grouping_and_styling[Liveboard styling and grouping] feature. +Enables the #xref:customize-liveboard-layout.adoc#liveboard-grouping-styling[Liveboard styling and grouping]# feature. * Filter interactivity (`isEnhancedFilterInteractivityEnabled`) + Enables interactive filter chips that allow users to add, update, or remove filters in an embedded Liveboard. @@ -500,7 +500,7 @@ Embedded Liveboards can now be downloaded in the PDF, XLSX and CSV file formats. Excel exports for pivot tables:: Pivot table visualizations can now be exported to Excel format. -For more information, see xref:embed-pinboard.adoc#_liveboard_download_options[Liveboard download options]. +For more information, see #xref:liveboard-data-downloads.adoc#liveboard-download-options[Liveboard download options]#. --- @@ -939,4 +939,4 @@ For information about the new features and enhancements introduced in Visual Emb ==== REST API For information about REST API v2 enhancements, see xref:rest-apiv2-changelog.adoc[REST API v2.0 changelog]. -|=== \ No newline at end of file +|=== diff --git a/src/configs/doc-configs.js b/src/configs/doc-configs.js index 0e414264c..2edc6b6bb 100644 --- a/src/configs/doc-configs.js +++ b/src/configs/doc-configs.js @@ -114,6 +114,7 @@ module.exports = { 'getting-started', 'VisualEmbedSdk', 'embed-spotter', 'embed-spotter-agent', 'embed-ai-search-analytics', 'search-embed', 'embed-searchbar', 'embed-a-viz', 'embed-liveboard', + 'customize-liveboard-layout', 'customize-liveboard-filters', 'liveboard-data-downloads', 'full-embed', 'full-app-customize', 'react-app-embed', 'embed-without-sdk', 'mobile-embed', 'embed-ts-mobile-react-native', 'embed-ts-flutter', 'embed-ts-swift', 'embed-ts-android', From c524ecdb47d4780916a943bc73f94e8f307b7c37 Mon Sep 17 00:00:00 2001 From: Rani Gangwar Date: Tue, 6 Oct 2026 21:48:56 +0530 Subject: [PATCH 08/57] v1 API cleanup --- modules/ROOT/pages/api-user-management.adoc | 30 +- modules/ROOT/pages/audit-logs.adoc | 5 +- modules/ROOT/pages/connections.adoc | 2368 +---------------- modules/ROOT/pages/org-manage-api.adoc | 56 +- .../pages/rest-api-v2-metadata-search.adoc | 20 +- modules/ROOT/pages/rest-api-v2-search.adoc | 4 +- .../ROOT/pages/rest-api-v2-users-search.adoc | 4 +- modules/ROOT/pages/roles.adoc | 32 +- modules/ROOT/pages/runtime-sort.adoc | 22 - 9 files changed, 33 insertions(+), 2508 deletions(-) diff --git a/modules/ROOT/pages/api-user-management.adoc b/modules/ROOT/pages/api-user-management.adoc index 15312afc7..4989e7a95 100644 --- a/modules/ROOT/pages/api-user-management.adoc +++ b/modules/ROOT/pages/api-user-management.adoc @@ -10,7 +10,7 @@ To provide access to ThoughtSpot content or define privileges, you must add user == User creation and update -You can create users and update user profiles using xref:rest-api-reference.adoc#_user_management[REST API v1] or xref:rest-api-v2-reference.adoc#_users[v2 endpoints]. +#You can create users and update user profiles using the xref:rest-api-v2-reference.adoc#_users[REST API v2 user endpoints].# While you can delete users, it is preferable to deactivate a user, which maintains the user's references within the system. @@ -75,27 +75,22 @@ The common user state transitions include: To check the status of a user account, use one of the following API endpoints: * +++POST /api/rest/2.0/users/search+++ (REST API v2) -* xref:user-api.adoc[GET /tspublic/v1/user/] (REST API v1) To create a user in the `SUSPENDED` state, use one of the following API endpoints: * +++POST /api/rest/2.0/users/create+++ (Rest API v2) -* xref:user-api.adoc#create-user[POST /tspublic/v1/user] (Rest API v1) To update user status from `ACTIVE` or `PENDING` to `SUSPENDED`, or reactivate a `SUSPENDED` user to the ACTIVE or PENDING state, use one of the following API endpoints: * +++POST /api/rest/2.0/users/{user_identifier}/update+++ (Rest API v2) -* xref:user-api.adoc#update-user[PUT /tspublic/v1/user/{userid}] (Rest API v1) To deactivate an `ACTIVE` user, use one of these API endpoints: * +++POST /api/rest/2.0/users/deactivate+++ (Rest API v2) -* xref:user-api.adoc#deactivate-user[POST /tspublic/v1/user/inactivate] (Rest API v1) To activate an `INACTIVE` user account, use one of these API endpoints: * +++POST /api/rest/2.0/users/activate+++ (Rest API v2) -* xref:user-api.adoc#activate-user[POST /tspublic/v1/user/activate] (Rest API v1) //// @@ -118,13 +113,11 @@ Administrators can manage the user account status for all users. For IAMv2 users To create a user: -* +++POST /api/rest/2.0/users/create+++ (Rest API v2) -* xref:user-api.adoc#create-user[POST /tspublic/v1/user] (Rest API v1) +* #+++POST /api/rest/2.0/users/create+++# To update user details: -* +++POST /api/rest/2.0/users/{user_identifier}/update+++ (Rest API v2) -* xref:user-api.adoc#update-user[PUT /tspublic/v1/user/{userid}] (Rest API v1) +* #+++POST /api/rest/2.0/users/{user_identifier}/update+++# [NOTE] ==== @@ -172,28 +165,21 @@ For more information, see the following pages: == User group creation and update -Groups can be created via xref:rest-api-v2-reference.adoc#_groups[REST API v2] or xref:rest-api-reference.adoc#_groups_and_privileges[REST API v1] API endpoint. +#Groups can be created via the xref:rest-api-v2-reference.adoc#_groups[REST API v2 group endpoints].# The xref:api-user-management.adoc#group-privileges[Group privileges] are set directly on groups, either during group creation or via a group update API call. == User association to groups You can add users to specific groups when creating a user or group via REST API endpoints. -The REST API v1 xref:group-api.adoc#addMembers[group/addmemberships] endpoint allows adding multiple users to multiple groups in a single request. - -To get a list of users assigned to a group, you can use the +++POST /api/rest/2.0/groups/search+++ v2 endpoint or the xref:group-api.adoc#get-usersInGroup[/tspublic/v1/group/{groupid}/users] v1 endpoint. - -To remove a user from a group, use the update group REST v2 endpoint, or the following REST API v1 endpoints: +#To add users to an existing group or remove users from it, use the +++POST /api/rest/2.0/groups/{group_identifier}/update+++ endpoint with the `user_identifiers` parameter and the `ADD`, `REMOVE`, or `REPLACE` operation. To update the groups of a single user, use the +++POST /api/rest/2.0/users/{user_identifier}/update+++ endpoint with the `group_identifiers` parameter.# -* xref:group-api.adoc#delete-user-assoc[POST /tspublic/v1/group/{groupid}/user/{userid}] + -removes a user from a specific group -* xref:group-api.adoc#removeMembers[POST /tspublic/v1/group/removememberships] + -removes a list of users from many groups at once. +#To get a list of users assigned to a group, use the +++POST /api/rest/2.0/groups/search+++ endpoint.# == Access control (sharing) Access to objects is determined by content *shared* directly to the user or the groups they belong to. It is easier to manage and audit sharing through groups rather than object sharing at the individual user level. -Users can share objects or modify sharing properties through the UI or via an API call to the `POST /api/rest/2.0/security/metadata/share` or xref:security-api.adoc#share-object[/tspublic/v1/security/share] endpoint. +Users can share objects or modify sharing properties through the UI or via an API call to the #`POST /api/rest/2.0/security/metadata/share` endpoint#. [#group-privileges] == Group privileges @@ -222,7 +208,7 @@ When creating or updating a group using the REST API v2 endpoints, use the `role [NOTE] ==== -Roles must be available before they can be assigned. Use the xref:roles-api.adoc[Roles API] to create roles programmatically before calling the group create or update endpoints. +Roles must be available before they can be assigned. #Use the link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Froles%2Fcreate-role[`POST /api/rest/2.0/roles/create`] endpoint# to create roles programmatically before calling the group create or update endpoints. ==== For a complete list of available privileges and role categories, see xref:roles.adoc[Role-based access control]. diff --git a/modules/ROOT/pages/audit-logs.adoc b/modules/ROOT/pages/audit-logs.adoc index 8269969f2..817593b88 100644 --- a/modules/ROOT/pages/audit-logs.adoc +++ b/modules/ROOT/pages/audit-logs.adoc @@ -43,10 +43,9 @@ Error Message: Cannot use first or last name in the password.","userId":"e3dc495 Org ID if your ThoughtSpot application instance has Orgs. == Fetch logs via API -To fetch logs via REST API, you need `ADMINISTRATION` (*Can administer ThoughtSpot*) privilege. You can use any of the following APIs: +To fetch logs via REST API, you need `ADMINISTRATION` (*Can administer ThoughtSpot*) privilege. #Use the following API endpoint:# -* +++POST /api/rest/2.0/logs/fetch+++ (Recommended) -* xref:logs-api.adoc[GET /tspublic/v1/logs/topics/{topic}] (REST API v1) +* #+++POST /api/rest/2.0/logs/fetch+++# On Orgs-enabled clusters, the API fetches security logs for the current Org text. However, the administrator of the primary Org (Org 0) can fetch logs not just for the primary Org, but also for all other Orgs if the `get_all_logs` parameter is set to `true` in the API request. diff --git a/modules/ROOT/pages/connections.adoc b/modules/ROOT/pages/connections.adoc index bccbca98d..ca0b2e3ea 100644 --- a/modules/ROOT/pages/connections.adoc +++ b/modules/ROOT/pages/connections.adoc @@ -73,10 +73,7 @@ ThoughtSpot supports connecting to external data warehouses and using these as d This article describes the procedures for setting and managing data connections through the REST APIs. For information about how to set up a data connection, see link:https://docs.thoughtspot.com/cloud/latest/connections[Data Connections in ThoughtSpot, window=_blank]. == Data Connection APIs -The connection API endpoints allow you to create, edit, delete, export, and query data connections on a ThoughtSpot cluster. For more information about the API endpoints, see the following pages: - -* xref:connections-api.adoc[REST API v1, window=_blank] -* https://developers.thoughtspot.com/docs/rest-apiv2-reference#_connections[REST APIs v2, window=_blank] +The connection API endpoints allow you to create, edit, delete, export, and query data connections on a ThoughtSpot cluster. #For more information about the API endpoints, see https://developers.thoughtspot.com/docs/rest-apiv2-reference#_connections[REST API v2 Connection endpoints, window=_blank].# === Required permissions @@ -634,10 +631,7 @@ To understand about the data catalog tools supported by ThoughtSpot, see the fol * https://docs.thoughtspot.com/cloud/latest/catalog-integration-atlan[Atlan, window=_blank] * https://docs.thoughtspot.com/cloud/latest/catalog-integration-collibra[Collibra, window=_blank] -To understand the metadata API endpoints, see the following pages: - -* https://developers.thoughtspot.com/docs/catalog-and-audit[REST APIs v1, window=_blank] -* https://developers.thoughtspot.com/docs/rest-apiv2-reference#_metadata[REST APIs v2, window=_blank] +#To understand the metadata API endpoints, see https://developers.thoughtspot.com/docs/rest-apiv2-reference#_metadata[REST API v2 Metadata endpoints, window=_blank].# == Connection Configuration ThoughtSpot supports multiple configurations per data Connection. This feature allows data engineers to define and manage several distinct Connection configurations under a single data Connection object in ThoughtSpot. @@ -658,2361 +652,3 @@ Gets custom calendars for the connection ID specified in the API request. Deletes a custom calendar. * `POST /api/rest/2.0/calendars/{calendar_identifier}/update` + Updates a custom calendar. - - - -//// -[#cre-connection] -== Add a data connection for live query service - -ThoughtSpot supports connecting to external data warehouses and using these as data sources for analytics and visualizations. To create a connection to an external data source and set up a live query service, use the `/tspublic/v1/connection/create` API endpoint. - - -=== Resource URL ----- -POST /tspublic/v1/connection/create ----- - -=== Request parameters - -[width="100%" cols="1,4"] -[options='header'] -|==== -|Form parameter|Description -|`name` |__String__. Name of the connection. -|`description` |__String__ A short description of data connection. -|`type`|__String__. Type of the data connection. For example, `RDBMS_SNOWFLAKE`, `RDBMS_REDSHIFT`. - -Valid values for `type` map to the *name* property in the API response returned by the xref:connections-api.adoc#connection-types[/tspublic/v1/connection/types API endpoint]. -|`createEmpty`|__Boolean__. Allows creating a connection without tables. If you want to store only the configuration attributes and import tables later, you can set this attribute to `true`. The default value is `false`. -|`metadata` a|__String__. A JSON map of the connection metadata. The metadata must include xref:connections-api.adoc#connection-attr[configuration attributes], database, schema, and table objects. However, you can create a connection without tables by setting the `createEmpty` attribute to `true`. -|`state`|_Integer_. The GUID of the OAuth redirect URI. This attribute is automatically populated and cannot be set by users. -|==== - -=== Example request - -.cURL -[source,cURL] ----- -curl -X POST \ ---header 'Content-Type: application/x-www-form-urlencoded' \ ---header 'Accept: application/json' \ ---header 'X-Requested-By: ThoughtSpot' \ --d 'name=Snowflake&description=Snowflake_connection&type=RDBMS_SNOWFLAKE&metadata=' \ -'https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/create' ----- - -.Request URL ----- -https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/create ----- - -==== Example metadata JSON without tables - -The following example shows the metadata JSON map for creating a Snowflake data connection without tables. - -[source, JSON] ----- -{ - "configuration":{ - "accountName":"thoughtspot_partner", - "user":"tsadmin", - "password":"TestConn123", - "role":"sysadmin", - "warehouse":"MEDIUM_WH" - }, - "externalDatabases":[ - - ] -} ----- - - -==== Example metadata JSON with tables - -The following example shows the metadata JSON map for creating a Snowflake data connection. Note that the example shows the configuration attributes required to authenticate to the data source, and the object metadata (database, schema, and tables) to add to the connection. - -[source, JSON] ----- -{ - "configuration":{ - "accountName":"thoughtspot_partner", - "user":"tsadmin", - "password":"TestConn123", - "role":"sysadmin", - "warehouse":"MEDIUM_WH" - }, - "externalDatabases":[ - { - "name":"AllDatatypes", - "isAutoCreated":false, - "schemas":[ - { - "name":"alldatatypes", - "tables":[ - { - "name":"allDatatypes", - "type":"TABLE", - "description":"", - "selected":true, - "linked":true, - "columns":[ - { - "name":"CNUMBER", - "type":"INT64", - "canImport":true, - "selected":true, - "isLinkedActive":true, - "isImported":false, - "tableName":"allDatatypes", - "schemaName":"alldatatypes", - "dbName":"AllDatatypes" - }, - { - "name":"CDECIMAL", - "type":"INT64", - "canImport":true, - "selected":true, - "isLinkedActive":true, - "isImported":false, - "tableName":"allDatatypes", - "schemaName":"alldatatypes", - "dbName":"AllDatatypes" - } - ] - } - ] - } - ] - } - ] -} ----- - -=== Example response - -If the connection creation is successful, the API returns a response body with the connection metadata header: - -[source,JSON] ----- -{ - "header": { - "id": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "indexVersion": 0, - "generationNum": 0, - "name": "TEST_conn_1", - "description": "TEST_conn_private_1", - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "created": 1632118531177, - "modified": 1632118531177, - "modifiedBy": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "tags": [], - "isExternal": false, - "isDeprecated": false - }, - "type": "RDBMS_SNOWFLAKE", - "authenticationType": "SERVICE_ACCOUNT", - "logicalTableList": [ - { - "columns": [ - { - "physicalColumnGUID": "61d10324-ac22-43e2-8623-fa96f503bab5", - "position": 0, - "header": { - "id": "fd718c83-5e9c-45ae-a4cd-1cb13b03db2f", - "indexVersion": 381, - "generationNum": 384, - "name": "CNUMBER", - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "created": 1632118531988, - "modified": 1632118532985, - "modifiedBy": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "b0248372-5e68-4815-8682-67715456efb2", - "isDeleted": false, - "isHidden": false, - "schemaStripe": "AllDatatypes_alldatatypes_dMSAHvi", - "databaseStripe": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "tags": [], - "isExternal": false, - "isDeprecated": false - }, - "complete": true, - "incompleteDetail": [], - "isDerived": false, - "dataType": "INT64", - "type": "MEASURE", - "defaultAggrType": "SUM", - "physicalColumnName": "CNUMBER", - "ownerName": "allDatatypes", - "ownerType": "ONE_TO_ONE_LOGICAL", - "entityCategory": "DEFAULT", - "columnMappingInfo": { - "columnName": "CNUMBER" - }, - "spotiqPreference": "DEFAULT", - "isAdditive": true, - "indexType": "DONT_INDEX", - "indexPriority": 1, - "sources": [], - "synonyms": [], - "customActionTags": [], - "injectedInlineValues": [], - "precision": -1, - "scale": 0, - "schemaVersion": "4", - "isPrimaryKey": false, - "isAttributionDimension": true - }, - { - "physicalColumnGUID": "86c34243-9637-4737-993f-6ed30d3e682c", - "position": 1, - "header": { - "id": "6eed0b58-d195-4236-a376-f135f9b24204", - "indexVersion": 381, - "generationNum": 384, - "name": "CDECIMAL", - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "created": 1632118531988, - "modified": 1632118532985, - "modifiedBy": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "b0248372-5e68-4815-8682-67715456efb2", - "isDeleted": false, - "isHidden": false, - "schemaStripe": "AllDatatypes_alldatatypes_dMSAHvi", - "databaseStripe": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "tags": [], - "isExternal": false, - "isDeprecated": false - }, - "complete": true, - "incompleteDetail": [], - "isDerived": false, - "dataType": "INT64", - "type": "MEASURE", - "defaultAggrType": "SUM", - "physicalColumnName": "CDECIMAL", - "ownerName": "allDatatypes", - "ownerType": "ONE_TO_ONE_LOGICAL", - "entityCategory": "DEFAULT", - "columnMappingInfo": { - "columnName": "CDECIMAL" - }, - "spotiqPreference": "DEFAULT", - "isAdditive": true, - "indexType": "DONT_INDEX", - "indexPriority": 1, - "sources": [], - "synonyms": [], - "customActionTags": [], - "injectedInlineValues": [], - "precision": -1, - "scale": 0, - "schemaVersion": "4", - "isPrimaryKey": false, - "isAttributionDimension": true - } - ], - "tableFilters": [], - "physicalTableVersion": 0, - "logicalTableContent": { - "joinType": "INNER", - "joinOverrides": {}, - "physicalTableName": "allDatatypes", - "worksheetType": "VIEW", - "isAggregatedWorksheet": false, - "bypassRLS": false, - "shardingInfo": { - "primaryKeyColumns": [], - "shardingKeyColumns": [], - "numberOfShards": 0 - }, - "tableMappingInfo": { - "databaseName": "AllDatatypes", - "schemaName": "alldatatypes", - "tableName": "allDatatypes", - "tableType": "TABLE", - "cachingInfo": { - "isCached": false, - "lastLoadTime": 0, - "type": "UNKNOWN" - } - }, - "isOptimized": false, - "schemaVersion": "4" - }, - "type": "ONE_TO_ONE_LOGICAL", - "generationType": "DEFAULT", - "physicalTableGUID": "14b7589b-8a98-4786-a0c5-0396e05b91ba", - "relationships": [], - "dataSourceId": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "dataSourceTypeEnum": "DEFAULT", - "header": { - "id": "b0248372-5e68-4815-8682-67715456efb2", - "indexVersion": 381, - "generationNum": 382, -## "name": "allDatatypes", - "description": "", - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "authorName": "su", - "authorDisplayName": "Administrator Super-User", - "created": 1632118531988, - "modified": 1632118532985, - "modifiedBy": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "b0248372-5e68-4815-8682-67715456efb2", - "isDeleted": false, - "isHidden": false, - "schemaStripe": "AllDatatypes_alldatatypes_dMSAHvi", - "databaseStripe": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "tags": [], - "type": "ONE_TO_ONE_LOGICAL", - "isExternal": true, - "isDeprecated": false - }, - "complete": true, - "incompleteDetail": [], - "destinationRelationships": [], - "relationshipsWithin": "", - "isCached": false - } - ], - "dataSourceContent": { - "statistics": {}, - "dataLoadInfo": { - "tableToTaskIdMap": {} - }, - "schemaVersion": "4", - "configuration": { - "password": "", - "role": "DEVELOPER", - "warehouse": "MEDIUM_WH", - "accountName": "thoughtspot_partner", - "user": "tsadmin" - } - } -} - ----- - -=== Response codes - -[width="100%" cols="1,3"] -[options='header'] -|=== -|HTTP status code | Description - -| **200** -| Successful creation of the connection object - -| **401** -| Unauthorized or wrong credentials - -| **500** -| The connection could not be created -|=== - - -[#edit-connection] -== Edit an existing data connection - -To update or modify the connection attributes of a data connection, use the `/tspublic/v1/connection/update` API. - -=== Resource URL ----- -POST /tspublic/v1/connection/update ----- - -=== Request parameters - -[width="100%" cols="1,4"] -[options='header'] -|==== -|Form parameter|Description -|`name` |__String__. Name of the connection. -|`description` |__String__. The description of the data connection. -|`type`|__String__. Type of the data connection. For example, `RDBMS_SNOWFLAKE`, `RDBMS_REDSHIFT`. Valid values for `type` map to the *name* attribute from the in the response to the xref:connections-api.adoc#connection-types[/tspublic/v1/connection/types API endpoint]. -|`createEmpty`|__Boolean__. Allows updating a connection without importing tables. If you want to update only the configuration attributes, you can set this attribute to `true`. The default value is `false`. -|`metadata` a|__String__. A JSON map of the connection metadata. The metadata must include xref:connections-api.adoc#connection-attr[configuration attributes], database, schema, and table objects. However, if the `createEmpty` attribute to `true`, you can update the connection attributes without importing tables. -|`id`|__String__. The GUID of the data connection that you want to modify. -|`state`|_Integer_. The GUID of the OAuth redirect URI. This attribute is automatically populated and cannot be set by users. -|==== - -=== Example request - -.cURL -[source,cURL] ----- -curl -X POST \ ---header 'Content-Type: application/x-www-form-urlencoded' \ ---header 'Accept: application/json' \ ---header 'X-Requested-By: ThoughtSpot' \ --d 'name=Snow_TEST&description=Test_connection&type=RDBMS_SNOWFLAKE&id=9114f105-fc9e-48c9-a8f1-57f2fda4f056&metadata=' \ -'https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/update' ----- - -.Request URL ----- -https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/update ----- - -==== Example metadata JSON without tables - -The following example shows the metadata JSON map for modifying a Snowflake connection without importing tables. - -[source, JSON] ----- -{ - "configuration":{ - "accountName":"thoughtspot_partner", - "user":"tsadmin", - "password":"Embrace123", - "role":"sysadmin", - "warehouse":"MEDIUM_WH" - }, - "externalDatabases":[ - - ] -} ----- - - -==== Example metadata JSON with tables - -The following example shows the metadata JSON for modifying a Snowflake data connection. Note that the example shows the configuration attributes to authenticate to the data source, tables, and column data for connection modification. - -[source, JSON] ----- -{ - "configuration":{ - "accountName":"thoughtspot_partner", - "user":"tsadmin", - "password":"Embrace123", - "role":"sysadmin", - "warehouse":"MEDIUM_WH" - }, - "externalDatabases":[ - { - "name":"AllDatatypes", - "isAutoCreated":false, - "schemas":[ - { - "name":"alldatatypes", - "tables":[ - { - "name":"allDatatypes", - "type":"TABLE", - "description":"", - "selected":true, - "linked":true, - "columns":[ - { - "name":"CDECIMAL", - "type":"INT64", - "canImport":false, - "selected":true, - "isLinkedActive":true, - "isImported":false, - "tableName":"allDatatypes", - "schemaName":"alldatatypes", - "dbName":"AllDatatypes" - }, - { - "name":"CBIGINT", - "type":"INT64", - "canImport":false, - "selected":false, - "isLinkedActive":true, - "isImported":false, - "tableName":"allDatatypes", - "schemaName":"alldatatypes", - "dbName":"AllDatatypes" - } - ] - } - ] - } - ] - } - ] -} ----- - -=== Example response - -If the connection modification is successful, the API returns a response body with the updated details: - -[source,JSON] ----- -{ - "dataSource": { - "header": { - "id": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "indexVersion": 385, - "generationNum": 385, - "name": "TEST_conn_1", - "description": "TEST_conn_private_1", - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "authorName": "tsadmin", - "authorDisplayName": "Administrator", - "created": 1632118531177, - "modified": 1632118751633, - "modifiedBy": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "isDeleted": false, - "isHidden": false, - "isAutoCreated": false, - "statistics": { - "dataLoadStatistics": { - "scheduled": false, - "connectionType": "", - "connectionDisplayName": "", - "dataLoadStatusList": [], - "header": { - "id": "a2352b8c-e85e-4d58-8f6a-ac42c0252f2d", - "indexVersion": 385, - "generationNum": 385, - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "isDeleted": false, - "isHidden": false, - "tags": [], - "isExternal": false, - "isDeprecated": false - }, - "complete": true, - "incompleteDetail": [] - } - }, - "tags": [], - "isExternal": false, - "isDeprecated": false - }, - "type": "RDBMS_SNOWFLAKE", - "authenticationType": "SERVICE_ACCOUNT", - "logicalTableList": [ - { - "columns": [ - { - "physicalColumnGUID": "86c34243-9637-4737-993f-6ed30d3e682c", - "position": 1, - "header": { - "id": "6eed0b58-d195-4236-a376-f135f9b24204", - "indexVersion": 382, - "generationNum": 382, - "name": "CDECIMAL", - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "created": 1632118531988, - "modified": 1632118532985, - "modifiedBy": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "b0248372-5e68-4815-8682-67715456efb2", - "isDeleted": false, - "isHidden": false, - "schemaStripe": "AllDatatypes_alldatatypes_dMSAHvi", - "databaseStripe": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "tags": [], - "isExternal": false, - "isDeprecated": false - }, - "complete": true, - "incompleteDetail": [], - "isDerived": false, - "dataType": "INT64", - "type": "MEASURE", - "defaultAggrType": "SUM", - "physicalColumnName": "CDECIMAL", - "ownerName": "allDatatypes", - "ownerType": "ONE_TO_ONE_LOGICAL", - "entityCategory": "DEFAULT", - "columnMappingInfo": { - "columnName": "CDECIMAL" - }, - "spotiqPreference": "DEFAULT", - "isAdditive": true, - "indexType": "DONT_INDEX", - "indexPriority": 1, - "sources": [], - "synonyms": [], - "customActionTags": [], - "injectedInlineValues": [], - "precision": -1, - "scale": 0, - "isPrimaryKey": false, - "isAttributionDimension": true - } - ], - "tableFilters": [], - "physicalTableVersion": 0, - "logicalTableContent": { - "joinType": "INNER", - "joinOverrides": {}, - "physicalTableName": "allDatatypes", - "worksheetType": "VIEW", - "isAggregatedWorksheet": false, - "bypassRLS": false, - "shardingInfo": { - "primaryKeyColumns": [], - "shardingKeyColumns": [], - "numberOfShards": 0 - }, - "tableMappingInfo": { - "databaseName": "AllDatatypes", - "schemaName": "alldatatypes", - "tableName": "allDatatypes", - "tableType": "TABLE", - "cachingInfo": { - "isCached": false, - "lastLoadTime": 0, - "type": "UNKNOWN" - } - }, - "isOptimized": false - }, - "type": "ONE_TO_ONE_LOGICAL", - "generationType": "DEFAULT", - "physicalTableGUID": "14b7589b-8a98-4786-a0c5-0396e05b91ba", - "relationships": [], - "dataSourceId": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "dataSourceTypeEnum": "RDBMS_SNOWFLAKE", - "header": { - "id": "b0248372-5e68-4815-8682-67715456efb2", - "indexVersion": 384, - "generationNum": 384, - "name": "allDatatypes", - "description": "", - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "authorName": "tsadmin", - "authorDisplayName": "Administrator", - "created": 1632118531988, - "modified": 1632118532985, - "modifiedBy": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "b0248372-5e68-4815-8682-67715456efb2", - "isDeleted": false, - "isHidden": false, - "schemaStripe": "AllDatatypes_alldatatypes_dMSAHvi", - "databaseStripe": "d19bec78-4e10-446a-b531-2a0e69c5e62f", - "tags": [], - "type": "ONE_TO_ONE_LOGICAL", - "isExternal": true, - "isDeprecated": false - }, - "complete": true, - "incompleteDetail": [], - "destinationRelationships": [], - "relationshipsWithin": "", - "isCached": false - } - ], - "dataSourceContent": { - "statistics": {}, - "dataLoadInfo": { - "tableToTaskIdMap": {} - }, - "configuration": { - "password": "", - "role": "DEVELOPER", - "warehouse": "MEDIUM_WH", - "accountName": "thoughtspot_partner", - "user": "tsadmin" - } - } - }, - "deletedObjects": { - "deleteStatus": "SUCCESS", - "deleteFailTables": [], - "deleteFailColumns": {} - } -} - ----- - -=== Response codes - -[width="100%" cols="1,3"] -[options='header'] -|=== -|HTTP status code | Description - -| **200** -| Successful update operation -| **401** -| Unauthorized or wrong credentials - -| **500** -| Invalid parameters -|=== - - -[#connection-types] -== Get a list of data connections -To get a list of data connections and their associated attributes, send a `GET` request to the `/tspublic/v1/connection/types` API endpoint. - -=== Resource URL ----- -GET /tspublic/v1/connection/types ----- - -=== Request parameters - -None - -=== Example request - -.cURL -[source,cURL] ----- -curl -X GET \ ---header 'Accept: application/json' \ ---header 'X-Requested-By: ThoughtSpot' \ -'https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/types' ----- - -.Request URL ----- -https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/types ----- - -=== Example response - -[source,JSON] ----- -[ - { - "name": "RDBMS_SNOWFLAKE", - "displayName": "Snowflake", - "description": "", - "mode": "PRODUCTION", - "beta": false, - "enabled": true - }, - { - "name": "RDBMS_REDSHIFT", - "displayName": "Amazon Redshift", - "description": "", - "mode": "PRODUCTION", - "beta": false, - "enabled": true - }, - { - "name": "RDBMS_GCP_BIGQUERY", - "displayName": "Google BigQuery", - "description": "", - "mode": "PRODUCTION", - "beta": false, - "enabled": true - }, - { - "name": "RDBMS_AZURE_SQL_DATAWAREHOUSE", - "displayName": "Azure Synapse", - "description": "", - "mode": "PRODUCTION", - "beta": false, - "enabled": true - }, - { - "name": "RDBMS_PRESTO", - "displayName": "Starburst", - "description": "", - "mode": "PRODUCTION", - "beta": false, - "enabled": true - }, - { - "name": "RDBMS_ORACLE_ADW", - "displayName": "Oracle ADW", - "description": "", - "mode": "PRODUCTION", - "beta": false, - "enabled": true - }, - { - "name": "RDBMS_DATABRICKS", - "displayName": "Databricks", - "description": "", - "mode": "PRODUCTION", - "beta": false, - "enabled": true - } -] ----- - -=== Response codes - -[width="100%" cols="1,3"] -[options='header'] -|=== -|HTTP status code | Description - -| **200** -| Successful retrieval of connection types - -| **401** -| Unauthorized or wrong credentials - -| **500** -| No data connections set up on the cluster -|=== - -[#live-query-connections] -== Get a list of live query connections - -To get a list of data connections that are set up as data sources for live query, send a `GET` request to the `GET /tspublic/v1/connection/list` API endpoint. - -=== Resource URL ----- -GET /tspublic/v1/connection/list ----- - -=== Request parameters - -[width="100%" cols="1,4"] -[options='header'] -|==== -|Query parameter|Description -|`category` __Optional__ a|__String__. Type of the live query object. Valid values are: - -* `All` -+ -Gets all live query objects set up on the cluster - -* `MY` -+ -Gets a list of live query objects that are set up on your cluster. - -|`sort` __Optional__ a|__String__. Sort order for the headers. Valid values are: - -* `DEFAULT` to use the default sort order in the response. -* `NAME` to sort headers by the connection name. -* `DISPLAY_NAME` to sort headers by the display name of the connections. -* `AUTHOR` to sort the headers by the author who set up the live query connection. -* `CREATED` to sort headers by the timestamp at which live query connections were set up. -* `MODIFIED` to sort headers by the timestamp at which the live query connections were modified. -| `sortascending` + a|__Boolean__. A flag to specify the sort order. A null value defines the default order. -* To set an ascending order, specify `true`. -* To set a descending order, specify `false`. -|`offset` __Optional__|__Integer__. Batch offset to value to fetch page headers. The default value is `-1`, which implies first page. -|`batchsize` __Optional__ |__Integer__. Number of batches in which you want to fetch page headers in a single query. The default value is `-1`, which implies no pagination. -|`tagname` __Optional__ |__Array of strings__. A JSON array of tag names to filter response headers by tags. A tag is a label applied to a ThoughtSpot object. -|`pattern` __Optional__ |__String__. A pattern to match the name of the object. This parameter supports matching case-insensitive strings. For a wildcard match, use `%`. -|==== - -=== Example request - -.cURL - -[source,curl] ----- -curl -X GET \ ---header 'Accept: application/json' \ ---header 'X-Requested-By: ThoughtSpot' \ -'https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/list' ----- - -.Request URL ----- -https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/list ----- - -=== Example response - -[source,JSON] ----- -{ - "headers": [ - { - "id": "c1f17251-b2b1-4230-b10b-c81254d5189f", - "indexVersion": 149, - "generationNum": 149, - "name": "test", - "description": "", - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "authorName": "tsadmin", - "authorDisplayName": "Administrator", - "created": 1625126133409, - "modified": 1625126133410, - "modifiedBy": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "c1f17251-b2b1-4230-b10b-c81254d5189f", - "isDeleted": false, - "isHidden": false, - "statistics": { - "dataLoadStatistics": { - "scheduled": false, - "connectionType": "", - "connectionDisplayName": "", - "dataLoadStatusList": [], - "header": { - "id": "c4831f2f-c0a2-498b-9ac5-98b8bd222058", - "indexVersion": 149, - "generationNum": 149, - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "c1f17251-b2b1-4230-b10b-c81254d5189f", - "isDeleted": false, - "isHidden": false, - "tags": [], - "isExternal": false, - "isDeprecated": false - }, - "complete": true, - "incompleteDetail": [] - } - }, - "tags": [], - "type": "RDBMS_SNOWFLAKE", - "isExternal": false, - "isDeprecated": false - }, - { - "id": "55bf4728-bea1-4e89-ae29-822413660daf", - "indexVersion": 328, - "generationNum": 328, - "name": "testsnow", - "description": "", - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "authorName": "tsadmin", - "authorDisplayName": "Administrator", - "created": 1625139996582, - "modified": 1625139996583, - "modifiedBy": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "55bf4728-bea1-4e89-ae29-822413660daf", - "isDeleted": false, - "isHidden": false, - "statistics": { - "dataLoadStatistics": { - "scheduled": false, - "connectionType": "", - "connectionDisplayName": "", - "dataLoadStatusList": [], - "header": { - "id": "179697ab-5212-4da8-9508-e0550b7b2327", - "indexVersion": 328, - "generationNum": 328, - "author": "59481331-ee53-42be-a548-bd87be6ddd4a", - "owner": "55bf4728-bea1-4e89-ae29-822413660daf", - "isDeleted": false, - "isHidden": false, - "tags": [], - "isExternal": false, - "isDeprecated": false - }, - "complete": true, - "incompleteDetail": [] - } - }, - "tags": [], - "type": "RDBMS_SNOWFLAKE", - "isExternal": false, - "isDeprecated": false - } - ], - "isLastBatch": true, - "debugInfo": {} -} ----- - - -=== Response codes - -[width="100%" cols="1,3"] -[options='header'] -|=== -|HTTP status code | Description - -| **200** -| Successful retrieval of live query connections list - -| **401** -| Unauthorized or wrong credentials - -| **500** -| No live query connections on the cluster -|=== - -[#connMetadata] -== Get details of a specific connection - -To get details of a specific data connection and its associated columns, send a `POST` request to the `/tspublic/v1/connection/fetchConnection` endpoint. - - -=== Resource URL - ----- -POST /tspublic/v1/connection/fetchConnection ----- - -=== Request parameters - -[width="100%" cols="2,6"] -[options='header'] -|==== -|Form Data Parameter|Description -|`id` + a|__String__. GUID of the data connection. -|`includeColumns` + a|__Boolean__. A flag to include or exclude column metadata in the API response. By default, the API returns data for all columns in the response. -|`config` + -__Optional__ a|__String__. Configuration attributes to override the connection metadata details in the API response. Use this attribute to query data from a specific database. For example, if your connection has a database named `PROD`, and you want to fetch details with the database name as `DEVELOPMENT`, you can define the `config` string as `{"database":"DEVELOPMENT"}`. - -[NOTE] -==== -Note that the `config` attribute does not update the connection metadata in the ThoughtSpot system. It only allows you to modify the metadata in the API response. To update a connection, you must use the `/tspublic/v1/connection/update` endpoint. -==== -|`authentication_type` + a|__String__. Type of authentication to use when fetching data from the Cloud Data Warehouse. Valid values are: + - -* `SERVICE_ACCOUNT` + -For data connections that require service account credentials to authenticate to the Cloud Data Warehouse and fetch data. - -* `OAUTH` + -For data connections that require OAuth credentials to authenticate to the Cloud Data Warehouse and fetch data. + -ThoughtSpot data connections to Teradata, Oracle, and Presto Cloud Data Warehouse do not support the `OAuth` authentication type. - -* `IAM` + -For data connections that have the IAM OAuth set up. This authentication type is supported on Amazon Redshift connections only. - -* `EXTOAUTH` + -For data connections that have external OAuth set up. ThoughtSpot supports external OAuth with Microsoft Azure Active Directory (AD) and Okta for Snowflake data connections. For more information, see link:https://docs.thoughtspot.com/cloud/latest/connections-snowflake-okta-oauth[Configure Okta OAuth for a Snowflake connection, window=_blank] and link:https://docs.thoughtspot.com/cloud/latest/connections-snowflake-azure-ad-oauth[Configure Azure AD external OAuth for a Snowflake connection, window=_blank]. -|==== - -=== Example request - -.cURL - -[source,CURL] ----- -curl -X POST \ ---header 'Content-Type: application/x-www-form-urlencoded' \ ---header 'Accept: application/json' \ ---header 'X-Requested-By: ThoughtSpot' \ --d 'id=abb64e33-1f06-4994-9af7-f6ea8729c9f1&includeColumns=false&config={"database":"RETAILAPPAREL-DEV"}' \ -'https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/fetchConnection' ----- - -.Request URL - ----- -https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/fetchConnection ----- - -=== Example response - -.Sample response with column data -[%collapsible] -==== -[source,JSON] ----- -{ - "type": "RDBMS_SNOWFLAKE", - "authenticationType": "SERVICE_ACCOUNT", - "externalDatabases": [ - { - "name": "RETAILAPPAREL", - "isAutoCreated": false, - "schemas": [ - { - "name": "INFORMATION_SCHEMA", - "tables": [ - { - "name": "APPLICABLE_ROLES", - "type": "VIEW", - "description": "The roles that can be applied to the current user.", - "selected": false, - "linked": true, - "columns": [ - { - "name": "SCHEMA_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "FILE_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "LAST_LOAD_TIME", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STATUS", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "ROW_COUNT", - "type": "INT64", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "ROW_PARSED", - "type": "INT64", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "FIRST_ERROR_MESSAGE", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "FIRST_ERROR_LINE_NUMBER", - "type": "INT64", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "FIRST_ERROR_CHARACTER_POSITION", - "type": "INT64", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "FIRST_ERROR_COL_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "ERROR_COUNT", - "type": "INT64", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "ERROR_LIMIT", - "type": "INT64", - "canImport": true, - "selected": false, - "isLinkedActive": true - } - ], - "relationships": [] - }, - { - "name": "SCHEMATA", - "type": "VIEW", - "description": "The schemas defined in this database that are accessible to the current user's role.", - "selected": false, - "linked": true, - "columns": [ - { - "name": "CATALOG_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "SCHEMA_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "SCHEMA_OWNER", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "IS_TRANSIENT", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "IS_MANAGED_ACCESS", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "RETENTION_TIME", - "type": "INT64", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "DEFAULT_CHARACTER_SET_CATALOG", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "DEFAULT_CHARACTER_SET_SCHEMA", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "DEFAULT_CHARACTER_SET_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "SQL_PATH", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "CREATED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "LAST_ALTERED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "COMMENT", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - } - ], - "relationships": [] - }, - { - "name": "FACT_RETAPP_SALES", - "type": "TABLE", - "description": "", - "selected": true, - "linked": true, - "columns": [ - { - "name": "SALESID", - "type": "INT64", - "canImport": true, - "selected": true, - "isLinkedActive": true - }, - { - "name": "PRODUCTID", - "type": "INT64", - "canImport": true, - "selected": true, - "isLinkedActive": true - }, - { - "name": "STOREID", - "type": "INT64", - "canImport": true, - "selected": true, - "isLinkedActive": true - }, - { - "name": "QUANTITYPURCHASED", - "type": "INT64", - "canImport": true, - "selected": true, - "isLinkedActive": true - }, - { - "name": "ITEMPRICE", - "type": "DOUBLE", - "canImport": true, - "selected": true, - "isLinkedActive": true - }, - { - "name": "SALES", - "type": "DOUBLE", - "canImport": true, - "selected": true, - "isLinkedActive": true - }, - { - "name": "RECORDDATE", - "type": "DATE", - "canImport": true, - "selected": true, - "isLinkedActive": true - } - ], - "relationships": [] - } - ] - } - ] - } - ], - "tables": [], - "selectedTables": [], - "selectedColumns": [], - "configuration": { - "password": "", - "database": "RETAILAPPAREL-DEV", - "role": "PUBLIC", - "accountName": "coa41300.us-east-1", - "warehouse": "DEMO_WH", - "user": "tsuser" - } -} ----- -==== - -.Sample response without column data -[%collapsible] -==== -[source,JSON] ----- -{ - "type": "RDBMS_SNOWFLAKE", - "authenticationType": "SERVICE_ACCOUNT", - "externalDatabases": [ - { - "name": "RETAILAPPAREL", - "isAutoCreated": false, - "schemas": [ - { - "name": "INFORMATION_SCHEMA", - "tables": [ - { - "name": "APPLICABLE_ROLES", - "type": "VIEW", - "description": "The roles that can be applied to the current user.", - "selected": false, - "linked": true, - "columns": [], - "relationships": [] - }, - { - "name": "COLUMNS", - "type": "VIEW", - "description": "The columns of tables defined in this database that are accessible to the current user's role.", - "selected": false, - "linked": true, - "columns": [], - "relationships": [] - }, - { - "name": "DATABASES", - "type": "VIEW", - "description": "The databases that are accessible to the current user's role.", - "selected": false, - "linked": true, - "columns": [], - "relationships": [] - }, - { - "name": "ENABLED_ROLES", - "type": "VIEW", - "description": "The roles that are enabled to the current user.", - "selected": false, - "linked": true, - "columns": [], - "relationships": [] - }, - { - "name": "EXTERNAL_TABLES", - "type": "VIEW", - "description": "The external tables defined in this database that are accessible to the current user's role.", - "selected": false, - "linked": true, - "columns": [], - "relationships": [] - }, - { - "name": "FILE_FORMATS", - "type": "VIEW", - "description": "The file formats defined in this database that are accessible to the current user's role.", - "selected": false, - "linked": true, - "columns": [], - "relationships": [] - }, - { - "name": "FUNCTIONS", - "type": "VIEW", - "description": "The user-defined functions defined in this database that are accessible to the current user's role.", - "selected": false, - "linked": true, - "columns": [], - "relationships": [] - }, - { - "name": "INFORMATION_SCHEMA_CATALOG_NAME", - "type": "VIEW", - "description": "Identifies the database (or catalog, in SQL terminology) that contains the information_schema", - "selected": false, - "linked": true, - "columns": [], - "relationships": [] - }, - { - "name": "LOAD_HISTORY", - "type": "VIEW", - "description": "The loading information of the copy command", - "selected": false, - "linked": true, - "columns": [], - "relationships": [] - }, - { - "name": "SCHEMATA", - "type": "VIEW", - "description": "The schemas defined in this database that are accessible to the current user's role.", - "selected": false, - "linked": true, - "columns": [], - "relationships": [] - } - ] - }, - { - "name": "PUBLIC", - "tables": [ - { - "name": "FACT_RETAPP_SALES", - "type": "TABLE", - "description": "", - "selected": true, - "linked": true, - "columns": [], - "relationships": [] - } - ] - } - ] - } - ], - "tables": [], - "selectedTables": [], - "selectedColumns": [], - "configuration": { - "password": "", - "database": "RETAILAPPAREL-DEV", - "role": "PUBLIC", - "accountName": "coa41300.us-east-1", - "warehouse": "DEMO_WH", - "user": "tsuser" - } -} ----- -==== - -=== Response codes - -[width="100%" cols="1,4"] -[options='header'] -|=== -|HTTP status code | Description - -| **200** -| Successful operation -| **400** -| Invalid parameter values -| **500** -| Invalid connection object ID -|=== - -[#fetchLiveColums] -== Get column data for connections with external tables - -To get column data for a connection with external tables, send a `POST` request to the `/tspublic/v1/connection/fetchLiveColumns` endpoint. - -=== Resource URL - ----- -POST /tspublic/v1/connection/fetchLiveColumns ----- - -=== Request parameters - -[width="100%" cols="1,4"] -[options='header'] -|==== -|Form Data Parameter|Description -|`connection_id` + a|__String__. GUID of the data connection. -|`tables` + a|__String__. The external tables to include in the output. The `tables` string allows you to fetch one or several tables from multiple external databases. - -* To get column data for a specific table, specify the string as shown here: + -`[{"tableName":"PIPES"}]` -* To get column data for multiple tables, specify the string as shown here: + -`[{"tableName":"PIPES"},{"tableName":"STAGES"},{"tableName":"SEQUENCES"}]` -* To get column data for tables with a specific schema, specify the string as shown here: + -`[{"databaseName":"RETAILAPPAREL","schemaName":"INFORMATION_SCHEMA"}]` -* To get column data for a specific table from a specific database, specify the string as shown here: + -`[{"databaseName":"RETAILAPPAREL","tableName":"PIPES"}]` -* To get column data for a specific table in an external database with a schema, specify the string as shown here: + -`[{"databaseName":"RETAILAPPAREL","schemaName":"INFORMATION_SCHEMA","tableName":"PIPES"}]` -* To get column data for multiple tables in a specific database, you can specify the string as shown here: + -`[{"databaseName": "RETAILAPPAREL"},{"tableName": "PIPES"},{"tableName":"STAGES"},{"tableName": "SEQUENCES"}]` -* To get column data for specific tables from multiple databases, you can specify the `tables` string as shown here: + -`[{"databaseName": "RETAILAPPAREL","tableName": "PIPES"},{"databaseName": "RETAILAPPAREL-DEV","tableName": "STAGES"}]` - -|`config` + -__Optional__ a|__String__. Configuration attributes to override the metadata details in the API response. Use this attribute to query data from a specific database. For example, if your connection has a database named `PROD`, and you want to fetch details with the database name as `DEVELOPMENT`, you can define the `config` string as `{"database":"DEVELOPMENT"}`. - -[IMPORTANT] -==== -Note that the `config` attribute does not update the connection metadata in the ThoughtSpot system. It only allows you to modify the metadata in the API response. To update the connection metadata, you must use the `/tspublic/v1/connection/update` endpoint. -==== -|`authentication_type` + a|__String__. Type of authentication to use when fetching data from the Cloud Data Warehouse. Valid values are: + - -* `SERVICE_ACCOUNT` __Default__ + -For data connections that require service account credentials to authenticate to the Cloud Data Warehouse and fetch data. - -* `OAUTH` + -For data connections that require OAuth credentials to authenticate to the Cloud Data Warehouse and fetch data. + -ThoughtSpot data connections to Teradata, Oracle, and Presto Cloud Data Warehouse do not support the `OAuth` authentication type. - -* `IAM` + -For data connections that have the IAM OAuth set up. This authentication type is supported on Amazon Redshift connections only. - -* `EXTOAUTH` + -For data connections that have external OAuth set up. ThoughtSpot supports external OAuth with Microsoft Azure Active Directory (AD) and Okta for Snowflake data connections. For more information, see link:https://docs.thoughtspot.com/cloud/latest/connections-snowflake-okta-oauth[Configure Okta OAuth for a Snowflake connection, window=_blank] and link:https://docs.thoughtspot.com/cloud/latest/connections-snowflake-azure-ad-oauth[Configure Azure AD external OAuth for a Snowflake connection, window=_blank]. -|==== - -=== Example request - -.cURL - -[source,cURL] ----- -curl -X POST \ ---header 'Content-Type: application/x-www-form-urlencoded' \ ---header 'Accept: application/json' \ ---header 'X-Requested-By: ThoughtSpot' \ --d 'connection_id=2aa36dbd-dda6-4497-a6db-bc47e128862e&tables=[{"tableName":"PIPES"},{"tableName":"STAGES"},{"tableName":"SEQUENCES"}]&config={"database":"DEVELOPMENT"}' \ -'https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/fetchLiveColumns' ----- - -.Request URL ----- -https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/fetchLiveColumns ----- - -=== Example response - -.Sample response with a single table - -[%collapsible] -==== -[source,JSON] ----- -{ - "..PIPES": [ - { - "name": "PIPE_CATALOG", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "PIPE_SCHEMA", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "PIPE_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "PIPE_OWNER", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "CREATED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "LAST_ALTERED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - } - ] -} ----- -==== - -.Sample response with multiple tables -[%collapsible] -==== -[source,JSON] ----- -{ - "..STAGES": [ - { - "name": "STAGE_CATALOG", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_SCHEMA", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_URL", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_REGION", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_TYPE", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_OWNER", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "CREATED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "LAST_ALTERED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - } - ], - "..PIPES": [ - { - "name": "PIPE_CATALOG", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "PIPE_SCHEMA", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "PIPE_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "PIPE_OWNER", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "CREATED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "LAST_ALTERED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - } - ], - "..SEQUENCES": [ - { - "name": "SEQUENCE_CATALOG", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "SEQUENCE_SCHEMA", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "SEQUENCE_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "SEQUENCE_OWNER", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "DATA_TYPE", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "CREATED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "LAST_ALTERED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - } - ] -} ----- -==== - -.Sample response with tables associated to a specific schema -[%collapsible] -==== -[source,JSON] ----- -{ - "RETAILAPPAREL.INFORMATION_SCHEMA.": [ - { - "name": "GRANTEE", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "ROLE_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "ROLE_OWNER", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "IS_GRANTABLE", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "TABLE_CATALOG", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "TABLE_SCHEMA", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "TABLE_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "COLUMN_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "DATA_TYPE", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "DOMAIN_CATALOG", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "DOMAIN_SCHEMA", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "DOMAIN_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "DATABASE_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "DATABASE_OWNER", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "IS_TRANSIENT", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "CREATED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "LAST_ALTERED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "SEQUENCE_CATALOG", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "SEQUENCE_SCHEMA", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "SEQUENCE_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "SEQUENCE_OWNER", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "CREATED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "LAST_ALTERED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_CATALOG", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_SCHEMA", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_URL", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_REGION", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_TYPE", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_OWNER", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "CREATED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "LAST_ALTERED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - } - ] -} ----- -==== - -.Sample response with a single table from a specific database -[%collapsible] -==== -[source,JSON] ----- -{ -"RETAILAPPAREL..PIPES": [ -{ -"name": "PIPE_CATALOG", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "PIPE_SCHEMA", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "PIPE_NAME", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "PIPE_OWNER", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "DEFINITION", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "CREATED", -"type": "DATE_TIME", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "LAST_ALTERED", -"type": "DATE_TIME", -"canImport": true, -"selected": false, -"isLinkedActive": true -} -] -} ----- -==== - -.Sample response with multiple tables from one or more external databases - -[%collapsible] -==== -[source,JSON] ----- -{ -"RETAIL.INFORMATION_SCHEMA.PIPES": [ -{ -"name": "PIPE_CATALOG", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "PIPE_SCHEMA", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "PIPE_NAME", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "PIPE_OWNER", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "DEFINITION", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "CREATED", -"type": "DATE_TIME", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "LAST_ALTERED", -"type": "DATE_TIME", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "COMMENT", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -}, -{ -"name": "PATTERN", -"type": "VARCHAR", -"canImport": true, -"selected": false, -"isLinkedActive": true -} -], -"RETAILCLOUD.INFORMATION_SCHEMA.STAGES": [ - { - "name": "STAGE_CATALOG", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_SCHEMA", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_NAME", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_URL", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_REGION", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_TYPE", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "STAGE_OWNER", - "type": "VARCHAR", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "CREATED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - }, - { - "name": "LAST_ALTERED", - "type": "DATE_TIME", - "canImport": true, - "selected": false, - "isLinkedActive": true - } -] -} ----- -==== - -=== Response codes -[width="100%" cols="1,4"] -[options='header'] -|=== -|HTTP status code | Description - -| **200** -| Successful operation -| **400** -| Invalid connection ID -| **500** -| Invalid string format -|=== - - -[#export-connections] -== Export a connection map - -To export the metadata of an active connection map, send a `GET` request to the `/tspublic/v1/connection/export` API. - -=== Resource URL ----- -GET /tspublic/v1/connection/export ----- - -=== Request parameters - -[width="100%" cols="1,4"] -[options='header'] -|==== -|Query parameter|Description -|`id` + a|__String__. The GUID of the data connection you want to export. -|==== - -=== Example request - -.cURL -[source,cURL] ----- -curl -X GET ---header 'Accept: text/yaml' ---header 'X-Requested-By: ThoughtSpot' -'https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/export?id=627c35cb-768d-45c7-b41d-346a2220cd82' ----- - -.Request URL ----- -https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/export?id=627c35cb-768d-45c7-b41d-346a2220cd82 ----- -=== Example response - -If the specified GUID is valid and points to an existing data connection on your cluster, the API returns a YAML file containing the connection mapping data. - -Click the link in the `Response body` to download the YAML file. - -=== Response codes - -[width="100%" cols="1,3"] -[options='header'] -|=== -|HTTP status code | Description - -| **200** -| Successful export of the connection object -| **401** -| Unauthorized request or wrong credentials - -| **500** -| Invalid connection ID -|=== - -[#del-connection] -== Delete a data connection - -To delete a data connection, send a `POST` request to the `/tspublic/v1/connection/delete` API. - -[IMPORTANT] -==== -A connection can be used in multiple data sources or visualizations. If a data connection has dependent objects, make sure you remove its associations before running the delete operation. -==== - -=== Resource URL ----- -POST /tspublic/v1/connection/delete ----- -=== Request parameters - -[width="100%" cols="1,4"] -[options='header'] -|==== -|Form parameter|Description -|`ids` + a|__Array of Strings__. A JSON array of data connection GUIDs to delete. -|==== - -=== Example request - -.cURL -[source,cURL] ----- -curl -X POST \ ---header 'Content-Type: application/x-www-form-urlencoded' \ ---header 'Accept: application/json' \ ---header 'X-Requested-By: ThoughtSpot' \ --d 'ids=%5B%229114f105-fc9e-48c9-a8f1-57f2fda4f056%22%5D' \ -'https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/delete' ----- - -.Request URL ----- -https://{ThoughtSpot-Host}/callosum/v1/tspublic/v1/connection/delete ----- - -=== Example response - -If the connection deletion is successful, the following response code is returned: - ----- -Response Code -204 ----- - -=== Response codes - -[width="100%" cols="1,3"] -[options='header'] -|=== -|HTTP status code | Description - -| **204** -|Successful deletion of the connection object - -| **401** -| Unauthorized request or wrong credentials - -| **500** -| The connection could not be deleted due to table dependencies -|=== - diff --git a/modules/ROOT/pages/org-manage-api.adoc b/modules/ROOT/pages/org-manage-api.adoc index efea5a898..27679ef1d 100644 --- a/modules/ROOT/pages/org-manage-api.adoc +++ b/modules/ROOT/pages/org-manage-api.adoc @@ -17,9 +17,9 @@ include::{path}/rest-apiv1-deprecation-note.adoc[] If you do not see the Orgs feature enabled on your instance, contact ThoughtSpot Support. . Log in to your application instance as an administrator. -. Create Orgs in the *Admin* page of the UI or via xref:org-api.adoc#createOrg[REST API]. +. Create Orgs in the *Admin* page of the UI or via #the link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Forgs%2Fcreate-org[`POST /api/rest/2.0/orgs/create`] endpoint#. + -To create and manage Orgs, you must set the Org context to `All`. To do this, you can switch to the *All Orgs* tab in the Admin page of UI or pass the Org scope `ALL` in your API requests to Orgs API endpoints. For more information, see xref:org-api.adoc[Org API]. +To create and manage Orgs, you must set the Org context to `All`. To do this, you can switch to the *All Orgs* tab in the Admin page of UI or pass the Org scope `ALL` in your API requests to Orgs API endpoints. #For more information, see <<_create_and_manage_orgs,Org management operations>>.# . Create users and map the users to Orgs. + You can create an administrator profile for each Org and let these Org administrators manage users, groups, and role privileges in their respective Orgs. @@ -35,55 +35,28 @@ To create an Org on a multi-tenant ThoughtSpot instance, you need cluster admini === REST API v2.0 endpoints -For Orgs CRUD operations, you can also use the following REST API v2.0 Org endpoints: +#For Orgs CRUD operations, use the following REST API v2.0 Org endpoints:# * +++/api/rest/2.0/orgs/create+++ * +++ /api/rest/2.0/orgs/search+++ * +++/api/rest/2.0/orgs/{org_identifier}/update +++ * +++ /api/rest/2.0/orgs/{org_identifier}/delete+++ -//// -=== REST API v1 endpoints - -For Orgs CRUD operations, use the xref:org-api.adoc[`/tspublic/v1/org/`] endpoints. To access Org API endpoints, you must log in to ThoughtSpot as a cluster administrator: - -Note that for all Org operations, you must set the org scope to `ALL` in your API request and pass it as a query parameter in the request URL. - -* To create an Org, send a xref:org-api.adoc#createOrg[`POST` request to the `/tspublic/v1/org/` endpoint] with the Org name and description text in the request body. -+ -If the API operation is successful, ThoughtSpot creates an Org and assigns a system-generated number as Org ID. -* To view the Orgs configured on a ThoughtSpot instance, use the xref:org-api.adoc#getOrgs[`/tspublic/v1/org/search`] or xref:org-api.adoc#getOrg[`GET /tspublic/v1/org/`] API endpoint. -* To edit the properties of an Org object, use the xref:org-api.adoc#editOrg[`PUT /tspublic/v1/org/`] endpoint. -+ -The `PUT` request allows modifying the name and description properties of an Org object. -* To delete an Org object, send a `DELETE` request to the xref:org-api.adoc#deleteOrg[`/tspublic/v1/org/` endpoint] with Org ID or name in the request body. -//// - == Manage users and groups On a multi-tenant instance, the cluster and Org administrators can create users and associate these users to Orgs and groups within these Orgs. The cluster administrator can perform CRUD operations on Org objects at the All Orgs level and assign users, whereas the Org administrator can create users and groups within the context of their Org workspace. [NOTE] ==== -The xref:session-api.adoc#orgSwitch[`/tspublic/v1/session/orgs`] REST API v1 endpoint allows administrators to switch between Org contexts. For REST API v2 operations, the Org context is determined based on the authentication token used by the user in their API requests. Users must log in to the appropriate Org context from which they want to send API requests. +#For REST API v2 operations, the Org context is determined based on the authentication token used by the user in their API requests. Users must log in to the appropriate Org context from which they want to send API requests.# ==== -[width="100%" cols="5,8,7"] +[width="100%" cols="5,15"] [options='header'] |===== -|Operation type|REST API v1 endpoints|REST API v2 endpoints - -|Authentication a| -**Basic authentication** + -To log in to a specific Org context with basic authentication, specify the Org ID in the xref:session-api.adoc#session-login[`/tspublic/v1/session/login`] API request. +|#Operation type#|#REST API v2 endpoints# -**Token-based authentication** + -For xref:trusted-authentication.adoc[trusted authentication], use the xref:session-api.adoc#session-authToken[`/tspublic/v1/session/auth/token`] API endpoint. If a user is mapped to a specific org, you can pass the `orgid` in the API request to obtain an authentication token to log in the user to the relevant Org context. + -You can also create a user just-in-time and assign Orgs, groups, and privileges in this API call. - -For more information, see xref:api-auth-session.adoc[REST API v1 authentication]. - -a| **Basic authentication** + +|Authentication a| **Basic authentication** + To log in to a specific Org context with basic authentication, specify the Org ID in the +++ /api/rest/2.0/auth/session/login +++ API request. **Token-based authentication** + @@ -94,22 +67,13 @@ Use the following API endpoints: These above endpoints also support just-in-time provisioning of users to Orgs and groups. For more information, see xref:authentication.adoc[REST API v2 authentication]. -|User provisioning a|To create users, use the xref:user-api.adoc#create-user[`POST /tspublic/v1/user/`] API endpoint. This endpoint also allows assigning a user to Orgs. + - -To add multiple users to an Org in bulk, use the xref:user-api.adoc#addOrg[`/tspublic/v1/user/addtoorg`] API endpoint. + - -To remove a user from an Org, use the xref:user-api.adoc#delete-user[`DELETE /tspublic/v1/user/{userid}`] endpoint. If a user belongs only to the Org specified in the `DELETE` request, the user will be deleted from the Org and ThoughtSpot system. If the user belongs to more than one Org, the `DELETE` operation removes the user from only the Org specified in the API request. To remove the user from the ThoughtSpot system and all Orgs, you must set the `orgid` to `-1` in your API request. -|To create users and assign users to one or several Orgs, use the +++ /api/rest/2.0/users/create +++ API endpoint. + +|User provisioning a|To create users and assign users to one or several Orgs, use the +++ /api/rest/2.0/users/create +++ API endpoint. + To assign an existing user to new Orgs or remove a user from an Org, use the +++ /api/rest/2.0/users/{user_identifier}/update +++ API endpoint. -|Group provisioning a|To create a group in an Org, log in to the Org context or xref:session-api.adoc#orgSwitch[switch to the Org context] and send an API request to the xref:group-api.adoc#create-group[`/tspublic/v1/group/`] endpoint. -a|To create a group in an Org, use the +++ /api/rest/2.0/groups/create +++ API endpoint. - -|Fetching Orgs a| * To get a list of Orgs for the currently logged-in user, use the xref:session-api.adoc#getOrgs[/tspublic/v1/session/orgs] API endpoint. + +|Group provisioning a|To create a group in an Org, use the +++ /api/rest/2.0/groups/create +++ API endpoint. -* To get Orgs for a specific user, use the xref:session-api.adoc#getOrgsForUser[/tspublic/v1/session/orgs/users/{userid}] endpoint. Set the Org scope to `ALL` to get all Orgs for a given user. -a| The REST API v2 endpoints allow you to perform query operations to get Org details: +|Fetching Orgs a| The REST API v2 endpoints allow you to perform query operations to get Org details: * To get a list of all Orgs on the cluster, use +++/api/rest/2.0/orgs/search +++ (Requires cluster administrator privileges) diff --git a/modules/ROOT/pages/rest-api-v2-metadata-search.adoc b/modules/ROOT/pages/rest-api-v2-metadata-search.adoc index a7620633d..a6a9e2f28 100644 --- a/modules/ROOT/pages/rest-api-v2-metadata-search.adoc +++ b/modules/ROOT/pages/rest-api-v2-metadata-search.adoc @@ -6,7 +6,7 @@ :page-pageid: rest-apiv2-metadata-search :page-description: Many use cases are possible with the V2.0 metadata/search endpoint -The `link:https://developers.thoughtspot.com/docs/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fmetadata%2Fsearch-metadata[/metadata/search, target=_blank]` endpoint is the most versatile of all metadata endpoints. It can be used to search for lists or to retrieve very detailed information about specific objects. This endpoint replaces `metadata/list`, `metadata/listobjectheaders`, `metadata/details`, and `metadata/listvizheaders` from REST API v1. +The `link:https://developers.thoughtspot.com/docs/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fmetadata%2Fsearch-metadata[/metadata/search, target=_blank]` endpoint is the most versatile of all metadata endpoints. It can be used to search for lists or to retrieve very detailed information about specific objects. == Overview @@ -94,7 +94,7 @@ The response from any call to `metadata/search` returns an array of link:https:/ You'll notice a number of other keys with a `null` value. Various request parameters cause those sections of the response to be filled in with additional details. -The `metadata_header` key is always returned with values, regardless of the request. This section resembles the V1 REST API response from the xref:metadata-api.adoc#metadata-list[`metadata/list`] and xref:metadata-api.adoc#object-header[`metadata/listobjectheaders`] endpoints. +The `metadata_header` key is always returned with values, regardless of the request. The value of `metadata_header` is a complex object with the most important set of attributes of the item. Note that `author` is the creator or current user who has been assigned the __ownership__ of the object, while `owner` describes a hierarchical relationship between ThoughtSpot objects (for example, this Liveboard has an `owner` property identical to its own `id` property): @@ -3574,7 +3574,7 @@ If you need multiple levels of sorting, you'll have to parse the response progra === permissions You can filter responses based on who can access the object, that is, who the content has been shared to, using the `permissions` parameter. -While the `permissions` parameter filters the response set of `metadata/search`, the objects in the response do not list the full set of assigned permissions. Please use the `link:https://developers.thoughtspot.com/docs/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fsecurity%2Ffetch-permissions-on-metadata[/security/metadata/fetch-permissions/`, target=_blank]` endpoint for a full listing, which replaces the individual `security` endpoints in the v1 REST API, such as the xref:security-api.adoc#obj-permission-all[get object permission details for a specific object type] endpoint. +While the `permissions` parameter filters the response set of `metadata/search`, the objects in the response do not list the full set of assigned permissions. Please use the `link:https://developers.thoughtspot.com/docs/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fsecurity%2Ffetch-permissions-on-metadata[/security/metadata/fetch-permissions/`, target=_blank]` endpoint #for a full listing.# The `permissions` object takes an array of objects that define a `principal` and a `link:https://developers.thoughtspot.com/docs/restV2-playground?apiResourceId=http%2Fmodels%2Fenumerations%2Fshare-mode[share_mode, target=_blank]`. `principal` is an object with a `type` of `USER` or `USER_GROUP` and an `identifier`, either the name or the GUID of the principal: @@ -3695,12 +3695,10 @@ The `exclude_objects` parameter takes an array of objects that can exclude items ---- == Additional response details -There are a number of parameters that add new data to the response, allowing the `metadata/search` endpoint to answer questions that require multiple API calls in the v1 REST API. +There are a number of parameters that add new data to the response, allowing the `metadata/search` endpoint #to return related details in a single API call.# === include_visualization_headers -The v1 REST API endpoint xref:metadata-api.adoc#viz-header[`metadata/listvizheaders`] retrieves the header details of all individual visualizations on a Liveboard. - -In v2, the `include_visualization_headers` parameter adds the visualization headers to the response. +#The `include_visualization_headers` parameter adds the header details of all individual visualizations on a Liveboard to the response.# [source,JSON] ---- @@ -3719,9 +3717,7 @@ Note from above that the response to **every** request has the `visualization_he The `include_visualization_headers` adds the array of visualization headers for every element in the response, so you can request a list of all Liveboards and all the visualizations on those Liveboards all at once. === include_details -The v1 REST API has an endpoint called `metadata/details` for retrieving a very large and complex object containing as much detail as possible about the requested object and its relationships with other objects within ThoughtSpot. - -The `include_details` parameter in the `metadata/search` API request adds the equivalent details object to each element retrieved by `metadata/search` to the response under the `metadata_detail` key. +The `include_details` parameter in the `metadata/search` API request adds #a detailed object, with as much detail as possible about each requested object and its relationships with other objects within ThoughtSpot,# to each element retrieved by `metadata/search` to the response under the `metadata_detail` key. [source,JSON] ---- @@ -3830,9 +3826,7 @@ Data objects in ThoughtSpot like Tables and Worksheets have **dependent objects* An object can only be deleted if all of its dependent objects are deleted first. -The v1 REST API had an entire set of xref:dependency-apis.adoc[dependent objects APIs] for retrieving these relationships. - -The equivalent information is retrieved from `metadata/search` by setting the `include_dependent_objects` parameter to `true`: +#To retrieve these relationships from `metadata/search`, set the `include_dependent_objects` parameter to `true`:# [source,JSON] ---- diff --git a/modules/ROOT/pages/rest-api-v2-search.adoc b/modules/ROOT/pages/rest-api-v2-search.adoc index 208d16cfd..f0de4a393 100644 --- a/modules/ROOT/pages/rest-api-v2-search.adoc +++ b/modules/ROOT/pages/rest-api-v2-search.adoc @@ -8,7 +8,7 @@ The REST API v2 endpoints that end in `/search` have very flexible request formats that solve a number of use cases. -Some capabilities that have separate endpoints in the V1 REST API are done using the correct request on the smaller subset of `/search` endpoints in v2.0. +#A small set of `/search` endpoints covers many capabilities, depending on the request parameters you send.# Note that many parameters in the requests are *optional*, and leaving the parameter name out entirely has a different effect from including the parameter and specific values. @@ -25,7 +25,7 @@ Assume every request you see has the following parameters in addition to what is ---- == Search metadata -The `/metadata/search` endpoint in the V2 REST API replaces a large set of distinct endpoints in REST API V1. +#The `/metadata/search` endpoint retrieves metadata objects and their details, such as headers, permissions, and dependent objects.# For more information about this API, xref:rest-api-v2-metadata-search.adoc[Search metadata]. diff --git a/modules/ROOT/pages/rest-api-v2-users-search.adoc b/modules/ROOT/pages/rest-api-v2-users-search.adoc index 1d7ebcb58..7ad9e269c 100644 --- a/modules/ROOT/pages/rest-api-v2-users-search.adoc +++ b/modules/ROOT/pages/rest-api-v2-users-search.adoc @@ -144,8 +144,6 @@ If you need multiple levels of sorting, you'll have to parse the response progra === group_identifiers You can filter responses based on which groups the user belongs to. -This replaces some of the individual group endpoints in the V1 REST API, such as the xref:group-api.adoc#get-users-group[`/tspublic/v1/group/listuser/{groupid}`] endpoint. - The `group_identifiers` parameter takes an array of strings of either **group name** or **group GUID**. Note that groups have a `display_name` property which is different from **group_name** and cannot be used in the `group_identifiers` array. [source,JSON] @@ -254,7 +252,7 @@ The `notify_on_share`, `show_onboarding_experience`, and `onboarding_experience_ Note that the *absence* of the parameter is how you remove the filtering. If the parameter value is present as a key, the value must be set to `true` or `false`, so that the response can be filtered to get only those users that match the condition. == Additional response details -There are a number of parameters that add new data to the response, allowing the `metadata/search` endpoint to answer questions that require multiple API calls in the V1 REST API. +There are a number of parameters that add new data to the response, allowing the `metadata/search` endpoint #to return related details in a single API call.# === include_favorite_metadata The `user` object in the response always includes a `favorite_metadata` key, but it will always be an empty array unless the `include_favorite_metadata` parameter is added to the request set to `true`: diff --git a/modules/ROOT/pages/roles.adoc b/modules/ROOT/pages/roles.adoc index 4ee8fb39d..e51afc451 100644 --- a/modules/ROOT/pages/roles.adoc +++ b/modules/ROOT/pages/roles.adoc @@ -196,37 +196,7 @@ UI: **Can download Data**| Allows users to download data from objects such as Li == How to create and assign Roles -You can create and assign Roles to a group on the link:https://docs.thoughtspot.com/cloud/latest/rbac[Admin page of the UI, window=_blank] or by using the REST API v1 and v2 endpoints. - -=== REST API v1 endpoints for Role administration and assignment - -[width="100%" cols="2,4"] -[options='header'] -|=== -|Operation type|API endpoints -|CRUD operations -a| To create, edit, and manage Role objects, use the following endpoints: - -* xref:roles-api.adoc#createRole[`POST /tspublic/v1/role`] + -Create a Role -* xref:roles-api.adoc#editRole[`PUT /tspublic/v1/role/{role_identifier}`] + -Edit properties of a Role object. -* xref:roles-api.adoc#searchRole[`POST /tspublic/v1/role/search`] + -Get a list of Role objects -* xref:roles-api.adoc#deleteRole[`DELETE /tspublic/v1/role/{role_identifier}`] + -Delete a Role object -|Role assignment to groups a| * xref:group-api.adoc#addRole[`POST /tspublic/v1/group/addrole`] + -Allows group administrators to assign a specific Role to a group -* xref:group-api.adoc#create-group[`/tspublic/v1/group/`] + -Allows group administrators to assign one or several Roles to a group -* xref:group-api.adoc#removeRole[`POST /tspublic/v1/group/removerole`] + -Removes the Roles assigned to a group -* xref:group-api.adoc#update-group[`PUT /tspublic/v1/group/{groupid}`] + -Edit Role associations of a group object -|Object query|To get the details of Roles assigned to a group object, use the following API endpoint: -* xref:group-api.adoc#get-users-group[`GET /tspublic/v1/group/`] + -Note that the API response shows the assigned Roles and privileges in the `assignedRoles` and `granularPrivileges` arrays. -|=== +You can create and assign Roles to a group on the link:https://docs.thoughtspot.com/cloud/latest/rbac[Admin page of the UI, window=_blank] or by using the #REST API v2 endpoints#. === REST API v2 endpoints for Role administration and assignment diff --git a/modules/ROOT/pages/runtime-sort.adoc b/modules/ROOT/pages/runtime-sort.adoc index 136026a8c..ac8033a58 100644 --- a/modules/ROOT/pages/runtime-sort.adoc +++ b/modules/ROOT/pages/runtime-sort.adoc @@ -161,25 +161,3 @@ curl -X POST \ } } ---- - -=== REST API v1 (Legacy endpoints) - -If you are using REST API v1 endpoints, runtime sorts can be applied when fetching a Liveboard or Answer object from ThoughtSpot. - -To sort columns when fetching data from a Liveboard object, add sort properties to the Liveboard data API request URL as shown in the example here: - ----- -http://{ThoughtSpot-host}/callosum/v1/tspublic/v1/pinboarddata?id=e36ee65e-64be-436b-a29a-22d8998c4fae&batchsize=-1&pagenumber=-1&offset=-1&formattype=COMPACT&sortCol1=Sales&asc1=false ----- ----- -https://{ThoughtSpot-host}/callosum/v1/tspublic/v1/pinboarddata?id=e36ee65e-64be-436b-a29a-22d8998c4fae&batchsize=-1&pagenumber=-1&offset=-1&formattype=COMPACT&sortCol1=Sales&asc1=false&sortCol2=Tea ----- - -To sort columns of an Answer object, add the sort attributes to the search data API request URL as shown in the following examples: ----- -https://{ThoughtSpot-host}/callosum/v1/tspublic/v1/searchdata?batchsize=-1&pagenumber=-1&offset=-1&formattype=COMPACT&sortCol1=Sales&asc1=false ----- - ----- -https://{ThoughtSpot-host}/callosum/v1/tspublic/v1/searchdata?batchsize=-1&pagenumber=-1&offset=-1&formattype=COMPACT&sortCol1=Sales&asc1=false&sortCol2=Tea ----- From 176c6783ddcfa5550c9efd6166d43e54b8f5d5e9 Mon Sep 17 00:00:00 2001 From: Rani Gangwar Date: Tue, 6 Oct 2026 22:04:47 +0530 Subject: [PATCH 09/57] v1 API cleanup --- .idea/.gitignore | 3 --- .idea/developer-docs.iml | 9 --------- .idea/misc.xml | 6 ------ .idea/modules.xml | 8 -------- .idea/vcs.xml | 6 ------ 5 files changed, 32 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/developer-docs.iml delete mode 100644 .idea/misc.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d33521a..000000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/developer-docs.iml b/.idea/developer-docs.iml deleted file mode 100644 index d6ebd4805..000000000 --- a/.idea/developer-docs.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 639900d13..000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index aff6e430d..000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1ddfb..000000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file From 21043101f1555b4149eb07a523272257e263f149 Mon Sep 17 00:00:00 2001 From: Rani Gangwar Date: Wed, 7 Oct 2026 10:35:33 +0530 Subject: [PATCH 10/57] v1 cleanup --- modules/ROOT/pages/api-user-management.adoc | 47 ++++++++++++++++++- modules/ROOT/pages/authentication.adoc | 13 +++++ modules/ROOT/pages/connections.adoc | 19 ++++++-- modules/ROOT/pages/data-apis-v2.adoc | 4 +- modules/ROOT/pages/org-manage-api.adoc | 16 ++++++- modules/ROOT/pages/report-apis-v2.adoc | 41 +++++++++++----- .../pages/rest-api-v2-metadata-search.adoc | 9 ++++ modules/ROOT/pages/roles.adoc | 6 ++- modules/ROOT/pages/tml-export.adoc | 2 +- modules/ROOT/pages/tml-import.adoc | 14 +++--- modules/ROOT/pages/tml.adoc | 4 +- 11 files changed, 142 insertions(+), 33 deletions(-) diff --git a/modules/ROOT/pages/api-user-management.adoc b/modules/ROOT/pages/api-user-management.adoc index 4989e7a95..d21949c07 100644 --- a/modules/ROOT/pages/api-user-management.adoc +++ b/modules/ROOT/pages/api-user-management.adoc @@ -18,12 +18,35 @@ When configuring xref:configure-saml.adoc[SAML SSO] in ThoughtSpot UI, you can s By default, ThoughtSpot sends e-mail messages to a new user and enables onboarding workflows when they log in for the first time, even when you are embedding ThoughtSpot content. To alter this behavior at a system-wide level, you need to xref:customize-email-settings.adoc[Customize the onboarding settings]. The user update API also allows setting onboarding experience values for an individual user. If you need to modify the default behavior beyond the available UI options, contact your ThoughtSpot team. +#To specify whether an activation email is sent to a new user, set the `trigger_activation_email` parameter in the `POST /api/rest/2.0/users/create` request.# [NOTE] ==== ThoughtSpot supports local management of users. For initial development and testing purposes, you can create users in ThoughtSpot and manage their profiles locally. However, in large production environments, avoid creating local user profiles to reduce administration overhead. ==== +=== #Required privileges# +#To create, edit, or delete users, assign users to groups, transfer objects from one user to another, or activate, deactivate, or log out users, you need administrator privileges (`ADMINISTRATION`).# + +#If Role-Based Access Control (RBAC) is enabled on your instance, the `USER_ADMINISTRATION` (*Can manage users*) privilege is required. On instances with Orgs, this privilege allows managing users only within the Org context of the API request. For more information, see xref:roles.adoc[Role-based access control].# + +=== #User attributes# +#Note the following points about user attributes:# + +* #*Username*: The username must be unique. An API request to create a user with a username that already exists returns an error.# +* #*Account type*: The `account_type` attribute indicates how the user was created and how the user authenticates. Valid values are:# +** #`LOCAL_USER`: A user created and authenticated locally in ThoughtSpot. This is the default account type.# +** #`LDAP_USER`: A user imported from an LDAP server.# +** #`SAML_USER`: A user authenticated through SAML SSO.# +** #`OIDC_USER`: A user authenticated through OpenID Connect (OIDC).# +** #`REMOTE_USER`: A user authenticated by an external system.# ++ +#When users are imported from an external server, such as an LDAP server or an IdP, ThoughtSpot assigns the account type automatically. For example, a user imported from an LDAP server is added as `LDAP_USER`.# +* #*Visibility*: The `visibility` attribute determines whether other users can share objects with the user. By default, users are set to `SHARABLE`, which allows other users and groups to share objects with them. To prevent other users from finding the user and sharing objects with them, set `visibility` to `NON_SHARABLE`. In multi-tenant deployments, users are commonly set to `NON_SHARABLE`.# + +=== #Password reset# +#After you reset a user's password with the `POST /api/rest/2.0/users/reset-password` endpoint, you can log the user out of their active sessions by using the `POST /api/rest/2.0/users/force-logout` endpoint. The user must then log in again with the new password.# + //// === User account status @@ -107,7 +130,7 @@ ThoughtSpot is gradually migrating its users to link:https://docs.thoughtspot.co * The `email` attribute is mandatory when creating a user in ThoughtSpot Okta. ThoughtSpot recommends that users provide a valid email address during user creation. * You must set a password during user creation if you do not want to trigger an activation email. -* The default value for `usertype` will be `OIDC_USER`. +* The default value for #`account_type`# will be `OIDC_USER`. Administrators can manage the user account status for all users. For IAMv2 users, the API response for the following APIs will also include `SUSPENDED` as a user state: @@ -167,13 +190,22 @@ For more information, see the following pages: == User group creation and update #Groups can be created via the xref:rest-api-v2-reference.adoc#_groups[REST API v2 group endpoints].# +#To create, edit, or delete groups, you need administrator privileges. If RBAC is enabled on your instance, the `GROUP_ADMINISTRATION` (*Can manage groups*) privilege is required. To assign Roles to a group or remove Roles from a group, you need both the `GROUP_ADMINISTRATION` and `ROLE_ADMINISTRATION` privileges.# + The xref:api-user-management.adoc#group-privileges[Group privileges] are set directly on groups, either during group creation or via a group update API call. +=== #Group hierarchy# +#ThoughtSpot supports hierarchical groups. You can create a parent group and add other groups to it as sub-groups. For example, you can create a parent group called `Site` and add your tenant groups to it. Sub-groups inherit the privileges of the parent group.# + +#To add sub-groups to a group or remove sub-groups from it, use the +++POST /api/rest/2.0/groups/{group_identifier}/update+++ endpoint. Removing a sub-group from a group removes only the association; it does not delete the sub-group from ThoughtSpot.# + == User association to groups -You can add users to specific groups when creating a user or group via REST API endpoints. +You can add users to specific groups when creating a user or group via REST API endpoints. #When you assign a user to a group, the user inherits the privileges and Roles assigned to that group.# #To add users to an existing group or remove users from it, use the +++POST /api/rest/2.0/groups/{group_identifier}/update+++ endpoint with the `user_identifiers` parameter and the `ADD`, `REMOVE`, or `REPLACE` operation. To update the groups of a single user, use the +++POST /api/rest/2.0/users/{user_identifier}/update+++ endpoint with the `group_identifiers` parameter.# +#Removing a user from a group removes only the group association; it does not delete the user from ThoughtSpot. To delete a user, use the +++POST /api/rest/2.0/users/{user_identifier}/delete+++ endpoint.# + #To get a list of users assigned to a group, use the +++POST /api/rest/2.0/groups/search+++ endpoint.# == Access control (sharing) @@ -181,6 +213,17 @@ Access to objects is determined by content *shared* directly to the user or the Users can share objects or modify sharing properties through the UI or via an API call to the #`POST /api/rest/2.0/security/metadata/share` endpoint#. +#Note the following points about sharing objects via REST API:# + +* #To share an object, you must have edit access to the object.# +* #The `share_mode` attribute can be set to `READ_ONLY` (*Can view*), `MODIFY` (*Can edit*), or `NO_ACCESS`. Setting `share_mode` to `NO_ACCESS` removes any access previously granted to a user or group.# +* #To send a share notification to the users and groups with whom the object is shared, set `notify_on_share` to `true`.# +* #To make a shared Liveboard or Answer discoverable, set `has_lenient_discoverability` to `true`.# + +#To audit access, use the `POST /api/rest/2.0/security/metadata/fetch-permissions` endpoint to get the users and groups that have access to an object, or the `POST /api/rest/2.0/security/principals/fetch-permissions` endpoint to get the objects a user or group can access.# + +#For more information and examples, see xref:access-control-sharing.adoc[Access control and sharing].# + [#group-privileges] == Group privileges Each user group includes a set of privileges for its users. When a user is assigned to a group in ThoughtSpot, the default privileges associated with a group are assigned to its users. The group privileges allow users belonging to a group to perform specific operations and access workflows. If a user belongs to more than one group, they will have the highest level of privileges from all the groups they belong to. diff --git a/modules/ROOT/pages/authentication.adoc b/modules/ROOT/pages/authentication.adoc index 6e72126ab..5b922944c 100644 --- a/modules/ROOT/pages/authentication.adoc +++ b/modules/ROOT/pages/authentication.adoc @@ -972,6 +972,17 @@ If the API request is successful, ThoughtSpot returns the following response: |**500**|Failed operation |=== //// +[#switch-org] +== #Switching Org context# +#On ThoughtSpot instances with Orgs, only cluster administrators and users who belong to more than one Org can switch between Orgs.# + +#For REST API v2 requests, the Org context is determined by the session or token used in the request. To send API requests in a different Org:# + +* #For cookie-based authentication, log in again by sending a request to the `/api/rest/2.0/auth/session/login` endpoint with the ID or name of the Org in the `org_identifier` attribute.# +* #For token-based authentication, request a new token with the ID of the Org in the `org_id` attribute, or use a <>.# + +#To view the Org context of the current session and the Orgs available to the user, send a request to the `/api/rest/2.0/auth/session/user` endpoint and check the `current_org` and `orgs` properties in the API response. For more information, see <<_viewing_session_information,Viewing session information>>.# + == Logging out of a session To log out of your current session, send a `POST` request to the `/api/rest/2.0/auth/session/logout` API endpoint. @@ -986,6 +997,8 @@ curl -X POST \ -H 'Authorization: Bearer {AUTH_TOKEN}' ---- +#If you send the logout request as an AJAX call from a browser with cookie-based authentication, include credentials in the request so that the session cookie is sent. For example, set `xhrFields: { withCredentials: true }` in a jQuery AJAX request, or `credentials: 'include'` in a Fetch API request.# + === API response If the API request is successful, ThoughtSpot returns a 204 status code and ends the user session. diff --git a/modules/ROOT/pages/connections.adoc b/modules/ROOT/pages/connections.adoc index ca0b2e3ea..ddf1dcfd4 100644 --- a/modules/ROOT/pages/connections.adoc +++ b/modules/ROOT/pages/connections.adoc @@ -585,8 +585,7 @@ To create or modify data connections through REST APIs, you need to provide a ma } ] } - ] -} + ], "validate": true } ---- @@ -607,13 +606,25 @@ To create or modify data connections through REST APIs, you need to provide a ma }, "externalDatabases":[ - ] -} + ], "validate": false } ---- +[#export-connection] +== #Export a connection# +#To export a connection, export its TML by using the `POST /api/rest/2.0/metadata/tml/export` endpoint. To include the Tables of the connection in the export, set `export_dependent` to `true`. For more information, see xref:tml-export.adoc[Export TML].# + +[#delete-connection] +== #Delete a connection# +#To delete a connection, use the `POST /api/rest/2.0/connections/{connection_identifier}/delete` endpoint.# + +[IMPORTANT] +==== +#A connection can be used by multiple Tables, Models, and visualizations. If a connection has dependent objects, remove the dependent objects or their associations before you delete the connection.# +==== + == Integrate with dbt ThoughtSpot’s dbt sync allows you to easily provide your existing dbt models and automatically create ThoughtSpot Worksheets, which you can use to search your data. Integration with dbt is certified only for Amazon Redshift, Databricks, Google BigQuery, and Snowflake connections. diff --git a/modules/ROOT/pages/data-apis-v2.adoc b/modules/ROOT/pages/data-apis-v2.adoc index bf259489d..2670cd006 100644 --- a/modules/ROOT/pages/data-apis-v2.adoc +++ b/modules/ROOT/pages/data-apis-v2.adoc @@ -82,6 +82,8 @@ curl -X POST \ }' ---- +#To verify your query string, run an equivalent search in the ThoughtSpot UI search bar and check whether it returns the same data. Note that the search syntax in the UI differs slightly from the query string syntax.# + //// === Using tokens generated from Spotter APIs as raw data @@ -113,7 +115,7 @@ If the API request is successful, ThoughtSpot returns the Answer data for the qu == Fetch Liveboard Data API To get data from a Liveboard object and its visualizations via `POST /api/rest/2.0/metadata/liveboard/data` endpoint, your user account must have at least view access to the Liveboard specified in the API request. -The API request body must include the name or GUID of the Liveboard to fetch data. To get specific visualizations from a given Liveboard, add the names or GUIDs of the visualizations in the `visualization_identifiers` array. +The API request body must include the name or GUID of the Liveboard to fetch data. To get specific visualizations from a given Liveboard, add the names or GUIDs of the visualizations in the `visualization_identifiers` array. #If `visualization_identifiers` is not specified, the API returns data for all visualizations on the Liveboard.# === Liveboard data request example [source,cURL] diff --git a/modules/ROOT/pages/org-manage-api.adoc b/modules/ROOT/pages/org-manage-api.adoc index 27679ef1d..06644b07e 100644 --- a/modules/ROOT/pages/org-manage-api.adoc +++ b/modules/ROOT/pages/org-manage-api.adoc @@ -40,7 +40,21 @@ To create an Org on a multi-tenant ThoughtSpot instance, you need cluster admini * +++/api/rest/2.0/orgs/create+++ * +++ /api/rest/2.0/orgs/search+++ * +++/api/rest/2.0/orgs/{org_identifier}/update +++ -* +++ /api/rest/2.0/orgs/{org_identifier}/delete+++ +* +++ /api/rest/2.0/orgs/{org_identifier}/delete+++ + +#Note the following points about Org names and descriptions:# + +* #The Org name must be unique and must not exceed 48 characters. The name can contain only letters (a-z, A-Z), digits (0-9), and hyphens (-). Spaces and other special characters are not supported.# +* #The Org description must not exceed 124 characters.# + +=== #Deleting an Org# +[NOTE] +==== +* #ThoughtSpot does not allow deleting the Primary Org (Org 0).# +* #When an Org is deleted, the Org and its objects become inaccessible immediately. However, the objects remain in the ThoughtSpot system for seven days from the deletion date.# +* #To restore a deleted Org, contact ThoughtSpot Support within seven days of deletion.# +* #To remove the objects of a deleted Org before the seven-day period ends, or to reuse a deleted Org name or object names immediately after deletion, contact ThoughtSpot Support.# +==== == Manage users and groups diff --git a/modules/ROOT/pages/report-apis-v2.adoc b/modules/ROOT/pages/report-apis-v2.adoc index a6f42fe91..b7705e344 100644 --- a/modules/ROOT/pages/report-apis-v2.adoc +++ b/modules/ROOT/pages/report-apis-v2.adoc @@ -95,6 +95,15 @@ curl -X POST \ For *PDF* downloads, you can specify additional parameters to customize the page orientation and include or exclude the cover page, logo, footer text, and page numbers. +#The following `pdf_options` attributes determine the content of the PDF:# + +* #`include_cover_page`: Adds a cover page with the Liveboard title.# +* #`include_filter_page`: Adds a page that lists the filters applied to the Liveboard.# +* #`include_custom_logo`: Adds the custom wide logo, if one is configured for your instance, to the footer of each page.# +* #`page_footer_text`: Adds the specified text to the footer of each page.# +* #`include_page_number`: Adds the page number to the footer of each page.# +* #`truncate_table`: Includes only the first page of each table visualization in the PDF.# + You can now also download continuous pdfs which matches the full length of your Liveboard, without breaking them into multiple A4 pages. * `page_size = CONTINUOUS` Unlike the A4 format, which introduces forced page breaks between visualizations, this continuous flow maintains your exact design and intended layout. @@ -232,22 +241,28 @@ include::{path}/transient-lb-content.adoc[] [source,JavaScript] ---- -< iframe src = "http://ts_host:port/" id = "ts-embed" > < /iframe> -< script src = "/path/to/liveboard.js" > < /script> -< script > - const embed = new LiveboardEmbed("#embed", { - frameParams: {}, - }); - async function liveboardData() { +const embed = new LiveboardEmbed("#embed", { + frameParams: {}, + liveboardId: "", +}); + +async function downloadLiveboardReport() { const transientPinboardContent = await embed.trigger(HostEvent.getExportRequestForCurrentPinboard); - const liveboardDataResponse = await fetch("https://ts_host:port/api/rest/2.0/report/liveboard", { + const payload = { + metadata_identifier: "", + file_format: "PDF", + transient_content: JSON.stringify(transientPinboardContent.data), + }; + const response = await fetch("https://{ThoughtSpot-Host}/api/rest/2.0/report/liveboard", { method: "POST", - body: createFormDataObjectWith({ - "transient_content": transientPinboardContent, - }), + headers: { + Authorization: "Bearer {access-token}", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), }); - } -< /script> + const file = await response.blob(); +} ---- See also, link:https://developers.thoughtspot.com/docs/Enumeration_HostEvent#_getexportrequestforcurrentpinboard[HostEvent.getExportRequestForCurrentPinboard]. diff --git a/modules/ROOT/pages/rest-api-v2-metadata-search.adoc b/modules/ROOT/pages/rest-api-v2-metadata-search.adoc index a6a9e2f28..857815c4f 100644 --- a/modules/ROOT/pages/rest-api-v2-metadata-search.adoc +++ b/modules/ROOT/pages/rest-api-v2-metadata-search.adoc @@ -3576,6 +3576,11 @@ You can filter responses based on who can access the object, that is, who the co While the `permissions` parameter filters the response set of `metadata/search`, the objects in the response do not list the full set of assigned permissions. Please use the `link:https://developers.thoughtspot.com/docs/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fsecurity%2Ffetch-permissions-on-metadata[/security/metadata/fetch-permissions/`, target=_blank]` endpoint #for a full listing.# +#The `/security/metadata/fetch-permissions` endpoint supports the following permission types in the `permission_type` attribute:# + +* #`DEFINED`: Returns the access granted to users and groups through object sharing.# +* #`EFFECTIVE`: Returns the access that users have, including the access granted through the privileges of the groups to which they belong.# + The `permissions` object takes an array of objects that define a `principal` and a `link:https://developers.thoughtspot.com/docs/restV2-playground?apiResourceId=http%2Fmodels%2Fenumerations%2Fshare-mode[share_mode, target=_blank]`. `principal` is an object with a `type` of `USER` or `USER_GROUP` and an `identifier`, either the name or the GUID of the principal: [source,JSON] @@ -3826,6 +3831,10 @@ Data objects in ThoughtSpot like Tables and Worksheets have **dependent objects* An object can only be deleted if all of its dependent objects are deleted first. +#A dependency is a relationship between a referenced object and the objects that reference it. For example, consider a Model (`Model1`) with a formula column (`Column1`) that refers to a base column (`Column2`). In this example, `Model1` is a referencing object and `Column2` is a referenced object. You cannot delete `Column2` until you delete or modify `Column1` and `Model1`.# + +#Before you delete a data object such as a Table, Model, or column by using the `POST /api/rest/2.0/metadata/delete` endpoint, check whether the object has dependent objects, and remove the dependent objects or their references first.# + #To retrieve these relationships from `metadata/search`, set the `include_dependent_objects` parameter to `true`:# [source,JSON] diff --git a/modules/ROOT/pages/roles.adoc b/modules/ROOT/pages/roles.adoc index e51afc451..1e0883bc7 100644 --- a/modules/ROOT/pages/roles.adoc +++ b/modules/ROOT/pages/roles.adoc @@ -198,6 +198,8 @@ UI: **Can download Data**| Allows users to download data from objects such as Li You can create and assign Roles to a group on the link:https://docs.thoughtspot.com/cloud/latest/rbac[Admin page of the UI, window=_blank] or by using the #REST API v2 endpoints#. +#To create and manage Roles, you need the `ROLE_ADMINISTRATION` (*Can manage roles*) privilege. To assign Roles to a group, you need both the `GROUP_ADMINISTRATION` and `ROLE_ADMINISTRATION` privileges.# + === REST API v2 endpoints for Role administration and assignment [width="100%" cols="2,4"] @@ -220,8 +222,8 @@ Delete a Role object * link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fgroups%2Fupdate-user-group[`POST /api/rest/2.0/groups/{group_identifier}/update`] |Object query a| * link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Froles%2Fsearch-roles[`POST /api/rest/2.0/roles/search`] + -To get Roles assigned to specific groups, specify the name or GUID of the Role in the `group_identifiers` attribute. + -Similarly, to search for Roles configured in an Org, specify the name or the GUID of the Org in the `org_identifiers` attribute. +To get Roles assigned to specific groups, specify the name or GUID of the #group# in the `group_identifiers` attribute. + +Similarly, to search for Roles configured in an Org, specify the name or the GUID of the Org in the `org_identifiers` attribute. #If `org_identifiers` is not specified, the API returns the Roles for the Org context from which the API request is sent.# * link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fgroups%2Fsearch-user-groups[`POST /api/rest/2.0/groups/search`] + To filter group objects assigned to a particular Role, specify the name or GUID of the Role in the `role_identifiers` attribute. * link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fusers%2Fsearch-users[`POST /api/rest/2.0/users/search`] + diff --git a/modules/ROOT/pages/tml-export.adoc b/modules/ROOT/pages/tml-export.adoc index 7bfd58583..60bc3c777 100644 --- a/modules/ROOT/pages/tml-export.adoc +++ b/modules/ROOT/pages/tml-export.adoc @@ -34,7 +34,7 @@ When set to `true`, exports the associated objects for the `export_ids` specifie * `export_dependent` + Specifies if the Tables of the referenced Connection object must be included in the export. * `export_connection_as_dependent` + -Specifies if a Connection object must be included as a dependent object when exporting a Table, Model, Answer, or Liveboard TML. +Specifies if a Connection object must be included as a dependent object when exporting a Table, Model, Answer, or Liveboard TML. #This attribute works only when `export_associated` is set to `true`.# == Export FQNs diff --git a/modules/ROOT/pages/tml-import.adoc b/modules/ROOT/pages/tml-import.adoc index ea1ae5f5e..1e13480b3 100644 --- a/modules/ROOT/pages/tml-import.adoc +++ b/modules/ROOT/pages/tml-import.adoc @@ -27,8 +27,8 @@ To import TML representation of the metadata objects into ThoughtSpot, use the f There are multiple kinds of imports possible: -* `PARTIAL` imports all objects that validate successfully, and ignores objects that do not validate successfully. -* `ALL_OR_NONE` imports the objects that validate successfully. +* `PARTIAL` imports all objects that validate successfully, and ignores objects that do not validate successfully #and the objects that depend on them#. +* `ALL_OR_NONE` #imports all objects only if all objects validate successfully. If the import fails for one object, no objects are imported.# * `VALIDATE_ONLY` validates the objects but does not import them. * `PARTIAL_OBJECT` imports objects that validate successfully and skips the objects that do not validate successfully. If the import fails for a visualization object in a Liveboard TML, the Liveboard will be imported without that visualization object. Similarly, if importing a relationship in a logical table fails, the table TML will be imported with warnings in the API response. @@ -279,8 +279,8 @@ You can import TML objects asynchronously by scheduling TML import tasks via `PO |Form parameter|Description| Default |`metadata_tmls` |__Array of strings__. Array of the TML strings. |__None__ |`create_new` + -__Optional__ |__Boolean__. To create TML objects with new GUIDs during import, specify `true`. By default, ThoughtSpot updates the existing objects that have the same GUID as the objects you are importing. | `false` -|`all_orgs_context` + +__Optional__ |__Boolean__. To create TML objects with new GUIDs during import, specify `true`. By default, ThoughtSpot updates the existing objects that have the same GUID as the objects you are importing. #When set to `true`, a `guid` in the imported TML that is already used by another object is replaced with a new GUID. If the `guid` in the imported TML is not used by any other object, ThoughtSpot creates the new object with that `guid`.# | `false` +|#`all_orgs_override`# + __Optional__ |__Boolean__. Specify if import operation must be run for all Orgs on your instance. __Requires Org administration privileges to access TML objects across all Orgs.__| `false` @@ -324,7 +324,7 @@ curl -X POST \ "{\\\"guid\\\": \\\"2ed8192a-1e9d-47d1-810d-52b14cb0e9fe\\\",\\\"liveboard\\\": {\\\"name\\\": \\\"Total Sales\\\",\\\"visualizations\\\": [{\\\"id\\\": \\\"Viz_1\\\",\\\"answer\\\": {\\\"name\\\": \\\"Total quantity purchased, Total sales by region\\\",\\\"description\\\": \\\"test\\\",\\\"tables\\\": [{\\\"id\\\": \\\"(Sample) Retail - Apparel\\\",\\\"name\\\": \\\"(Sample) Retail - Apparel\\\"}],\\\"search_query\\\": \\\"[region] [quantity purchased] [sales]\\\",\\\"answer_columns\\\": [{\\\"name\\\": \\\"Total quantity purchased\\\"},{\\\"name\\\": \\\"Total sales\\\"},{\\\"name\\\": \\\"region\\\"}],\\\"table\\\": {\\\"table_columns\\\": [{\\\"column_id\\\": \\\"Total quantity purchased\\\",\\\"show_headline\\\": false},{\\\"column_id\\\": \\\"Total sales\\\",\\\"show_headline\\\": false},{\\\"column_id\\\": \\\"region\\\",\\\"show_headline\\\": false}],\\\"ordered_column_ids\\\": [\\\"region\\\",\\\"Total quantity purchased\\\",\\\"Total sales\\\"],\\\"client_state\\\": \\\"\\\",\\\"client_state_v2\\\": \\\"{\\\\\\\"tableVizPropVersion\\\\\\\": \\\\\\\"V1\\\\\\\"}\\\"},\\\"chart\\\": {\\\"type\\\": \\\"COLUMN\\\",\\\"chart_columns\\\": [{\\\"column_id\\\": \\\"Total quantity purchased\\\"},{\\\"column_id\\\": \\\"Total sales\\\"},{\\\"column_id\\\": \\\"region\\\"}],\\\"axis_configs\\\": [{\\\"x\\\": [\\\"region\\\"],\\\"y\\\": [\\\"Total quantity purchased\\\",\\\"Total sales\\\"]}],\\\"client_state\\\": \\\"\\\",\\\"client_state_v2\\\": \\\"{\\\\\\\"version\\\\\\\": \\\\\\\"V4DOT2\\\\\\\",\\\\\\\"chartProperties\\\\\\\": {\\\\\\\"responsiveLayoutPreference\\\\\\\": \\\\\\\"AUTO_ON\\\\\\\",\\\\\\\"chartSpecific\\\\\\\": {}},\\\\\\\"axisProperties\\\\\\\": [{\\\\\\\"id\\\\\\\": \\\\\\\"f6701821-5630-49cd-b26f-f98bebb4e98e\\\\\\\",\\\\\\\"properties\\\\\\\": {\\\\\\\"axisType\\\\\\\": \\\\\\\"Y\\\\\\\",\\\\\\\"linkedColumns\\\\\\\": [\\\\\\\"Total quantity purchased\\\\\\\"],\\\\\\\"isOpposite\\\\\\\": false}},{\\\\\\\"id\\\\\\\": \\\\\\\"1e086aef-236d-4cf0-8909-9e04e04a1ac4\\\\\\\",\\\\\\\"properties\\\\\\\": {\\\\\\\"axisType\\\\\\\": \\\\\\\"Y\\\\\\\",\\\\\\\"linkedColumns\\\\\\\": [\\\\\\\"Total sales\\\\\\\"],\\\\\\\"isOpposite\\\\\\\": true}},{\\\\\\\"id\\\\\\\": \\\\\\\"7be648d3-c791-43e4-bb13-63fea808f326\\\\\\\",\\\\\\\"properties\\\\\\\": {\\\\\\\"axisType\\\\\\\": \\\\\\\"X\\\\\\\",\\\\\\\"linkedColumns\\\\\\\": [\\\\\\\"region\\\\\\\"]}}],\\\\\\\"systemSeriesColors\\\\\\\": [{\\\\\\\"serieName\\\\\\\": \\\\\\\"Total quantity purchased\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#48D1E0\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"Total sales\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#2E75F0\\\\\\\"}]}\\\"},\\\"display_mode\\\": \\\"CHART_MODE\\\"},\\\"viz_guid\\\": \\\"b8b38851-5980-40a1-bd88-608b7a9c6e86\\\"},{\\\"id\\\": \\\"Viz_2\\\",\\\"answer\\\": {\\\"name\\\": \\\"Total sales in a year\\\",\\\"tables\\\": [{\\\"id\\\": \\\"(Sample) Retail - Apparel\\\",\\\"name\\\": \\\"(Sample) Retail - Apparel\\\"}],\\\"search_query\\\": \\\"[sales] [store] [date].monthly [date].'\''this year'\''\\\",\\\"answer_columns\\\": [{\\\"name\\\": \\\"Month(date)\\\"},{\\\"name\\\": \\\"Total sales\\\"},{\\\"name\\\": \\\"store\\\"}],\\\"table\\\": {\\\"table_columns\\\": [{\\\"column_id\\\": \\\"Month(date)\\\",\\\"show_headline\\\": false},{\\\"column_id\\\": \\\"Total sales\\\",\\\"show_headline\\\": false},{\\\"column_id\\\": \\\"store\\\",\\\"show_headline\\\": false}],\\\"ordered_column_ids\\\": [\\\"store\\\",\\\"Month(date)\\\",\\\"Total sales\\\"],\\\"client_state\\\": \\\"\\\",\\\"client_state_v2\\\": \\\"{\\\\\\\"tableVizPropVersion\\\\\\\": \\\\\\\"V1\\\\\\\"}\\\"},\\\"chart\\\": {\\\"type\\\": \\\"LINE\\\",\\\"chart_columns\\\": [{\\\"column_id\\\": \\\"Month(date)\\\"},{\\\"column_id\\\": \\\"Total sales\\\"},{\\\"column_id\\\": \\\"store\\\"}],\\\"axis_configs\\\": [{\\\"x\\\": [\\\"Month(date)\\\"],\\\"y\\\": [\\\"Total sales\\\"],\\\"color\\\": [\\\"store\\\"]}],\\\"client_state\\\": \\\"\\\",\\\"client_state_v2\\\": \\\"{\\\\\\\"version\\\\\\\": \\\\\\\"V4DOT2\\\\\\\",\\\\\\\"chartProperties\\\\\\\": {\\\\\\\"responsiveLayoutPreference\\\\\\\": \\\\\\\"AUTO_ON\\\\\\\",\\\\\\\"chartSpecific\\\\\\\": {}},\\\\\\\"axisProperties\\\\\\\": [{\\\\\\\"id\\\\\\\": \\\\\\\"feb33c4a-614b-4623-9d12-1c4cf2250801\\\\\\\",\\\\\\\"properties\\\\\\\": {\\\\\\\"axisType\\\\\\\": \\\\\\\"Y\\\\\\\",\\\\\\\"linkedColumns\\\\\\\": [\\\\\\\"Total sales\\\\\\\"],\\\\\\\"isOpposite\\\\\\\": false}},{\\\\\\\"id\\\\\\\": \\\\\\\"e89293cb-d10a-40d5-b787-81be33cc8b81\\\\\\\",\\\\\\\"properties\\\\\\\": {\\\\\\\"axisType\\\\\\\": \\\\\\\"X\\\\\\\",\\\\\\\"linkedColumns\\\\\\\": [\\\\\\\"Month(date)\\\\\\\"]}}],\\\\\\\"systemSeriesColors\\\\\\\": [{\\\\\\\"serieName\\\\\\\": \\\\\\\"6a4acb32-9036-4d4c-a830-f2b06966a322\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#48D1E0\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"39e5242e-43d0-4ef7-8673-73b12433e0c5\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#2E75F0\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"arizona (85226)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#2E75F0\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"arizona (86001)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#48D1E0\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"california (91006)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#FCC838\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"california (94702)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#06BF7F\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"colorado (80301)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#8C62F5\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"colorado (80920)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#FF8142\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"connecticut (06110)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#ABC7F9\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"connecticut (06854)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#B5ECF2\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"delaware (19702)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#FDE9AF\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"georgia (30022)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#9BE5CB\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"georgia (30329)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#D1C0FB\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"idaho (83704)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#FFCCB3\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"illinois (60062)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#2359B6\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"illinois (60642)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#369FAA\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"indiana (46250)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#BF982A\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"iowa (50266)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#049160\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"maryland (21045)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#6A4ABA\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"massachusetts (01701)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#C26232\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"massachusetts (02215)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#71A1F4\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"michigan (48103)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#82DFE9\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"michigan (49512)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#FCD977\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"minnesota (55369)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#56D3A8\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"minnesota (55420)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#B094F8\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"missouri (63144)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#FFA97E\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"montana (59106)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#163772\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"montana (59718)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#22636B\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"nevada (89052)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#785F1A\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"nevada (89145)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#025B3C\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"new hampshire (03860)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#422E75\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"new jersey (07936)\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#7A3D1F\\\\\\\"}]}\\\"},\\\"display_mode\\\": \\\"CHART_MODE\\\"},\\\"viz_guid\\\": \\\"7efc7b0e-e680-44a4-ba9c-3bd5d7272367\\\"},{\\\"id\\\": \\\"Viz_3\\\",\\\"answer\\\": {\\\"name\\\": \\\"Total sales by item type and region\\\",\\\"tables\\\": [{\\\"id\\\": \\\"(Sample) Retail - Apparel\\\",\\\"name\\\": \\\"(Sample) Retail - Apparel\\\"}],\\\"search_query\\\": \\\"[sales] [item type] [region]\\\",\\\"answer_columns\\\": [{\\\"name\\\": \\\"Total sales\\\"},{\\\"name\\\": \\\"item type\\\"},{\\\"name\\\": \\\"region\\\"}],\\\"table\\\": {\\\"table_columns\\\": [{\\\"column_id\\\": \\\"Total sales\\\",\\\"show_headline\\\": false},{\\\"column_id\\\": \\\"item type\\\",\\\"show_headline\\\": false},{\\\"column_id\\\": \\\"region\\\",\\\"show_headline\\\": false}],\\\"ordered_column_ids\\\": [\\\"item type\\\",\\\"region\\\",\\\"Total sales\\\"],\\\"client_state\\\": \\\"\\\",\\\"client_state_v2\\\": \\\"{\\\\\\\"tableVizPropVersion\\\\\\\": \\\\\\\"V1\\\\\\\"}\\\"},\\\"chart\\\": {\\\"type\\\": \\\"STACKED_COLUMN\\\",\\\"chart_columns\\\": [{\\\"column_id\\\": \\\"Total sales\\\"},{\\\"column_id\\\": \\\"item type\\\"},{\\\"column_id\\\": \\\"region\\\"}],\\\"axis_configs\\\": [{\\\"x\\\": [\\\"item type\\\"],\\\"y\\\": [\\\"Total sales\\\"],\\\"color\\\": [\\\"region\\\"]}],\\\"client_state\\\": \\\"\\\",\\\"client_state_v2\\\": \\\"{\\\\\\\"version\\\\\\\": \\\\\\\"V4DOT2\\\\\\\",\\\\\\\"chartProperties\\\\\\\": {\\\\\\\"responsiveLayoutPreference\\\\\\\": \\\\\\\"AUTO_ON\\\\\\\",\\\\\\\"chartSpecific\\\\\\\": {}},\\\\\\\"axisProperties\\\\\\\": [{\\\\\\\"id\\\\\\\": \\\\\\\"a330db6d-3714-4b5f-aed7-cca5ab679502\\\\\\\",\\\\\\\"properties\\\\\\\": {\\\\\\\"axisType\\\\\\\": \\\\\\\"Y\\\\\\\",\\\\\\\"linkedColumns\\\\\\\": [\\\\\\\"Total sales\\\\\\\"],\\\\\\\"isOpposite\\\\\\\": false}},{\\\\\\\"id\\\\\\\": \\\\\\\"96ac1ce3-7efd-455e-a569-2cd0792b5f95\\\\\\\",\\\\\\\"properties\\\\\\\": {\\\\\\\"axisType\\\\\\\": \\\\\\\"X\\\\\\\",\\\\\\\"linkedColumns\\\\\\\": [\\\\\\\"item type\\\\\\\"]}}],\\\\\\\"systemSeriesColors\\\\\\\": [{\\\\\\\"serieName\\\\\\\": \\\\\\\"Total sales\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#48D1E0\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"92153012-664c-458e-9a74-6138c5030838\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#2E75F0\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"east\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#06BF7F\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"midwest\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#FCC838\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"south\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#48D1E0\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"southwest\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#71A1F4\\\\\\\"},{\\\\\\\"serieName\\\\\\\": \\\\\\\"west\\\\\\\",\\\\\\\"color\\\\\\\": \\\\\\\"#2E75F0\\\\\\\"}]}\\\"},\\\"display_mode\\\": \\\"CHART_MODE\\\"},\\\"viz_guid\\\": \\\"9de47a0e-6f20-40ba-9a52-3374ee530f09\\\"},{\\\"id\\\": \\\"Viz_4\\\",\\\"answer\\\": {\\\"name\\\": \\\"Total sales by state\\\",\\\"tables\\\": [{\\\"id\\\": \\\"(Sample) Retail - Apparel\\\",\\\"name\\\": \\\"(Sample) Retail - Apparel\\\"}],\\\"search_query\\\": \\\"[state] [store] [sales]\\\",\\\"answer_columns\\\": [{\\\"name\\\": \\\"Total sales\\\"},{\\\"name\\\": \\\"state\\\"},{\\\"name\\\": \\\"store\\\"}],\\\"table\\\": {\\\"table_columns\\\": [{\\\"column_id\\\": \\\"Total sales\\\",\\\"show_headline\\\": false},{\\\"column_id\\\": \\\"state\\\",\\\"show_headline\\\": false},{\\\"column_id\\\": \\\"store\\\",\\\"show_headline\\\": false}],\\\"ordered_column_ids\\\": [\\\"state\\\",\\\"store\\\",\\\"Total sales\\\"],\\\"client_state\\\": \\\"\\\",\\\"client_state_v2\\\": \\\"{\\\\\\\"tableVizPropVersion\\\\\\\": \\\\\\\"V1\\\\\\\"}\\\"},\\\"chart\\\": {\\\"type\\\": \\\"GEO_AREA\\\",\\\"chart_columns\\\": [{\\\"column_id\\\": \\\"Total sales\\\"},{\\\"column_id\\\": \\\"state\\\"},{\\\"column_id\\\": \\\"store\\\"}],\\\"axis_configs\\\": [{\\\"x\\\": [\\\"state\\\"],\\\"y\\\": [\\\"Total sales\\\"]}],\\\"client_state\\\": \\\"\\\",\\\"client_state_v2\\\": \\\"{\\\\\\\"version\\\\\\\": \\\\\\\"V4DOT2\\\\\\\",\\\\\\\"chartProperties\\\\\\\": {\\\\\\\"chartSpecific\\\\\\\": {}},\\\\\\\"axisProperties\\\\\\\": [{\\\\\\\"id\\\\\\\": \\\\\\\"e4943da4-f159-470c-836b-7c6c55d59c2f\\\\\\\",\\\\\\\"properties\\\\\\\": {\\\\\\\"axisType\\\\\\\": \\\\\\\"Y\\\\\\\",\\\\\\\"linkedColumns\\\\\\\": [\\\\\\\"Total sales\\\\\\\"],\\\\\\\"isOpposite\\\\\\\": false}},{\\\\\\\"id\\\\\\\": \\\\\\\"b99d582a-b215-4381-89ad-280c451979e3\\\\\\\",\\\\\\\"properties\\\\\\\": {\\\\\\\"axisType\\\\\\\": \\\\\\\"X\\\\\\\",\\\\\\\"linkedColumns\\\\\\\": [\\\\\\\"state\\\\\\\"]}}],\\\\\\\"systemMultiColorSeriesColors\\\\\\\": [{\\\\\\\"serieName\\\\\\\": \\\\\\\"Total sales\\\\\\\",\\\\\\\"colorMap\\\\\\\": [{\\\\\\\"serieName\\\\\\\": \\\\\\\"state\\\\\\\",\\\\\\\"color\\\\\\\": [\\\\\\\"#ffffb2\\\\\\\",\\\\\\\"#fddd87\\\\\\\",\\\\\\\"#fba35d\\\\\\\",\\\\\\\"#f75534\\\\\\\",\\\\\\\"#f9140a\\\\\\\",\\\\\\\"#d70315\\\\\\\",\\\\\\\"#b10026\\\\\\\"]}]}]}\\\"},\\\"display_mode\\\": \\\"CHART_MODE\\\"},\\\"viz_guid\\\": \\\"4ab1ed61-2930-46d4-af6f-778279d7414a\\\"}],\\\"filters\\\": [{\\\"column\\\": [\\\"region\\\"],\\\"oper\\\": \\\"in\\\",\\\"values\\\": [\\\"Midwest\\\"],\\\"is_mandatory\\\": false,\\\"is_single_value\\\": false,\\\"display_name\\\": \\\"region\\\"}],\\\"layout\\\": {\\\"tabs\\\": [{\\\"name\\\": \\\"Tab 1\\\",\\\"description\\\": \\\"\\\",\\\"tiles\\\": [{\\\"visualization_id\\\": \\\"Viz_1\\\",\\\"x\\\": 0,\\\"y\\\": 0,\\\"height\\\": 8,\\\"width\\\": 6},{\\\"visualization_id\\\": \\\"Viz_4\\\",\\\"x\\\": 6,\\\"y\\\": 0,\\\"height\\\": 8,\\\"width\\\": 6}],\\\"id\\\": \\\"2c4014b5-a606-4639-8ad0-01032ff3fc13\\\"},{\\\"name\\\": \\\"Tab 2\\\",\\\"description\\\": \\\"\\\",\\\"tiles\\\": [{\\\"visualization_id\\\": \\\"Viz_2\\\",\\\"x\\\": 0,\\\"y\\\": 0,\\\"height\\\": 8,\\\"width\\\": 6},{\\\"visualization_id\\\": \\\"Viz_3\\\",\\\"x\\\": 6,\\\"y\\\": 0,\\\"height\\\": 8,\\\"width\\\": 6}],\\\"id\\\": \\\"185b4875-e7c5-43d2-a9bc-0a404107a3ec\\\"}]}}}\", \"info\": { \"filename\": \"Copy of Total Sales.liveboard.tml\", \"name\": \"Copy of Total Sales\", \"id\": \"2ed8192a-1e9d-47d1-810d-52b14cb0e9fe\", \"type\": \"liveboard\", \"status\": { \"status_code\": \"OK\" } }" ], "create_new": true, - "all_orgs_context": false, + "all_orgs_override": false, }' ---- @@ -537,7 +537,7 @@ In the JSON preview, you'll see the following code: ], "import_policy": "PARTIAL", "create_new": false, - "all_orgs_context": false + "all_orgs_override": false } ---- . Remove the text in the `metadata_tmls` array and paste your YAML content inside the array. If you are importing more than one TML, use a comma after pasting a TML YAML and then add YAML for the other TML. @@ -550,7 +550,7 @@ In the JSON preview, you'll see the following code: ], "import_policy": "PARTIAL", "create_new": false, - "all_orgs_context": false + "all_orgs_override": false } ---- + diff --git a/modules/ROOT/pages/tml.adoc b/modules/ROOT/pages/tml.adoc index aba737cd2..1f20c1543 100644 --- a/modules/ROOT/pages/tml.adoc +++ b/modules/ROOT/pages/tml.adoc @@ -89,7 +89,7 @@ In the JSON preview, you'll see the following code: ], "import_policy": "PARTIAL", "create_new": false, - "all_orgs_context": false + "all_orgs_override": false } ---- . Remove the text in the `metadata_tmls` array and paste your YAML content inside the array. If you are importing more than one TML, use a comma after pasting a TML YAML and then add YAML for the otehr TML. @@ -102,7 +102,7 @@ In the JSON preview, you'll see the following code: ], "import_policy": "PARTIAL", "create_new": false, - "all_orgs_context": false + "all_orgs_override": false } ---- + From 0b80e76e09c33d1e6b7a41400378ed99826f371f Mon Sep 17 00:00:00 2001 From: Rani Gangwar Date: Wed, 7 Oct 2026 14:04:37 +0530 Subject: [PATCH 11/57] v1 cleanup --- modules/ROOT/pages/api-user-management.adoc | 11 +++++++++++ modules/ROOT/pages/audit-logs.adoc | 4 ++-- modules/ROOT/pages/connections.adoc | 1 - modules/ROOT/pages/embed-pinboard.adoc | 2 +- modules/ROOT/pages/liveboard-data-downloads.adoc | 2 +- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/modules/ROOT/pages/api-user-management.adoc b/modules/ROOT/pages/api-user-management.adoc index d21949c07..1d7194cae 100644 --- a/modules/ROOT/pages/api-user-management.adoc +++ b/modules/ROOT/pages/api-user-management.adoc @@ -215,6 +215,7 @@ Users can share objects or modify sharing properties through the UI or via an AP #Note the following points about sharing objects via REST API:# +* #You can share the following types of objects: Liveboards, visualizations, Answers, Models, Views, Connections, and Collections.# * #To share an object, you must have edit access to the object.# * #The `share_mode` attribute can be set to `READ_ONLY` (*Can view*), `MODIFY` (*Can edit*), or `NO_ACCESS`. Setting `share_mode` to `NO_ACCESS` removes any access previously granted to a user or group.# * #To send a share notification to the users and groups with whom the object is shared, set `notify_on_share` to `true`.# @@ -224,6 +225,16 @@ Users can share objects or modify sharing properties through the UI or via an AP #For more information and examples, see xref:access-control-sharing.adoc[Access control and sharing].# +=== #Sharing a visualization# +#Note the following points about sharing a visualization:# + +* #Sharing a visualization implicitly shares the entire Liveboard with the recipient.# +* #The object permissions set for a shared visualization also apply to the Liveboard, unless they are overridden by another API request or in the UI.# +* #If email notifications for object sharing are enabled, the recipient receives a notification with a link to the shared visualization. Although this link opens the shared visualization, the recipient can also access other visualizations on the Liveboard.# + +=== #Sharing a Collection# +#Collections support separate permissions for the Collection and for the objects within it. For more information, see xref:collections.adoc#_share_a_collection[Share a Collection].# + [#group-privileges] == Group privileges Each user group includes a set of privileges for its users. When a user is assigned to a group in ThoughtSpot, the default privileges associated with a group are assigned to its users. The group privileges allow users belonging to a group to perform specific operations and access workflows. If a user belongs to more than one group, they will have the highest level of privileges from all the groups they belong to. diff --git a/modules/ROOT/pages/audit-logs.adoc b/modules/ROOT/pages/audit-logs.adoc index 817593b88..bc907d6f5 100644 --- a/modules/ROOT/pages/audit-logs.adoc +++ b/modules/ROOT/pages/audit-logs.adoc @@ -43,9 +43,9 @@ Error Message: Cannot use first or last name in the password.","userId":"e3dc495 Org ID if your ThoughtSpot application instance has Orgs. == Fetch logs via API -To fetch logs via REST API, you need `ADMINISTRATION` (*Can administer ThoughtSpot*) privilege. #Use the following API endpoint:# +To fetch logs via REST API, you need `ADMINISTRATION` (*Can administer ThoughtSpot*) privilege. Use the following API endpoint: -* #+++POST /api/rest/2.0/logs/fetch+++# +* +++POST /api/rest/2.0/logs/fetch+++ On Orgs-enabled clusters, the API fetches security logs for the current Org text. However, the administrator of the primary Org (Org 0) can fetch logs not just for the primary Org, but also for all other Orgs if the `get_all_logs` parameter is set to `true` in the API request. diff --git a/modules/ROOT/pages/connections.adoc b/modules/ROOT/pages/connections.adoc index ddf1dcfd4..99ed03d24 100644 --- a/modules/ROOT/pages/connections.adoc +++ b/modules/ROOT/pages/connections.adoc @@ -642,7 +642,6 @@ To understand about the data catalog tools supported by ThoughtSpot, see the fol * https://docs.thoughtspot.com/cloud/latest/catalog-integration-atlan[Atlan, window=_blank] * https://docs.thoughtspot.com/cloud/latest/catalog-integration-collibra[Collibra, window=_blank] -#To understand the metadata API endpoints, see https://developers.thoughtspot.com/docs/rest-apiv2-reference#_metadata[REST API v2 Metadata endpoints, window=_blank].# == Connection Configuration ThoughtSpot supports multiple configurations per data Connection. This feature allows data engineers to define and manage several distinct Connection configurations under a single data Connection object in ThoughtSpot. diff --git a/modules/ROOT/pages/embed-pinboard.adoc b/modules/ROOT/pages/embed-pinboard.adoc index 54067ac77..5033c55a4 100644 --- a/modules/ROOT/pages/embed-pinboard.adoc +++ b/modules/ROOT/pages/embed-pinboard.adoc @@ -110,7 +110,7 @@ The *xref:LiveboardViewConfig.adoc[LiveboardViewConfig]* object includes several [width="100%",cols="2,4",options="header"] |==== -|#Page# |#Covers# +|#Page# |#Customization impact# |#xref:customize-liveboard-layout.adoc[Customize Liveboard layout and UI]# |#Header visibility and compact header, tabs, visible tabs and visualizations, tile gutter, Note tiles, responsive breakpoints and 12-column layout, and grouping and styling.# |#xref:customize-liveboard-filters.adoc[Customize Liveboard filters]# diff --git a/modules/ROOT/pages/liveboard-data-downloads.adoc b/modules/ROOT/pages/liveboard-data-downloads.adoc index dc5697326..a393bfb7e 100644 --- a/modules/ROOT/pages/liveboard-data-downloads.adoc +++ b/modules/ROOT/pages/liveboard-data-downloads.adoc @@ -38,7 +38,7 @@ image::./images/liveboard-refresh.png[Liveboard refresh] The Visual Embed SDK also provides the following action IDs and events to customize the cache refresh visibility and workflow. -[width="100%",cols="2,4"] +[width="100%",cols="3,4"] |==== |ID | Description From cfd49f02fe189938a45b8815c9e3a6767fce6b68 Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Wed, 7 Oct 2026 15:13:07 +0530 Subject: [PATCH 12/57] broken link and typo fixes --- modules/ROOT/pages/customize-liveboard-layout.adoc | 2 +- modules/ROOT/pages/filters_overview.adoc | 2 +- modules/ROOT/pages/whats-new.adoc | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/ROOT/pages/customize-liveboard-layout.adoc b/modules/ROOT/pages/customize-liveboard-layout.adoc index adc1a7835..bcb535aff 100644 --- a/modules/ROOT/pages/customize-liveboard-layout.adoc +++ b/modules/ROOT/pages/customize-liveboard-layout.adoc @@ -151,7 +151,7 @@ The following rules apply to the `liveboardGutter` value: ==== `liveboardGutter` controls the spacing between tiles and groups, not the spacing within a group. -The Liveboard header's horizontal margin is controlled separately by the `--ts-var-liveboard-header-horizontal-margin` CSS variable. To align the Liveboard header and tab/filter section with the tile grid, set both `liveboardGutter` and `--ts-var-liveboard-header-horizontal-margin` to matching values. For more information, see xref:customize-css-styles.adoc#lb-header-margin[Liveboard CSS variables]. +The Liveboard header's horizontal margin is controlled separately by the `--ts-var-liveboard-header-horizontal-margin` CSS variable. To align the Liveboard header and tab/filter section with the tile grid, set both `liveboardGutter` and `--ts-var-liveboard-header-horizontal-margin` to matching values. For more information, see xref:customize-css-styles.adoc#_liveboard[Liveboard CSS variables]. ==== [#noteTiles] diff --git a/modules/ROOT/pages/filters_overview.adoc b/modules/ROOT/pages/filters_overview.adoc index 3e9bd0c5a..a0faaad6b 100644 --- a/modules/ROOT/pages/filters_overview.adoc +++ b/modules/ROOT/pages/filters_overview.adoc @@ -230,7 +230,7 @@ Starting with ThoughtSpot Cloud 26.10.0.cl release, Liveboard filters and parame * *Tab*: applies only to visualizations on a specific tab. * *Group*: applies only to visualizations in a specific group on a tab. -#To enable group-level scoping in an embedded Liveboard, set `isScopedLiveboardFilteringEnabled` to `true` in the embed configuration. For more information, see xref:customize-liveboard-filters.adoc#contextual-liveboard-filtering[Scoped Liveboard filtering].# +To enable group-level scoping in an embedded Liveboard, set `isScopedLiveboardFilteringEnabled` to `true` in the embed configuration. For more information, see xref:customize-liveboard-filters.adoc#contextual-liveboard-filtering[Contextual filtering in Liveboards]. To scope an update to a tab or group, pass the `applicability` attribute with `HostEvent.UpdateFilters` or `HostEvent.UpdateParameters`. Set `level` to `TAB` or `GROUP`, and set `targetId` to the ID of the tab or group. To get the group IDs on a Liveboard, use `HostEvent.GetGroups`. diff --git a/modules/ROOT/pages/whats-new.adoc b/modules/ROOT/pages/whats-new.adoc index bc29e0157..bc37f9aa8 100644 --- a/modules/ROOT/pages/whats-new.adoc +++ b/modules/ROOT/pages/whats-new.adoc @@ -64,7 +64,7 @@ ThoughtSpot now supports programmatic feature management with new REST API v2 en ==== Liveboard enhancements * *Contextual filtering in Liveboards* [earlyAccess eaBackground]#Early Access# + -Liveboard filters now have a three-tier filter hierarchy : Liveboard level, tab level, and group level. You can enable this feature in embedded Liveboards using the `isScopedLiveboardFilteringEnabled` parameter in the SDK. For more information, see #xref:customize-liveboard-filters.adoc#contextual-liveboard-filtering[Contextual filtering in Liveboards]#. +Liveboard filters now have a three-tier filter hierarchy: Liveboard level, tab level, and group level. You can enable this feature in embedded Liveboards using the `isScopedLiveboardFilteringEnabled` parameter in the SDK. For more information, see #xref:customize-liveboard-filters.adoc#contextual-liveboard-filtering[Contextual filtering in Liveboards]#. * *Host Events* + From bc48577642a54d13268c103ecc28a9dfc9fb6bbf Mon Sep 17 00:00:00 2001 From: Rani Gangwar Date: Wed, 7 Oct 2026 16:49:32 +0530 Subject: [PATCH 13/57] final changes for lb embed split and v1 cleanup --- modules/ROOT/pages/api-changelog.adoc | 10 +- modules/ROOT/pages/api-user-management.adoc | 94 +++++++++---------- modules/ROOT/pages/authentication.adoc | 14 +-- modules/ROOT/pages/common/nav-embedding.adoc | 2 +- .../pages/common/nav-in-product-help.adoc | 2 +- modules/ROOT/pages/connections.adoc | 12 +-- .../pages/customize-liveboard-filters.adoc | 32 +++---- .../pages/customize-liveboard-layout.adoc | 80 ++++++++-------- modules/ROOT/pages/data-apis-v2.adoc | 4 +- modules/ROOT/pages/data-security.adoc | 2 +- modules/ROOT/pages/embed-pinboard.adoc | 20 ++-- modules/ROOT/pages/filters_overview.adoc | 2 +- .../ROOT/pages/liveboard-data-downloads.adoc | 20 ++-- modules/ROOT/pages/org-manage-api.adoc | 26 ++--- modules/ROOT/pages/report-apis-v2.adoc | 16 ++-- .../pages/rest-api-v2-metadata-search.adoc | 20 ++-- modules/ROOT/pages/rest-api-v2-search.adoc | 4 +- .../ROOT/pages/rest-api-v2-users-search.adoc | 2 +- modules/ROOT/pages/roles.adoc | 8 +- modules/ROOT/pages/tml-export.adoc | 2 +- modules/ROOT/pages/tml-import.adoc | 8 +- .../ROOT/pages/whats-new-prev-history.adoc | 10 +- modules/ROOT/pages/whats-new.adoc | 10 +- 23 files changed, 200 insertions(+), 200 deletions(-) diff --git a/modules/ROOT/pages/api-changelog.adoc b/modules/ROOT/pages/api-changelog.adoc index 407da9247..3435deff8 100644 --- a/modules/ROOT/pages/api-changelog.adoc +++ b/modules/ROOT/pages/api-changelog.adoc @@ -263,11 +263,11 @@ The SDK includes the following new features and enhancements in Liveboard embedd `liveboardGutter` embed config property:: A new `liveboardGutter` property is available on `LiveboardViewConfig`. Use this property to set the gutter spacing between tiles on an embedded Liveboard. -For more information, see #xref:customize-liveboard-layout.adoc#liveboard-gutter[Set Liveboard tile gutter]# +For more information, see xref:customize-liveboard-layout.adoc#liveboard-gutter[Set Liveboard tile gutter] 12-column layout for Liveboard tile positioning:: A new embed config property is available on `LiveboardViewConfig` to always use a 12-column grid layout for Liveboard tile positioning. When enabled, tile placement and sizing follow a fixed 12-column grid regardless of the Liveboard's default layout settings. -For more information, see #xref:customize-liveboard-layout.adoc#force-12col-layout[12-column layout in embedded Liveboards]# +For more information, see xref:customize-liveboard-layout.adoc#force-12col-layout[12-column layout in embedded Liveboards] New host events for the Liveboard edit page:: * `HostEvent.OpenAddFilterModal` + @@ -316,7 +316,7 @@ Enables compact header layout in embedded Liveboards. * `coverAndFilterOptionInPDF` + Enables the *Include cover page* and *Include filter page(s)* checkboxes in the Liveboard download modal. * `isLiveboardMasterpiecesEnabled` + -Enables the #xref:customize-liveboard-layout.adoc#liveboard-grouping-styling[Liveboard styling and grouping]# feature. +Enables the xref:customize-liveboard-layout.adoc#liveboard-grouping-styling[Liveboard styling and grouping] feature. * `isEnhancedFilterInteractivityEnabled` + Enables interactive filter chips that allow users to add, update, or remove filters in an embedded Liveboard. @@ -387,7 +387,7 @@ Trigger a manual cache refresh from the host application. * `Action.RefreshLiveboardBrowserCache` + Action ID to show or hide the cache refresh button. -For more information, see #xref:liveboard-data-downloads.adoc#liveboard-data-cache[Enable Liveboard refresh]#. +For more information, see xref:liveboard-data-downloads.adoc#liveboard-data-cache[Enable Liveboard refresh]. |[tag greenBackground]#NEW# a| [discrete] @@ -1731,7 +1731,7 @@ For more information, see xref:prefetch-and-cache.adoc[Prefetch static resources |[tag greenBackground]#NEW#| For embedded instances with the new Liveboard experience, the Visual Embed SDK provides the `activeTabId` attribute, using which you can set a Liveboard tab as an active tab. -For more information, see #xref:customize-liveboard-layout.adoc#liveboard-tabs[Customize Liveboard tabs]#. +For more information, see xref:customize-liveboard-layout.adoc#liveboard-tabs[Customize Liveboard tabs]. |[tag greenBackground]#NEW# a|The new version of the SDK supports firing events for Liveboard menu actions from the host application. The SDK introduces the following host event enumeration members for Liveboard objects: diff --git a/modules/ROOT/pages/api-user-management.adoc b/modules/ROOT/pages/api-user-management.adoc index 1d7194cae..acc6c4697 100644 --- a/modules/ROOT/pages/api-user-management.adoc +++ b/modules/ROOT/pages/api-user-management.adoc @@ -10,7 +10,7 @@ To provide access to ThoughtSpot content or define privileges, you must add user == User creation and update -#You can create users and update user profiles using the xref:rest-api-v2-reference.adoc#_users[REST API v2 user endpoints].# +You can create users and update user profiles using the xref:rest-api-v2-reference.adoc#_users[REST API v2 user endpoints]. While you can delete users, it is preferable to deactivate a user, which maintains the user's references within the system. @@ -18,34 +18,34 @@ When configuring xref:configure-saml.adoc[SAML SSO] in ThoughtSpot UI, you can s By default, ThoughtSpot sends e-mail messages to a new user and enables onboarding workflows when they log in for the first time, even when you are embedding ThoughtSpot content. To alter this behavior at a system-wide level, you need to xref:customize-email-settings.adoc[Customize the onboarding settings]. The user update API also allows setting onboarding experience values for an individual user. If you need to modify the default behavior beyond the available UI options, contact your ThoughtSpot team. -#To specify whether an activation email is sent to a new user, set the `trigger_activation_email` parameter in the `POST /api/rest/2.0/users/create` request.# +To specify whether an activation email is sent to a new user, set the `trigger_activation_email` parameter in the `POST /api/rest/2.0/users/create` request. [NOTE] ==== ThoughtSpot supports local management of users. For initial development and testing purposes, you can create users in ThoughtSpot and manage their profiles locally. However, in large production environments, avoid creating local user profiles to reduce administration overhead. ==== -=== #Required privileges# -#To create, edit, or delete users, assign users to groups, transfer objects from one user to another, or activate, deactivate, or log out users, you need administrator privileges (`ADMINISTRATION`).# +=== Required privileges +To create, edit, or delete users, assign users to groups, transfer objects from one user to another, or activate, deactivate, or log out users, you need administrator privileges (`ADMINISTRATION`). -#If Role-Based Access Control (RBAC) is enabled on your instance, the `USER_ADMINISTRATION` (*Can manage users*) privilege is required. On instances with Orgs, this privilege allows managing users only within the Org context of the API request. For more information, see xref:roles.adoc[Role-based access control].# +If Role-Based Access Control (RBAC) is enabled on your instance, the `USER_ADMINISTRATION` (*Can manage users*) privilege is required. On instances with Orgs, this privilege allows managing users only within the Org context of the API request. For more information, see xref:roles.adoc[Role-based access control]. -=== #User attributes# -#Note the following points about user attributes:# +=== User attributes +Note the following points about user attributes: -* #*Username*: The username must be unique. An API request to create a user with a username that already exists returns an error.# -* #*Account type*: The `account_type` attribute indicates how the user was created and how the user authenticates. Valid values are:# -** #`LOCAL_USER`: A user created and authenticated locally in ThoughtSpot. This is the default account type.# -** #`LDAP_USER`: A user imported from an LDAP server.# -** #`SAML_USER`: A user authenticated through SAML SSO.# -** #`OIDC_USER`: A user authenticated through OpenID Connect (OIDC).# -** #`REMOTE_USER`: A user authenticated by an external system.# +* *Username*: The username must be unique. An API request to create a user with a username that already exists returns an error. +* *Account type*: The `account_type` attribute indicates how the user was created and how the user authenticates. Valid values are: +** `LOCAL_USER`: A user created and authenticated locally in ThoughtSpot. This is the default account type. +** `LDAP_USER`: A user imported from an LDAP server. +** `SAML_USER`: A user authenticated through SAML SSO. +** `OIDC_USER`: A user authenticated through OpenID Connect (OIDC). +** `REMOTE_USER`: A user authenticated by an external system. + -#When users are imported from an external server, such as an LDAP server or an IdP, ThoughtSpot assigns the account type automatically. For example, a user imported from an LDAP server is added as `LDAP_USER`.# -* #*Visibility*: The `visibility` attribute determines whether other users can share objects with the user. By default, users are set to `SHARABLE`, which allows other users and groups to share objects with them. To prevent other users from finding the user and sharing objects with them, set `visibility` to `NON_SHARABLE`. In multi-tenant deployments, users are commonly set to `NON_SHARABLE`.# +When users are imported from an external server, such as an LDAP server or an IdP, ThoughtSpot assigns the account type automatically. For example, a user imported from an LDAP server is added as `LDAP_USER`. +* *Visibility*: The `visibility` attribute determines whether other users can share objects with the user. By default, users are set to `SHARABLE`, which allows other users and groups to share objects with them. To prevent other users from finding the user and sharing objects with them, set `visibility` to `NON_SHARABLE`. In multi-tenant deployments, users are commonly set to `NON_SHARABLE`. -=== #Password reset# -#After you reset a user's password with the `POST /api/rest/2.0/users/reset-password` endpoint, you can log the user out of their active sessions by using the `POST /api/rest/2.0/users/force-logout` endpoint. The user must then log in again with the new password.# +=== Password reset +After you reset a user's password with the `POST /api/rest/2.0/users/reset-password` endpoint, you can log the user out of their active sessions by using the `POST /api/rest/2.0/users/force-logout` endpoint. The user must then log in again with the new password. //// @@ -130,17 +130,17 @@ ThoughtSpot is gradually migrating its users to link:https://docs.thoughtspot.co * The `email` attribute is mandatory when creating a user in ThoughtSpot Okta. ThoughtSpot recommends that users provide a valid email address during user creation. * You must set a password during user creation if you do not want to trigger an activation email. -* The default value for #`account_type`# will be `OIDC_USER`. +* The default value for `account_type` will be `OIDC_USER`. Administrators can manage the user account status for all users. For IAMv2 users, the API response for the following APIs will also include `SUSPENDED` as a user state: To create a user: -* #+++POST /api/rest/2.0/users/create+++# +* +++POST /api/rest/2.0/users/create+++ To update user details: -* #+++POST /api/rest/2.0/users/{user_identifier}/update+++# +* +++POST /api/rest/2.0/users/{user_identifier}/update+++ [NOTE] ==== @@ -188,52 +188,52 @@ For more information, see the following pages: == User group creation and update -#Groups can be created via the xref:rest-api-v2-reference.adoc#_groups[REST API v2 group endpoints].# +Groups can be created via the xref:rest-api-v2-reference.adoc#_groups[REST API v2 group endpoints]. -#To create, edit, or delete groups, you need administrator privileges. If RBAC is enabled on your instance, the `GROUP_ADMINISTRATION` (*Can manage groups*) privilege is required. To assign Roles to a group or remove Roles from a group, you need both the `GROUP_ADMINISTRATION` and `ROLE_ADMINISTRATION` privileges.# +To create, edit, or delete groups, you need administrator privileges. If RBAC is enabled on your instance, the `GROUP_ADMINISTRATION` (*Can manage groups*) privilege is required. To assign Roles to a group or remove Roles from a group, you need both the `GROUP_ADMINISTRATION` and `ROLE_ADMINISTRATION` privileges. The xref:api-user-management.adoc#group-privileges[Group privileges] are set directly on groups, either during group creation or via a group update API call. -=== #Group hierarchy# -#ThoughtSpot supports hierarchical groups. You can create a parent group and add other groups to it as sub-groups. For example, you can create a parent group called `Site` and add your tenant groups to it. Sub-groups inherit the privileges of the parent group.# +=== Group hierarchy +ThoughtSpot supports hierarchical groups. You can create a parent group and add other groups to it as sub-groups. For example, you can create a parent group called `Site` and add your tenant groups to it. Sub-groups inherit the privileges of the parent group. -#To add sub-groups to a group or remove sub-groups from it, use the +++POST /api/rest/2.0/groups/{group_identifier}/update+++ endpoint. Removing a sub-group from a group removes only the association; it does not delete the sub-group from ThoughtSpot.# +To add sub-groups to a group or remove sub-groups from it, use the +++POST /api/rest/2.0/groups/{group_identifier}/update+++ endpoint. Removing a sub-group from a group removes only the association; it does not delete the sub-group from ThoughtSpot. == User association to groups -You can add users to specific groups when creating a user or group via REST API endpoints. #When you assign a user to a group, the user inherits the privileges and Roles assigned to that group.# +You can add users to specific groups when creating a user or group via REST API endpoints. When you assign a user to a group, the user inherits the privileges and Roles assigned to that group. -#To add users to an existing group or remove users from it, use the +++POST /api/rest/2.0/groups/{group_identifier}/update+++ endpoint with the `user_identifiers` parameter and the `ADD`, `REMOVE`, or `REPLACE` operation. To update the groups of a single user, use the +++POST /api/rest/2.0/users/{user_identifier}/update+++ endpoint with the `group_identifiers` parameter.# +To add users to an existing group or remove users from it, use the +++POST /api/rest/2.0/groups/{group_identifier}/update+++ endpoint with the `user_identifiers` parameter and the `ADD`, `REMOVE`, or `REPLACE` operation. To update the groups of a single user, use the +++POST /api/rest/2.0/users/{user_identifier}/update+++ endpoint with the `group_identifiers` parameter. -#Removing a user from a group removes only the group association; it does not delete the user from ThoughtSpot. To delete a user, use the +++POST /api/rest/2.0/users/{user_identifier}/delete+++ endpoint.# +Removing a user from a group removes only the group association; it does not delete the user from ThoughtSpot. To delete a user, use the +++POST /api/rest/2.0/users/{user_identifier}/delete+++ endpoint. -#To get a list of users assigned to a group, use the +++POST /api/rest/2.0/groups/search+++ endpoint.# +To get a list of users assigned to a group, use the +++POST /api/rest/2.0/groups/search+++ endpoint. == Access control (sharing) Access to objects is determined by content *shared* directly to the user or the groups they belong to. It is easier to manage and audit sharing through groups rather than object sharing at the individual user level. -Users can share objects or modify sharing properties through the UI or via an API call to the #`POST /api/rest/2.0/security/metadata/share` endpoint#. +Users can share objects or modify sharing properties through the UI or via an API call to the `POST /api/rest/2.0/security/metadata/share` endpoint. -#Note the following points about sharing objects via REST API:# +Note the following points about sharing objects via REST API: -* #You can share the following types of objects: Liveboards, visualizations, Answers, Models, Views, Connections, and Collections.# -* #To share an object, you must have edit access to the object.# -* #The `share_mode` attribute can be set to `READ_ONLY` (*Can view*), `MODIFY` (*Can edit*), or `NO_ACCESS`. Setting `share_mode` to `NO_ACCESS` removes any access previously granted to a user or group.# -* #To send a share notification to the users and groups with whom the object is shared, set `notify_on_share` to `true`.# -* #To make a shared Liveboard or Answer discoverable, set `has_lenient_discoverability` to `true`.# +* You can share the following types of objects: Liveboards, visualizations, Answers, Models, Views, Connections, and Collections. +* To share an object, you must have edit access to the object. +* The `share_mode` attribute can be set to `READ_ONLY` (*Can view*), `MODIFY` (*Can edit*), or `NO_ACCESS`. Setting `share_mode` to `NO_ACCESS` removes any access previously granted to a user or group. +* To send a share notification to the users and groups with whom the object is shared, set `notify_on_share` to `true`. +* To make a shared Liveboard or Answer discoverable, set `has_lenient_discoverability` to `true`. -#To audit access, use the `POST /api/rest/2.0/security/metadata/fetch-permissions` endpoint to get the users and groups that have access to an object, or the `POST /api/rest/2.0/security/principals/fetch-permissions` endpoint to get the objects a user or group can access.# +To audit access, use the `POST /api/rest/2.0/security/metadata/fetch-permissions` endpoint to get the users and groups that have access to an object, or the `POST /api/rest/2.0/security/principals/fetch-permissions` endpoint to get the objects a user or group can access. -#For more information and examples, see xref:access-control-sharing.adoc[Access control and sharing].# +For more information and examples, see xref:access-control-sharing.adoc[Access control and sharing]. -=== #Sharing a visualization# -#Note the following points about sharing a visualization:# +=== Sharing a visualization +Note the following points about sharing a visualization: -* #Sharing a visualization implicitly shares the entire Liveboard with the recipient.# -* #The object permissions set for a shared visualization also apply to the Liveboard, unless they are overridden by another API request or in the UI.# -* #If email notifications for object sharing are enabled, the recipient receives a notification with a link to the shared visualization. Although this link opens the shared visualization, the recipient can also access other visualizations on the Liveboard.# +* Sharing a visualization implicitly shares the entire Liveboard with the recipient. +* The object permissions set for a shared visualization also apply to the Liveboard, unless they are overridden by another API request or in the UI. +* If email notifications for object sharing are enabled, the recipient receives a notification with a link to the shared visualization. Although this link opens the shared visualization, the recipient can also access other visualizations on the Liveboard. -=== #Sharing a Collection# -#Collections support separate permissions for the Collection and for the objects within it. For more information, see xref:collections.adoc#_share_a_collection[Share a Collection].# +=== Sharing a Collection +Collections support separate permissions for the Collection and for the objects within it. For more information, see xref:collections.adoc#_share_a_collection[Share a Collection]. [#group-privileges] == Group privileges @@ -262,7 +262,7 @@ When creating or updating a group using the REST API v2 endpoints, use the `role [NOTE] ==== -Roles must be available before they can be assigned. #Use the link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Froles%2Fcreate-role[`POST /api/rest/2.0/roles/create`] endpoint# to create roles programmatically before calling the group create or update endpoints. +Roles must be available before they can be assigned. Use the link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Froles%2Fcreate-role[`POST /api/rest/2.0/roles/create`] endpoint to create roles programmatically before calling the group create or update endpoints. ==== For a complete list of available privileges and role categories, see xref:roles.adoc[Role-based access control]. diff --git a/modules/ROOT/pages/authentication.adoc b/modules/ROOT/pages/authentication.adoc index 5b922944c..c310b82e7 100644 --- a/modules/ROOT/pages/authentication.adoc +++ b/modules/ROOT/pages/authentication.adoc @@ -973,15 +973,15 @@ If the API request is successful, ThoughtSpot returns the following response: |=== //// [#switch-org] -== #Switching Org context# -#On ThoughtSpot instances with Orgs, only cluster administrators and users who belong to more than one Org can switch between Orgs.# +== Switching Org context +On ThoughtSpot instances with Orgs, only cluster administrators and users who belong to more than one Org can switch between Orgs. -#For REST API v2 requests, the Org context is determined by the session or token used in the request. To send API requests in a different Org:# +For REST API v2 requests, the Org context is determined by the session or token used in the request. To send API requests in a different Org: -* #For cookie-based authentication, log in again by sending a request to the `/api/rest/2.0/auth/session/login` endpoint with the ID or name of the Org in the `org_identifier` attribute.# -* #For token-based authentication, request a new token with the ID of the Org in the `org_id` attribute, or use a <>.# +* For cookie-based authentication, log in again by sending a request to the `/api/rest/2.0/auth/session/login` endpoint with the ID or name of the Org in the `org_identifier` attribute. +* For token-based authentication, request a new token with the ID of the Org in the `org_id` attribute, or use a <>. -#To view the Org context of the current session and the Orgs available to the user, send a request to the `/api/rest/2.0/auth/session/user` endpoint and check the `current_org` and `orgs` properties in the API response. For more information, see <<_viewing_session_information,Viewing session information>>.# +To view the Org context of the current session and the Orgs available to the user, send a request to the `/api/rest/2.0/auth/session/user` endpoint and check the `current_org` and `orgs` properties in the API response. For more information, see <<_viewing_session_information,Viewing session information>>. == Logging out of a session @@ -997,7 +997,7 @@ curl -X POST \ -H 'Authorization: Bearer {AUTH_TOKEN}' ---- -#If you send the logout request as an AJAX call from a browser with cookie-based authentication, include credentials in the request so that the session cookie is sent. For example, set `xhrFields: { withCredentials: true }` in a jQuery AJAX request, or `credentials: 'include'` in a Fetch API request.# +If you send the logout request as an AJAX call from a browser with cookie-based authentication, include credentials in the request so that the session cookie is sent. For example, set `xhrFields: { withCredentials: true }` in a jQuery AJAX request, or `credentials: 'include'` in a Fetch API request. === API response diff --git a/modules/ROOT/pages/common/nav-embedding.adoc b/modules/ROOT/pages/common/nav-embedding.adoc index 35d18dcd2..e9ea19007 100644 --- a/modules/ROOT/pages/common/nav-embedding.adoc +++ b/modules/ROOT/pages/common/nav-embedding.adoc @@ -22,7 +22,7 @@ Embed ThoughtSpot in a web app ** link:{{navprefix}}/embed-liveboard[Embed a Liveboard] *** link:{{navprefix}}/customize-liveboard-layout[Customize Liveboard layout and UI] *** link:{{navprefix}}/customize-liveboard-filters[Customize Liveboard filters] -*** link:{{navprefix}}/liveboard-data-downloads[Liveboard data refresh and downloads] +*** link:{{navprefix}}/liveboard-data-downloads[Customize data refresh and download options] ** link:{{navprefix}}/spotterViz-agent[SpotterViz AI agent in Liveboards] ** link:{{navprefix}}/embed-a-viz[Embed a visualization] * link:{{navprefix}}/full-embed[Embed full application] diff --git a/modules/ROOT/pages/common/nav-in-product-help.adoc b/modules/ROOT/pages/common/nav-in-product-help.adoc index a7173bacf..86cc82b95 100644 --- a/modules/ROOT/pages/common/nav-in-product-help.adoc +++ b/modules/ROOT/pages/common/nav-in-product-help.adoc @@ -41,7 +41,7 @@ Embed ThoughtSpot in a web app ** link:{{navprefix}}/embed-liveboard[Embed a Liveboard] *** link:{{navprefix}}/customize-liveboard-layout[Customize Liveboard layout and UI] *** link:{{navprefix}}/customize-liveboard-filters[Customize Liveboard filters] -*** link:{{navprefix}}/liveboard-data-downloads[Liveboard data refresh and downloads] +*** link:{{navprefix}}/liveboard-data-downloads[Customize data refresh and download options] ** link:{{navprefix}}/spotterViz-agent[SpotterViz AI agent in Liveboards] ** link:{{navprefix}}/embed-a-viz[Embed a visualization] * link:{{navprefix}}/full-embed[Embed full application] diff --git a/modules/ROOT/pages/connections.adoc b/modules/ROOT/pages/connections.adoc index 99ed03d24..25b3f67cb 100644 --- a/modules/ROOT/pages/connections.adoc +++ b/modules/ROOT/pages/connections.adoc @@ -73,7 +73,7 @@ ThoughtSpot supports connecting to external data warehouses and using these as d This article describes the procedures for setting and managing data connections through the REST APIs. For information about how to set up a data connection, see link:https://docs.thoughtspot.com/cloud/latest/connections[Data Connections in ThoughtSpot, window=_blank]. == Data Connection APIs -The connection API endpoints allow you to create, edit, delete, export, and query data connections on a ThoughtSpot cluster. #For more information about the API endpoints, see https://developers.thoughtspot.com/docs/rest-apiv2-reference#_connections[REST API v2 Connection endpoints, window=_blank].# +The connection API endpoints allow you to create, edit, delete, export, and query data connections on a ThoughtSpot cluster. For more information about the API endpoints, see https://developers.thoughtspot.com/docs/rest-apiv2-reference#_connections[REST API v2 Connection endpoints, window=_blank]. === Required permissions @@ -613,16 +613,16 @@ To create or modify data connections through REST APIs, you need to provide a ma [#export-connection] -== #Export a connection# -#To export a connection, export its TML by using the `POST /api/rest/2.0/metadata/tml/export` endpoint. To include the Tables of the connection in the export, set `export_dependent` to `true`. For more information, see xref:tml-export.adoc[Export TML].# +== Export a connection +To export a connection, export its TML by using the `POST /api/rest/2.0/metadata/tml/export` endpoint. To include the Tables of the connection in the export, set `export_dependent` to `true`. For more information, see xref:tml-export.adoc[Export TML]. [#delete-connection] -== #Delete a connection# -#To delete a connection, use the `POST /api/rest/2.0/connections/{connection_identifier}/delete` endpoint.# +== Delete a connection +To delete a connection, use the `POST /api/rest/2.0/connections/{connection_identifier}/delete` endpoint. [IMPORTANT] ==== -#A connection can be used by multiple Tables, Models, and visualizations. If a connection has dependent objects, remove the dependent objects or their associations before you delete the connection.# +A connection can be used by multiple Tables, Models, and visualizations. If a connection has dependent objects, remove the dependent objects or their associations before you delete the connection. ==== == Integrate with dbt diff --git a/modules/ROOT/pages/customize-liveboard-filters.adoc b/modules/ROOT/pages/customize-liveboard-filters.adoc index 27d7b95a4..c695e2606 100644 --- a/modules/ROOT/pages/customize-liveboard-filters.adoc +++ b/modules/ROOT/pages/customize-liveboard-filters.adoc @@ -6,13 +6,13 @@ :page-pageid: customize-liveboard-filters :page-description: Use the Visual Embed SDK to control filter visibility, apply filters at load time, scope filters to tabs and groups, and update filters on an embedded Liveboard. -To view specific data across the tables and charts on an embedded Liveboard, users can use Liveboard filter options. #The Visual Embed SDK provides properties and host events to control which filter chips are visible, apply filters at load time, scope filters to specific tabs or groups, and update filters programmatically.# +To view specific data across the tables and charts on an embedded Liveboard, users can use Liveboard filter options. The Visual Embed SDK provides properties and host events to control which filter chips are visible, apply filters at load time, scope filters to specific tabs or groups, and update filters programmatically. -#Before you begin, make sure you have xref:embed-pinboard.adoc[embedded a Liveboard] using the `LiveboardEmbed` component.# +Before you begin, make sure you have xref:embed-pinboard.adoc[embedded a Liveboard] using the `LiveboardEmbed` component. [#filter-visibility] == Customizing filter visibility -To hide the entire header area, including filters and the Liveboard menu, set `hideLiveboardHeader` to `true`. You can then trigger filter interactions programmatically using host events. #For more information, see xref:customize-liveboard-layout.adoc#show-hide-ui-elements[Show/hide large UI elements].# +To hide the entire header area, including filters and the Liveboard menu, set `hideLiveboardHeader` to `true`. You can then trigger filter interactions programmatically using host events. For more information, see xref:customize-liveboard-layout.adoc#show-hide-ui-elements[Show/hide large UI elements]. If the Liveboard has multiple tabs and the compact header is enabled, you can set `hideIrrelevantChipsInLiveboardTabs` to `true` to hide filters that are not relevant to the current tab in an embedded Liveboard. Note that `hideIrrelevantChipsInLiveboardTabs` is set to `true` by default on all ThoughtSpot embedded instances using Visual Embed SDK v1.51.0 or later. @@ -31,14 +31,14 @@ When `hideIrrelevantChipsInLiveboardTabs` is `true`: * A *Show irrelevant filters* toggle button appears in the filter row when at least one chip is hidden, allowing users to temporarily reveal all chips. * When the user clicks *Show irrelevant filters*, a complementary *Hide irrelevant filters* button appears to restore the filtered view. -#For more information about the compact header, see xref:customize-liveboard-layout.adoc#compact-header[Enable compact Liveboard header].# +For more information about the compact header, see xref:customize-liveboard-layout.adoc#compact-header[Enable compact Liveboard header]. [#masked-filter-chips] -=== #Masked filter chips# -#If a Liveboard includes filters on columns that a user cannot access because of column-level security, the filter values are masked for that user. Use the `showMaskedFilterChip` property to control whether these masked filter chips are visible:# +=== Masked filter chips +If a Liveboard includes filters on columns that a user cannot access because of column-level security, the filter values are masked for that user. Use the `showMaskedFilterChip` property to control whether these masked filter chips are visible: -* #`true`: the filter chips for inaccessible columns are displayed as masked.# -* #`false`: the filter chips for inaccessible columns are hidden.# +* `true`: the filter chips for inaccessible columns are displayed as masked. +* `false`: the filter chips for inaccessible columns are hidden. [source,JavaScript] ---- @@ -48,10 +48,10 @@ const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { }); ---- -#The `showMaskedFilterChip` property is also available in full application embedding. For more information, see xref:data-security.adoc#csr-liveboards[Column security rules on Liveboards].# +The `showMaskedFilterChip` property is also available in full application embedding. For more information, see xref:data-security.adoc#csr-liveboards[Column security rules on Liveboards]. [#filters-at-load] -== #Apply filters at load time# +== Apply filters at load time By default, Liveboard filters cannot be applied at load. You can either embed a Liveboard that already includes filters or use the xref:runtime-filters.adoc[runtime filters] feature in the Visual Embed SDK to apply filters at load time. [IMPORTANT] @@ -126,9 +126,9 @@ Use the following host events in the Visual Embed SDK to update filters: For more information and examples, see xref:filters_overview.adoc[Filter types and application layers]. -== #Related information# -* #xref:embed-pinboard.adoc[Embed a Liveboard]# -* #xref:customize-liveboard-layout.adoc[Customize Liveboard layout and UI]# -* #xref:liveboard-data-downloads.adoc[Liveboard data refresh and downloads]# -* #xref:runtime-filters.adoc[Runtime filters]# -* #xref:runtime-parameters.adoc[Runtime Parameter overrides]# +== Related information +* xref:embed-pinboard.adoc[Embed a Liveboard] +* xref:customize-liveboard-layout.adoc[Customize Liveboard layout and UI] +* xref:liveboard-data-downloads.adoc[Customize data refresh and download options] +* xref:runtime-filters.adoc[Runtime filters] +* xref:runtime-parameters.adoc[Runtime Parameter overrides] diff --git a/modules/ROOT/pages/customize-liveboard-layout.adoc b/modules/ROOT/pages/customize-liveboard-layout.adoc index bcb535aff..046ee4f14 100644 --- a/modules/ROOT/pages/customize-liveboard-layout.adoc +++ b/modules/ROOT/pages/customize-liveboard-layout.adoc @@ -6,9 +6,9 @@ :page-pageid: customize-liveboard-layout :page-description: Use the LiveboardViewConfig properties in the Visual Embed SDK to customize the header, tabs, tiles, and layout of an embedded Liveboard. -#The xref:LiveboardViewConfig.adoc[LiveboardViewConfig] object includes several properties that control the look and layout of an embedded Liveboard, such as the header, tabs, tile spacing, responsive layout, and grouping and styling of tiles.# +The xref:LiveboardViewConfig.adoc[LiveboardViewConfig] object includes several properties that control the look and layout of an embedded Liveboard, such as the header, tabs, tile spacing, responsive layout, and grouping and styling of tiles. -#Before you begin, make sure you have xref:embed-pinboard.adoc[embedded a Liveboard] using the `LiveboardEmbed` component.# +Before you begin, make sure you have xref:embed-pinboard.adoc[embedded a Liveboard] using the `LiveboardEmbed` component. [#show-hide-ui-elements] == Show/hide large UI elements @@ -29,7 +29,7 @@ const embed = new LiveboardEmbed('#embed', { }); ---- -#When the header is hidden, you can trigger filter interactions programmatically using host events. For more information, see xref:customize-liveboard-filters.adoc#update-filters[Updating filters].# +When the header is hidden, you can trigger filter interactions programmatically using host events. For more information, see xref:customize-liveboard-filters.adoc#update-filters[Updating filters]. The following constructor disables the __sticky header__ and shows the Liveboard title, which is hidden by default: @@ -92,7 +92,7 @@ const liveboardEmbed = new LiveboardEmbed(document.getElementById('ts-embed'), { }); ---- -#To hide filter chips that are not relevant to the active tab in the compact header, see xref:customize-liveboard-filters.adoc#filter-visibility[Customizing filter visibility].# +To hide filter chips that are not relevant to the active tab in the compact header, see xref:customize-liveboard-filters.adoc#filter-visibility[Customizing filter visibility]. [#liveboard-tabs] == Customize Liveboard tabs @@ -121,6 +121,37 @@ The `activeTabId` property is available only in the `LiveboardEmbed` package and For example, a template Liveboard with many different key performance indicators (KPIs) could be reduced to a smaller set by giving a user an interface to select the particular visualizations to show, storing their selections, and using that saved set of visualization globally unique identifiers (GUIDs) as the array for `visibleVizs` on page load (there is an equivalent xref:embed-events.adoc[HostEvent] called `SetVisibleVizs` to make an update after the Liveboard has loaded). +[#noteTiles] +== Add Note tiles +You can add a link:https://docs.thoughtspot.com/cloud/latest/liveboard-notes[Liveboard Note tile, window=_blank] with custom text, images, and links on an embedded Liveboard. + +* Only users with edit access to a Liveboard can add a Note tile. +* Users require `Can upload data` privilege to upload an image to the note tile. +* If you are adding links and images from an external site, or embedding multimedia or a web page in an iFrame, make sure the URLs are added to CORS and CSP allowlists. For more information, see xref:security-settings.adoc[Security settings]. + +[#liveboard-grouping-styling] +== Liveboard grouping and styling + +You can now create a visual group of Answers and note tiles together in the Liveboard. You can select multiple Answers and notes in the Liveboard editor. You can also style parts of the Liveboard, groups and Answers with the new styling panel. +To enable this feature, set `isLiveboardMasterpiecesEnabled` to `true`. +Note the following changes that occur in the Liveboard UI and layout when this feature is enabled. + +* All tiles on the Liveboard now have a default border and an increased border-radius, resulting in more pronounced curved corners. This is part of the broader visualization tile customization options to enhance the visual appearance of charts and tables. +* For Note tiles, the default scrollbar is now hidden for long content. Users must scroll within the tile area itself, and if the tile is not sized appropriately for its content, it may appear clipped due to the new container styles. This change emphasizes the importance of sizing Note tiles correctly to avoid content being visually cut off. +* If a Liveboard is saved with Groups in the ThoughtSpot interface, but the embedding code does not have the grouping and styling feature enabled, the Liveboard fails to load and returns an error. ++ +[.bordered] +[.widthAuto] +-- +image::./images/lb-grp-styling-error.png[Liveboard with groups styling error] +-- ++ +To embed a Liveboard that uses Groups, you must set `isLiveboardMasterpiecesEnabled` to `true` in your embedding configuration. This is required for compatibility with the new grouping and styling features; otherwise, the embedded Liveboard does not render correctly. + +For more information, see link:https://docs.thoughtspot.com/cloud/latest/liveboard-grouping-styling[Liveboard grouping and styling, window=_blank]. + +For information about the CSS variables for this feature, see xref:customize-css-styles.adoc#grp-style[CSS variables reference]. + [#liveboard-gutter] == Set Liveboard tile gutter @@ -154,14 +185,6 @@ The following rules apply to the `liveboardGutter` value: The Liveboard header's horizontal margin is controlled separately by the `--ts-var-liveboard-header-horizontal-margin` CSS variable. To align the Liveboard header and tab/filter section with the tile grid, set both `liveboardGutter` and `--ts-var-liveboard-header-horizontal-margin` to matching values. For more information, see xref:customize-css-styles.adoc#_liveboard[Liveboard CSS variables]. ==== -[#noteTiles] -== Add Note tiles -You can add a link:https://docs.thoughtspot.com/cloud/latest/liveboard-notes[Liveboard Note tile, window=_blank] with custom text, images, and links on an embedded Liveboard. - -* Only users with edit access to a Liveboard can add a Note tile. -* Users require `Can upload data` privilege to upload an image to the note tile. -* If you are adding links and images from an external site, or embedding multimedia or a web page in an iFrame, make sure the URLs are added to CORS and CSP allowlists. For more information, see xref:security-settings.adoc[Security settings]. - [#liveboard-breakpoints] == Redefine Liveboard breakpoint widths The `enable2ColumnLayout` property, when enabled, allows you to customize the Liveboard breakpoint width for embedded users. @@ -216,31 +239,8 @@ For more information about the CSS layout variables available for Liveboard layo see xref:customize-css-styles.adoc#liveboard-layout-vars[Liveboard layout CSS variables]. ==== -[#liveboard-grouping-styling] -== Liveboard grouping and styling - -You can now create a visual group of Answers and note tiles together in the Liveboard. You can select multiple Answers and notes in the Liveboard editor. You can also style parts of the Liveboard, groups and Answers with the new styling panel. -To enable this feature, set `isLiveboardMasterpiecesEnabled` to `true`. -Note the following changes that occur in the Liveboard UI and layout when this feature is enabled. - -* All tiles on the Liveboard now have a default border and an increased border-radius, resulting in more pronounced curved corners. This is part of the broader visualization tile customization options to enhance the visual appearance of charts and tables. -* For Note tiles, the default scrollbar is now hidden for long content. Users must scroll within the tile area itself, and if the tile is not sized appropriately for its content, it may appear clipped due to the new container styles. This change emphasizes the importance of sizing Note tiles correctly to avoid content being visually cut off. -* If a Liveboard is saved with Groups in the ThoughtSpot interface, but the embedding code does not have the grouping and styling feature enabled, the Liveboard fails to load and returns an error. -+ -[.bordered] -[.widthAuto] --- -image::./images/lb-grp-styling-error.png[Liveboard with groups styling error] --- -+ -To embed a Liveboard that uses Groups, you must set `isLiveboardMasterpiecesEnabled` to `true` in your embedding configuration. This is required for compatibility with the new grouping and styling features; otherwise, the embedded Liveboard does not render correctly. - -For more information, see link:https://docs.thoughtspot.com/cloud/latest/liveboard-grouping-styling[Liveboard grouping and styling, window=_blank]. - -For information about the CSS variables for this feature, see xref:customize-css-styles.adoc#grp-style[CSS variables reference]. - -== #Related information# -* #xref:embed-pinboard.adoc[Embed a Liveboard]# -* #xref:customize-liveboard-filters.adoc[Customize Liveboard filters]# -* #xref:liveboard-data-downloads.adoc[Liveboard data refresh and downloads]# -* #xref:LiveboardViewConfig.adoc[LiveboardViewConfig]# +== Related information +* xref:embed-pinboard.adoc[Embed a Liveboard] +* xref:customize-liveboard-filters.adoc[Customize Liveboard filters] +* xref:liveboard-data-downloads.adoc[Customize data refresh and download options] +* xref:LiveboardViewConfig.adoc[LiveboardViewConfig] diff --git a/modules/ROOT/pages/data-apis-v2.adoc b/modules/ROOT/pages/data-apis-v2.adoc index 2670cd006..372019d89 100644 --- a/modules/ROOT/pages/data-apis-v2.adoc +++ b/modules/ROOT/pages/data-apis-v2.adoc @@ -82,7 +82,7 @@ curl -X POST \ }' ---- -#To verify your query string, run an equivalent search in the ThoughtSpot UI search bar and check whether it returns the same data. Note that the search syntax in the UI differs slightly from the query string syntax.# +To verify your query string, run an equivalent search in the ThoughtSpot UI search bar and check whether it returns the same data. Note that the search syntax in the UI differs slightly from the query string syntax. //// === Using tokens generated from Spotter APIs as raw data @@ -115,7 +115,7 @@ If the API request is successful, ThoughtSpot returns the Answer data for the qu == Fetch Liveboard Data API To get data from a Liveboard object and its visualizations via `POST /api/rest/2.0/metadata/liveboard/data` endpoint, your user account must have at least view access to the Liveboard specified in the API request. -The API request body must include the name or GUID of the Liveboard to fetch data. To get specific visualizations from a given Liveboard, add the names or GUIDs of the visualizations in the `visualization_identifiers` array. #If `visualization_identifiers` is not specified, the API returns data for all visualizations on the Liveboard.# +The API request body must include the name or GUID of the Liveboard to fetch data. To get specific visualizations from a given Liveboard, add the names or GUIDs of the visualizations in the `visualization_identifiers` array. If `visualization_identifiers` is not specified, the API returns data for all visualizations on the Liveboard. === Liveboard data request example [source,cURL] diff --git a/modules/ROOT/pages/data-security.adoc b/modules/ROOT/pages/data-security.adoc index d1d997f7c..d9662010a 100644 --- a/modules/ROOT/pages/data-security.adoc +++ b/modules/ROOT/pages/data-security.adoc @@ -34,6 +34,6 @@ Starting with ThoughtSpot Cloud 26.10.0.cl, Liveboards that include columns rest * Filter chips from columns that the user cannot access are masked instead of blocking the Liveboard. Only one masked filter chip is shown, at the end of the filter bar, to indicate that one or more filters are inaccessible. For example, if five filters are inaccessible, the Liveboard still shows a single masked filter chip. * Scheduled Liveboard deliveries apply CSR separately for each recipient. -To control whether masked filter chips are visible in an embedded Liveboard, use the `showMaskedFilterChip` SDK property. For more information, see #xref:customize-liveboard-filters.adoc#masked-filter-chips[Masked filter chips]#. +To control whether masked filter chips are visible in an embedded Liveboard, use the `showMaskedFilterChip` SDK property. For more information, see xref:customize-liveboard-filters.adoc#masked-filter-chips[Masked filter chips]. For more information, see link:https://docs.thoughtspot.com/cloud/latest/security-data-object#csr-liveboard[Column security rules on Liveboards, window=_blank]. diff --git a/modules/ROOT/pages/embed-pinboard.adoc b/modules/ROOT/pages/embed-pinboard.adoc index 5033c55a4..2664f383a 100644 --- a/modules/ROOT/pages/embed-pinboard.adoc +++ b/modules/ROOT/pages/embed-pinboard.adoc @@ -67,7 +67,7 @@ For more information about the Liveboard embed object, classes, methods, interfa The `LiveboardEmbed` component includes the `xref:LiveboardViewConfig.adoc[LiveboardViewConfig]` object. This object provides various attributes and properties to xref:css-customization.adoc[customize the look and feel of the Liveboard page], xref:embed-actions.adoc[control the visibility of menu actions] and features, and xref:embed-events.adoc[manage interactions between the host and embedded app]. -The most common customization is controlling the visibility of menu items in the embedded view by configuring the `disabledActions`, `hiddenActions`, and `visibleActions` properties with an array of xref:Action.adoc[Action] IDs. For information about the other frequently used properties, see #<>#. +The most common customization is controlling the visibility of menu items in the embedded view by configuring the `disabledActions`, `hiddenActions`, and `visibleActions` properties with an array of xref:Action.adoc[Action] IDs. For information about the other frequently used properties, see <>. == Register, handle, and trigger events @@ -106,22 +106,22 @@ image::./images/embed-lb.png[Liveboard embed] == Common customization options The *xref:LiveboardViewConfig.adoc[LiveboardViewConfig]* object includes several properties and attributes that allow fine-grained control of the embedded experience. You can specify settings that enable or disable a specific feature, control visible or disabled menu elements and actions, set tabs and layout preferences, manage filters, hide the header, and more. -#The customization options are grouped into the following pages:# +The customization options are grouped into the following pages: [width="100%",cols="2,4",options="header"] |==== -|#Page# |#Customization impact# -|#xref:customize-liveboard-layout.adoc[Customize Liveboard layout and UI]# -|#Header visibility and compact header, tabs, visible tabs and visualizations, tile gutter, Note tiles, responsive breakpoints and 12-column layout, and grouping and styling.# -|#xref:customize-liveboard-filters.adoc[Customize Liveboard filters]# -|#Filter chip visibility, masked filter chips, filters at load time, contextual filtering at the Liveboard, tab, and group levels, and updating filters with host events.# -|#xref:liveboard-data-downloads.adoc[Liveboard data refresh and downloads]# -|#Browser data cache and refresh, PDF, XLSX, and CSV downloads, download action IDs and host events, and scheduled exports.# +|Page |Customization impact +|xref:customize-liveboard-layout.adoc[Customize Liveboard layout and UI] +|Header visibility and compact header, tabs, visible tabs and visualizations, tile gutter, Note tiles, responsive breakpoints and 12-column layout, and grouping and styling. +|xref:customize-liveboard-filters.adoc[Customize Liveboard filters] +|Filter chip visibility, masked filter chips, filters at load time, contextual filtering at the Liveboard, tab, and group levels, and updating filters with host events. +|xref:liveboard-data-downloads.adoc[Customize data refresh and download options] +|Browser data cache and refresh, PDF, XLSX, and CSV downloads, download action IDs and host events, and scheduled exports. |==== [IMPORTANT] ==== -#If a Liveboard is saved with Groups in the ThoughtSpot interface, you must set `isLiveboardMasterpiecesEnabled` to `true` in your embed configuration. Otherwise, the embedded Liveboard fails to load. For more information, see xref:customize-liveboard-layout.adoc#liveboard-grouping-styling[Liveboard grouping and styling].# +If a Liveboard is saved with Groups in the ThoughtSpot interface, you must set `isLiveboardMasterpiecesEnabled` to `true` in your embed configuration. Otherwise, the embedded Liveboard fails to load. For more information, see xref:customize-liveboard-layout.adoc#liveboard-grouping-styling[Liveboard grouping and styling]. ==== == Additional resources diff --git a/modules/ROOT/pages/filters_overview.adoc b/modules/ROOT/pages/filters_overview.adoc index a0faaad6b..3a2f51c72 100644 --- a/modules/ROOT/pages/filters_overview.adoc +++ b/modules/ROOT/pages/filters_overview.adoc @@ -36,7 +36,7 @@ xref:runtime-filters.adoc#_maximum_filter_count[Runtime filter limit] for more i ==== 4. link:https://docs.thoughtspot.com/cloud/latest/liveboard-filters[Liveboard filters, window=_blank] + -Liveboard filters are visible as UI components at the top of a Liveboard page. #By default, a Liveboard filter applies to all visualizations on the Liveboard. Starting with ThoughtSpot Cloud 26.10.0.cl, a filter can also be scoped to a specific tab or group. For more information, see <>.# When a filter is clicked, a modal with filter options appropriate for the data type is displayed. + +Liveboard filters are visible as UI components at the top of a Liveboard page. By default, a Liveboard filter applies to all visualizations on the Liveboard. Starting with ThoughtSpot Cloud 26.10.0.cl, a filter can also be scoped to a specific tab or group. For more information, see <>. When a filter is clicked, a modal with filter options appropriate for the data type is displayed. + Liveboard users can add or modify filters as needed. If you are embedding a Liveboard that includes preset filters, you can programmatically update, reset, or remove filters using `HostEvent.UpdateFilters`. 5. link:https://docs.thoughtspot.com/cloud/latest/liveboard-filters-cross[Liveboard cross filters, window=_blank] + diff --git a/modules/ROOT/pages/liveboard-data-downloads.adoc b/modules/ROOT/pages/liveboard-data-downloads.adoc index a393bfb7e..6b02d6e71 100644 --- a/modules/ROOT/pages/liveboard-data-downloads.adoc +++ b/modules/ROOT/pages/liveboard-data-downloads.adoc @@ -1,14 +1,14 @@ -= Liveboard data refresh and downloads += Customize data refresh and download options :toc: true :toclevels: 2 -:page-title: Liveboard data refresh and downloads +:page-title: Customize data refresh and download options :page-pageid: liveboard-data-downloads :page-description: Use the Visual Embed SDK to enable browser data caching and refresh, and to configure PDF, XLSX, and CSV downloads and scheduled exports on an embedded Liveboard. -#The Visual Embed SDK provides properties, action IDs, and events to control how users refresh Liveboard data and download or export Liveboards from the embedded view.# +The Visual Embed SDK provides properties, action IDs, and events to control how users refresh Liveboard data and download or export Liveboards from the embedded view. -#Before you begin, make sure you have xref:embed-pinboard.adoc[embedded a Liveboard] using the `LiveboardEmbed` component.# +Before you begin, make sure you have xref:embed-pinboard.adoc[embedded a Liveboard] using the `LiveboardEmbed` component. [#liveboard-data-cache] == Liveboard browser cache and refresh @@ -164,9 +164,9 @@ const embed = new LiveboardEmbed('#tsEmbed', { }); ---- -== #Related information# -* #xref:embed-pinboard.adoc[Embed a Liveboard]# -* #xref:customize-liveboard-layout.adoc[Customize Liveboard layout and UI]# -* #xref:customize-liveboard-filters.adoc[Customize Liveboard filters]# -* #xref:Action.adoc[Actions]# -* #xref:HostEvent.adoc[HostEvent]# +== Related information +* xref:embed-pinboard.adoc[Embed a Liveboard] +* xref:customize-liveboard-layout.adoc[Customize Liveboard layout and UI] +* xref:customize-liveboard-filters.adoc[Customize Liveboard filters] +* xref:Action.adoc[Actions] +* xref:HostEvent.adoc[HostEvent] diff --git a/modules/ROOT/pages/org-manage-api.adoc b/modules/ROOT/pages/org-manage-api.adoc index 06644b07e..304732c25 100644 --- a/modules/ROOT/pages/org-manage-api.adoc +++ b/modules/ROOT/pages/org-manage-api.adoc @@ -17,9 +17,9 @@ include::{path}/rest-apiv1-deprecation-note.adoc[] If you do not see the Orgs feature enabled on your instance, contact ThoughtSpot Support. . Log in to your application instance as an administrator. -. Create Orgs in the *Admin* page of the UI or via #the link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Forgs%2Fcreate-org[`POST /api/rest/2.0/orgs/create`] endpoint#. +. Create Orgs in the *Admin* page of the UI or via the link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Forgs%2Fcreate-org[`POST /api/rest/2.0/orgs/create`] endpoint. + -To create and manage Orgs, you must set the Org context to `All`. To do this, you can switch to the *All Orgs* tab in the Admin page of UI or pass the Org scope `ALL` in your API requests to Orgs API endpoints. #For more information, see <<_create_and_manage_orgs,Org management operations>>.# +To create and manage Orgs, you must set the Org context to `All`. To do this, you can switch to the *All Orgs* tab in the Admin page of UI or pass the Org scope `ALL` in your API requests to Orgs API endpoints. For more information, see <<_create_and_manage_orgs,Org management operations>>. . Create users and map the users to Orgs. + You can create an administrator profile for each Org and let these Org administrators manage users, groups, and role privileges in their respective Orgs. @@ -35,25 +35,25 @@ To create an Org on a multi-tenant ThoughtSpot instance, you need cluster admini === REST API v2.0 endpoints -#For Orgs CRUD operations, use the following REST API v2.0 Org endpoints:# +For Orgs CRUD operations, use the following REST API v2.0 Org endpoints: * +++/api/rest/2.0/orgs/create+++ * +++ /api/rest/2.0/orgs/search+++ * +++/api/rest/2.0/orgs/{org_identifier}/update +++ * +++ /api/rest/2.0/orgs/{org_identifier}/delete+++ -#Note the following points about Org names and descriptions:# +Note the following points about Org names and descriptions: -* #The Org name must be unique and must not exceed 48 characters. The name can contain only letters (a-z, A-Z), digits (0-9), and hyphens (-). Spaces and other special characters are not supported.# -* #The Org description must not exceed 124 characters.# +* The Org name must be unique and must not exceed 48 characters. The name can contain only letters (a-z, A-Z), digits (0-9), and hyphens (-). Spaces and other special characters are not supported. +* The Org description must not exceed 124 characters. -=== #Deleting an Org# +=== Deleting an Org [NOTE] ==== -* #ThoughtSpot does not allow deleting the Primary Org (Org 0).# -* #When an Org is deleted, the Org and its objects become inaccessible immediately. However, the objects remain in the ThoughtSpot system for seven days from the deletion date.# -* #To restore a deleted Org, contact ThoughtSpot Support within seven days of deletion.# -* #To remove the objects of a deleted Org before the seven-day period ends, or to reuse a deleted Org name or object names immediately after deletion, contact ThoughtSpot Support.# +* ThoughtSpot does not allow deleting the Primary Org (Org 0). +* When an Org is deleted, the Org and its objects become inaccessible immediately. However, the objects remain in the ThoughtSpot system for seven days from the deletion date. +* To restore a deleted Org, contact ThoughtSpot Support within seven days of deletion. +* To remove the objects of a deleted Org before the seven-day period ends, or to reuse a deleted Org name or object names immediately after deletion, contact ThoughtSpot Support. ==== == Manage users and groups @@ -62,13 +62,13 @@ On a multi-tenant instance, the cluster and Org administrators can create users [NOTE] ==== -#For REST API v2 operations, the Org context is determined based on the authentication token used by the user in their API requests. Users must log in to the appropriate Org context from which they want to send API requests.# +For REST API v2 operations, the Org context is determined based on the authentication token used by the user in their API requests. Users must log in to the appropriate Org context from which they want to send API requests. ==== [width="100%" cols="5,15"] [options='header'] |===== -|#Operation type#|#REST API v2 endpoints# +|Operation type|REST API v2 endpoints |Authentication a| **Basic authentication** + To log in to a specific Org context with basic authentication, specify the Org ID in the +++ /api/rest/2.0/auth/session/login +++ API request. diff --git a/modules/ROOT/pages/report-apis-v2.adoc b/modules/ROOT/pages/report-apis-v2.adoc index b7705e344..0d8764a8e 100644 --- a/modules/ROOT/pages/report-apis-v2.adoc +++ b/modules/ROOT/pages/report-apis-v2.adoc @@ -95,14 +95,14 @@ curl -X POST \ For *PDF* downloads, you can specify additional parameters to customize the page orientation and include or exclude the cover page, logo, footer text, and page numbers. -#The following `pdf_options` attributes determine the content of the PDF:# - -* #`include_cover_page`: Adds a cover page with the Liveboard title.# -* #`include_filter_page`: Adds a page that lists the filters applied to the Liveboard.# -* #`include_custom_logo`: Adds the custom wide logo, if one is configured for your instance, to the footer of each page.# -* #`page_footer_text`: Adds the specified text to the footer of each page.# -* #`include_page_number`: Adds the page number to the footer of each page.# -* #`truncate_table`: Includes only the first page of each table visualization in the PDF.# +The following `pdf_options` attributes determine the content of the PDF: + +* `include_cover_page`: Adds a cover page with the Liveboard title. +* `include_filter_page`: Adds a page that lists the filters applied to the Liveboard. +* `include_custom_logo`: Adds the custom wide logo, if one is configured for your instance, to the footer of each page. +* `page_footer_text`: Adds the specified text to the footer of each page. +* `include_page_number`: Adds the page number to the footer of each page. +* `truncate_table`: Includes only the first page of each table visualization in the PDF. You can now also download continuous pdfs which matches the full length of your Liveboard, without breaking them into multiple A4 pages. diff --git a/modules/ROOT/pages/rest-api-v2-metadata-search.adoc b/modules/ROOT/pages/rest-api-v2-metadata-search.adoc index 857815c4f..40ee5ec97 100644 --- a/modules/ROOT/pages/rest-api-v2-metadata-search.adoc +++ b/modules/ROOT/pages/rest-api-v2-metadata-search.adoc @@ -3574,12 +3574,12 @@ If you need multiple levels of sorting, you'll have to parse the response progra === permissions You can filter responses based on who can access the object, that is, who the content has been shared to, using the `permissions` parameter. -While the `permissions` parameter filters the response set of `metadata/search`, the objects in the response do not list the full set of assigned permissions. Please use the `link:https://developers.thoughtspot.com/docs/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fsecurity%2Ffetch-permissions-on-metadata[/security/metadata/fetch-permissions/`, target=_blank]` endpoint #for a full listing.# +While the `permissions` parameter filters the response set of `metadata/search`, the objects in the response do not list the full set of assigned permissions. Please use the `link:https://developers.thoughtspot.com/docs/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fsecurity%2Ffetch-permissions-on-metadata[/security/metadata/fetch-permissions/`, target=_blank]` endpoint for a full listing. -#The `/security/metadata/fetch-permissions` endpoint supports the following permission types in the `permission_type` attribute:# +The `/security/metadata/fetch-permissions` endpoint supports the following permission types in the `permission_type` attribute: -* #`DEFINED`: Returns the access granted to users and groups through object sharing.# -* #`EFFECTIVE`: Returns the access that users have, including the access granted through the privileges of the groups to which they belong.# +* `DEFINED`: Returns the access granted to users and groups through object sharing. +* `EFFECTIVE`: Returns the access that users have, including the access granted through the privileges of the groups to which they belong. The `permissions` object takes an array of objects that define a `principal` and a `link:https://developers.thoughtspot.com/docs/restV2-playground?apiResourceId=http%2Fmodels%2Fenumerations%2Fshare-mode[share_mode, target=_blank]`. `principal` is an object with a `type` of `USER` or `USER_GROUP` and an `identifier`, either the name or the GUID of the principal: @@ -3700,10 +3700,10 @@ The `exclude_objects` parameter takes an array of objects that can exclude items ---- == Additional response details -There are a number of parameters that add new data to the response, allowing the `metadata/search` endpoint #to return related details in a single API call.# +There are a number of parameters that add new data to the response, allowing the `metadata/search` endpoint to return related details in a single API call. === include_visualization_headers -#The `include_visualization_headers` parameter adds the header details of all individual visualizations on a Liveboard to the response.# +The `include_visualization_headers` parameter adds the header details of all individual visualizations on a Liveboard to the response. [source,JSON] ---- @@ -3722,7 +3722,7 @@ Note from above that the response to **every** request has the `visualization_he The `include_visualization_headers` adds the array of visualization headers for every element in the response, so you can request a list of all Liveboards and all the visualizations on those Liveboards all at once. === include_details -The `include_details` parameter in the `metadata/search` API request adds #a detailed object, with as much detail as possible about each requested object and its relationships with other objects within ThoughtSpot,# to each element retrieved by `metadata/search` to the response under the `metadata_detail` key. +The `include_details` parameter in the `metadata/search` API request adds a detailed object, with as much detail as possible about each requested object and its relationships with other objects within ThoughtSpot, to each element retrieved by `metadata/search` to the response under the `metadata_detail` key. [source,JSON] ---- @@ -3831,11 +3831,11 @@ Data objects in ThoughtSpot like Tables and Worksheets have **dependent objects* An object can only be deleted if all of its dependent objects are deleted first. -#A dependency is a relationship between a referenced object and the objects that reference it. For example, consider a Model (`Model1`) with a formula column (`Column1`) that refers to a base column (`Column2`). In this example, `Model1` is a referencing object and `Column2` is a referenced object. You cannot delete `Column2` until you delete or modify `Column1` and `Model1`.# +A dependency is a relationship between a referenced object and the objects that reference it. For example, consider a Model (`Model1`) with a formula column (`Column1`) that refers to a base column (`Column2`). In this example, `Model1` is a referencing object and `Column2` is a referenced object. You cannot delete `Column2` until you delete or modify `Column1` and `Model1`. -#Before you delete a data object such as a Table, Model, or column by using the `POST /api/rest/2.0/metadata/delete` endpoint, check whether the object has dependent objects, and remove the dependent objects or their references first.# +Before you delete a data object such as a Table, Model, or column by using the `POST /api/rest/2.0/metadata/delete` endpoint, check whether the object has dependent objects, and remove the dependent objects or their references first. -#To retrieve these relationships from `metadata/search`, set the `include_dependent_objects` parameter to `true`:# +To retrieve these relationships from `metadata/search`, set the `include_dependent_objects` parameter to `true`: [source,JSON] ---- diff --git a/modules/ROOT/pages/rest-api-v2-search.adoc b/modules/ROOT/pages/rest-api-v2-search.adoc index f0de4a393..33293e1d4 100644 --- a/modules/ROOT/pages/rest-api-v2-search.adoc +++ b/modules/ROOT/pages/rest-api-v2-search.adoc @@ -8,7 +8,7 @@ The REST API v2 endpoints that end in `/search` have very flexible request formats that solve a number of use cases. -#A small set of `/search` endpoints covers many capabilities, depending on the request parameters you send.# +A small set of `/search` endpoints covers many capabilities, depending on the request parameters you send. Note that many parameters in the requests are *optional*, and leaving the parameter name out entirely has a different effect from including the parameter and specific values. @@ -25,7 +25,7 @@ Assume every request you see has the following parameters in addition to what is ---- == Search metadata -#The `/metadata/search` endpoint retrieves metadata objects and their details, such as headers, permissions, and dependent objects.# +The `/metadata/search` endpoint retrieves metadata objects and their details, such as headers, permissions, and dependent objects. For more information about this API, xref:rest-api-v2-metadata-search.adoc[Search metadata]. diff --git a/modules/ROOT/pages/rest-api-v2-users-search.adoc b/modules/ROOT/pages/rest-api-v2-users-search.adoc index 7ad9e269c..563415154 100644 --- a/modules/ROOT/pages/rest-api-v2-users-search.adoc +++ b/modules/ROOT/pages/rest-api-v2-users-search.adoc @@ -252,7 +252,7 @@ The `notify_on_share`, `show_onboarding_experience`, and `onboarding_experience_ Note that the *absence* of the parameter is how you remove the filtering. If the parameter value is present as a key, the value must be set to `true` or `false`, so that the response can be filtered to get only those users that match the condition. == Additional response details -There are a number of parameters that add new data to the response, allowing the `metadata/search` endpoint #to return related details in a single API call.# +There are a number of parameters that add new data to the response, allowing the `metadata/search` endpoint to return related details in a single API call. === include_favorite_metadata The `user` object in the response always includes a `favorite_metadata` key, but it will always be an empty array unless the `include_favorite_metadata` parameter is added to the request set to `true`: diff --git a/modules/ROOT/pages/roles.adoc b/modules/ROOT/pages/roles.adoc index 1e0883bc7..992e60f1c 100644 --- a/modules/ROOT/pages/roles.adoc +++ b/modules/ROOT/pages/roles.adoc @@ -196,9 +196,9 @@ UI: **Can download Data**| Allows users to download data from objects such as Li == How to create and assign Roles -You can create and assign Roles to a group on the link:https://docs.thoughtspot.com/cloud/latest/rbac[Admin page of the UI, window=_blank] or by using the #REST API v2 endpoints#. +You can create and assign Roles to a group on the link:https://docs.thoughtspot.com/cloud/latest/rbac[Admin page of the UI, window=_blank] or by using the REST API v2 endpoints. -#To create and manage Roles, you need the `ROLE_ADMINISTRATION` (*Can manage roles*) privilege. To assign Roles to a group, you need both the `GROUP_ADMINISTRATION` and `ROLE_ADMINISTRATION` privileges.# +To create and manage Roles, you need the `ROLE_ADMINISTRATION` (*Can manage roles*) privilege. To assign Roles to a group, you need both the `GROUP_ADMINISTRATION` and `ROLE_ADMINISTRATION` privileges. === REST API v2 endpoints for Role administration and assignment @@ -222,8 +222,8 @@ Delete a Role object * link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fgroups%2Fupdate-user-group[`POST /api/rest/2.0/groups/{group_identifier}/update`] |Object query a| * link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Froles%2Fsearch-roles[`POST /api/rest/2.0/roles/search`] + -To get Roles assigned to specific groups, specify the name or GUID of the #group# in the `group_identifiers` attribute. + -Similarly, to search for Roles configured in an Org, specify the name or the GUID of the Org in the `org_identifiers` attribute. #If `org_identifiers` is not specified, the API returns the Roles for the Org context from which the API request is sent.# +To get Roles assigned to specific groups, specify the name or GUID of the group in the `group_identifiers` attribute. + +Similarly, to search for Roles configured in an Org, specify the name or the GUID of the Org in the `org_identifiers` attribute. If `org_identifiers` is not specified, the API returns the Roles for the Org context from which the API request is sent. * link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fgroups%2Fsearch-user-groups[`POST /api/rest/2.0/groups/search`] + To filter group objects assigned to a particular Role, specify the name or GUID of the Role in the `role_identifiers` attribute. * link:{{navprefix}}/restV2-playground?apiResourceId=http%2Fapi-endpoints%2Fusers%2Fsearch-users[`POST /api/rest/2.0/users/search`] + diff --git a/modules/ROOT/pages/tml-export.adoc b/modules/ROOT/pages/tml-export.adoc index 60bc3c777..51aa94772 100644 --- a/modules/ROOT/pages/tml-export.adoc +++ b/modules/ROOT/pages/tml-export.adoc @@ -34,7 +34,7 @@ When set to `true`, exports the associated objects for the `export_ids` specifie * `export_dependent` + Specifies if the Tables of the referenced Connection object must be included in the export. * `export_connection_as_dependent` + -Specifies if a Connection object must be included as a dependent object when exporting a Table, Model, Answer, or Liveboard TML. #This attribute works only when `export_associated` is set to `true`.# +Specifies if a Connection object must be included as a dependent object when exporting a Table, Model, Answer, or Liveboard TML. This attribute works only when `export_associated` is set to `true`. == Export FQNs diff --git a/modules/ROOT/pages/tml-import.adoc b/modules/ROOT/pages/tml-import.adoc index 1e13480b3..ceec8f09e 100644 --- a/modules/ROOT/pages/tml-import.adoc +++ b/modules/ROOT/pages/tml-import.adoc @@ -27,8 +27,8 @@ To import TML representation of the metadata objects into ThoughtSpot, use the f There are multiple kinds of imports possible: -* `PARTIAL` imports all objects that validate successfully, and ignores objects that do not validate successfully #and the objects that depend on them#. -* `ALL_OR_NONE` #imports all objects only if all objects validate successfully. If the import fails for one object, no objects are imported.# +* `PARTIAL` imports all objects that validate successfully, and ignores objects that do not validate successfully and the objects that depend on them. +* `ALL_OR_NONE` imports all objects only if all objects validate successfully. If the import fails for one object, no objects are imported. * `VALIDATE_ONLY` validates the objects but does not import them. * `PARTIAL_OBJECT` imports objects that validate successfully and skips the objects that do not validate successfully. If the import fails for a visualization object in a Liveboard TML, the Liveboard will be imported without that visualization object. Similarly, if importing a relationship in a logical table fails, the table TML will be imported with warnings in the API response. @@ -279,8 +279,8 @@ You can import TML objects asynchronously by scheduling TML import tasks via `PO |Form parameter|Description| Default |`metadata_tmls` |__Array of strings__. Array of the TML strings. |__None__ |`create_new` + -__Optional__ |__Boolean__. To create TML objects with new GUIDs during import, specify `true`. By default, ThoughtSpot updates the existing objects that have the same GUID as the objects you are importing. #When set to `true`, a `guid` in the imported TML that is already used by another object is replaced with a new GUID. If the `guid` in the imported TML is not used by any other object, ThoughtSpot creates the new object with that `guid`.# | `false` -|#`all_orgs_override`# + +__Optional__ |__Boolean__. To create TML objects with new GUIDs during import, specify `true`. By default, ThoughtSpot updates the existing objects that have the same GUID as the objects you are importing. When set to `true`, a `guid` in the imported TML that is already used by another object is replaced with a new GUID. If the `guid` in the imported TML is not used by any other object, ThoughtSpot creates the new object with that `guid`. | `false` +|`all_orgs_override` + __Optional__ |__Boolean__. Specify if import operation must be run for all Orgs on your instance. __Requires Org administration privileges to access TML objects across all Orgs.__| `false` diff --git a/modules/ROOT/pages/whats-new-prev-history.adoc b/modules/ROOT/pages/whats-new-prev-history.adoc index eefffcd92..6cf22e9a4 100644 --- a/modules/ROOT/pages/whats-new-prev-history.adoc +++ b/modules/ROOT/pages/whats-new-prev-history.adoc @@ -736,7 +736,7 @@ For more information, see xref:selective-user-access.adoc[User access to non-emb ==== You can now customize the width of the Liveboard breakpoint on an embedded instance. To enable it, you must set the `enable2ColumnLayout` property to `true`. -For more information, see #xref:customize-liveboard-layout.adoc#liveboard-breakpoints[Redefine Liveboard breakpoint widths]#. +For more information, see xref:customize-liveboard-layout.adoc#liveboard-breakpoints[Redefine Liveboard breakpoint widths]. ==== .Visual Embed SDK @@ -1389,7 +1389,7 @@ We recommend using a development environment to test your customizations before ==== In the new Liveboard experience mode, you can now add Note tiles with custom text, images, and links. This feature is turned off by default and can be enabled by ThoughtSpot administrators. -For more information, see #xref:customize-liveboard-layout.adoc#noteTiles[Note tiles]#. +For more information, see xref:customize-liveboard-layout.adoc#noteTiles[Note tiles]. ==== .Visual Embed SDK [%collapsible] @@ -1407,7 +1407,7 @@ For information about REST API enhancements, see xref:rest-apiv1-changelog.adoc[ .New Liveboard experience [%collapsible] ==== -The #xref:embed-pinboard.adoc[new Liveboard experience]# is now Generally Available (GA) on embedded instances and is enabled by default. +The xref:embed-pinboard.adoc[new Liveboard experience] is now Generally Available (GA) on embedded instances and is enabled by default. ==== .New CSS variables for custom styling @@ -1605,7 +1605,7 @@ For more information, see xref:fixed-issues.adoc[Fixed issues]. ==== The new Liveboard experience[beta betaBackground]^Beta^ now supports organizing visualizations in tabs. Users with edit access to a Liveboard can add, edit, and move visualizations to tabs on a Liveboard. On embedded ThoughtSpot instances, developers can set a specific tab as an active tab using the Visual Embed SDK. -This feature is available only on deployments that have the new Liveboard experience enabled. For more information, see #xref:customize-liveboard-layout.adoc#liveboard-tabs[Customize Liveboard tabs]#. +This feature is available only on deployments that have the new Liveboard experience enabled. For more information, see xref:customize-liveboard-layout.adoc#liveboard-tabs[Customize Liveboard tabs]. [IMPORTANT] The new Liveboard experience is in BETA on embedded ThoughtSpot instances. This feature is turned off by default on embedded ThoughtSpot instances. @@ -1665,7 +1665,7 @@ The new Liveboard experience is currently available in BETA on embedded ThoughtS In ThoughtSpot Cloud 8.6.0.cl:: -The new Liveboard experience is turned off by default on embedded ThoughtSpot instances. If you are using the Visual Embed SDK to embed ThoughtSpot, you can #xref:embed-pinboard.adoc[set the `liveboardV2` parameter]# in the SDK package to `true` and enable the new experience globally for all users on your instance. + +The new Liveboard experience is turned off by default on embedded ThoughtSpot instances. If you are using the Visual Embed SDK to embed ThoughtSpot, you can xref:embed-pinboard.adoc[set the `liveboardV2` parameter] in the SDK package to `true` and enable the new experience globally for all users on your instance. + + ThoughtSpot users with administrator privileges can also link:https://docs.thoughtspot.com/cloud/latest/liveboard-experience-new[turn on the new Liveboard experience, window=_blank] at the cluster level. diff --git a/modules/ROOT/pages/whats-new.adoc b/modules/ROOT/pages/whats-new.adoc index bc37f9aa8..da24307d4 100644 --- a/modules/ROOT/pages/whats-new.adoc +++ b/modules/ROOT/pages/whats-new.adoc @@ -64,7 +64,7 @@ ThoughtSpot now supports programmatic feature management with new REST API v2 en ==== Liveboard enhancements * *Contextual filtering in Liveboards* [earlyAccess eaBackground]#Early Access# + -Liveboard filters now have a three-tier filter hierarchy: Liveboard level, tab level, and group level. You can enable this feature in embedded Liveboards using the `isScopedLiveboardFilteringEnabled` parameter in the SDK. For more information, see #xref:customize-liveboard-filters.adoc#contextual-liveboard-filtering[Contextual filtering in Liveboards]#. +Liveboard filters now have a three-tier filter hierarchy : Liveboard level, tab level, and group level. You can enable this feature in embedded Liveboards using the `isScopedLiveboardFilteringEnabled` parameter in the SDK. For more information, see xref:customize-liveboard-filters.adoc#contextual-liveboard-filtering[Contextual filtering in Liveboards]. * *Host Events* + @@ -227,13 +227,13 @@ Embedded Spotter interface supports onboarding starter prompts to guide first-ti The following features, previously in Early Access, are now generally available and enabled by default on ThoughtSpot Embedded instances: * Hide irrelevant filters (`hideIrrelevantChipsInLiveboardTabs`) + -#xref:customize-liveboard-filters.adoc#filter-visibility[Hides filters]# that are not relevant to the displayed visualization in a tab. +xref:customize-liveboard-filters.adoc#filter-visibility[Hides filters] that are not relevant to the displayed visualization in a tab. * Compact header (`isLiveboardCompactHeaderEnabled`) + -Enables in compact header in embedded Liveboards. For information about breaking changes and the affected elements, see #xref:customize-liveboard-layout.adoc#compact-header[compact Liveboard header]#. +Enables in compact header in embedded Liveboards. For information about breaking changes and the affected elements, see xref:customize-liveboard-layout.adoc#compact-header[compact Liveboard header]. * Cover page filtering options (`coverAndFilterOptionInPDF`) + Enables the *Include cover page* and *Include filter page(s)* checkboxes in the Liveboard download modal. * Liveboard styling and grouping (isLiveboardMasterpiecesEnabled) + -Enables the #xref:customize-liveboard-layout.adoc#liveboard-grouping-styling[Liveboard styling and grouping]# feature. +Enables the xref:customize-liveboard-layout.adoc#liveboard-grouping-styling[Liveboard styling and grouping] feature. * Filter interactivity (`isEnhancedFilterInteractivityEnabled`) + Enables interactive filter chips that allow users to add, update, or remove filters in an embedded Liveboard. @@ -500,7 +500,7 @@ Embedded Liveboards can now be downloaded in the PDF, XLSX and CSV file formats. Excel exports for pivot tables:: Pivot table visualizations can now be exported to Excel format. -For more information, see #xref:liveboard-data-downloads.adoc#liveboard-download-options[Liveboard download options]#. +For more information, see xref:liveboard-data-downloads.adoc#liveboard-download-options[Liveboard download options]. --- From e755b4f8ab01377be0d9ddbac443dc16bc6b7047 Mon Sep 17 00:00:00 2001 From: ShashiSubramanya <76986173+ShashiSubramanya@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:13:32 +0530 Subject: [PATCH 14/57] Update doc-configs.js --- src/configs/doc-configs.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/configs/doc-configs.js b/src/configs/doc-configs.js index 99c3cd8e7..0e414264c 100644 --- a/src/configs/doc-configs.js +++ b/src/configs/doc-configs.js @@ -56,8 +56,8 @@ module.exports = { { label: '26.9.0.cl', link: '26.9.0.cl', - subLabel: 'Cloud (Latest)', - iframeUrl: 'https://developer-docs-26-10-0-cl.vercel.app/docs/', + subLabel: 'Cloud', + iframeUrl: 'https://developer-docs-26-9-0-cl.vercel.app/docs/', }, { label: '26.8.0.cl', From fb224dfd606f342562cfdc8bb97d4ef1c47a29cf Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Thu, 8 Oct 2026 16:10:44 +0530 Subject: [PATCH 15/57] fix: show hero gradient in embedded/in-product mode, drop _public flag Design confirmed the radiance gradient should show everywhere, so remove the .embedded-mode overrides that hid it and forced the flat #232F43 banner. This supersedes the _public=1 approach from 4bbf6892, so that plumbing (VersionIframe flag, embedded-public-hero class) is reverted. The walkthroughs hero keeps its navy override. Co-Authored-By: Claude Sonnet 5.5 --- src/assets/styles/index.scss | 35 +++---------------------- src/components/DevDocTemplate/index.tsx | 14 +--------- src/components/VersionIframe/index.tsx | 6 ----- 3 files changed, 4 insertions(+), 51 deletions(-) diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss index 9a3572d6d..b05a6e1e8 100644 --- a/src/assets/styles/index.scss +++ b/src/assets/styles/index.scss @@ -1522,23 +1522,10 @@ a.anchor { } } -// In-product presentation only: override banner color to match product sidebar (#232F43), -// and suppress the page-wide gradient entirely. +// In-product presentation only: the walkthroughs hero keeps the product sidebar color (#232F43). +// The docs header banner (.header-banner) intentionally keeps the same radiance gradient as the +// public site, so it is not overridden here. .embedded-mode { - .radiance-bg { - display: none; - } - - .header-banner { - background: #232F43; - border-bottom: none; - box-shadow: none; - - .header-banner-text { - color: #fff; - } - } - .walkthroughs-page { background: #232F43; @@ -1546,22 +1533,6 @@ a.anchor { color: #fff; } } - - // Nested version site shown inside the public docs wrapper (?_iframe=1&_public=1): keep the - // public hero, i.e. undo the in-product banner overrides above. - &.embedded-public-hero { - .radiance-bg { - display: block; - } - - .header-banner { - background: none; - - .header-banner-text { - color: var(--primary-color); - } - } - } } .introCard, diff --git a/src/components/DevDocTemplate/index.tsx b/src/components/DevDocTemplate/index.tsx index 74f0a41c2..e3760503f 100644 --- a/src/components/DevDocTemplate/index.tsx +++ b/src/components/DevDocTemplate/index.tsx @@ -663,20 +663,8 @@ if (isVersionedIframe) { return cName; }; - // Nested version site (?_iframe=1) whose wrapper is the public docs site (VersionIframe adds - // ?_public=1), as opposed to a wrapper embedded in the product. It is still "embedded" for - // layout/nav, but keeps the public hero (gradient) instead of the flat in-product banner. - const isPublicWrapperIframe = () => { - if (!isBrowser()) return false; - const urlParams = new URLSearchParams(location.search); - return urlParams.get('_iframe') === '1' && urlParams.get('_public') === '1'; - }; - const getWrapperClassName = () => { - if (!isEmbeddedContext()) return ''; - return isPublicWrapperIframe() - ? 'embedded-mode embedded-public-hero' - : 'embedded-mode'; + return isEmbeddedContext() ? 'embedded-mode' : ''; }; const getCloudLatestVersion = () => { diff --git a/src/components/VersionIframe/index.tsx b/src/components/VersionIframe/index.tsx index c90de598c..e6e23eaa3 100644 --- a/src/components/VersionIframe/index.tsx +++ b/src/components/VersionIframe/index.tsx @@ -1,5 +1,4 @@ import React, { useMemo } from 'react'; -import { isPublicSite } from '../../utils/app-utils'; import './index.scss'; interface VersionIframeProps { @@ -39,11 +38,6 @@ const VersionIframe: React.FC = ({ url.hash = location?.hash; } url.searchParams.set('_iframe', '1'); - // Tell the nested site its wrapper is the public docs site (not the product), so it - // can show the public hero gradient instead of the in-product banner. - if (isPublicSite(window.location.search)) { - url.searchParams.set('_public', '1'); - } } return url.toString(); From 884c092979fded462c5a19fc57a065e2a3aa0b6b Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Fri, 9 Oct 2026 13:17:20 +0530 Subject: [PATCH 16/57] feat: use the Radiance home pattern for the docs hero gradient The hero used hand-written CSS blobs with keyframe loops, so light and dark looked like different gradients and did not match the product home page. Use the product's Radiance 'home' pattern instead: the unmodified v2.0.0 renderer and home recipe CSS (vendored, byte-identical to the Radiance language repo), with the site theme mapped to data-radiance-appearance. The renderer is loaded lazily, only on pages that contain a surface. The walkthroughs hero keeps its existing CSS blobs. Co-Authored-By: Claude Sonnet 5.5 --- modules/ROOT/pages/home.adoc | 14 +- src/assets/styles/radiance-home.css | 142 ++ src/components/DevDocTemplate/index.tsx | 5 + src/index.d.ts | 3 + src/utils/radiance.ts | 64 + static/radiance/radiance-renderer.js | 2514 +++++++++++++++++++++++ 6 files changed, 2731 insertions(+), 11 deletions(-) create mode 100644 src/assets/styles/radiance-home.css create mode 100644 src/utils/radiance.ts create mode 100644 static/radiance/radiance-renderer.js diff --git a/modules/ROOT/pages/home.adoc b/modules/ROOT/pages/home.adoc index f0f150e82..f6e036d4a 100644 --- a/modules/ROOT/pages/home.adoc +++ b/modules/ROOT/pages/home.adoc @@ -7,17 +7,9 @@ ++++
-
-
-
-
-
-
-
-
-
-
-
+
+

ThoughtSpot Embedded Documentation

diff --git a/src/assets/styles/radiance-home.css b/src/assets/styles/radiance-home.css new file mode 100644 index 000000000..1c8c3947b --- /dev/null +++ b/src/assets/styles/radiance-home.css @@ -0,0 +1,142 @@ +/* + * Radiance product pattern "home" - the Home page hero recipe (FIXED, admin-owned). + * + * The finished Home page (Spotter home) draws its hero from two layers: + * .radiance-home the Radiance surface's wrapper - bleeds past the hero, + * fades toward the content, and carries the fine noise + * and tone lift at the language's values (GRAIN-2) + * .radiance-home-stage the hero's container: a faint glow layer (::before) + * The grain is the language's one grain per theme (GRAIN-6): the renderer draws + * it, over Home's colour at the hero's strength (PRODUCT_PATTERNS.home). + * Markup (see README.md): + *
+ * + * ...hero content... + *
+ * The renderer draws the top wash itself (subtle, fast; locked). Nothing here is + * tunable - these values are the product pattern (RULES.md PRODUCT-1..5). The + * glow layer follows the clean-blend order (COLOR-5): pink - violet - blue - mint. + * Theme follows the surface's own data-radiance-appearance (read with :has()). + */ + +.radiance-home-stage { + --radiance-home-base: #ffffff; + position: relative; +} + +.radiance-home-stage:has(.radiance-local[data-radiance-appearance="dark"]) { + --radiance-home-base: #131416; +} + +/* The glow layer under the hero. */ +.radiance-home-stage::before { + content: ""; + position: absolute; + z-index: 0; + top: 0; + left: -16vw; + right: -16vw; + height: 640px; + pointer-events: none; + background: + radial-gradient(ellipse 58% 36% at 22% -4%, rgba(255, 118, 154, 0.072), rgba(255, 118, 154, 0.028) 44%, rgba(255, 118, 154, 0.006) 64%, transparent 88%), + radial-gradient(ellipse 66% 38% at 38% -2%, rgba(165, 102, 255, 0.066), rgba(165, 102, 255, 0.026) 38%, rgba(165, 102, 255, 0.006) 64%, transparent 88%), + radial-gradient(ellipse 72% 42% at 62% 2%, rgba(88, 150, 255, 0.076), rgba(88, 150, 255, 0.032) 42%, rgba(88, 150, 255, 0.006) 66%, transparent 90%), + radial-gradient(ellipse 70% 44% at 84% 4%, rgba(80, 220, 180, 0.048), rgba(80, 220, 180, 0.017) 40%, transparent 78%), + linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.16) 42%, rgba(255, 255, 255, 0.56) 78%, var(--radiance-home-base) 100%); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.94) 26%, rgba(0, 0, 0, 0.54) 58%, rgba(0, 0, 0, 0.16) 84%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.94) 26%, rgba(0, 0, 0, 0.54) 58%, rgba(0, 0, 0, 0.16) 84%, transparent 100%); +} + +.radiance-home-stage:has(.radiance-local[data-radiance-appearance="dark"])::before { + height: 560px; + background: + radial-gradient(ellipse 58% 34% at 20% -4%, rgba(255, 93, 142, 0.06), rgba(255, 93, 142, 0.024) 42%, rgba(255, 93, 142, 0.006) 62%, transparent 86%), + radial-gradient(ellipse 66% 38% at 38% -2%, rgba(138, 93, 255, 0.068), rgba(138, 93, 255, 0.028) 36%, rgba(138, 93, 255, 0.006) 64%, transparent 88%), + radial-gradient(ellipse 72% 42% at 62% 2%, rgba(39, 140, 255, 0.078), rgba(39, 140, 255, 0.032) 38%, rgba(39, 140, 255, 0.007) 66%, transparent 90%), + radial-gradient(ellipse 70% 44% at 84% 4%, rgba(45, 220, 177, 0.026), rgba(45, 220, 177, 0.01) 40%, transparent 78%), + linear-gradient(to bottom, rgba(26, 27, 30, 0.08) 0%, rgba(26, 27, 30, 0.24) 38%, rgba(26, 27, 30, 0.62) 70%, var(--radiance-home-base) 100%); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.96) 24%, rgba(0, 0, 0, 0.5) 54%, rgba(0, 0, 0, 0.14) 78%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.96) 24%, rgba(0, 0, 0, 0.5) 54%, rgba(0, 0, 0, 0.14) 78%, transparent 100%); +} + +/* The surface's wrapper: bleeds past the hero, fades toward the content, and + carries the tone lift and the 56px fine noise at the language's values. The + renderer draws Home's colour at the hero's strength and the grain on top. */ +.radiance-home { + position: absolute; + z-index: 0; + inset: -96px -12vw -220px; + overflow: hidden; + pointer-events: none; + filter: saturate(1.16) contrast(1.06); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.92) 22%, rgba(0, 0, 0, 0.52) 54%, rgba(0, 0, 0, 0.18) 78%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.92) 22%, rgba(0, 0, 0, 0.52) 54%, rgba(0, 0, 0, 0.18) 78%, transparent 100%); +} + +.radiance-home:has(> .radiance-local[data-radiance-appearance="dark"]) { + inset: -88px -12vw -150px; + filter: saturate(1.2) contrast(1.08); + -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.86) 20%, rgba(0, 0, 0, 0.42) 48%, rgba(0, 0, 0, 0.12) 70%, transparent 90%); + mask-image: linear-gradient(to bottom, #000 0%, rgba(0, 0, 0, 0.86) 20%, rgba(0, 0, 0, 0.42) 48%, rgba(0, 0, 0, 0.12) 70%, transparent 90%); +} + +.radiance-home::before { + content: ""; + position: absolute; + z-index: 3; + inset: 0; + pointer-events: none; + opacity: 0.22; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='56' viewBox='0 0 56 56'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.48' numOctaves='3' seed='21' stitchTiles='stitch'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='1.58' intercept='-0.18'/%3E%3CfeFuncG type='linear' slope='1.22' intercept='-0.12'/%3E%3CfeFuncB type='linear' slope='1.5' intercept='-0.16'/%3E%3CfeFuncA type='linear' slope='0.64'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='56' height='56' filter='url(%23n)'/%3E%3C/svg%3E"); + background-size: 56px 56px; + mix-blend-mode: multiply; +} + +.radiance-home:has(> .radiance-local[data-radiance-appearance="dark"])::before { + opacity: 0.27; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='56' height='56' viewBox='0 0 56 56'%3E%3Cfilter id='n' x='0' y='0' width='100%25' height='100%25'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.48' numOctaves='3' seed='21' stitchTiles='stitch'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='1.64' intercept='-0.2'/%3E%3CfeFuncG type='linear' slope='1.14' intercept='-0.1'/%3E%3CfeFuncB type='linear' slope='1.58' intercept='-0.18'/%3E%3CfeFuncA type='linear' slope='0.7'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='56' height='56' filter='url(%23n)'/%3E%3C/svg%3E"); + mix-blend-mode: screen; +} + +.radiance-home::after { + content: ""; + position: absolute; + z-index: 2; + inset: 0; + background: + radial-gradient(ellipse 72% 34% at 50% 8%, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.034) 48%, rgba(255, 255, 255, 0) 78%), + linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 0%, rgba(255, 255, 255, 0.16) 38%, rgba(255, 255, 255, 0.48) 70%, #ffffff 100%); +} + +.radiance-home:has(> .radiance-local[data-radiance-appearance="dark"])::after { + background: + radial-gradient(ellipse 72% 34% at 50% 8%, rgba(26, 27, 30, 0.12), rgba(26, 27, 30, 0.052) 46%, rgba(26, 27, 30, 0) 76%), + linear-gradient(to bottom, rgba(26, 27, 30, 0.06) 0%, rgba(26, 27, 30, 0.22) 34%, rgba(26, 27, 30, 0.56) 62%, #131416 100%); +} + +.radiance-home > .radiance-local { + position: absolute; + inset: 0; + overflow: hidden; +} + +.radiance-home > .radiance-local canvas { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; +} + +@media (max-width: 780px) { + .radiance-home { inset: -82px -18vw -176px; } + .radiance-home:has(> .radiance-local[data-radiance-appearance="dark"]) { inset: -76px -18vw -132px; } +} + +@media (max-width: 520px) { + .radiance-home { inset: -72px -28vw -162px; } + .radiance-home:has(> .radiance-local[data-radiance-appearance="dark"]) { inset: -68px -28vw -126px; } +} diff --git a/src/components/DevDocTemplate/index.tsx b/src/components/DevDocTemplate/index.tsx index e3760503f..0af542a6c 100644 --- a/src/components/DevDocTemplate/index.tsx +++ b/src/components/DevDocTemplate/index.tsx @@ -12,6 +12,7 @@ import { BiSearch } from '@react-icons/all-files/bi/BiSearch'; import { Analytics } from '@vercel/analytics/react'; import { Seo } from '../Seo'; import { queryStringParser, isPublicSite } from '../../utils/app-utils'; +import { useRadianceTheme } from '../../utils/radiance'; import { passThroughHandler, fetchChild, getPageIdFromHref } from '../../utils/doc-utils'; import Header from '../Header'; import SecondaryHeader, { DocCategory, CATEGORY_PAGEIDS, CATEGORY_NAV_ID } from '../SecondaryHeader'; @@ -20,6 +21,7 @@ import Docmap from '../Docmap'; import Document from '../Document'; import Search from '../Search'; import '../../assets/styles/index.scss'; +import '../../assets/styles/radiance-home.css'; import { getAlgoliaIndex } from '../../configs/algolia-search-config'; import RenderPlayGround from './playGround/RESTAPI'; import GraphQLPlayGround from './playGround/GraphQL'; @@ -130,6 +132,9 @@ const DevDocTemplate: FC = (props) => { }); const [key, setKey] = useState(''); + // Keeps the Radiance hero surface (home.adoc) in the site's current theme. + useRadianceTheme(isDarkMode); + // Pre-process all category nav HTMLs once ({{navprefix}} substitution applied to each) const processedNavMap = React.useMemo(() => Object.fromEntries( diff --git a/src/index.d.ts b/src/index.d.ts index 44350b3aa..dce9face6 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -2,3 +2,6 @@ declare module '*.svg' { const content: string; export default content; } + +// Injected by Gatsby at build time (the site's pathPrefix, e.g. '/docs'). +declare const __PATH_PREFIX__: string; diff --git a/src/utils/radiance.ts b/src/utils/radiance.ts new file mode 100644 index 000000000..be0bfa25d --- /dev/null +++ b/src/utils/radiance.ts @@ -0,0 +1,64 @@ +import { useEffect } from 'react'; + +/** + * Glue for the Radiance "home" product pattern used by the docs hero (home.adoc). + * + * The renderer (static/radiance/radiance-renderer.js) and the hero recipe + * (src/assets/styles/radiance-home.css) are unmodified copies from the Radiance + * language repo and must stay that way (RULES.md IMPL-1, PRODUCT-3). The only thing the + * page owns is the theme: it sets data-radiance-appearance on the surface, and the + * renderer repaints on change. + */ + +const RENDERER_SRC = '/radiance/radiance-renderer.js'; +const SURFACE_SELECTOR = '.radiance-local'; + +let rendererRequested = false; + +// The renderer scans for .radiance-local elements on load and watches the DOM for new +// ones, so it is loaded once, and only when a page actually contains a surface. +const ensureRendererLoaded = () => { + if (rendererRequested || typeof document === 'undefined') return; + rendererRequested = true; + const script = document.createElement('script'); + // __PATH_PREFIX__ is injected by Gatsby (e.g. '/docs' in production, '' locally). + const prefix = typeof __PATH_PREFIX__ === 'string' ? __PATH_PREFIX__ : ''; + script.src = `${prefix}${RENDERER_SRC}`; + script.async = true; + document.body.appendChild(script); +}; + +const applyAppearance = (isDarkMode: boolean) => { + const appearance = isDarkMode ? 'dark' : 'light'; + const surfaces = document.querySelectorAll(SURFACE_SELECTOR); + surfaces.forEach((surface) => { + if (surface.dataset.radianceAppearance !== appearance) { + surface.dataset.radianceAppearance = appearance; + } + }); + if (surfaces.length) ensureRendererLoaded(); +}; + +/** + * Keeps every Radiance surface on the page in the site's current theme. Page content is + * injected after render, so new surfaces are picked up through a body observer too. + */ +export const useRadianceTheme = (isDarkMode: boolean) => { + useEffect(() => { + if (typeof document === 'undefined') return undefined; + applyAppearance(isDarkMode); + if (!('MutationObserver' in window)) return undefined; + const observer = new MutationObserver((mutations) => { + const added = mutations.some((mutation) => + Array.from(mutation.addedNodes).some( + (node) => + node instanceof Element && + (node.matches(SURFACE_SELECTOR) || node.querySelector(SURFACE_SELECTOR)), + ), + ); + if (added) applyAppearance(isDarkMode); + }); + observer.observe(document.body, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [isDarkMode]); +}; diff --git a/static/radiance/radiance-renderer.js b/static/radiance/radiance-renderer.js new file mode 100644 index 000000000..81386fffe --- /dev/null +++ b/static/radiance/radiance-renderer.js @@ -0,0 +1,2514 @@ +(() => { + /* + * Radiance Language renderer - v2. + * + * One file for every Radiance surface: the five templates (right-wash, + * left-wash, neutral, top-wash, bottom-wash) and the fixed product + * patterns (signup, home, spotterx, liveboard). v1 is frozen at + * renderer/v1/radiance-renderer.js for reference and comparison. + * + * v2 carries the system-level upgrades first built for SpotterX and + * Liveboard (see RULES.md and CHANGELOG.md): + * F1 film Built on Home's first-launch film: grain that reaches + * through faint colour, a fine second noise layer, and a + * gentle tone lift - all confined to the colour field + * (GRAIN-4), so the bare surface stays clean. + * F2 motion Organic drift: every orbit, size, stretch and tilt is 2-3 + * sines at incommensurate per-blob frequencies, and every blob + * breathes on its own rhythm (MOTION-2). + * F3 blend Clean blends: overlapping colours are wheel neighbours + * (mint - blue - violet - pink - peach); peach and mint stay + * compact accents (COLOR-5). + * Base A surface sits on its host's own background colour, per + * theme (data-radiance-base / --radiance-base, SURFACE-1). + */ + const RADIANCE_VERSION = "2.0.0"; + const LIGHT_SURFACE = "#ffffff"; + const DARK_SURFACE = "#1a1b1e"; + const RADIANCE_FILM_GRAIN_SIZE = 1.0; + const MAX_DPR = 2; + const TIME_ORIGIN_KEY = "radianceLanguageTimeOrigin"; + const MAX_TEXTURE_CACHE_ITEMS = 24; + const TAU = Math.PI * 2; + // GRAIN-4: how hard the colour field is stacked before it masks the film. + // 12 brings the film to ~80% by 12% colour and ~93% by 20% - the texture + // carries through faint colour the way Home's hero does - while the bare + // surface (under ~0.5% colour) keeps under ~6% of it. v1 used 4. + const GRAIN_MASK_SATURATION = 12; + // The mask is the blurred colour field, so it is smooth: it is built at a + // quarter of the canvas resolution and upscaled. The film stays full-res. + const MASK_SCALE = 0.25; + // F1 - the film stack per theme (built on Home's hero, as SpotterX and + // Liveboard carry it): grain strength, the dark dither, the fine noise layer + // and its blend, and the tone lift. The renderer draws all of it, masked to + // the colour field - consumers still add no CSS filter (BLEND-3). This is + // the language's one film per theme: every surface, template or product, + // draws exactly this grain (GRAIN-6). Light is at 1.0's full strength and + // dark at 0.76, so the grain still reads where the colour is subtle (the + // admin's 2.0 review). + const FILM = { + light: { grain: 1, dither: 0, overlay: 0.22, overlayBlend: "multiply", saturate: 1.16, contrast: 1.06 }, + dark: { grain: 0.76, dither: 0.42 * 0.76, overlay: 0.27, overlayBlend: "screen", saturate: 1.2, contrast: 1.08 } + }; + // The fine noise layer: SVG fractal noise rendered by the browser at the + // surface's pixel density and tiled every 56 CSS px - the same layer + // Home's hero draws over its canvas. + const FILM_OVERLAY_TILE = 56; + const FILM_OVERLAY_SVG = { + light: "", + dark: "" + }; + // F2 - every blob breathes: size and brightness swell a few per cent on the + // blob's own rhythm (ms at speed 1; the tempo follows the square root of the + // speed, so a fast pattern breathes a little quicker, never frantically). + const BREATH = { size: 0.05, glow: 0.05, periodMs: [5000, 8000] }; + + // Percentage adjustments. 100% is always the pattern's shipped value, so a + // surface that sets none of these is byte-identical to one that sets 100%. + // Ranges are what the language still reads as itself; outside them the + // renderer warns (RULES.md ADJUST-2). + const ADJUST_RANGES = { + prominence: { min: 0.5, max: 1.5, rule: "ADJUST-3" }, + spread: { min: 0.75, max: 1.25, rule: "ADJUST-4" }, + speed: { min: 0.5, max: 1.5, rule: "ADJUST-5" } + }; + + // Accepts "120%", "120" or "1.2" — all mean the same thing. + function parseAdjustment(raw) { + if (raw === undefined || raw === null || raw === "") return 1; + const text = String(raw).trim(); + const value = Number(text.replace("%", "").trim()); + if (!Number.isFinite(value) || value <= 0) return 1; + if (text.includes("%") || value > 5) return value / 100; + return value; + } + + // ------------------------------------------------------------------ + // Named patterns. These five are the only sanctioned entry points into + // the language; every Radiance surface starts from one of them. + // Canonical values are mirrored in radiance.tokens.json — change both or + // neither. See RULES.md (PLACE-1, MOTION-6). + // ------------------------------------------------------------------ + const RADIANCE_PATTERNS = { + "right-wash": { layout: "right-wash", speeds: { slow: 0.62, fast: 1.02 } }, + "left-wash": { layout: "left-wash", speeds: { slow: 0.62, fast: 1.02 } }, + "neutral": { layout: "neutral", speeds: { slow: 0.86, fast: 1.34 } }, + "top-wash": { layout: "top-wash", speeds: { slow: 0.92, fast: 1.72 } }, + "bottom-wash": { layout: "bottom-wash", speeds: { slow: 0.92, fast: 1.72 } } + }; + + const PLACEMENT_ALIASES = { + right: "right-wash", + left: "left-wash", + top: "top-wash", + bottom: "bottom-wash", + neutral: "neutral", + modal: "neutral", + developer: "neutral" + }; + + // Shorthand fallbacks are the product-safe values, not the legacy + // attribute defaults. Agents still spell the full four-part name out. + const PATTERN_FALLBACKS = { intensity: "subtle", motion: "dynamic", speed: "slow" }; + + // ------------------------------------------------------------------ + // Product patterns (RULES.md section 12). Fixed, admin-owned patterns for + // ThoughtSpot's finished product screens. They take no adjustment, no + // override and no base colour from the page: every value is locked here, + // and only the Radiance admin changes them, in this repository. + // "template" products are a locked template configuration; + // "spotter" products run the Spotter radiance engine (below). + // Mirrored in radiance.tokens.json and products/manifest.json. + // ------------------------------------------------------------------ + const PRODUCT_PATTERNS = { + signup: { + name: "Sign up", + engine: "template", + layout: "right-wash", + intensity: "prominent", + speed: { light: 1.02, dark: 1.02 }, + seed: "signin-v2", + film: "renderer", + // Under reduced motion Sign up shows right-wash's curated static + // frame, as the product always has (MOTION-7). + reducedMotion: "static", + base: { light: "#ffffff", dark: "#1a1b1e" } + }, + home: { + name: "Home page", + engine: "template", + layout: "top-wash", + intensity: "subtle", + speed: { light: 1.86, dark: 1.72 }, + seed: "home-hero", + // Home's page recipe carries the fine noise and the tone lift + // (products/home/radiance-home.css); the renderer draws the grain over + // the whole hero field, as Home was built. Home's colour sits at the + // hero's strength (76% light / 66% dark over the base), drawn here so + // the grain on top is the language's one grain at full value (GRAIN-6). + film: "page", + fieldOpacity: { light: 0.76, dark: 0.66 }, + base: { light: "#ffffff", dark: "#131416" } + }, + spotterx: { + name: "SpotterX", + engine: "spotter", + // The page recipe (products//radiance-.css) carries the film. + film: "recipe", + base: { light: "#ffffff", dark: "#131416" } + }, + liveboard: { + name: "Liveboard", + engine: "spotter", + // The page recipe (products//radiance-.css) carries the film. + film: "recipe", + base: { light: "#ffffff", dark: "#131416" } + } + }; + // data-radiance-state on a spotter product: [reasoning energy, after-answer calm]. + const PRODUCT_STATES = { idle: [0, 0], reasoning: [1, 0], answered: [0, 1] }; + // Everything a product pattern ignores (PRODUCT-2). + const PRODUCT_LOCKED_ATTRIBUTES = [ + ["radianceProminence", "data-radiance-prominence"], + ["radianceSpread", "data-radiance-spread"], + ["radianceSpeedScale", "data-radiance-speed-scale"], + ["radianceSpeed", "data-radiance-speed"], + ["radianceBase", "data-radiance-base"], + ["radianceIntensity", "data-radiance-intensity"], + ["radianceLayout", "data-radiance-layout"], + ["radianceVariant", "data-radiance-variant"], + ["radianceMotion", "data-radiance-motion"], + ["radianceDarkGlow", "data-radiance-dark-glow"], + ["radianceSeed", "data-radiance-seed"] + ]; + // The Spotter engine's locked tuning: the values SpotterX and Liveboard ship. + const SBG_TUNING = { gain: 1, lift: 0, spread: 1, wash: 1, flow: 2.5, breath: 1, calmGain: 1.5, pace: 1 }; + // The Radiance admin's Spotter lab (spotter-lab/) previews other values on + // one surface through RadianceLanguageRenderer.admin; nothing else sets them. + const ADMIN_SPOTTER = new WeakMap(); + + /* + * The Spotter radiance engine - product patterns "spotterx" and "liveboard" + * (formerly the RADIANCE-OVERRIDE proposal "Spotter radiance BG" v5). + * Radiance blobs gathered along the product's top bar, spilling soft light + * below it - drifting organically at rest, breathing while reasoning, and + * retreating to a faint leak at the top after an answer: + * + * - Blobs: ~6.4 across the view (radius 1.6 spacings, hanging 2x taller + * than wide), drifting across the width so no colour owns a region. The + * colour sequence is a random walk along the wheel (see SBG_PATH), so + * neighbours are wheel-neighbours and overlaps stay clean tints; a blob + * that wraps off-screen is reborn, so the sequence never recurs. + * - Motion that never loops (MOTION-2): every blob's position, size, + * stretch and tilt is a sum of 2-3 sines at incommensurate per-blob + * frequencies, on top of a drift whose own speed swells and eases. + * - Breathing: every blob breathes - swells and brightens - on its own + * random rhythm, so many regions inhale and exhale out of step, never in + * one spot. Barely there at rest; while reasoning the swings deepen and + * quicken (1.8-3.4s), the whole glow breathes along (3.2s, starting + * exhaled at ignition), and a second stream of light flows the other way + * along the edge, taking the colour of the light beneath it - so the + * crossings brighten colours instead of muddying them. + * - States: ONE surface; data-radiance-state = idle | reasoning | answered + * (reasoning energy, and the calm after an answer). Both ease + * on critically damped springs; the clock is INTEGRATED (clock += dt * + * speed), so every speed change is live and phase-continuous. Clock rate + * per unit of Motion energy (2.5 by default): default 1.15 (2.88), + * reasoning 1.7 (4.25), after an answer 0.32 (0.8 - a slow, visible + * drift). + * - After an answer the colour drops to a faint leak (x0.5) and the blobs + * rise behind the bar; the page contracts its CSS fade toward the top, so + * colour and grain retreat together. + * - Shape: the vertical fade is the wrapper's CSS mask (the page builds it + * from the rig), exactly as on Home - colour, grain and overlay fade + * together. The renderer adds only reasoning's extra brightness, confined + * to the top band: reasoning never brings colour further down. + * - Grain: the language's one grain per theme (FILM, GRAIN-6), over the + * whole lit canvas, under the page's CSS fine noise and tone filter. + * - Rendering: blobs at quarter resolution with the canonical blur + * (BLEND-2, scaled), padded past every edge so the blur never darkens an + * edge, upscaled onto a dpr-1 canvas shown pixelated, so the 1.0px grain + * stays crisp (GRAIN-1/3). + * + * Sanctioned product exceptions (RULES.md PRODUCT-3): state-bound energy + * (MOTION-1), an integrated per-surface clock (MOTION-4), speeds above 2.0 + * (MOTION-5), per-load randomness (IMPL-4), and the page-level film in + * products//radiance-.css (GRAIN-4/5, BLEND-3). Everything is + * locked: the values below are the only ones these products ever run. + */ + const SBG_PALETTE = { + light: { pink: [255, 120, 160], violet: [140, 100, 255], blue: [40, 140, 255], peach: [255, 180, 100], mint: [80, 220, 180] }, + dark: { pink: [255, 93, 142], violet: [138, 93, 255], blue: [39, 140, 255], peach: [255, 180, 90], mint: [45, 220, 177] } + }; + /* The colour sequence is a random walk with momentum along the wheel - + mint, blue, violet, pink, peach - so neighbours are always + wheel-neighbours (blue never meets peach, pink never meets mint: every + blend a clean tint). Cool colour always leads (COLOR-4): the walk heads + into peach only 40% of the time it reaches pink, must pass back through + violet before any second peach, never runs more than 3-4 warm blobs, + and after a warm run stays cool for at least two - so every six-blob + screenful holds at least two blues/violets and nothing ever sits warm + (the yellow-red cast pink + peach makes in dark). A blob that wraps + off-screen is reborn with the next step of the walk and fresh size, + timing and shape, so the sequence never recurs. Long-run balance + (simulated over 1M steps): blue 29%, violet 35%, pink 24%, peach 7% + (warmth), mint 5% (quiet support). */ + const SBG_PATH = ["mint", "blue", "violet", "pink", "peach"]; + /* Peach and mint stay compact accents so their glow never reaches a hue + two steps away (peach + violet = mauve, the muddiest blend). */ + const SBG_ROLE = { blue: 1.04, violet: 0.84, pink: 0.92, peach: 0.85, mint: 0.5 }; + const SBG_SIZE = { blue: 1.05, violet: 0.96, pink: 0.98, peach: 0.84, mint: 0.78 }; + /* Blob centre opacity at rest and while reasoning (before the page's + saturate/contrast filter, which carries dark a little further); `calm` + is the after-answer leak as a fraction of rest (x the rig's After-answer + intensity, 1.5 by default). + `floor` is what reasoning keeps below its bright top band; light's rest + was raised on its own, so its reasoning keeps the earlier floor and + reads exactly as before. */ + const SBG_STATES = { + // 2.0: reasoning +15% (0.235 -> 0.27, floor 0.14 -> 0.155), calm +10% (0.5 -> 0.55). + light: { rest: 0.165, reason: 0.27, calm: 0.55, floor: 0.155 }, + dark: { rest: 0.12, reason: 0.28, calm: 0.5, floor: 0.12 } + }; + const SBG_MOTION = { + speedDefault: 1.15, // clock rate per unit of Motion energy (x2.5 = 2.88) + speedReason: 1.7, // x2.5 = 4.25 + speedSettled: 0.32, // x2.5 = 0.8 - the calm after an answer: a slow, visible drift + speedCap: 8, + flow: 0.016, // drift, px per clock-ms: ~46 px/s default, ~68 reasoning, ~13 calm + wanderReason: 0.6, // blobs wander 1.6x as far while reasoning + /* Each blob breathes on its own rhythm (ms, random per blob in these + ranges) with these size / brightness swings - rest, reasoning, calm. */ + breathMs: { rest: [5000, 8000], reason: [1800, 3400], calm: [5500, 8500] }, + breathSize: { rest: 0.05, reason: 0.22, calm: 0.06 }, + breathGlow: { rest: 0.05, reason: 0.2, calm: 0.06 }, + /* While reasoning the whole glow breathes along ... */ + chorusMs: 3200, + chorusSize: 0.06, + chorusGlow: 0.08, + /* ... and a stream of light flows the other way along the edge. */ + streamFlow: -1.6, + streamGlow: 0.7, + rise: 3.6, // spring rates (rad/s): ~1.1s into reasoning, + fall: 1.45, // ~2.7s back out, + settle: 1.3 // ~3s between the default and the after-answer calm + }; + const SBG_CALM_RISE = 0.1; // after an answer the blobs rise behind the bar (x radius) + /* The fade (mirrored by the page's CSS mask builder): a short fall - the + edge light, "spread" - plus a long faint tail - "wash coverage". The + reasoning gain lives only in the top `band` px. */ + const SBG_ENVELOPE = { spread: 70, wash: 320, tail: 0.45, band: 80 }; + const SBG_TALL = 2.0; + const SBG_BASE_LIFT = -8; + const SBG_BLUR = { light: 40, dark: 64 }; + const SBG_SCALE = 0.25; + const SBG_PAD = 96; + + + const warnedElements = new WeakMap(); + + function warnOnce(element, code, message) { + let codes = warnedElements.get(element); + if (!codes) { + codes = new Set(); + warnedElements.set(element, codes); + } + if (codes.has(code)) return; + codes.add(code); + console.warn(`[Radiance ${code}] ${message}`); + } + + function resolveRadiancePattern(raw) { + const tokens = String(raw || "").toLowerCase().split(/[^a-z]+/).filter(Boolean); + if (!tokens.length) return null; + + let layout = null; + for (const token of tokens) { + if (PLACEMENT_ALIASES[token]) { + layout = PLACEMENT_ALIASES[token]; + break; + } + } + if (!layout) return null; + + const has = (token) => tokens.includes(token); + const intensity = has("prominent") ? "prominent" : (has("subtle") ? "subtle" : PATTERN_FALLBACKS.intensity); + const motion = (has("static") || has("still")) ? "static" : PATTERN_FALLBACKS.motion; + const speedName = has("fast") ? "fast" : (has("slow") ? "slow" : PATTERN_FALLBACKS.speed); + const appearance = has("dark") ? "dark" : (has("light") ? "light" : null); + + return { + name: `${layout}-${intensity}-${motion}-${speedName}`, + layout, + intensity, + motion, + speedName, + speed: RADIANCE_PATTERNS[layout].speeds[speedName], + appearance + }; + } + + const reducedMotionQuery = typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-reduced-motion: reduce)") + : null; + let prefersReducedMotion = reducedMotionQuery ? reducedMotionQuery.matches : false; + + const LIGHT_BLOBS = [ + { anchorX: 0.9, anchorY: 0.4, color: [255, 120, 160], radius: 0.4, opacity: 0.6, orbitX: 60, orbitY: 80, freqX: 0.0007, freqY: 0.0005, stretchFreq: 0.001 }, + { anchorX: 0.85, anchorY: 0.7, color: [80, 220, 180], radius: 0.36, opacity: 0.32, orbitX: -70, orbitY: -90, freqX: 0.0008, freqY: 0.0006, stretchFreq: 0.0012 }, + { anchorX: 0.95, anchorY: 0.5, color: [140, 100, 255], radius: 0.45, opacity: 0.6, orbitX: 50, orbitY: 100, freqX: 0.0005, freqY: 0.0009, stretchFreq: 0.0008 }, + { anchorX: 0.75, anchorY: 0.3, color: [255, 180, 100], radius: 0.3, opacity: 0.6, orbitX: -60, orbitY: 120, freqX: 0.0009, freqY: 0.0007, stretchFreq: 0.0015 }, + { anchorX: 0.8, anchorY: 0.6, color: [40, 140, 255], radius: 0.4, opacity: 0.6, orbitX: 90, orbitY: -80, freqX: 0.0008, freqY: 0.0011, stretchFreq: 0.0009 }, + { anchorX: 0.3, anchorY: 0.3, color: [200, 220, 255], radius: 0.7, opacity: 0.15, orbitX: 100, orbitY: 50, freqX: 0.0003, freqY: 0.0004, stretchFreq: 0.0005 }, + { anchorX: 0.2, anchorY: 0.7, color: [255, 200, 220], radius: 0.8, opacity: 0.15, orbitX: -80, orbitY: -100, freqX: 0.0004, freqY: 0.0003, stretchFreq: 0.0006 }, + { anchorX: 0.5, anchorY: 0.6, color: [255, 240, 180], radius: 0.6, opacity: 0.15, orbitX: 120, orbitY: -60, freqX: 0.0005, freqY: 0.0004, stretchFreq: 0.0007 } + ]; + + function scaleBlobSet(blobs, opacityScale, orbitScale) { + return blobs.map((blob) => ({ + ...blob, + opacity: blob.opacity * opacityScale, + orbitX: (blob.orbitX || 0) * orbitScale, + orbitY: (blob.orbitY || 0) * orbitScale + })); + } + + function mirrorBlobSet(blobs) { + return blobs.map((blob) => ({ + ...blob, + anchorX: 1 - blob.anchorX, + orbitX: -(blob.orbitX || 0) + })); + } + + function topBlobSet(blobs) { + return blobs.map((blob) => ({ + ...blob, + anchorX: clamp(blob.anchorY, 0.08, 0.94), + anchorY: clamp(1 - blob.anchorX, -0.04, 0.34), + orbitX: (blob.orbitY || 0) * 1.18, + orbitY: -(blob.orbitX || 0) * 0.58, + radius: blob.radius * 1.06 + })); + } + + function bottomBlobSet(blobs) { + return topBlobSet(blobs).map((blob) => ({ + ...blob, + anchorY: 1 - blob.anchorY, + orbitY: -(blob.orbitY || 0) + })); + } + + function lockBlobSet(blobs) { + return blobs.map((blob) => ({ + ...blob, + orbitX: 0, + orbitY: 0, + freqX: 0, + freqY: 0, + stretchFreq: 0, + locked: true + })); + } + + function neutralBlobSet(blobs) { + return blobs.map((blob) => ({ + ...blob, + surfaceScaled: true + })); + } + + function clamp(value, min, max) { + return Math.min(max, Math.max(min, value)); + } + + const mix = (a, b, t) => a + (b - a) * t; + const wave = (phase) => 0.5 - 0.5 * Math.cos(phase); + + // SURFACE-1: a base colour is any opaque CSS colour. Parsed once through a + // canvas, so every syntax the browser accepts works. + const colourCache = new Map(); + let colourProbe = null; + + function parseColour(value) { + const text = String(value || "").trim(); + if (!text) return null; + if (colourCache.has(text)) return colourCache.get(text); + colourProbe = colourProbe || document.createElement("canvas").getContext("2d"); + let rgb = null; + for (const sentinel of ["#010203", "#fefdfc"]) { + colourProbe.fillStyle = sentinel; + colourProbe.fillStyle = text; + const normal = String(colourProbe.fillStyle); + if (normal === sentinel) continue; + if (normal.startsWith("#")) { + rgb = [1, 3, 5].map((i) => parseInt(normal.slice(i, i + 2), 16)); + } else { + const parts = (normal.match(/rgba?\(([^)]+)\)/) || [])[1]; + const values = parts ? parts.split(",").map((part) => Number(part.trim())) : []; + if (values.length >= 3 && (values.length < 4 || values[3] >= 0.99)) rgb = values.slice(0, 3).map(Math.round); + } + break; + } + if (!rgb && text.toLowerCase() === "#010203") rgb = [1, 2, 3]; + colourCache.set(text, rgb); + return rgb; + } + + function hashSeed(value) { + let hash = 2166136261; + + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + return hash >>> 0; + } + + function createSeededRandom(seed) { + let state = hashSeed(seed) || 1; + + return () => { + state += 0x6d2b79f5; + let result = state; + result = Math.imul(result ^ (result >>> 15), result | 1); + result ^= result + Math.imul(result ^ (result >>> 7), result | 61); + return ((result ^ (result >>> 14)) >>> 0) / 4294967296; + }; + } + + function getAnimationTimeOrigin() { + const now = Date.now(); + + try { + const storedOrigin = Number(window.sessionStorage.getItem(TIME_ORIGIN_KEY)); + if (Number.isFinite(storedOrigin) && storedOrigin > 0) return storedOrigin; + + window.sessionStorage.setItem(TIME_ORIGIN_KEY, String(now)); + } catch { + return now; + } + + return now; + } + + function getBlobSurfaceScale(config, width, height) { + if (!config.surfaceScaled) return { radius: 1, orbitX: 1, orbitY: 1 }; + + const minSide = Math.max(1, Math.min(width, height)); + const maxSide = Math.max(width, height); + const aspect = maxSide / minSide; + const compactBoost = clamp((480 - minSide) / 280, 0, 1); + const fieldBoost = clamp((maxSide - 760) / 720, 0, 1); + + return { + radius: 1.06 + clamp(aspect - 1, 0, 1.1) * 0.13 + compactBoost * 0.14 + fieldBoost * 0.05, + orbitX: clamp(width / 920, 0.28, 1.38), + orbitY: clamp(height / 680, 0.28, 1.38) + }; + } + + const LIGHT_SUBTLE_BLOBS = scaleBlobSet(LIGHT_BLOBS, 0.44, 0.62); + + const STATIC_LIGHT_BLOBS = [ + { anchorX: 1.02, anchorY: 0.14, color: [255, 120, 160], radius: 0.48, opacity: 0.58, locked: true }, + { anchorX: 0.96, anchorY: 0.42, color: [140, 100, 255], radius: 0.54, opacity: 0.6, locked: true }, + { anchorX: 0.8, anchorY: 0.62, color: [40, 140, 255], radius: 0.54, opacity: 0.58, locked: true }, + { anchorX: 0.78, anchorY: 0.28, color: [255, 180, 100], radius: 0.36, opacity: 0.44, locked: true }, + { anchorX: 1.02, anchorY: 0.82, color: [80, 220, 180], radius: 0.38, opacity: 0.1, locked: true }, + { anchorX: 0.34, anchorY: 0.34, color: [200, 220, 255], radius: 0.76, opacity: 0.1, locked: true }, + { anchorX: 0.2, anchorY: 0.74, color: [255, 200, 220], radius: 0.84, opacity: 0.075, locked: true }, + { anchorX: 0.52, anchorY: 0.62, color: [255, 240, 180], radius: 0.62, opacity: 0.075, locked: true } + ]; + + const STATIC_LIGHT_SUBTLE_BLOBS = scaleBlobSet(STATIC_LIGHT_BLOBS, 0.48, 1); + + const NEUTRAL_LIGHT_BLOBS = neutralBlobSet([ + { anchorX: 0.12, anchorY: 0.12, color: [255, 180, 100], radius: 0.39, opacity: 0.34, orbitX: 90, orbitY: 68, freqX: 0.00072, freqY: 0.00056, stretchFreq: 0.00108 }, + { anchorX: 0.28, anchorY: 0.28, color: [255, 120, 160], radius: 0.46, opacity: 0.5, orbitX: -118, orbitY: 84, freqX: 0.00086, freqY: 0.00068, stretchFreq: 0.00118 }, + { anchorX: 0.14, anchorY: 0.78, color: [40, 140, 255], radius: 0.54, opacity: 0.56, orbitX: 126, orbitY: -110, freqX: 0.00074, freqY: 0.00092, stretchFreq: 0.00102 }, + { anchorX: 0.44, anchorY: 0.18, color: [140, 100, 255], radius: 0.45, opacity: 0.48, orbitX: 106, orbitY: 92, freqX: 0.00064, freqY: 0.00086, stretchFreq: 0.00112 }, + { anchorX: 0.68, anchorY: 0.32, color: [40, 140, 255], radius: 0.49, opacity: 0.54, orbitX: 108, orbitY: -92, freqX: 0.00084, freqY: 0.00064, stretchFreq: 0.00114 }, + { anchorX: 0.84, anchorY: 0.18, color: [255, 120, 160], radius: 0.42, opacity: 0.45, orbitX: -98, orbitY: 98, freqX: 0.00078, freqY: 0.00088, stretchFreq: 0.00106 }, + { anchorX: 0.88, anchorY: 0.62, color: [140, 100, 255], radius: 0.56, opacity: 0.58, orbitX: -122, orbitY: -104, freqX: 0.00058, freqY: 0.00094, stretchFreq: 0.00098 }, + { anchorX: 0.68, anchorY: 0.86, color: [40, 140, 255], radius: 0.54, opacity: 0.54, orbitX: 108, orbitY: -110, freqX: 0.0009, freqY: 0.00072, stretchFreq: 0.0012 }, + { anchorX: 0.36, anchorY: 0.84, color: [255, 120, 160], radius: 0.42, opacity: 0.38, orbitX: 98, orbitY: -88, freqX: 0.0007, freqY: 0.00082, stretchFreq: 0.00104 }, + { anchorX: 0.48, anchorY: 0.48, color: [80, 220, 180], radius: 0.3, opacity: 0.04, orbitX: 78, orbitY: -72, freqX: 0.00068, freqY: 0.00072, stretchFreq: 0.00092 } + ]); + + const NEUTRAL_LIGHT_SUBTLE_BLOBS = scaleBlobSet(NEUTRAL_LIGHT_BLOBS, 0.52, 0.86); + const STATIC_NEUTRAL_LIGHT_BLOBS = lockBlobSet(NEUTRAL_LIGHT_BLOBS); + const STATIC_NEUTRAL_LIGHT_SUBTLE_BLOBS = lockBlobSet(NEUTRAL_LIGHT_SUBTLE_BLOBS); + + const DARK_SUBTLE_BLOBS = [ + { anchorX: 0.9, anchorY: 0.4, color: [255, 93, 142], radius: 0.42, opacity: 0.34, orbitX: 60, orbitY: 80, freqX: 0.0007, freqY: 0.0005, stretchFreq: 0.001 }, + { anchorX: 0.86, anchorY: 0.72, color: [45, 220, 177], radius: 0.38, opacity: 0.12, orbitX: -70, orbitY: -90, freqX: 0.0008, freqY: 0.0006, stretchFreq: 0.0012 }, + { anchorX: 0.95, anchorY: 0.5, color: [138, 93, 255], radius: 0.48, opacity: 0.36, orbitX: 50, orbitY: 100, freqX: 0.0005, freqY: 0.0009, stretchFreq: 0.0008 }, + { anchorX: 0.74, anchorY: 0.3, color: [255, 180, 90], radius: 0.29, opacity: 0.2, orbitX: -60, orbitY: 120, freqX: 0.0009, freqY: 0.0007, stretchFreq: 0.0015 }, + { anchorX: 0.8, anchorY: 0.62, color: [39, 140, 255], radius: 0.42, opacity: 0.32, orbitX: 90, orbitY: -80, freqX: 0.0008, freqY: 0.0011, stretchFreq: 0.0009 }, + { anchorX: 0.3, anchorY: 0.3, color: [160, 196, 255], radius: 0.62, opacity: 0.055, orbitX: 100, orbitY: 50, freqX: 0.0003, freqY: 0.0004, stretchFreq: 0.0005 }, + { anchorX: 0.2, anchorY: 0.72, color: [255, 170, 210], radius: 0.66, opacity: 0.055, orbitX: -80, orbitY: -100, freqX: 0.0004, freqY: 0.0003, stretchFreq: 0.0006 }, + { anchorX: 0.5, anchorY: 0.6, color: [255, 232, 150], radius: 0.52, opacity: 0.045, orbitX: 120, orbitY: -60, freqX: 0.0005, freqY: 0.0004, stretchFreq: 0.0007 } + ]; + + const DARK_PROMINENT_BLOBS = [ + { anchorX: 0.9, anchorY: 0.4, color: [255, 93, 142], radius: 0.46, opacity: 0.44, orbitX: 60, orbitY: 80, freqX: 0.0007, freqY: 0.0005, stretchFreq: 0.001 }, + { anchorX: 0.86, anchorY: 0.72, color: [45, 220, 177], radius: 0.42, opacity: 0.18, orbitX: -70, orbitY: -90, freqX: 0.0008, freqY: 0.0006, stretchFreq: 0.0012 }, + { anchorX: 0.95, anchorY: 0.5, color: [138, 93, 255], radius: 0.52, opacity: 0.46, orbitX: 50, orbitY: 100, freqX: 0.0005, freqY: 0.0009, stretchFreq: 0.0008 }, + { anchorX: 0.74, anchorY: 0.3, color: [255, 180, 90], radius: 0.32, opacity: 0.28, orbitX: -60, orbitY: 120, freqX: 0.0009, freqY: 0.0007, stretchFreq: 0.0015 }, + { anchorX: 0.8, anchorY: 0.62, color: [39, 140, 255], radius: 0.46, opacity: 0.42, orbitX: 90, orbitY: -80, freqX: 0.0008, freqY: 0.0011, stretchFreq: 0.0009 }, + { anchorX: 0.3, anchorY: 0.3, color: [160, 196, 255], radius: 0.78, opacity: 0.12, orbitX: 100, orbitY: 50, freqX: 0.0003, freqY: 0.0004, stretchFreq: 0.0005 }, + { anchorX: 0.2, anchorY: 0.72, color: [255, 170, 210], radius: 0.82, opacity: 0.12, orbitX: -80, orbitY: -100, freqX: 0.0004, freqY: 0.0003, stretchFreq: 0.0006 }, + { anchorX: 0.5, anchorY: 0.6, color: [255, 232, 150], radius: 0.64, opacity: 0.1, orbitX: 120, orbitY: -60, freqX: 0.0005, freqY: 0.0004, stretchFreq: 0.0007 } + ]; + + const STATIC_DARK_PROMINENT_BLOBS = [ + { anchorX: 1.02, anchorY: 0.14, color: [255, 93, 142], radius: 0.5, opacity: 0.42, locked: true }, + { anchorX: 0.96, anchorY: 0.44, color: [138, 93, 255], radius: 0.56, opacity: 0.46, locked: true }, + { anchorX: 0.8, anchorY: 0.64, color: [39, 140, 255], radius: 0.56, opacity: 0.42, locked: true }, + { anchorX: 0.76, anchorY: 0.28, color: [255, 180, 90], radius: 0.36, opacity: 0.24, locked: true }, + { anchorX: 1.02, anchorY: 0.84, color: [45, 220, 177], radius: 0.38, opacity: 0.07, locked: true }, + { anchorX: 0.34, anchorY: 0.34, color: [160, 196, 255], radius: 0.76, opacity: 0.08, locked: true }, + { anchorX: 0.2, anchorY: 0.74, color: [255, 170, 210], radius: 0.82, opacity: 0.075, locked: true }, + { anchorX: 0.52, anchorY: 0.62, color: [255, 232, 150], radius: 0.62, opacity: 0.055, locked: true } + ]; + + const STATIC_DARK_SUBTLE_BLOBS = scaleBlobSet(STATIC_DARK_PROMINENT_BLOBS, 0.54, 1); + + const NEUTRAL_DARK_PROMINENT_BLOBS = neutralBlobSet([ + { anchorX: 0.12, anchorY: 0.12, color: [255, 180, 90], radius: 0.39, opacity: 0.22, orbitX: 90, orbitY: 68, freqX: 0.00072, freqY: 0.00056, stretchFreq: 0.00108 }, + { anchorX: 0.28, anchorY: 0.28, color: [255, 93, 142], radius: 0.46, opacity: 0.36, orbitX: -118, orbitY: 84, freqX: 0.00086, freqY: 0.00068, stretchFreq: 0.00118 }, + { anchorX: 0.14, anchorY: 0.78, color: [39, 140, 255], radius: 0.54, opacity: 0.4, orbitX: 126, orbitY: -110, freqX: 0.00074, freqY: 0.00092, stretchFreq: 0.00102 }, + { anchorX: 0.44, anchorY: 0.18, color: [138, 93, 255], radius: 0.45, opacity: 0.36, orbitX: 106, orbitY: 92, freqX: 0.00064, freqY: 0.00086, stretchFreq: 0.00112 }, + { anchorX: 0.68, anchorY: 0.32, color: [39, 140, 255], radius: 0.49, opacity: 0.38, orbitX: 108, orbitY: -92, freqX: 0.00084, freqY: 0.00064, stretchFreq: 0.00114 }, + { anchorX: 0.84, anchorY: 0.18, color: [255, 93, 142], radius: 0.42, opacity: 0.32, orbitX: -98, orbitY: 98, freqX: 0.00078, freqY: 0.00088, stretchFreq: 0.00106 }, + { anchorX: 0.88, anchorY: 0.62, color: [138, 93, 255], radius: 0.56, opacity: 0.42, orbitX: -122, orbitY: -104, freqX: 0.00058, freqY: 0.00094, stretchFreq: 0.00098 }, + { anchorX: 0.68, anchorY: 0.86, color: [39, 140, 255], radius: 0.54, opacity: 0.4, orbitX: 108, orbitY: -110, freqX: 0.0009, freqY: 0.00072, stretchFreq: 0.0012 }, + { anchorX: 0.36, anchorY: 0.84, color: [255, 93, 142], radius: 0.42, opacity: 0.28, orbitX: 98, orbitY: -88, freqX: 0.0007, freqY: 0.00082, stretchFreq: 0.00104 }, + { anchorX: 0.48, anchorY: 0.48, color: [45, 220, 177], radius: 0.3, opacity: 0.024, orbitX: 78, orbitY: -72, freqX: 0.00068, freqY: 0.00072, stretchFreq: 0.00092 } + ]); + + const NEUTRAL_DARK_SUBTLE_BLOBS = scaleBlobSet(NEUTRAL_DARK_PROMINENT_BLOBS, 0.52, 0.86); + const STATIC_NEUTRAL_DARK_PROMINENT_BLOBS = lockBlobSet(NEUTRAL_DARK_PROMINENT_BLOBS); + const STATIC_NEUTRAL_DARK_SUBTLE_BLOBS = lockBlobSet(NEUTRAL_DARK_SUBTLE_BLOBS); + + const NEUTRAL_VEIL_BLOBS = [ + { anchorX: 0.08, anchorY: 0.18, radius: 0.56, opacity: 0.28, orbitX: 52, orbitY: 44, freqX: 0.00048, freqY: 0.0004, phase: 0.32 }, + { anchorX: 0.52, anchorY: 0.08, radius: 0.48, opacity: 0.2, orbitX: -56, orbitY: 38, freqX: 0.00038, freqY: 0.00046, phase: 1.18 }, + { anchorX: 0.94, anchorY: 0.26, radius: 0.54, opacity: 0.24, orbitX: -48, orbitY: 54, freqX: 0.00044, freqY: 0.00036, phase: 2.42 }, + { anchorX: 0.22, anchorY: 0.82, radius: 0.6, opacity: 0.22, orbitX: 62, orbitY: -46, freqX: 0.00034, freqY: 0.00042, phase: 3.36 }, + { anchorX: 0.74, anchorY: 0.78, radius: 0.58, opacity: 0.24, orbitX: -64, orbitY: -52, freqX: 0.00042, freqY: 0.00038, phase: 4.72 }, + { anchorX: 0.48, anchorY: 0.5, radius: 0.72, opacity: 0.16, orbitX: 46, orbitY: -42, freqX: 0.0003, freqY: 0.00034, phase: 5.58 } + ]; + + const surfaces = new Set(); + const surfaceByElement = new WeakMap(); + const textureCache = new Map(); + const timeOrigin = getAnimationTimeOrigin(); + let isDocumentVisible = document.visibilityState !== "hidden"; + let mutationObserver; + let raf = 0; + + function getTimelineTime() { + return Math.max(0, Date.now() - timeOrigin); + } + + function rememberTexture(key, texture) { + if (textureCache.has(key)) return textureCache.get(key); + textureCache.set(key, texture); + + if (textureCache.size > MAX_TEXTURE_CACHE_ITEMS) { + const oldestKey = textureCache.keys().next().value; + textureCache.delete(oldestKey); + } + + return texture; + } + + class RadianceBlob { + constructor(config, random) { + this.config = config; + this.phaseX = random() * Math.PI * 2; + this.phaseY = random() * Math.PI * 2; + this.phaseStretch = random() * Math.PI * 2; + this.phaseDrift = random() * Math.PI * 2; + // F2: the extra sines' ratios and phases, a slow tilt, and the blob's + // own breath - all seeded (IMPL-4), so a surface is stable on reload. + this.phases = Array.from({ length: 8 }, () => random() * TAU); + this.ratios = [ + 1.52 + random() * 0.2, + 2.3 + random() * 0.3, + 1.6 + random() * 0.3, + 1.4 + random() * 0.3, + 1.5 + random() * 0.3, + 1.5 + random() * 0.3, + 1.6 + random() * 0.3 + ]; + this.tiltFreq = 0.00022 + random() * 0.00022; + this.breathMs = BREATH.periodMs[0] + random() * (BREATH.periodMs[1] - BREATH.periodMs[0]); + this.breathPhase = random() * TAU; + this.x = 0; + this.y = 0; + this.r = 0; + this.stretchX = 1; + this.stretchY = 1; + this.tilt = 0; + this.hasState = false; + } + + update(t, width, height, mouse, breathT = 0) { + const config = this.config; + const baseX = width * config.anchorX; + const baseY = height * config.anchorY; + const minSide = Math.min(width, height); + const surfaceScale = getBlobSurfaceScale(config, width, height); + const baseRadius = minSide * config.radius * surfaceScale.radius; + + if (config.locked) { + this.x = baseX; + this.y = baseY; + this.r = baseRadius; + this.stretchX = 1; + this.stretchY = 1; + this.tilt = 0; + this.opacity = config.opacity; + this.hasState = true; + return; + } + + const freqX = config.freqX || 0.00022; + const freqY = config.freqY || 0.00022; + const stretchFreq = config.stretchFreq || 0.00032; + let targetX; + let targetY; + let targetR; + let targetStretchX; + let targetStretchY; + + // F2 (MOTION-2): each motion is 2-3 sines at incommensurate per-blob + // ratios - the same reach as v1's single sines, but a path that + // never retraces - and the blob breathes on its own rhythm. + const p = this.phases; + const k = this.ratios; + const orbitX = (config.orbitX || 0) * surfaceScale.orbitX; + const orbitY = (config.orbitY || 0) * surfaceScale.orbitY; + const breath = wave(TAU * breathT / this.breathMs + this.breathPhase); + targetX = baseX + orbitX * ( + 0.72 * Math.sin(t * freqX + this.phaseX) + + 0.2 * Math.sin(t * freqX * k[0] + p[0]) + + 0.08 * Math.sin(t * freqX * k[1] + p[1])); + targetY = baseY + orbitY * ( + 0.78 * Math.cos(t * freqY + this.phaseY) + + 0.22 * Math.sin(t * freqY * k[2] + p[2])); + targetR = baseRadius + * (1 + 0.1 * Math.sin(t * 0.001 + this.phaseX) + 0.05 * Math.sin(t * 0.001 * k[3] + p[3])) + * (1 + BREATH.size * (breath - 0.4)); + targetStretchX = 1 + 0.3 * (0.72 * Math.sin(t * stretchFreq + this.phaseX) + 0.28 * Math.sin(t * stretchFreq * k[4] + p[4])); + targetStretchY = 1 + 0.3 * (0.72 * Math.cos(t * stretchFreq + this.phaseY) + 0.28 * Math.sin(t * stretchFreq * k[5] + p[5])); + this.tilt = 0.16 * Math.sin(t * this.tiltFreq + p[6]) + 0.08 * Math.sin(t * this.tiltFreq * k[6] + p[7]); + this.opacity = config.opacity * (1 + BREATH.glow * (breath - 0.5)); + + const ease = 1; + this.x += (targetX - this.x) * ease; + this.y += (targetY - this.y) * ease; + this.r += (targetR - this.r) * ease; + this.stretchX += (targetStretchX - this.stretchX) * ease; + this.stretchY += (targetStretchY - this.stretchY) * ease; + this.hasState = true; + } + + draw(ctx) { + const [r, g, b] = this.config.color; + const opacity = this.opacity ?? this.config.opacity; + ctx.save(); + ctx.translate(this.x, this.y); + if (this.tilt) ctx.rotate(this.tilt); + ctx.scale(this.stretchX, this.stretchY); + const gradient = ctx.createRadialGradient(0, 0, 0, 0, 0, this.r); + gradient.addColorStop(0, `rgba(${r},${g},${b},${opacity})`); + gradient.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(0, 0, this.r, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + } + + class RadianceSurface { + constructor(element) { + this.element = element; + this.canvas = document.createElement("canvas"); + this.ctx = this.canvas.getContext("2d", { alpha: false }); + this.mouse = { x: -9999, y: -9999 }; + this.width = 0; + this.height = 0; + this.dpr = 1; + this.textureKey = ""; + this.ready = false; + this.isInViewport = true; + this.needsResize = true; + this.staticFrameKey = ""; + this.blobCanvas = document.createElement("canvas"); + this.blobCtx = this.blobCanvas.getContext("2d"); + this.fieldCanvas = document.createElement("canvas"); + this.fieldCtx = this.fieldCanvas.getContext("2d"); + this.grainCanvas = document.createElement("canvas"); + this.grainMaskCtx = this.grainCanvas.getContext("2d"); + this.maskCanvas = document.createElement("canvas"); + this.maskCtx = this.maskCanvas.getContext("2d"); + this.liftCanvas = document.createElement("canvas"); + this.liftCtx = this.liftCanvas.getContext("2d"); + this.blobs = []; + this.lastConfigKey = ""; + this.appliedPatternSource = null; + this.product = null; + this.productName = ""; + this.cssBase = ""; + this.baseKey = ""; + this.baseRgb = [255, 255, 255]; + this.baseFill = LIGHT_SURFACE; + this.element.appendChild(this.canvas); + this.element.dataset.radianceReady = "false"; + + if ("ResizeObserver" in window) { + this.resizeObserver = new ResizeObserver(() => { + this.needsResize = true; + }); + this.resizeObserver.observe(this.element); + } + + if ("IntersectionObserver" in window) { + this.intersectionObserver = new IntersectionObserver((entries) => { + this.isInViewport = entries.some((entry) => entry.isIntersecting); + if (this.isInViewport) startLoop(); + }); + this.intersectionObserver.observe(this.element); + } + } + + destroy() { + this.resizeObserver?.disconnect(); + this.intersectionObserver?.disconnect(); + this.canvas.remove(); + } + + get appearance() { + return this.element.dataset.radianceAppearance === "dark" ? "dark" : "light"; + } + + // A template product pattern (signup, home), or null. + get productTemplate() { + return this.product && this.product.engine === "template" ? this.product : null; + } + + get isSpotter() { + return Boolean(this.product && this.product.engine === "spotter"); + } + + get speed() { + const product = this.productTemplate; + if (product) return product.speed[this.appearance]; + const value = Number(this.element.dataset.radianceSpeed || "1"); + const base = Number.isFinite(value) ? value : 1; + return base * this.adjustment("speed", this.element.dataset.radianceSpeedScale); + } + + // ADJUST-1: every adjustment is a percentage of the pattern's own value, + // never an absolute. 100% means "exactly what the pattern ships". + adjustment(kind, raw) { + const scale = parseAdjustment(raw); + const range = ADJUST_RANGES[kind]; + if (range && (scale < range.min || scale > range.max)) { + warnOnce( + this.element, + range.rule, + `${kind} adjusted to ${Math.round(scale * 100)}% — the Radiance range is ` + + `${Math.round(range.min * 100)}%-${Math.round(range.max * 100)}%. ` + + "Outside it this surface stops matching the rest of the product. See RULES.md." + ); + } + return scale; + } + + get prominenceScale() { + if (this.product) return 1; + return this.adjustment("prominence", this.element.dataset.radianceProminence); + } + + get spreadScale() { + if (this.product) return 1; + return this.adjustment("spread", this.element.dataset.radianceSpread); + } + + get variant() { + const product = this.productTemplate; + if (product) { + const motion = prefersReducedMotion && product.reducedMotion === "static" ? "static" : "dynamic"; + return `${motion}-${product.intensity}`; + } + return this.element.dataset.radianceVariant || ""; + } + + get darkGlow() { + const product = this.productTemplate; + if (product) return product.intensity; + return this.element.dataset.radianceDarkGlow || "subtle"; + } + + get seed() { + const product = this.productTemplate; + if (product) return product.seed; + return this.element.dataset.radianceSeed || ""; + } + + // "renderer": the v2 film stack (F1). "page": the product's own page + // recipe carries the film; the renderer draws plain full-surface grain. + get filmMode() { + const product = this.productTemplate; + return product ? product.film : "renderer"; + } + + get layout() { + const product = this.productTemplate; + if (product) return product.layout; + const layout = this.element.dataset.radianceLayout || "right-wash"; + if (layout === "left" || layout === "left-wash") return "left-wash"; + if (layout === "top" || layout === "top-wash") return "top-wash"; + if (layout === "bottom" || layout === "bottom-wash") return "bottom-wash"; + if (layout === "neutral" || layout === "home-corners" || layout === "modal-balanced" || layout === "developer-sides") return "neutral"; + return "right-wash"; + } + + get isSubtle() { + const product = this.productTemplate; + if (product) return product.intensity === "subtle"; + return this.variant.endsWith("subtle") || this.element.dataset.radianceIntensity === "subtle"; + } + + get isDark() { + return this.appearance === "dark"; + } + + get isStill() { + // MOTION-7: a reduced-motion request freezes the surface at its + // resting frame. Same pattern, same composition, no drift. + if (prefersReducedMotion) return true; + if (this.product) return false; + return this.element.dataset.radianceMotion === "still" || this.variant.startsWith("static"); + } + + applyPatternShorthand() { + const raw = (this.element.dataset.radiancePattern || "").trim(); + if (raw === this.appliedPatternSource) { + if (this.product) this.guardProduct(); + return; + } + this.appliedPatternSource = raw; + + // PRODUCT-1: a product name selects that product's fixed pattern. + const productKey = raw.toLowerCase(); + if (Object.prototype.hasOwnProperty.call(PRODUCT_PATTERNS, productKey)) { + this.product = PRODUCT_PATTERNS[productKey]; + this.productName = productKey; + this.needsResize = true; + this.lastConfigKey = ""; + this.staticFrameKey = ""; + this.baseKey = ""; + this.guardProduct(); + return; + } + if (this.product) { + this.product = null; + this.productName = ""; + this.needsResize = true; + this.lastConfigKey = ""; + this.baseKey = ""; + } + if (!raw) return; + + const pattern = resolveRadiancePattern(raw); + if (!pattern) { + warnOnce( + this.element, + "PLACE-1", + `Unknown pattern "${raw}". Templates are: ${Object.keys(RADIANCE_PATTERNS).join(", ")}; ` + + `product patterns are: ${Object.keys(PRODUCT_PATTERNS).join(", ")}. ` + + "Keeping the data-radiance-* attributes already on this element." + ); + return; + } + + const data = this.element.dataset; + data.radianceLayout = pattern.layout; + data.radianceIntensity = pattern.intensity; + data.radianceDarkGlow = pattern.intensity; + data.radianceVariant = `${pattern.motion}-${pattern.intensity}`; + data.radianceSpeed = String(pattern.speed); + if (pattern.appearance) data.radianceAppearance = pattern.appearance; + if (pattern.motion === "static") data.radianceMotion = "still"; + else delete data.radianceMotion; + } + + // PRODUCT-2: a product pattern takes no adjustment or override. The + // attributes stay on the element (the page owns them) but are ignored. + guardProduct() { + const data = this.element.dataset; + const present = PRODUCT_LOCKED_ATTRIBUTES.filter(([key]) => data[key] !== undefined).map(([, attr]) => attr); + if (!present.length) return; + warnOnce( + this.element, + "PRODUCT-2", + `data-radiance-pattern="${this.productName}" is a fixed product pattern, so ${present.join(", ")} ` + + `${present.length === 1 ? "is" : "are"} ignored. Product patterns take no adjustment or override; ` + + "only the Radiance admin changes them. See RULES.md section 12." + ); + } + + // SURFACE-1: the base is the host's own background colour. Product + // patterns lock theirs; templates take data-radiance-base, then the + // --radiance-base custom property, then the language default. + resolveBase() { + const theme = this.appearance; + const raw = this.product + ? this.product.base[theme] + : (this.element.dataset.radianceBase || this.cssBase || ""); + const key = `${theme}|${raw}`; + if (key === this.baseKey) return this.baseRgb; + this.baseKey = key; + let rgb = raw ? parseColour(raw) : null; + if (raw && !rgb) { + warnOnce( + this.element, + "SURFACE-1", + `base colour "${raw}" is not an opaque colour, so the default ${theme} base is used. ` + + "Set data-radiance-base (or --radiance-base) to the exact, opaque background of the area this surface sits in." + ); + } + if (!rgb) rgb = theme === "dark" ? [26, 27, 30] : [255, 255, 255]; + this.baseRgb = rgb; + this.baseFill = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`; + this.staticFrameKey = ""; + return rgb; + } + + get baseTone() { + return `${this.baseRgb[0]},${this.baseRgb[1]},${this.baseRgb[2]}`; + } + + checkConsistency() { + const data = this.element.dataset; + if (this.product) return; + + if (data.radianceIntensity && data.radianceDarkGlow && data.radianceIntensity !== data.radianceDarkGlow) { + warnOnce( + this.element, + "INT-3", + `data-radiance-dark-glow="${data.radianceDarkGlow}" does not match ` + + `data-radiance-intensity="${data.radianceIntensity}". Dark mode draws a different falloff ` + + "when these disagree, so the surface will not match the rest of the product. Set both to the same value." + ); + } + + // MOTION-5 is an absolute guardrail: it applies to the EFFECTIVE speed, + // after any data-radiance-speed-scale percentage has been applied. + const effective = this.speed; + if (Number.isFinite(effective) && (effective < 0.4 || effective > 2)) { + const scale = parseAdjustment(data.radianceSpeedScale); + const via = scale === 1 + ? "" + : ` (${data.radianceSpeed} x ${Math.round(scale * 100)}%)`; + warnOnce( + this.element, + "MOTION-5", + `effective speed ${Math.round(effective * 1000) / 1000}${via} is outside the Radiance ` + + "range 0.4-2.0. Below 0.4 the drift reads as dead, above 2.0 it reads as nervous. " + + "See RULES.md (MOTION-5)." + ); + } + } + + isRenderable() { + if (!isDocumentVisible || !this.element.isConnected || !this.isInViewport) return false; + if (document.documentElement.dataset.radiancePaused === "true" || document.body?.classList.contains("is-proof-paused")) return false; + const slide = this.element.closest(".slide"); + if (slide && !slide.classList.contains("is-active")) return false; + const proofView = this.element.closest(".proof-view"); + if (proofView && !proofView.classList.contains("is-active")) return false; + const style = window.getComputedStyle(this.element); + if (style.display === "none" || style.visibility === "hidden" || Number(style.opacity) === 0) return false; + this.cssBase = style.getPropertyValue("--radiance-base").trim(); + const rect = this.element.getBoundingClientRect(); + return rect.width > 2 && rect.height > 2; + } + + getConfig() { + const layout = this.layout; + + if (!this.isDark) { + if (layout === "neutral") { + if (this.variant.startsWith("static")) return this.isSubtle ? STATIC_NEUTRAL_LIGHT_SUBTLE_BLOBS : STATIC_NEUTRAL_LIGHT_BLOBS; + return this.isSubtle ? NEUTRAL_LIGHT_SUBTLE_BLOBS : NEUTRAL_LIGHT_BLOBS; + } + const base = this.variant.startsWith("static") + ? (this.isSubtle ? STATIC_LIGHT_SUBTLE_BLOBS : STATIC_LIGHT_BLOBS) + : (this.isSubtle ? LIGHT_SUBTLE_BLOBS : LIGHT_BLOBS); + if (layout === "top-wash") return topBlobSet(base); + if (layout === "bottom-wash") return bottomBlobSet(base); + return layout === "left-wash" ? mirrorBlobSet(base) : base; + } + + if (layout === "neutral") { + if (this.variant.startsWith("static")) return this.isSubtle ? STATIC_NEUTRAL_DARK_SUBTLE_BLOBS : STATIC_NEUTRAL_DARK_PROMINENT_BLOBS; + return this.isSubtle ? NEUTRAL_DARK_SUBTLE_BLOBS : NEUTRAL_DARK_PROMINENT_BLOBS; + } + const base = this.variant.startsWith("static") + ? (this.isSubtle ? STATIC_DARK_SUBTLE_BLOBS : STATIC_DARK_PROMINENT_BLOBS) + : (this.darkGlow === "subtle" || this.isSubtle ? DARK_SUBTLE_BLOBS : DARK_PROMINENT_BLOBS); + if (layout === "top-wash") return topBlobSet(base); + if (layout === "bottom-wash") return bottomBlobSet(base); + return layout === "left-wash" ? mirrorBlobSet(base) : base; + } + + ensureSize() { + if (this.resizeObserver && !this.needsResize && this.width > 0 && this.height > 0) return; + + const rect = this.element.getBoundingClientRect(); + const nextWidth = Math.max(1, Math.round(this.element.clientWidth || rect.width)); + const nextHeight = Math.max(1, Math.round(this.element.clientHeight || rect.height)); + // The spotter engine renders one canvas pixel per CSS pixel and is + // shown pixelated, so its 1.0px film stays crisp (GRAIN-1/3). + const nextDpr = this.isSpotter ? 1 : Math.min(window.devicePixelRatio || 1, MAX_DPR); + this.canvas.style.imageRendering = this.isSpotter ? "pixelated" : ""; + + if (nextWidth === this.width && nextHeight === this.height && nextDpr === this.dpr) { + this.needsResize = false; + return; + } + + this.width = nextWidth; + this.height = nextHeight; + this.dpr = nextDpr; + this.canvas.width = Math.max(1, Math.floor(this.width * this.dpr)); + this.canvas.height = Math.max(1, Math.floor(this.height * this.dpr)); + this.canvas.style.width = `${this.width}px`; + this.canvas.style.height = `${this.height}px`; + this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.ctx.imageSmoothingEnabled = true; + this.ctx.imageSmoothingQuality = "high"; + this.textureKey = ""; + this.staticFrameKey = ""; + this.needsResize = false; + } + + ensureBlobs() { + const configKey = this.product + ? [this.appearance, this.productName, this.variant].join(":") + : [ + this.appearance, + this.darkGlow, + this.element.dataset.radianceLayout || "default", + this.element.dataset.radianceIntensity || "default", + this.variant || "default", + this.element.dataset.radianceMotion || "dynamic", + this.element.dataset.radianceProminence || "100%", + this.element.dataset.radianceSpread || "100%" + ].join(":"); + if (configKey === this.lastConfigKey && this.blobs.length) return; + this.lastConfigKey = configKey; + this.checkConsistency(); + this.staticFrameKey = ""; + const seed = this.seed || configKey; + const random = createSeededRandom(`radiance-blobs:${seed}`); + const prominence = this.prominenceScale; + const spread = this.spreadScale; + const configs = (prominence === 1 && spread === 1) + ? this.getConfig() + : this.getConfig().map((config) => ({ + ...config, + opacity: config.opacity * prominence, + radius: config.radius * spread + })); + this.blobs = configs.map((config) => new RadianceBlob(config, random)); + } + + createGrain(mode) { + const texture = document.createElement("canvas"); + texture.width = Math.max(1, Math.floor(this.width)); + texture.height = Math.max(1, Math.floor(this.height)); + const grainCtx = texture.getContext("2d"); + const isDark = mode === "dark"; + + const rawTexture = document.createElement("canvas"); + const rawWidth = Math.max(1, Math.ceil(texture.width / RADIANCE_FILM_GRAIN_SIZE)); + const rawHeight = Math.max(1, Math.ceil(texture.height / RADIANCE_FILM_GRAIN_SIZE)); + rawTexture.width = rawWidth; + rawTexture.height = rawHeight; + const rawCtx = rawTexture.getContext("2d"); + const image = rawCtx.createImageData(rawWidth, rawHeight); + const lightFilmBright = [ + [232, 248, 252], + [244, 229, 253], + [253, 228, 240], + [253, 244, 212], + [235, 241, 248] + ]; + const lightFilmChroma = [ + [120, 208, 230], + [194, 132, 232], + [244, 132, 196], + [246, 204, 104], + [116, 154, 246], + [128, 218, 194] + ]; + const lightFilmMid = [ + [174, 214, 220], + [204, 177, 224], + [224, 178, 202], + [224, 206, 150], + [184, 196, 210] + ]; + const lightFilmDark = [ + [122, 150, 156], + [150, 124, 168], + [168, 126, 148], + [150, 140, 116], + [132, 138, 146] + ]; + const darkFilmColors = [ + [76, 116, 164], + [122, 82, 140], + [148, 78, 112], + [118, 112, 92], + [132, 138, 148] + ]; + const darkFilmShadows = [ + [10, 12, 16], + [14, 15, 18], + [18, 16, 23], + [16, 18, 24] + ]; + + const writeFilmGrain = (data, alphaMultiplier, random) => { + for (let i = 0; i < data.length; i += 4) { + let color; + let alpha; + let sharedJitter; + let channelJitter; + + if (isDark) { + const toneRoll = random(); + const useShadowFleck = toneRoll < 0.16; + color = useShadowFleck + ? darkFilmShadows[Math.floor(random() * darkFilmShadows.length)] + : darkFilmColors[Math.floor(random() * darkFilmColors.length)]; + alpha = useShadowFleck + ? 7 + Math.floor(random() * 9) + : 14 + Math.floor(random() * 14); + sharedJitter = Math.floor((random() - 0.5) * (useShadowFleck ? 10 : 38)); + channelJitter = useShadowFleck ? 5 : 12; + } else { + const toneRoll = random(); + const palette = toneRoll < 0.68 + ? lightFilmBright + : toneRoll < 0.9 + ? lightFilmMid + : toneRoll < 0.97 + ? lightFilmChroma + : lightFilmDark; + color = palette[Math.floor(random() * palette.length)]; + alpha = toneRoll < 0.68 + ? 44 + Math.floor(random() * 28) + : toneRoll < 0.9 + ? 28 + Math.floor(random() * 22) + : toneRoll < 0.97 + ? 22 + Math.floor(random() * 20) + : 10 + Math.floor(random() * 14); + sharedJitter = Math.floor((random() - 0.5) * (toneRoll < 0.9 ? 28 : 16)); + channelJitter = toneRoll < 0.97 ? 12 : 8; + } + + data[i] = Math.max(0, Math.min(255, color[0] + sharedJitter + Math.floor((random() - 0.5) * channelJitter))); + data[i + 1] = Math.max(0, Math.min(255, color[1] + sharedJitter + Math.floor((random() - 0.5) * channelJitter))); + data[i + 2] = Math.max(0, Math.min(255, color[2] + sharedJitter + Math.floor((random() - 0.5) * channelJitter))); + data[i + 3] = Math.max(0, Math.min(255, Math.round(alpha * alphaMultiplier))); + } + }; + + writeFilmGrain( + image.data, + isDark ? 1.18 : 1.16, + createSeededRandom(`radiance-grain:${mode}:macro:${texture.width}x${texture.height}`) + ); + rawCtx.putImageData(image, 0, 0); + + grainCtx.save(); + grainCtx.imageSmoothingEnabled = false; + grainCtx.drawImage(rawTexture, 0, 0, texture.width, texture.height); + grainCtx.restore(); + + const microTexture = document.createElement("canvas"); + microTexture.width = texture.width; + microTexture.height = texture.height; + const microCtx = microTexture.getContext("2d"); + const microImage = microCtx.createImageData(texture.width, texture.height); + writeFilmGrain( + microImage.data, + isDark ? 0.36 : 0.3, + createSeededRandom(`radiance-grain:${mode}:micro:${texture.width}x${texture.height}`) + ); + microCtx.putImageData(microImage, 0, 0); + grainCtx.drawImage(microTexture, 0, 0); + + return texture; + } + + createDarkDither() { + const texture = document.createElement("canvas"); + texture.width = Math.max(1, Math.floor(this.width * this.dpr)); + texture.height = Math.max(1, Math.floor(this.height * this.dpr)); + const ditherCtx = texture.getContext("2d"); + const image = ditherCtx.createImageData(texture.width, texture.height); + const data = image.data; + const random = createSeededRandom(`radiance-dither:${texture.width}x${texture.height}`); + + for (let i = 0; i < data.length; i += 4) { + const isLightSpeck = random() > 0.44; + const alpha = isLightSpeck + ? 6 + Math.floor(random() * 8) + : 4 + Math.floor(random() * 5); + + data[i] = isLightSpeck ? 255 : 10; + data[i + 1] = isLightSpeck ? 255 : 12; + data[i + 2] = isLightSpeck ? 255 : 16; + data[i + 3] = alpha; + } + + ditherCtx.putImageData(image, 0, 0); + return texture; + } + + drawLightRightEdgeSupport(t) { + const ctx = this.ctx; + const w = this.width; + const h = this.height; + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + + const intensity = (this.isSubtle ? 0.58 : 1) * this.prominenceScale; + const pulse = (0.92 + 0.08 * Math.sin(t * 0.0005)) * intensity; + const rightWash = ctx.createLinearGradient(w * 0.36, 0, w, 0); + rightWash.addColorStop(0, "rgba(255,255,255,0)"); + rightWash.addColorStop(0.42, `rgba(200,220,255,${0.045 * pulse})`); + rightWash.addColorStop(0.66, `rgba(140,100,255,${0.075 * pulse})`); + rightWash.addColorStop(0.84, `rgba(255,120,160,${0.085 * pulse})`); + rightWash.addColorStop(1, `rgba(40,140,255,${0.11 * pulse})`); + ctx.fillStyle = rightWash; + ctx.fillRect(0, 0, w, h); + + const supportRadius = Math.max(w, h) * 0.42; + const supportGlows = [ + { x: w * 1.02, y: h * 0.12, radius: supportRadius, color: [255, 120, 160], alpha: 0.18 }, + { x: w * 0.96, y: h * 0.42, radius: supportRadius * 1.05, color: [140, 100, 255], alpha: 0.16 }, + { x: w * 1.02, y: h * 0.86, radius: supportRadius * 1.08, color: [40, 140, 255], alpha: 0.18 }, + { x: w * 0.84, y: h * 0.98, radius: supportRadius * 0.9, color: [80, 220, 180], alpha: 0.035 } + ]; + + for (const glow of supportGlows) { + const [r, g, b] = glow.color; + const grd = ctx.createRadialGradient(glow.x, glow.y, 0, glow.x, glow.y, glow.radius); + grd.addColorStop(0, `rgba(${r},${g},${b},${glow.alpha * pulse})`); + grd.addColorStop(0.58, `rgba(${r},${g},${b},${glow.alpha * 0.3 * pulse})`); + grd.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, w, h); + } + + ctx.restore(); + } + + drawDarkRightEdgeSupport(t) { + const ctx = this.ctx; + const w = this.width; + const h = this.height; + ctx.save(); + ctx.globalCompositeOperation = "screen"; + + const intensity = (this.isSubtle ? 0.58 : 1) * this.prominenceScale; + const pulse = (0.86 + 0.14 * Math.sin(t * 0.00062)) * intensity; + const rightWash = ctx.createLinearGradient(w * 0.5, 0, w, 0); + rightWash.addColorStop(0, "rgba(0,0,0,0)"); + rightWash.addColorStop(0.56, `rgba(138,93,255,${0.035 * pulse})`); + rightWash.addColorStop(0.82, `rgba(39,140,255,${0.05 * pulse})`); + rightWash.addColorStop(1, `rgba(255,93,142,${0.055 * pulse})`); + ctx.fillStyle = rightWash; + ctx.fillRect(0, 0, w, h); + + const cornerGlowRadius = Math.max(w, h) * 0.44; + const cornerGlows = [ + { x: w * 1.03, y: h * 0.06, radius: cornerGlowRadius, color: [255, 93, 142], alpha: 0.14 }, + { x: w * 1.04, y: h * 0.92, radius: cornerGlowRadius * 1.05, color: [39, 140, 255], alpha: 0.15 }, + { x: w * 0.9, y: h * 1.02, radius: cornerGlowRadius * 0.82, color: [45, 220, 177], alpha: 0.03 } + ]; + + for (const glow of cornerGlows) { + const [r, g, b] = glow.color; + const grd = ctx.createRadialGradient(glow.x, glow.y, 0, glow.x, glow.y, glow.radius); + grd.addColorStop(0, `rgba(${r},${g},${b},${glow.alpha * pulse})`); + grd.addColorStop(0.58, `rgba(${r},${g},${b},${glow.alpha * 0.28 * pulse})`); + grd.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, w, h); + } + + ctx.restore(); + } + + drawLightTopSupport(t) { + const ctx = this.ctx; + const w = this.width; + const h = this.height; + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + + const intensity = (this.isSubtle ? 0.64 : 1) * this.prominenceScale; + const pulse = (0.9 + 0.1 * Math.sin(t * 0.0007)) * intensity; + const topWash = ctx.createLinearGradient(0, 0, 0, h); + topWash.addColorStop(0, `rgba(200,220,255,${0.105 * pulse})`); + topWash.addColorStop(0.18, `rgba(140,100,255,${0.062 * pulse})`); + topWash.addColorStop(0.34, `rgba(255,120,160,${0.052 * pulse})`); + topWash.addColorStop(0.58, `rgba(255,200,220,${0.024 * pulse})`); + topWash.addColorStop(1, "rgba(255,255,255,0)"); + ctx.fillStyle = topWash; + ctx.fillRect(0, 0, w, h); + + const supportRadius = Math.max(w, h) * 0.42; + const supportGlows = [ + { x: w * 0.16, y: h * -0.04, radius: supportRadius, color: [255, 120, 160], alpha: 0.13 }, + { x: w * 0.36, y: h * -0.05, radius: supportRadius * 1.04, color: [140, 100, 255], alpha: 0.13 }, + { x: w * 0.64, y: h * -0.02, radius: supportRadius * 1.08, color: [40, 140, 255], alpha: 0.15 }, + { x: w * 0.86, y: h * 0.02, radius: supportRadius * 1.12, color: [80, 220, 180], alpha: 0.06 } + ]; + + for (const glow of supportGlows) { + const [r, g, b] = glow.color; + const grd = ctx.createRadialGradient(glow.x, glow.y, 0, glow.x, glow.y, glow.radius); + grd.addColorStop(0, `rgba(${r},${g},${b},${glow.alpha * pulse})`); + grd.addColorStop(0.52, `rgba(${r},${g},${b},${glow.alpha * 0.28 * pulse})`); + grd.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, w, h); + } + + ctx.restore(); + } + + drawDarkTopSupport(t) { + const ctx = this.ctx; + const w = this.width; + const h = this.height; + ctx.save(); + ctx.globalCompositeOperation = "screen"; + + const intensity = (this.isSubtle ? 0.68 : 1) * this.prominenceScale; + const pulse = (0.86 + 0.14 * Math.sin(t * 0.00072)) * intensity; + const topWash = ctx.createLinearGradient(0, 0, 0, h); + topWash.addColorStop(0, `rgba(138,93,255,${0.082 * pulse})`); + topWash.addColorStop(0.18, `rgba(39,140,255,${0.058 * pulse})`); + topWash.addColorStop(0.34, `rgba(255,93,142,${0.043 * pulse})`); + topWash.addColorStop(0.58, `rgba(255,170,210,${0.018 * pulse})`); + topWash.addColorStop(1, "rgba(0,0,0,0)"); + ctx.fillStyle = topWash; + ctx.fillRect(0, 0, w, h); + + const supportRadius = Math.max(w, h) * 0.42; + const supportGlows = [ + { x: w * 0.18, y: h * -0.04, radius: supportRadius, color: [255, 93, 142], alpha: 0.105 }, + { x: w * 0.34, y: h * -0.05, radius: supportRadius * 1.04, color: [138, 93, 255], alpha: 0.12 }, + { x: w * 0.62, y: h * -0.02, radius: supportRadius * 1.08, color: [39, 140, 255], alpha: 0.13 }, + { x: w * 0.86, y: h * 0.02, radius: supportRadius * 1.12, color: [45, 220, 177], alpha: 0.035 } + ]; + + for (const glow of supportGlows) { + const [r, g, b] = glow.color; + const grd = ctx.createRadialGradient(glow.x, glow.y, 0, glow.x, glow.y, glow.radius); + grd.addColorStop(0, `rgba(${r},${g},${b},${glow.alpha * pulse})`); + grd.addColorStop(0.54, `rgba(${r},${g},${b},${glow.alpha * 0.3 * pulse})`); + grd.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = grd; + ctx.fillRect(0, 0, w, h); + } + + ctx.restore(); + } + + drawNeutralSupport(t) { + const ctx = this.ctx; + const w = this.width; + const h = this.height; + const cycle = t * 0.00044; + const focusX = w * (0.5 + Math.sin(cycle) * 0.04); + const focusY = h * (0.48 + Math.cos(cycle * 0.86) * 0.035); + const radius = Math.max(w, h) * 0.74; + const washAlpha = (this.isSubtle ? 1.18 : 0.88) / this.prominenceScale; + + ctx.save(); + ctx.globalCompositeOperation = "source-over"; + + const tone = this.baseTone; + const centerWash = ctx.createRadialGradient(focusX, focusY, radius * 0.06, focusX, focusY, radius); + if (this.isDark) { + centerWash.addColorStop(0, `rgba(${tone},${0.18 * washAlpha})`); + centerWash.addColorStop(0.48, `rgba(${tone},${0.08 * washAlpha})`); + centerWash.addColorStop(1, `rgba(${tone},0)`); + } else { + centerWash.addColorStop(0, `rgba(${tone},${0.34 * washAlpha})`); + centerWash.addColorStop(0.46, `rgba(${tone},${0.18 * washAlpha})`); + centerWash.addColorStop(1, `rgba(${tone},0)`); + } + ctx.fillStyle = centerWash; + ctx.fillRect(0, 0, w, h); + + const crossWash = ctx.createLinearGradient(0, h * 0.1, w, h * 0.92); + if (this.isDark) { + crossWash.addColorStop(0, `rgba(${tone},${0.08 * washAlpha})`); + crossWash.addColorStop(0.52, `rgba(${tone},0)`); + crossWash.addColorStop(1, `rgba(${tone},${0.065 * washAlpha})`); + } else { + crossWash.addColorStop(0, `rgba(${tone},${0.08 * washAlpha})`); + crossWash.addColorStop(0.5, `rgba(${tone},0)`); + crossWash.addColorStop(1, `rgba(${tone},${0.07 * washAlpha})`); + } + ctx.fillStyle = crossWash; + ctx.fillRect(0, 0, w, h); + + const minSide = Math.min(w, h); + const veilAlpha = (this.isSubtle ? 1.34 : 0.32) / this.prominenceScale; + const veilTone = tone; + + for (const veil of NEUTRAL_VEIL_BLOBS) { + const x = w * veil.anchorX + Math.sin(t * veil.freqX + veil.phase) * veil.orbitX; + const y = h * veil.anchorY + Math.cos(t * veil.freqY + veil.phase) * veil.orbitY; + const veilRadius = minSide * veil.radius; + const alpha = veil.opacity * veilAlpha; + const neutralBlob = ctx.createRadialGradient(x, y, veilRadius * 0.06, x, y, veilRadius); + neutralBlob.addColorStop(0, `rgba(${veilTone},${alpha})`); + neutralBlob.addColorStop(0.54, `rgba(${veilTone},${alpha * 0.42})`); + neutralBlob.addColorStop(1, `rgba(${veilTone},0)`); + ctx.fillStyle = neutralBlob; + ctx.fillRect(0, 0, w, h); + } + ctx.restore(); + } + + // GRAIN-4 (experiment): keep grain inside the colour field. + // The draw methods all write to this.ctx, so swap it for the offscreen + // field context rather than threading a target through every method. + renderColourField(t) { + const realCtx = this.ctx; + this.fieldCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.fieldCtx.clearRect(0, 0, this.width, this.height); + + this.fieldCtx.save(); + this.fieldCtx.filter = `blur(${this.layout === "neutral" ? 22 : 40}px)`; + for (const blob of this.blobs) { + blob.update(t, this.width, this.height, this.mouse, this.breathT); + blob.draw(this.fieldCtx); + } + this.fieldCtx.restore(); + + this.ctx = this.fieldCtx; + try { + this.drawSupport(t); + } finally { + this.ctx = realCtx; + } + } + + // Dark keeps its own colour compositing (each layer screened onto the + // base separately). This pass exists only to collect the field's ALPHA, + // so the colours it produces here are never shown. + renderDarkColourField(t) { + const realCtx = this.ctx; + this.fieldCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.fieldCtx.globalCompositeOperation = "source-over"; + this.fieldCtx.clearRect(0, 0, this.width, this.height); + + this.fieldCtx.save(); + this.fieldCtx.filter = `blur(${this.layout === "neutral" ? 40 : 64}px)`; + this.fieldCtx.drawImage(this.blobCanvas, 0, 0, this.width, this.height); + this.fieldCtx.restore(); + + this.ctx = this.fieldCtx; + try { + this.drawSupport(t); + } finally { + this.ctx = realCtx; + } + } + + // Stack the colour field onto itself so the mask reads closer to + // present/absent than to the field's own opacity. Without this the + // texture would be thinned everywhere in proportion to colour strength. + buildColourMask(saturation) { + const maskCtx = this.maskCtx; + const mw = this.maskCanvas.width; + const mh = this.maskCanvas.height; + maskCtx.setTransform(1, 0, 0, 1, 0, 0); + maskCtx.globalCompositeOperation = "source-over"; + maskCtx.imageSmoothingEnabled = true; + maskCtx.clearRect(0, 0, mw, mh); + // Stacking n copies gives 1 - (1 - a)^n; drawing the mask onto itself + // squares that, so 3 copies doubled twice is exactly the 12th power. + let power = saturation; + let doublings = 0; + while (power % 2 === 0 && power > 3) { + power /= 2; + doublings += 1; + } + for (let pass = 0; pass < power; pass += 1) { + maskCtx.drawImage(this.fieldCanvas, 0, 0, mw, mh); + } + for (let pass = 0; pass < doublings; pass += 1) { + maskCtx.drawImage(this.maskCanvas, 0, 0); + } + } + + // GRAIN-4: draw a texture through the colour mask, so it never lands on + // the bare surface. Call buildColourMask() first. + drawMaskedTexture(texture, alpha = 1) { + const grainCtx = this.grainMaskCtx; + grainCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + grainCtx.globalCompositeOperation = "source-over"; + grainCtx.clearRect(0, 0, this.width, this.height); + grainCtx.imageSmoothingEnabled = false; + grainCtx.drawImage(texture, 0, 0, this.width, this.height); + grainCtx.globalCompositeOperation = "destination-in"; + grainCtx.imageSmoothingEnabled = true; + grainCtx.drawImage(this.maskCanvas, 0, 0, this.width, this.height); + grainCtx.globalCompositeOperation = "source-over"; + + this.ctx.save(); + this.ctx.globalAlpha = alpha; + this.ctx.imageSmoothingEnabled = false; + this.ctx.drawImage(this.grainCanvas, 0, 0, this.width, this.height); + this.ctx.restore(); + } + + // F1: the fine noise layer, through the colour mask (GRAIN-4). + drawMaskedOverlay(theme) { + const pattern = getOverlayPattern(theme, this.dpr, this.grainMaskCtx); + if (!pattern) return; + const film = FILM[theme]; + const grainCtx = this.grainMaskCtx; + grainCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + grainCtx.globalCompositeOperation = "source-over"; + grainCtx.clearRect(0, 0, this.width, this.height); + grainCtx.fillStyle = pattern; + grainCtx.fillRect(0, 0, this.width, this.height); + grainCtx.globalCompositeOperation = "destination-in"; + grainCtx.imageSmoothingEnabled = true; + grainCtx.drawImage(this.maskCanvas, 0, 0, this.width, this.height); + grainCtx.globalCompositeOperation = "source-over"; + + this.ctx.save(); + this.ctx.globalAlpha = film.overlay; + this.ctx.globalCompositeOperation = film.overlayBlend; + this.ctx.imageSmoothingEnabled = false; + this.ctx.drawImage(this.grainCanvas, 0, 0, this.width, this.height); + this.ctx.restore(); + } + + // F1: the tone lift (Home's saturate/contrast), applied to the colour + // field only - the bare surface keeps the host's exact base colour. + applyToneLift(theme) { + const film = FILM[theme]; + const liftCtx = this.liftCtx; + const w = this.canvas.width; + const h = this.canvas.height; + liftCtx.setTransform(1, 0, 0, 1, 0, 0); + liftCtx.globalCompositeOperation = "copy"; + liftCtx.filter = `saturate(${film.saturate}) contrast(${film.contrast})`; + liftCtx.drawImage(this.canvas, 0, 0); + liftCtx.filter = "none"; + liftCtx.globalCompositeOperation = "destination-in"; + liftCtx.imageSmoothingEnabled = true; + liftCtx.drawImage(this.maskCanvas, 0, 0, w, h); + liftCtx.globalCompositeOperation = "source-over"; + + this.ctx.save(); + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.globalAlpha = 1; + this.ctx.globalCompositeOperation = "source-over"; + this.ctx.drawImage(this.liftCanvas, 0, 0); + this.ctx.restore(); + } + + drawSupport(t) { + if (this.layout === "neutral") { + this.drawNeutralSupport(t); + return; + } + + if (this.layout === "top-wash") { + if (this.isDark) this.drawDarkTopSupport(t); + else this.drawLightTopSupport(t); + return; + } + + if (this.layout === "bottom-wash") { + this.ctx.save(); + this.ctx.translate(0, this.height); + this.ctx.scale(1, -1); + if (this.isDark) this.drawDarkTopSupport(t); + else this.drawLightTopSupport(t); + this.ctx.restore(); + return; + } + + const draw = () => { + if (this.isDark) { + this.drawDarkRightEdgeSupport(t); + } else { + this.drawLightRightEdgeSupport(t); + } + }; + + if (this.layout === "left-wash") { + this.ctx.save(); + this.ctx.translate(this.width, 0); + this.ctx.scale(-1, 1); + draw(); + this.ctx.restore(); + } else { + draw(); + } + } + + drawDarkFalloff() { + const ctx = this.ctx; + if (this.layout === "top-wash" || this.layout === "bottom-wash") { + const startY = this.layout === "bottom-wash" ? this.height : 0; + const endY = this.layout === "bottom-wash" ? 0 : this.height; + const verticalFalloff = ctx.createLinearGradient(0, startY, 0, endY); + const tone = this.baseTone; + verticalFalloff.addColorStop(0, `rgba(${tone},0.26)`); + verticalFalloff.addColorStop(0.28, `rgba(${tone},0.16)`); + verticalFalloff.addColorStop(0.58, `rgba(${tone},0.06)`); + verticalFalloff.addColorStop(1, `rgba(${tone},0)`); + ctx.fillStyle = verticalFalloff; + ctx.fillRect(0, 0, this.width, this.height); + return; + } + const startX = this.layout === "left-wash" ? this.width : 0; + const endX = this.layout === "left-wash" ? 0 : this.width; + const falloff = ctx.createLinearGradient(startX, 0, endX, this.height); + const falloffTone = this.baseTone; + falloff.addColorStop(0, `rgba(${falloffTone},0.38)`); + falloff.addColorStop(0.42, `rgba(${falloffTone},0.2)`); + falloff.addColorStop(0.78, `rgba(${falloffTone},0.04)`); + falloff.addColorStop(1, `rgba(${falloffTone},0)`); + ctx.fillStyle = falloff; + ctx.fillRect(0, 0, this.width, this.height); + } + + ensureTextures() { + const nextKey = `${this.width}x${this.height}@${this.dpr}:${this.appearance}`; + if (nextKey === this.textureKey) return; + this.textureKey = nextKey; + + if (this.isDark) { + this.lightGrain = undefined; + this.darkGrain = this.getCachedTexture("dark-grain", () => this.createGrain("dark")); + this.darkDither = this.getCachedTexture("dark-dither", () => this.createDarkDither()); + } else { + this.lightGrain = this.getCachedTexture("light-grain", () => this.createGrain("light")); + this.darkGrain = undefined; + this.darkDither = undefined; + } + + this.blobCanvas.width = Math.max(1, Math.floor(this.width * this.dpr)); + this.blobCanvas.height = Math.max(1, Math.floor(this.height * this.dpr)); + this.blobCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + for (const target of [this.fieldCanvas, this.grainCanvas, this.liftCanvas]) { + target.width = this.blobCanvas.width; + target.height = this.blobCanvas.height; + } + this.maskCanvas.width = Math.max(1, Math.ceil(this.blobCanvas.width * MASK_SCALE)); + this.maskCanvas.height = Math.max(1, Math.ceil(this.blobCanvas.height * MASK_SCALE)); + this.fieldCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.grainMaskCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.staticFrameKey = ""; + } + + getCachedTexture(kind, factory) { + const key = `${kind}:${this.width}x${this.height}@${this.dpr}`; + const cachedTexture = textureCache.get(key); + if (cachedTexture) return cachedTexture; + return rememberTexture(key, factory()); + } + + // "page" film (the Home product): the full-surface grain Home was built + // on; its page recipe fades and tones it. + /* A product whose colour sits below full strength (Home's hero) mixes it + toward the base here, before the grain, so the grain keeps its one + value (GRAIN-6). */ + drawFieldOpacity() { + const opacity = this.product && this.product.fieldOpacity ? this.product.fieldOpacity[this.isDark ? "dark" : "light"] : 1; + if (!(opacity < 1)) return; + this.ctx.save(); + this.ctx.globalCompositeOperation = "source-over"; + this.ctx.globalAlpha = 1 - opacity; + this.ctx.fillStyle = this.baseFill; + this.ctx.fillRect(0, 0, this.width, this.height); + this.ctx.restore(); + } + + drawPlainTexture(texture, alpha) { + if (!texture) return; + this.ctx.save(); + this.ctx.globalAlpha = alpha; + this.ctx.imageSmoothingEnabled = false; + this.ctx.drawImage(texture, 0, 0, this.width, this.height); + this.ctx.restore(); + } + + /* ---- the Spotter radiance engine (product patterns spotterx, liveboard) ---- */ + + /* Locked: the values SpotterX and Liveboard ship (SBG_TUNING). Only the + state comes from the page; the admin lab may preview other values on + one surface (RadianceLanguageRenderer.admin). */ + get sbgTuning() { + const raw = (this.element.dataset.radianceState || "idle").trim().toLowerCase(); + let state = PRODUCT_STATES[raw]; + if (!state) { + warnOnce(this.element, "PRODUCT-4", `data-radiance-state="${raw}" is not a state. Use idle, reasoning or answered.`); + state = PRODUCT_STATES.idle; + } + const tuning = { ...SBG_TUNING }; + const admin = ADMIN_SPOTTER.get(this.element); + if (admin) { + Object.keys(SBG_TUNING).forEach((key) => { + if (Number.isFinite(admin[key])) tuning[key] = admin[key]; + }); + } + return { energy: state[0], settled: state[1], ...tuning }; + } + + /* The state levels for a theme: SBG_STATES, or the admin lab's preview. */ + sbgLevels(theme) { + const admin = ADMIN_SPOTTER.get(this.element); + const levels = admin && admin.levels && admin.levels[theme]; + return levels ? { ...SBG_STATES[theme], ...levels } : SBG_STATES[theme]; + } + + /* The grain strength for a theme: the language's one grain (FILM, + GRAIN-6), or the admin lab's preview of a new one. */ + sbgGrainStrength(theme) { + const admin = ADMIN_SPOTTER.get(this.element); + const grain = admin && admin.grain && admin.grain[theme]; + return Number.isFinite(grain) ? clamp(grain, 0, 1) : FILM[theme].grain; + } + + ensureSbg(tune) { + const key = `${this.width}x${this.height}`; + if (this.sbg && this.sbg.key === key) return this.sbg; + const prev = this.sbg; + const W = this.width; + const H = this.height; + const spacing = clamp(W / 6.4, 170, 300); + const radius = spacing * 1.6; + /* A blob wraps from one edge to the other only when fully out of + view: widest horizontal extent of the tilted, stretched, breathing + ellipse (~2.4 radii) + wander + blur. */ + const reachX = radius * 2.4 + spacing * 0.5 + 80; + const count = Math.max(6, Math.ceil((W + reachX * 2) / spacing)); + const band = count * spacing; + const rand = (lo, hi) => lo + Math.random() * (hi - lo); + const blobs = []; + let walk = { i: 1, d: 1, hold: false, cool: false, warm: 0, run: 1 }; + for (let i = 0; i < count; i += 1) { + const blob = { slot: i, lap: null }; + RadianceSurface.reseedBlob(blob, walk, spacing, band, W); + blobs.push(blob); + walk = RadianceSurface.walkHue(walk); + } + /* The reasoning stream: fewer, smaller lights flowing the other way. */ + const streamSpacing = spacing * 1.7; + const streamCount = Math.max(4, Math.ceil((W + reachX * 2) / streamSpacing)); + const streamBand = streamCount * streamSpacing; + const stream = []; + for (let i = 0; i < streamCount; i += 1) { + stream.push({ + x0: -(streamBand - W) / 2 + (i + 0.5 + rand(-0.25, 0.25)) * streamSpacing, + sig: rand(0.8, 1.2), + f: [rand(0.0006, 0.001), rand(0.0011, 0.0017), rand(0.0005, 0.0011), rand(0.0007, 0.0013)], + p: [rand(0, TAU), rand(0, TAU), rand(0, TAU), rand(0, TAU)], + bp: rand(0, TAU), + bMs: rand(1500, 2800) + }); + } + const lw = Math.ceil((W + SBG_PAD * 2) * SBG_SCALE); + const lh = Math.ceil((H + SBG_PAD) * SBG_SCALE); + const layer = () => { + const canvas = document.createElement("canvas"); + canvas.width = lw; + canvas.height = lh; + return { canvas, ctx: canvas.getContext("2d") }; + }; + this.sbg = { + key, W, H, spacing, radius, band, blobs, lw, lh, + margin: (band - W) / 2, + stream, streamSpacing, streamBand, + streamMargin: (streamBand - W) / 2, + ax: new Float32Array(count), + ah: new Array(count), + blob: layer(), + field: layer(), + grainKey: "", grain: null, + clock: prev ? prev.clock : rand(0, 600000), + chorus: prev ? prev.chorus : 0, + energy: prev ? prev.energy : tune.energy, + vel: prev ? prev.vel : 0, + target: prev ? prev.target : tune.energy, + settled: prev ? prev.settled : tune.settled, + settledVel: prev ? prev.settledVel : 0, + last: 0, + cost: prev ? prev.cost : 0, + drawnH: H, + drawnTheme: "" + }; + return this.sbg; + } + + /* Home's hero grain: the canonical passes at the hero's opacity. */ + sbgGrain(S) { + const strength = this.sbgGrainStrength(this.isDark ? "dark" : "light"); + const key = `${S.W}x${S.H}:${this.appearance}:${strength}`; + if (S.grainKey === key && S.grain) return S.grain; + const texture = document.createElement("canvas"); + texture.width = S.W; + texture.height = S.H; + const tctx = texture.getContext("2d"); + tctx.imageSmoothingEnabled = false; + if (this.isDark) { + tctx.globalAlpha = 0.42 * strength; + tctx.drawImage(this.darkDither, 0, 0, S.W, S.H); + tctx.globalAlpha = strength; + tctx.drawImage(this.darkGrain, 0, 0, S.W, S.H); + } else { + tctx.globalAlpha = strength; + tctx.drawImage(this.lightGrain, 0, 0, S.W, S.H); + } + S.grain = texture; + S.grainKey = key; + return texture; + } + + /* One step of the colour walk (see SBG_PATH). State: i (index on the + path), d (direction), hold (repeat once), cool (peach seen - no second + peach before violet), warm / run (length of the current warm / cool + stretch). */ + static walkHue(state) { + const isWarm = (index) => index >= 3; + let next; + if (SBG_PATH[state.i] === "pink" && state.warm >= 3) { + next = { i: 2, d: -1, hold: false, cool: true }; + } else if (!isWarm(state.i) && state.run >= 5) { + next = { i: state.i + 1, d: 1, hold: false, cool: false }; + } else { + next = RadianceSurface.walkStep(state); + if (!isWarm(state.i) && isWarm(next.i) && state.run < 2) { + next = { i: 1, d: -1, hold: false, cool: next.cool }; + } + } + next.warm = isWarm(next.i) ? (isWarm(state.i) ? state.warm + 1 : 1) : 0; + next.run = isWarm(next.i) ? 0 : (isWarm(state.i) ? 1 : state.run + 1); + return next; + } + + static walkStep(state) { + const { i, hold } = state; + let { d, cool } = state; + if (hold) return { i: i + d, d, hold: false, cool }; + const roll = Math.random(); + const hue = SBG_PATH[i]; + if (hue === "peach") return { i: i - 1, d: -1, hold: false, cool: true }; + if (hue === "mint") return { i: i + 1, d: 1, hold: false, cool }; + if (hue === "blue") { + if (d === -1) { + if (roll < 0.3) return { i: i - 1, d: -1, hold: false, cool }; + if (roll < 0.72) return { i, d: 1, hold: true, cool }; + return { i: i + 1, d: 1, hold: false, cool }; + } + if (roll < 0.1) return { i: i - 1, d: -1, hold: false, cool }; + if (roll < 0.18) return { i, d, hold: false, cool }; + return { i: i + 1, d, hold: false, cool }; + } + if (hue === "violet") { + cool = false; + if (roll < 0.14) d = -d; + else if (roll < 0.22) return { i, d, hold: false, cool }; + return { i: i + d, d, hold: false, cool }; + } + if (d === 1 && !cool) { + if (roll < 0.4) return { i: i + 1, d: 1, hold: false, cool }; + if (roll < 0.5) return { i, d, hold: false, cool }; + return { i: i - 1, d: -1, hold: false, cool }; + } + if (roll < 0.1) return { i, d: -1, hold: false, cool }; + return { i: i - 1, d: -1, hold: false, cool }; + } + + /* A blob's identity: hue, slot jitter, size, and per-blob frequencies + (rad per clock-ms) - every motion is 2-3 sines at incommensurate + ratios, so no blob ever retraces a path. */ + static reseedBlob(blob, walk, spacing, band, W) { + const rand = (lo, hi) => lo + Math.random() * (hi - lo); + const wx = rand(0.00045, 0.0008); + const wy = rand(0.0004, 0.0009); + const wr = rand(0.0006, 0.0011); + const ws = rand(0.0005, 0.0012); + const wt = rand(0.0005, 0.0012); + const wq = rand(0.00025, 0.0005); + blob.walk = walk; + blob.hue = SBG_PATH[walk.i]; + blob.x0 = -(band - W) / 2 + (blob.slot + 0.5 + rand(-0.18, 0.18)) * spacing; + blob.sig = rand(0.92, 1.08); + blob.amp = rand(0.9, 1.1); + blob.f = [ + wx, wx * rand(1.52, 1.72), wx * rand(2.3, 2.6), + wy, wy * rand(1.6, 1.9), + wr, wr * rand(1.4, 1.7), + ws, ws * rand(1.5, 1.8), + wt, wt * rand(1.5, 1.8), + wq, wq * rand(1.6, 1.9) + ]; + blob.p = Array.from({ length: 13 }, () => rand(0, TAU)); + const B = SBG_MOTION.breathMs; + blob.bRest = rand(B.rest[0], B.rest[1]); + blob.bReason = rand(B.reason[0], B.reason[1]); + blob.bCalm = rand(B.calm[0], B.calm[1]); + blob.bp = rand(0, TAU); + } + + /* Exact solution of a critically damped spring step: stable at any dt. */ + static spring(value, velocity, target, omega, dt) { + const e0 = value - target; + const k = velocity + omega * e0; + const decay = Math.exp(-omega * dt); + return [target + (e0 + k * dt) * decay, (velocity - omega * k * dt) * decay]; + } + + renderSbg(timestamp) { + const started = performance.now(); + const tune = this.sbgTuning; + const S = this.ensureSbg(tune); + const theme = this.isDark ? "dark" : "light"; + const still = this.isStill; + const M = SBG_MOTION; + + const dtMs = S.last ? clamp(timestamp - S.last, 0, 250) : 0; + S.last = timestamp; + const dt = dtMs / 1000; + if (tune.energy > 0.5 && S.target <= 0.5) S.chorus = 0; + S.target = tune.energy; + [S.energy, S.vel] = RadianceSurface.spring(S.energy, S.vel, tune.energy, + (tune.energy > S.energy ? M.rise : M.fall) / tune.pace, dt); + [S.settled, S.settledVel] = RadianceSurface.spring(S.settled, S.settledVel, tune.settled, M.settle / tune.pace, dt); + const E = clamp(S.energy, 0, 1); + const calm = clamp(S.settled, 0, 1); + + const m = tune.flow; + const rateRest = mix(M.speedDefault, M.speedSettled, calm) * m; + const rate = Math.min(M.speedCap, mix(rateRest, M.speedReason * m, E)); + const tempo = Math.sqrt(Math.max(m, 0.05) / 2.5); + if (!still) { + S.clock += dtMs * rate; + S.chorus += (TAU * dtMs / M.chorusMs) * tempo * (1 + 0.06 * Math.sin(S.clock * 0.00013)); + } + const t = S.clock; + const breath = still ? 0 : tune.breath; + const sizeSwing = mix(mix(M.breathSize.rest, M.breathSize.calm, calm), M.breathSize.reason, E) * breath; + const glowSwing = mix(mix(M.breathGlow.rest, M.breathGlow.calm, calm), M.breathGlow.reason, E) * breath; + const chorus = wave(S.chorus); + const chorusSize = 1 + M.chorusSize * E * breath * (chorus - 0.3); + const chorusGlow = 1 + M.chorusGlow * E * breath * (chorus - 0.5); + const state = this.sbgLevels(theme); + const restLevel = mix(state.rest, Math.min(1, state.calm * tune.calmGain) * state.rest, calm); + const level = mix(restLevel, state.reason, E); + const alphaNow = level * tune.gain; + const boost = level / mix(restLevel, state.floor, E); + const palette = SBG_PALETTE[theme]; + const W = S.W; + const H = S.H; + const baseLift = SBG_BASE_LIFT + tune.lift; + const lift = baseLift - S.radius * SBG_CALM_RISE * calm; + const wander = S.spacing * (1 + M.wanderReason * E); + + /* Blobs, at quarter resolution, in CSS-pixel coordinates. The drift's + own speed swells and eases on two slow sines (never reversing). */ + const bctx = S.blob.ctx; + bctx.setTransform(1, 0, 0, 1, 0, 0); + bctx.globalCompositeOperation = "source-over"; + bctx.clearRect(0, 0, S.lw, S.lh); + bctx.setTransform(SBG_SCALE, 0, 0, SBG_SCALE, SBG_PAD * SBG_SCALE, SBG_PAD * SBG_SCALE); + const flowX = t * M.flow + S.spacing * (0.5 * Math.sin(t * 0.000061) + 0.3 * Math.sin(t * 0.000097 + 1.3)); + /* A blob that has just wrapped (it is off-screen, entering on the + left) is reborn: the next step of the colour walk from its + right-hand neighbour, fresh size, timing and shape. */ + for (const blob of S.blobs) { + const lap = Math.floor((blob.x0 + flowX + S.margin) / S.band); + if (blob.lap !== null && lap !== blob.lap) { + const right = S.blobs[(blob.slot + 1) % S.blobs.length]; + RadianceSurface.reseedBlob(blob, RadianceSurface.walkHue(right.walk), S.spacing, S.band, W); + blob.lap = Math.floor((blob.x0 + flowX + S.margin) / S.band); + } else { + blob.lap = lap; + } + } + const draw = (x, y, r, sx, sy, tilt, rgb, opacity) => { + bctx.save(); + bctx.translate(x, y); + bctx.rotate(tilt); + bctx.scale(sx, sy); + const gradient = bctx.createRadialGradient(0, 0, 0, 0, 0, r); + gradient.addColorStop(0, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${opacity})`); + gradient.addColorStop(1, `rgba(${rgb[0]},${rgb[1]},${rgb[2]},0)`); + bctx.fillStyle = gradient; + bctx.beginPath(); + bctx.arc(0, 0, r, 0, TAU); + bctx.fill(); + bctx.restore(); + }; + let placed = 0; + for (const blob of S.blobs) { + const f = blob.f; + const q = blob.p; + let x = (blob.x0 + flowX + S.margin) % S.band; + if (x < 0) x += S.band; + x -= S.margin; + x += wander * (0.24 * Math.sin(t * f[0] + q[0]) + 0.13 * Math.sin(t * f[1] + q[1]) + 0.07 * Math.sin(t * f[2] + q[2])); + S.ax[placed] = x; + S.ah[placed] = blob.hue; + placed += 1; + if (!still) blob.bp += (TAU * dtMs / mix(mix(blob.bRest, blob.bCalm, calm), blob.bReason, E)) * tempo; + const own = wave(blob.bp); + const r = S.radius * SBG_SIZE[blob.hue] * blob.sig + * (1 + 0.1 * Math.sin(t * f[5] + q[5]) + 0.05 * Math.sin(t * f[6] + q[6])) + * (1 + sizeSwing * (own - 0.4)) * chorusSize; + const sx = 1 + 0.16 * Math.sin(t * f[7] + q[7]) + 0.06 * Math.sin(t * f[8] + q[8]); + const sy = SBG_TALL * (1 + 0.18 * Math.cos(t * f[9] + q[9]) + 0.07 * Math.sin(t * f[10] + q[10])); + const tilt = 0.3 * Math.sin(t * f[11] + q[11]) + 0.16 * Math.sin(t * f[12] + q[12]); + const cos = Math.cos(tilt); + const sin = Math.sin(tilt); + const extent = r * Math.sqrt(sx * sx * cos * cos + sy * sy * sin * sin); + if (x + extent < -SBG_PAD || x - extent > W + SBG_PAD) continue; + const y = lift + S.radius * (-0.1 + 0.05 * Math.cos(t * f[3] + q[3]) + 0.03 * Math.sin(t * f[4] + q[4])); + const opacity = Math.min(1, alphaNow * SBG_ROLE[blob.hue] * blob.amp * (1 + glowSwing * (own - 0.5)) * chorusGlow); + draw(x, y, r, sx, sy, tilt, palette[blob.hue], opacity); + } + + /* The reasoning stream: lights flowing the other way along the edge, + each taking the colour of the light beneath it (never a new hue, + so crossings brighten instead of muddying). */ + if (E > 0.01) { + const flowS = t * M.flow * M.streamFlow + S.streamSpacing * 0.4 * Math.sin(t * 0.00011 + 0.7); + const reach = S.spacing * 0.9; + for (const light of S.stream) { + const f = light.f; + const q = light.p; + let x = (light.x0 + flowS + S.streamMargin) % S.streamBand; + if (x < 0) x += S.streamBand; + x -= S.streamMargin; + x += S.streamSpacing * (0.2 * Math.sin(t * f[0] + q[0]) + 0.1 * Math.sin(t * f[1] + q[1])); + if (!still) light.bp += (TAU * dtMs / light.bMs) * tempo; + const lb = wave(light.bp); + const r = S.spacing * 0.8 * light.sig * (1 + 0.2 * lb); + const sx = 1.1 + 0.15 * Math.sin(t * f[2] + q[2]); + const sy = 1.4 + 0.2 * Math.cos(t * f[3] + q[3]); + if (x + r * sx < -SBG_PAD || x - r * sx > W + SBG_PAD) continue; + let cr = 0, cg = 0, cb = 0, cw = 0; + for (let k = 0; k < placed; k += 1) { + const u = (x - S.ax[k]) / reach; + if (u > 3 || u < -3) continue; + const w = Math.exp(-u * u); + const rgb = palette[S.ah[k]]; + cr += w * rgb[0]; + cg += w * rgb[1]; + cb += w * rgb[2]; + cw += w; + } + if (cw < 1e-4) continue; + const y = lift + r * (-0.2 + 0.1 * Math.cos(t * f[2] + q[3])); + const opacity = Math.min(1, alphaNow * M.streamGlow * E * (0.55 + 0.45 * lb)); + draw(x, y, r, sx, sy, 0, [Math.round(cr / cw), Math.round(cg / cw), Math.round(cb / cw)], opacity); + } + } + + /* Blur (BLEND-2); while reasoning, fold the extra brightness back to + the resting level below the top band. */ + const fctx = S.field.ctx; + fctx.setTransform(1, 0, 0, 1, 0, 0); + fctx.globalCompositeOperation = "source-over"; + fctx.clearRect(0, 0, S.lw, S.lh); + fctx.filter = `blur(${SBG_BLUR[theme] * SBG_SCALE}px)`; + fctx.drawImage(S.blob.canvas, 0, 0); + fctx.filter = "none"; + if (boost > 1.001) { + fctx.setTransform(SBG_SCALE, 0, 0, SBG_SCALE, SBG_PAD * SBG_SCALE, SBG_PAD * SBG_SCALE); + fctx.globalCompositeOperation = "destination-in"; + const band = fctx.createLinearGradient(0, 0, 0, SBG_ENVELOPE.band * 5); + for (let i = 0; i <= 10; i += 1) { + const y = (i / 10) * SBG_ENVELOPE.band * 5; + band.addColorStop(i / 10, `rgba(0,0,0,${(1 + (boost - 1) * Math.exp(-y / SBG_ENVELOPE.band)) / boost})`); + } + fctx.fillStyle = band; + fctx.fillRect(-SBG_PAD, -SBG_PAD, W + SBG_PAD * 2, H + SBG_PAD); + fctx.globalCompositeOperation = "source-over"; + } + + /* Compose: base -> field (BLEND-1) -> grain. Only the rows the page's + fade reaches change; below them the canvas is bare base, left as + drawn (a theme change repaints it once). */ + const y0 = Math.max(0, baseLift); + const litH = Math.min(H, Math.ceil(y0 + SBG_ENVELOPE.wash * tune.wash * 4.5 + 8)); + const paintKey = `${this.appearance}|${this.baseKey}`; + const drawH = S.drawnTheme === paintKey ? Math.max(litH, S.drawnH) : H; + S.drawnTheme = paintKey; + const inv = 1 / SBG_SCALE; + const ctx = this.ctx; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.globalAlpha = 1; + ctx.save(); + ctx.beginPath(); + ctx.rect(0, 0, W, drawH); + ctx.clip(); + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = this.baseFill; + ctx.fillRect(0, 0, W, drawH); + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = "low"; + ctx.globalCompositeOperation = this.isDark ? "screen" : "source-over"; + ctx.drawImage(S.field.canvas, 0, 0, S.lw, S.lh, -SBG_PAD, -SBG_PAD, S.lw * inv, S.lh * inv); + ctx.globalCompositeOperation = "source-over"; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(this.sbgGrain(S), 0, 0, W, drawH, 0, 0, W, drawH); + ctx.restore(); + S.drawnH = litH; + + S.cost = S.cost ? S.cost * 0.92 + (performance.now() - started) * 0.08 : performance.now() - started; + } + + markReady() { + if (this.ready) return; + this.ready = true; + this.element.dataset.radianceReady = "true"; + this.element.dispatchEvent(new CustomEvent("radiance-local-ready", { bubbles: true })); + } + + getFrameKey() { + return [ + this.textureKey, + this.lastConfigKey, + this.baseKey, + overlayGeneration, + this.width, + this.height, + this.dpr, + prefersReducedMotion ? "reduced" : "full" + ].join(":"); + } + + render(timestamp) { + if (!this.isRenderable()) return; + this.applyPatternShorthand(); + this.ensureSize(); + if (this.isSpotter) { + this.ensureTextures(); + this.resolveBase(); + this.renderSbg(timestamp || performance.now()); + this.markReady(); + return; + } + this.ensureBlobs(); + this.ensureTextures(); + this.resolveBase(); + + const frameKey = this.getFrameKey(); + if (this.isStill && this.staticFrameKey === frameKey) return; + + const time = this.isStill ? 0 : getTimelineTime(); + const t = time * this.speed; + // F2: breathing runs on the shared timeline at sqrt(speed). + this.breathT = time * Math.sqrt(Math.max(this.speed, 0.05)); + const ctx = this.ctx; + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = this.baseFill; + ctx.fillRect(0, 0, this.width, this.height); + const theme = this.isDark ? "dark" : "light"; + const film = FILM[theme]; + const pageFilm = this.filmMode === "page"; + + if (this.isDark) { + this.blobCtx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + this.blobCtx.clearRect(0, 0, this.width, this.height); + for (const blob of this.blobs) { + blob.update(t, this.width, this.height, this.mouse, this.breathT); + blob.draw(this.blobCtx); + } + + ctx.save(); + ctx.filter = `blur(${this.layout === "neutral" ? 40 : 64}px)`; + ctx.globalCompositeOperation = "screen"; + ctx.drawImage(this.blobCanvas, 0, 0, this.width, this.height); + ctx.restore(); + + this.drawSupport(t); + + if (this.darkGlow === "subtle") { + this.drawDarkFalloff(); + } + + if (pageFilm) { + this.drawFieldOpacity(); + this.drawPlainTexture(this.darkDither, film.dither); + this.drawPlainTexture(this.darkGrain, film.grain); + } else { + this.renderDarkColourField(t); + this.buildColourMask(GRAIN_MASK_SATURATION); + this.drawMaskedTexture(this.darkDither, film.dither); + this.drawMaskedTexture(this.darkGrain, film.grain); + this.drawMaskedOverlay(theme); + this.applyToneLift(theme); + } + } else { + this.renderColourField(t); + ctx.drawImage(this.fieldCanvas, 0, 0, this.width, this.height); + if (pageFilm) { + this.drawFieldOpacity(); + this.drawPlainTexture(this.lightGrain, film.grain); + } else { + this.buildColourMask(GRAIN_MASK_SATURATION); + this.drawMaskedTexture(this.lightGrain, film.grain); + this.drawMaskedOverlay(theme); + this.applyToneLift(theme); + } + } + + this.markReady(); + if (this.isStill) this.staticFrameKey = frameKey; + } + } + + // F1: the fine noise layer, rasterised once per theme and pixel density. + // The SVG loads asynchronously; surfaces repaint once it is ready. + const overlaySources = {}; + const overlayPatterns = new Map(); + let overlayGeneration = 0; + + function getOverlaySource(theme) { + if (overlaySources[theme]) return overlaySources[theme]; + const image = new Image(); + const entry = { image, ready: false }; + image.onload = () => { + entry.ready = true; + overlayGeneration += 1; + surfaces.forEach((surface) => { + surface.staticFrameKey = ""; + }); + }; + image.src = `data:image/svg+xml,${encodeURIComponent(FILM_OVERLAY_SVG[theme])}`; + overlaySources[theme] = entry; + return entry; + } + + function getOverlayPattern(theme, dpr, ctx) { + const key = `${theme}@${dpr}`; + if (overlayPatterns.has(key)) return overlayPatterns.get(key); + const source = getOverlaySource(theme); + if (!source.ready) return null; + const tile = document.createElement("canvas"); + const size = Math.max(1, Math.round(FILM_OVERLAY_TILE * dpr)); + tile.width = size; + tile.height = size; + tile.getContext("2d").drawImage(source.image, 0, 0, size, size); + const pattern = ctx.createPattern(tile, "repeat"); + if (pattern && pattern.setTransform && typeof DOMMatrix === "function") { + pattern.setTransform(new DOMMatrix().scaleSelf(1 / dpr, 1 / dpr)); + } + overlayPatterns.set(key, pattern); + return pattern; + } + + function scan(root = document) { + root.querySelectorAll?.(".radiance-local").forEach((element) => { + if (surfaceByElement.has(element)) return; + const surface = new RadianceSurface(element); + surfaceByElement.set(element, surface); + surfaces.add(surface); + }); + + surfaces.forEach((surface) => { + if (surface.element.isConnected) return; + surface.destroy(); + surfaces.delete(surface); + }); + + startLoop(); + } + + function frame(timestamp) { + if (!isDocumentVisible) { + raf = 0; + return; + } + + surfaces.forEach((surface) => surface.render(timestamp)); + raf = window.requestAnimationFrame(frame); + } + + function startLoop() { + if (raf || !isDocumentVisible) return; + raf = window.requestAnimationFrame(frame); + } + + function stopLoop() { + if (!raf) return; + window.cancelAnimationFrame(raf); + raf = 0; + } + + function handleVisibilityChange() { + isDocumentVisible = document.visibilityState !== "hidden"; + if (isDocumentVisible) { + startLoop(); + } else { + stopLoop(); + } + } + + function observeDomChanges() { + if (mutationObserver || !document.body || !("MutationObserver" in window)) return; + mutationObserver = new MutationObserver((mutations) => { + const needsScan = mutations.some((mutation) => ( + [...mutation.addedNodes].some((node) => node.nodeType === Node.ELEMENT_NODE) + || [...mutation.removedNodes].some((node) => node.nodeType === Node.ELEMENT_NODE) + )); + if (needsScan) scan(); + }); + mutationObserver.observe(document.body, { childList: true, subtree: true }); + } + + function renderGrainPreview(canvas, mode = "light") { + if (!(canvas instanceof HTMLCanvasElement)) return; + const safeMode = mode === "dark" ? "dark" : "light"; + const rect = canvas.getBoundingClientRect(); + const maxWidth = Number(canvas.dataset.grainPreviewMaxWidth || "0"); + const maxHeight = Number(canvas.dataset.grainPreviewMaxHeight || "0"); + const measuredWidth = Math.max(1, Math.round(rect.width || canvas.clientWidth || canvas.width || 280)); + const measuredHeight = Math.max(1, Math.round(rect.height || canvas.clientHeight || canvas.height || 120)); + const width = maxWidth > 0 ? Math.min(measuredWidth, maxWidth) : measuredWidth; + const height = maxHeight > 0 ? Math.min(measuredHeight, maxHeight) : measuredHeight; + const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR); + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + canvas.width = Math.max(1, Math.floor(width * dpr)); + canvas.height = Math.max(1, Math.floor(height * dpr)); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.fillStyle = safeMode === "dark" ? DARK_SURFACE : "#ffffff"; + ctx.fillRect(0, 0, width, height); + + if (safeMode === "dark") { + const dither = RadianceSurface.prototype.createDarkDither.call({ width, height, dpr: 1 }); + ctx.save(); + ctx.globalAlpha = 0.42; + ctx.imageSmoothingEnabled = false; + ctx.drawImage(dither, 0, 0, width, height); + ctx.restore(); + } + + const grain = RadianceSurface.prototype.createGrain.call({ width, height }, safeMode); + ctx.save(); + ctx.imageSmoothingEnabled = false; + ctx.drawImage(grain, 0, 0, width, height); + ctx.restore(); + } + + function handleReducedMotionChange(event) { + prefersReducedMotion = event.matches; + surfaces.forEach((surface) => { + surface.staticFrameKey = ""; + }); + startLoop(); + } + + /* Read-only snapshot of every surface (tests and tooling). */ + function inspect() { + return [...surfaces].map((surface) => ({ + pattern: surface.productName || surface.element.dataset.radiancePattern || surface.layout, + product: surface.productName || null, + engine: surface.product ? surface.product.engine : "template", + appearance: surface.appearance, + base: surface.baseFill, + width: surface.width, + height: surface.height, + energy: surface.sbg ? surface.sbg.energy : null, + settled: surface.sbg ? surface.sbg.settled : null, + clock: surface.sbg ? surface.sbg.clock : null, + costMs: surface.sbg ? surface.sbg.cost : null + })); + } + + window.RadianceLanguageRenderer = { + version: RADIANCE_VERSION, + refresh() { + scan(); + startLoop(); + }, + renderGrainPreview, + grainSize: RADIANCE_FILM_GRAIN_SIZE, + patterns: RADIANCE_PATTERNS, + products: PRODUCT_PATTERNS, + resolvePattern: resolveRadiancePattern, + inspect, + /* For the Radiance admin's Spotter lab (spotter-lab/) and nothing else: + preview a change to the Spotter engine on one surface before it is + made in this file. Product patterns stay fixed (PRODUCT-2), nothing + persists, and the linter fails any use outside spotter-lab/ (PRODUCT-5). */ + admin: Object.freeze({ + spotterDefaults() { + return JSON.parse(JSON.stringify({ ...SBG_TUNING, levels: SBG_STATES, grain: { light: FILM.light.grain, dark: FILM.dark.grain } })); + }, + tuneSpotter(element, values) { + if (element) ADMIN_SPOTTER.set(element, JSON.parse(JSON.stringify(values || {}))); + }, + resetSpotter(element) { + if (element) ADMIN_SPOTTER.delete(element); + } + }), + get prefersReducedMotion() { + return prefersReducedMotion; + } + }; + + scan(); + observeDomChanges(); + document.addEventListener("visibilitychange", handleVisibilityChange); + reducedMotionQuery?.addEventListener?.("change", handleReducedMotionChange); + window.addEventListener("beforeunload", () => { + stopLoop(); + mutationObserver?.disconnect(); + surfaces.forEach((surface) => surface.destroy()); + surfaces.clear(); + textureCache.clear(); + }); +})(); From 6dd0a3a835ca013fe69c57cc95197d0a41225a9b Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Wed, 14 Oct 2026 07:03:16 +0530 Subject: [PATCH 17/57] 404 page updates --- src/assets/fonts/BB-Roller-Mono-Regular.otf | Bin 0 -> 133304 bytes src/assets/svg/not-found-illustration.svg | 110 +++++++++++++++ src/components/Page404/index.scss | 145 ++++++++++++++++++-- src/components/Page404/index.tsx | 71 ++++++++-- src/intl/en.json | 11 +- 5 files changed, 313 insertions(+), 24 deletions(-) create mode 100755 src/assets/fonts/BB-Roller-Mono-Regular.otf create mode 100644 src/assets/svg/not-found-illustration.svg diff --git a/src/assets/fonts/BB-Roller-Mono-Regular.otf b/src/assets/fonts/BB-Roller-Mono-Regular.otf new file mode 100755 index 0000000000000000000000000000000000000000..f9d13b23413804f84ce38326148f098ab1878072 GIT binary patch literal 133304 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�M9?Eqi7M`1a+pT;Y0(~#U59*c15>;}`c zp4?pbz0E88-6jilWnH|HSR0lsngGEDZj_$y5meFyh&F8iaTo|h1~~Ym_;|nD(D$8^ zE{i3WyNGY&+;U1s%!Fv*xmAHp0Yi~} z-$b1`aPah*dIu^yalLZAdX=45Jf~GyRtQSKQdy!XU<-=v#v|+~C1QtvNi!ARP86+2 z57I*pcQ9_k$>785w&3$Zdfl5SRsx#7)b~(vdbg7$B5!uPPJ?>(aW3i8DW;tr!Fo@l z;KW@QuNB)o6UU7mKW@UI0hL z5Q$irYnSzC&t_`tmMvZDw6drh9KDMAt`%uH8hTZj-KmB)aYlx`uc32fF_4GrI0(&^6CNblnwn&7GiY_Ejoj zv#&te##e*1@pTF~K`L=4gShFNN!`hyZV)c3ga+{dkTqzU2pR%7bcP=YnrB?1@o=JC z9!``y)l1v6Kc?*_M8yL^#d&C@+5|4r8pa6xc0X-AJCIT~v%{;~GM{*|)v+5%)dFp*7 zWh#|wxfSnAHe0?=Wjz)CPG<3%=N-NB6n|&3E_Q<-MPP~M86)af<&92>`grZTQ^x%B zc>Yd3(K| z_Y41C?k=83e_iad!lg$FeLVH(6nf4j^qhA%<>}zhsA#Wtg0&=Hwiw_OVBw&kSy05FVwCV26{eL)r-9CPv^<+6WpqQ%CUVDR$f#!3Sw0 z*r6K8C3OVN`*{9A+6Wr=KaAi28o|=W2oCU$VCnxhf;gJ{uI7vVRIsW^=I3&p{ThIR?p+!#yWt6=JF!=Rrz8&et0rLcp8Jfw)P?E9)Uv0XgR~ zU+it$1GMcja#EU&!?%BJT&lw02Jm;b-tm39pKVFP+t?bF?eOFPCK>Z64PL6na^@`d zBG>I_&u_!24CawpZg{KsUURcR`rnWJ!Jn$tq#sXuykO~?oEg7C#BN|>Q{D( zqjIMn-8**dv23-2Mb{iG&l}WtW*>W%`t_?ggvCgn5RlA0D>LYnCouvu0_Jjt=ta)*0lvfqKw!92)Q!3YM*W{Zn;pj>FRoYMWNdyb+oR6oE1@mjl@V3N`k<#a3vy_;=>9|BPN4RXk#o z6qYMxsh<2)bf;i+xHaj?J*b)O;YCjBo;yJ4-mhMcta$iGdt1qRfz0_mr}UoL54ccW zJNIwV)RyN&jW^vLH~U#G4p`T&wym4iR%+OC-LB!ziv28Q;?LK)2^H4UYgeBw8MAN% zP^vDhT(w}^F58nz$8#=r{E%QNx1jy{!#4Q!ZlrS)oL4rB?&_i$iF4x|OM9;yxP92o zexWQ=*NN5o*?N!cGh~3H$D;Oe&1V-+2o;%DOgx!r3v?%4OJcS5Ur+MAm~?#~YnkNN zPh<00PIcXg&P#r?M@r4xw`=YcA#(F|?Y2AOr46f=texp>6k{1Oe%SCKxIbv<+!!Yd zkz?l0iNXDOb4CoAuj&NqW)+s`qvYHR<&#X~$QV!%{;gTNcfWUAn>+ zFK=(RuDK(hlmvkHcJ(; zDT&pQmgcf-la@TQh&oAZ6LtdwE$vGkel33NOTI=ecCON&x6huv{b!|f#lEjt@q33J zyko(l}V^02la-nmCxd$V>Mb~sr>^1*TJ+BNZV(_LXr zca@pgw5fmZ-ov_dwuy!^+~T3No;!8!tXYou_*E0*BjY0{c8-h$9xD!2<)Ns30atH8 z1`v)Qw*u(tAJQP=axg1u>RpTKqA1URdoDs25I}Mowv`Rp$_8)soyf95OOy?;Cj5$u zKy@g|@w+%M!oyhcm-mvcCOPZFpSDtmGWM!97H?_pgk7eC91V;>ltroH4)|*?!%v~6YwOo=!0NY~uj(9EClA5qhqA_cn_i>Y?psPujlJ}vU9NYZ4bLShBKYZvU=id32wIe&F zuACN)*Y{f%Lh&JOu)wU$IHOY_Jh=qF;;^J~r1C?zRqonR5dkpDItP{ry)?E+! zL$5Or#zDSqqgv??%e1)3aq%`*bia-`=dJ`UT@w+-&GrpgwZX=kDf_mxZ#tVWW-mon z0N-N%O5KGmH#7%C-mjgz)~{h}gyY0sDQV}zb*r68wJeAmR~vyH?v*zttXUcFV08g` z*}H!iAYg+F+)ir1^4?qfr@NilP^Hb=}v?&MofjWp_KCyUgG5WHE4St!X4;zg1LZrQ69>-M*J0 z1~`(PC5<6G={p07rt|Q>)RSdpBs!A=bFdWsQl#%;aLtsA)1}h~0ot;}HC7viSzzZi zOoE^2Pa}@>BEC0H`b{sHn)o>fWj%QmG>b-K?ymA{? z*gu*__Pn&*lDN!YLJDJwr(McK`ubh z4U)vRL^O#%>4=iwr0~f&mfOz^P@z^*&6UI?lwSK zAaZ_QhBB(oMm|-9om5ZWSP}sQU%_^5?Zbiq>HSe{lq7Wx+o@O^uSp(_#ncgtsc{+2 z+F)lns&+mK*H2xpn30p1^Wijyjgk{Ec$q~FP$Ny%3GaSy_fLb?R&LAh?g^2iJ5#l{gxaFKfceLX=3I8UC{&d z%$fDfS@_@C->4g{8Jrn>e0?(e1p5^BDdkhcr>;*^pSeB@e3tmE@JaGH;-mXq_RZpJ z^$qte?E9l{Ip3PTvA#!qPx^`sc`_8pFeXEEhQth;GHlPVJH!4Ahcg_@@HoRi8It{S z`xW#n=GWD)mtTLsL4L#hV*C>P*7T#*7&=Wz3o}B%>o^fs8dXw#~RaXtT^&X!)5A(r8mXv=iV8p}q@Wy_zIN0zsicTz@4mF&{j zQXc6$sjO5{Y9O_eev`ULkL7Ig1UXJVFF%x@$#3Mh{)&H~f3UyiU&_C+e{=t#{uBJC z`fu{z>3_yw_kW+sFH@mREi$#w)IHN5ijVTO(pnh|n1{v6YGs4+JfLhqlYl7!^8?}o z76&{F5Shzou9CTW=24kfWImetM&{>P%4VsarFE7?S$1bRndSE^H?q9UA_Bt#a|C`J zm@lwWV70*BfujN^1WpT_9k?&>c;NZKi-Ddme7>-J;rOEE7rnn2^+oI#%fC4E#g#Aa zWtFljSwpgZnKf_LB3X-Pt(vuQ)*e~=W*we&eby~mcV^v})t&WZ)^k}eXT6d2udMg8 z1!c>T?b~emvK7kKC|i$gk=cf18<}lfwi($LXIqhNeYP#x?q+)*6c7{?R5qx5P}`vK zLDPa_f)az41+5O+5VR%eV9>Fk^FcR)?gu>$dSkU%Gg~!lK5IeiFV?!&#?~n7XzMKN z66`~drWS^LQTK1Uii?grEzA5{*>`B=VWj~(%ZuW=S->Q-tpk`H7)vo4Hzg3H>HPtq1 z2erF8NR3is)I@cqx?bI)o>2c%AE=(-jKP_L!-5^b-v$>6E*@MqxMJ`x!S#Zh1h)!q zAKWdtZ}7n2VZq~r7YDBhUK@Nk_E> z{W-K&Xrs_hp*=$Tg$@oK8#+03MrcfELg@0)wV|6rw}m(A%N+LZ62I z6Z$sHFD!GIBP>r?!LaYcN`;jRs~q-ASnaUpVQs^@gbfLs95yX1F>G1bwlH_tsj%x| zkHVgZy$O47lWbXRA+~Va*S37NLbmU1rEKMGRc*Cw4Q$P9t!(XVooxec<88BSi)|Zh zTWx!7M{E~tw`~t>Pi)*S*#qoZ?b+>N_8j&+_5$`|_A+*ty{f&Iy}rGPy@kDly{~5pzx95c7F<^bdonxd=!8gB2+p;lQ5Sk@yRf5xgfLdKCuLK}TlLm8GXsDyrBUF5V39X)bHq`jo;nzU#2(v85jht`t1Q$TQ$pK(h-gn}%L0ZU@z{$F?R z0$+Ls%}L!bt_8TMI(&})zqj_MOD*OQ4~ed z)TpAUs!`OSDn`{9RaI4E)Tlb)q`cqf*_R|8)0y}6|IO$3f5rOl?^%1Twbx#kXRp22 zv!1oK0JF>oQ$OnF%XU4E6?xO>%MF{xUdp;f{^y|B1VhqK^l%xUm`1E8nL1fq@+)Tg zAI;JGE!;X1I^t~pdYQW*dwu#6luAk?uMxpXzu?N4IDKlie=ohYt@SO3O4a1dm;IkE z)<1gg$%4L7S*~uWPe3mVrmmN}KHG&>&|_BwDwr;udN70Tx4$dSW4JqdjQuCP2K260K?%&! zRlN+=&})`<>18N}UhD70RUY=N-{3dg@gAe}J~*vCZ@PYa`y1nm@6AA{$Ef9*>oxAC zFZrJ+OB!7;xo!ludZ(czdwSgMy+*3Hw5@}pDxOjn4mx_bAlzmwC+*RH$9`DHO817l zAN3eBVf}lZd)1QVE9d*(dtU$Gx%?>;BTj^YhJ6lfXos(a1Km1}&9U=uyv zKh-@kCoAg(-?%(|Twd1Vm5~Of`e~o)j$ZWI{ymK1*W7{oalL(SxKg|ZRmii*-nd}{ zbA3y@>R-J1@t;mc8ixZdBJxd*zD4G{dal`Tr<&I_3S29eW@k>!o}B3)*`oj}azFf| z-?*E?;Qw27L3@*_ny@~KeTcsH8nEL(+G9NJ@fk1E9;618sI-76*7R_?sSz%N^e~_> z1T~E7rW!_(rG|m;hQYDWzsO+A4y6?@0xsGV~B7X1fSp1sn8(KXJV}5=E zL)l9h%3@~rigD~;haq)Qh4Kzk&&3j`iE*_0b8hf!T89a^GR>m_}Ea7vFreRqqxdJnhp z(rk|beM)MBc=AC%PB(s~OhScMqDNvtJ0W}OqNQ&xTD;W%XV=9OCocLoxZho~w!pU> z&3soN+-nqi*@V<1w0k&P3J%WrocnXka zM{Vqfh8g!jE&K|@3c#A^gJHp1KI4-YJ{Z(H(n$H*-Fx)l)aQK5JL)HwetPWd$gkYS zV!R>M38v|Rv=rkMH1hVyDc3~jf4Ry^W0?a9ahqg{c1kq*+7J6&qu2^ZSW6&bJNow zM)3rLKX`8HKF>{0L@jR`dJgSIH5)x)jeBG6yV+}er%t8rN=nj~rjN=`i%NH;jmphV z_a~i7O+A&QXQ$_mN{dW)r{#}Yn(j+V+Le0hl%Boj-Q0~)Yg`-O&0o64e=2F$uB21? z(lz<-Zj1~LgCgx*?;0}tvF3(19rN2?m)Oky+6BAcSMP3L8{aSLkxBbyF8UaFw+*Li zp<&R@9vzAv@)kV-%MJYoWse!-$I@5mi3Og5oTVK+#(CL7&BeeUy}`FF8#Na>#7R%q zTkpW^`sbhQIpSZOGQ~Y1Gjqa(Y0Fkj^cm1n=;?`Q@MYWYsquW@_R9th+q7wjZ|%Fc z6xH+;)$ro~mfgN^Qq~B>cn#R2yJ?U{#0)Z)1sjq*Bg(Gx#5%gERm(0Hg^mbHUIgC` zyyCss1G>JgVFI%`~UF5f(v9G5upX8hN?DILl&knTpRE74ya#t|&rW&ydAIge% z+KMRo*;&EI&-o$3M^xL%s2R_?2l>=S_ujtVE@)Pm-~ z{hs3cHL~4#5%X#9nNqO<_F6dWfqE&a_L^ov?`ke^``Y!p_eU=8R3g z_k)1nQ#a)wc+5pYM*ezRd&Azdu&BI;j9t~lqb;kWzIw*ejPa4d1#YN^_yUtsf|E?P zz`b_as<#*TCLPs#PZ{!fx5(h@?vzg-E!?tk+4_~fF)KGt+7eax>8FMM!0YbMhJM_8 zu`j$(Jr$@J5m@Uz>}rl7NitTpTATc4S2l?ek-95B-tOq%dF^f;AHOU0@@2bQ|KPC& z!;oM_JR8#EobDWs+gj!ht}Ryd6l}^H{DV8Nwn)(n-53r)Vm?Q=yNUl^?*ADgjsprwa59nh1P9i#0bi#%A|>>iJh zLBBg#ZObzq1Jwq%4C2TQZLZs}6=maktTck{A+*@^%PhQ11-C ziu}6`nw*-AF%dT4H82PK2yCcNs_-q@1hSkyU+yT;VEdChZj4@>MVLHEYaf^r!*lH1jvB#r^1&(+=cx%J9HU3Wv-rsX9 z^39aV?x9(uhdk};nl`Lghe&S!n_OQX+xyir*h4Av2Du}Mrz*^dqpjkNvPxm_^P(cR z0UK6Jio81Q;#Osuy-xc#&88m`DAclQXmT>wyeQ}Nf|{qT!DIcT}S1@&~0(y^hrpz z+jKK`ONnC>-;~I11)E$i}9U^_P zYt374ty!}e$_73IyRvvyB;Owm9N&$pmEIh51j90Xu-;@^Xn?j5$`-l?Y^8oCQW@q7 zZ{6juQtn$W)`7}n% zfeToJUVsnY0R@DfBq#B;#aw1*(0WWG;;9jn$4~UH%^LmY@Tl~spU;`(uQ>HhGX3ku zcAS;m1#8w6yt_7iq_4OVMtyF>GI8w+U{^q_;aQLZ{a~`Po$Rp;FJD5f8warF8QmOu zD69X9SyU_>uDJ1V<)We3z3mZbC*m{6BgIAfiOQHMYE-luMip1<;Zr=}ojjvm%{n}q zOgf2>$Q!16M?eocv15m(G{f!RhSmjx<4?F@5xTRUa@ZVcr=XO2rR@E|Q|P|X=|tk| zP^sxodngne<`*TXG8!&g&uTP*5n@QzjKBj(XJet^I_x!i_V3>_bx6UETvD<&Zg{gk zLv#4M2fGo@)s3Pq?$2KNX!oBY--b+iq$mmItTtd|yBK2^dM~+^TN71D;s9TaNE`&- z^R?d z{n#ByE$XMkEOIy|OS^gc8>GX5I@*;QsEEHx!AiS!1uJ1Yol1dmXZskmPWducDjK+0nDBRBJD@6@FK9nqG z)T=$Y6*GK)-sm;t-$tS{V(G7e?))A~ZNJBFz_4$E$N1*+&%Zg_>1b1bufQN9@D~yS zZDHalxEUr=zM&5B0-ngUO9Oo=#0rufczX&&(cmdC&A6HO1b-|INUw1eRwJ;{CmA?5 zXjLw%{?ACQs3966Ci4SnMO|Qgg-x%7V+*Q_g~lhZf3R~OwPh&OBY%ZO6^|Ra`OoD& z`)bJIrFkn?V0gwQzY4(`Dsygh+%{iM4%6s1(h9qz&x zIodN0I|E2W+!QE>FMRkejK#lw_8Z->n6uk1EOYJD9%9g>Q!Y>BVYBL?E9=dTUY8m@I{<^}s&@c@Z#A z(gmBBZZ?9?V(qkHtqa{-`D?5Q#iya>uJ9-}s2}`(GM>^Ez32r7E%`0-pnmh{iT&7q z13JX8SsIvGXcRT^#1_c(cd)1>+}(?{MC6 z?QxGk{QdP~o-&Fqm|qZ}9ynpNZ%RtwjO*Be9f!!M^*2~nObup|$Jpo{i+6^imuU~J z7MSTbibA|WUy6ar|91@RT^ENNHzo!Cj!Qm=N#5s>K@4Xqp!Ju>rHhIt8G~uNc~sI3 z=oq80^UzGM8rbIuLmgpOy%7lYQf5x_$e7MAdEm?RCD z>Tq6Fj6eX3dNAPo8>t2uyWZLKVZJZ>pxz; zt=!_XfwmRT-a2hO?+J9ljSd)|HqbXY`tKKksVFmAPLpH~ z!MPG*1EE;tguy-R5dAWTG1*9k1H^hf{@tL!hrDr6%E-x*LwwF zKP$xGu4g|3OOhTK1Cs@u92Pwl?kKFd%YfngU=v>K9BhK-mp*dTcr?VKN+|}-X$96s z3`AYr^3an#!|r`ifafy2)+lahem{H+L~TF zjGjIcHCSo2ZzrC%m^mwgrVXZ=U+HHII%%>_?rwDgCxb}t&P zR7eaw2zEso_?Ef|PX-9`qV|x$M&PLT^+E$RNjn4?hj;9sa@d#zTkGiHP{Bm@u|gTW zapgq(Zh+QWuENDPJQc5?7uTjI(sj)DxzMXIviSlg9t>>YQyJ|KE&0TLgRHVlhu=+! zuZK6)fX9f;@8p??%6iok-q;&1@K3^pG1)B?(HXznz&(UNDN^t!MF)`&TqITl-x2$P zpNWIO!{Q5Iq4WzWtIJxz2V_HFW7!ngTs8-`l>LDN>HG)X=vYT7lD^FoYj8R{vo73Sbt8a z`pf!4p`y-o{C-*}`a~VM(I@GXfiLSX!{-%*P^>!kh||h0p|$VWwU3B=YI4R{QD;QP z@G+v<*ddwYMSG!98@O|a!-tjnB}KVXzZ4NJ5=F>E{W`>mfG2os0(=PcmL$yiO<;#`26l#(Lw%j2k0cF?MF` z%Q%cNgE5zJ9^(?mb&T5>_c0zvG3s3A3TlT+^?&m(UATl>gosei=l{-MRNp>PTQm^O zM6&1z)vmr`5SOeJJ?Kvn8)&0%wyAI!UX{*n_UwwbOhOasCjHz)jHlXkFC)E z9=x3tgBlAH>%;?)J_s!#D`XfPirLJ;r>-xr`Z#MqwCp6gT*4c^lY_F_ZBn z1(ILPQ(}Snj8iz|6h8Y2<6OqBiU(o3GoQxz5r0%o@^Rd29OXeKC>sa` z`6MWi#xjQeA7K9)%xh3Axu1RNvrm2IO_?V$k26vB#kJ_4Y}K<>8hT}z*d*eF;!cKZ z57z~*CtP2+0dPa%M#7DU%Y@5;%eCB09BnrTe1Q<|NVpid+CsWp;D}OXhqmcn2e%P! z3*1iUNa?EXRftb%LsmQ2pzGQWDva)EcdWZk=-JS-?gnto_*W};A|2t^)?w`D?&$97 z?!{+_rJA8Au6q!07~E(+8%=%=o4_oK!%YPeo9-r7He4O|EcZO*Dv#p2=b5RyXIbGE zIqB7LFE`J+SDRrFVuQ`VH(4?3xVPE9l#1oM>n?1sbO7>X86I)@e%YVHnapkn zAwK?%>xR^{nVlZ$7colLG}pQ1hZyk`gZ@GqIjot(l5FNaXV($78{GMHHdq>^U(g^Uf!AVz;7*>b+5 zz7u0b$PzOzAuCGxtu=W_L5R(^hita|cH0c`W>H+^4{<~Gndiwbbc z(7hbL&%^Q6m=%(CPM~93JZBM_MbupQpE2`Ddo*ce!=z!3n4jajg(iv~pZ|Hc(FVn77oJIXo z4boZ4m0g#m{HRQGC@$4jXr{wBJ@(5bKmHqU*IP+TG_T9G#dThW%{1FC{N~uTYUdzy z0a9GZsS;btY!z@FvyGu!LU%fB4>3-KTN5$!x*;=g<^YGIRE{|8xWi5n!?W_YM+i0P zNh8I=Ci&whyNq{r0~=K36;iEE!pQ3V+;0gp{B$CHv8bM0=%+0 zT2S|a^C$vi8uD+$Dg4Mhj>Zy76@gKWHA?Bm{!Nud6ieLBl?6Z^z*P8u?=L;hH>Kq>U*Fs<)|fcIp)#s2fzzXj*BGRKWmn;=XK^QLNb@TTfO zV5-^$n8dM?I95C(zZuGSP9dISz0CeUaL8ITPLmHZKFa=e+26!^>|c-G!Q~h1A4fh) zvdL8%Ez`(X%481Nhf|2-(OP}>F-L0&943K%THL|=wJc(v3yeRqe?!I?Ml=0y&6JhX zls@G_e1GR*@GSBXv-w(&aQG*<#IAApTAZs|oacJXA7K9nIHZMqHqOEqkY)_?u1W#; z4|nj5M(*QSad+l`H|My`Ikf@o-U3nV4@L}Te68rd-Z19%+rdXc&U)a9}^Hv

WWQ*w;9HxxU&#S>P-Y^oDaBd7Di3g&MRZoSWSLY(DzGFlB_hjCY06YDhcu;y zm%cB`4(wyf+79f~j(m_C{3Ll=e02NY|k2 z+O9|`h{}qZ8a2IUV$DQmvm7?hVR;T)JK?a? z4m;@Ax`3!9bPTeQpeiw<*G1(VfGjIQc1AF=3~C=o7~=y-=Yeo%wb6!w_TaDot?AY`D`DY9UR?{N*ru#bXsW^O=mOcY;^WNhl`$O`$f-i*zD-} z(fOP*u_X>$;jp#L3d)4r?3~^1u-y*Z=deQ#JLa&H4m-o_e3_IlIcKjr?1sZQr%@B4 zi@l2N=M5p|4Tr1but`IyRt~+OM+01U8Wx^pZbRXXK(N3GR zbA?>;b-g`N=Ujf?z789JvqS0Lyu?N_8}ImKIxNRwB{6axznNeQymQQwEV-^-8dPd# z{=lpf0^@S=F0{{jm)eYL!77b1ekG|m%)VdmD!aD8W|=hzHqT+39A?$8ca>GESqQhw zIlIka2OW0QVJ94R+F|FIT`7};>(1F*4s)(+IqPVuA+fnUPPR&-rj9Urk-sz1#%?=lp<@4BW4a{n5W&Esu2|ue}`aE{O zgrC(f;b-+rpT}38a!)DyD%&jT9J4EBQoin-z2z{gog$o>mwy|>ZqLZUIy)7+$CtjE zFWNbKmmY85IZDowIk*Gs-oUJ!QmL7#fLUdAml#~mPPme|)?K1)2>Yy^`@5%XmpIN| z|&@ z*UMq44jbgKVGbM34E?uMDifTuSq__OvyyOkV?M408Z-E~7MNM>w2V~wO}TbgX;vb0 z{;!i}cfa$0EyH|ycB^B?CCQBYxo?qkb~(yR5wFA(jhMLmWK5NFc+eWG9mMQD-oc3{^DF^>7CrhNj-O?-Z^KJ9( zvhH%5Z?D4+I_#*!O8ibZewMUAD(4)(iw;B2#g(#f_PWDL{A~HR&2LEt-!{L?4(AVZ zm?gPzwkrEwDV=A(&k2W~L09v~`x{xTnavP4$qrZQew{n_x3kasI}`JU`@7q5YbKUr zeay4)>qpE#*kNg4{A~0S%iy!bvYAaQb9RPvb~dy5IJ?B34_pCP;9m>e?D&h1f}lopIQCW|zu@yXu_1;jm(x#V8I7aag#+Dmg5YSWHamlw)c;XX`mE z!DePoV_L){$F#ToV!9B6Ur*Do6zhw-w5OPLJ3|hi!4#PKWJr*a3$faoBN(ndzM> zlk!>T>;;Eic9X}D9X7~eB{7DT@f+==Vx~6%XHot@VpEBcjIBPM7)i(Kvm7>$ z!{wFnTf~0L9lyQ~qqwX2?1nOaCFzy0O%#qs1!he%>q9v$>sNi7?N@!5!}dDtpu>(j z?1aNkJM5gpE;{TAGmI2Ua!~!cbM}_aO3wPt{QcV)vBHiU>vC9_&7z!KnT(z!u@!!+ zG)kpcl~RfI!J)>0@pvBXV%7-1nmH`VVeOc8w$D<}i|tN+%y4asagV1_eW|m9=`6kb zS$RD;m$Z`&pYgr!>%&B zff&WasFj${4`xWo^5eMV7Zb^TC|Nt4zho>BZ{LwW-mXD^d=14+1uVp2;SQ^0Gy6RX zajiNBv&seRpdGIIQHPyy*lCBIbC^|{+s)Q>%80OOLZkpRZEPs}VKW_-WQ_GxHpS=TVIo zY=FZ!y&6MHr(9#Cb9TJLG98xVFefiHa>=j8Oy}O_ zIBWs@m)2MaB(}<7rr$c}>_&%`#Mp8dKj&=Or-mh|@pSo*pC2X9l>f_~AOGskfBn>` zu@mLDhiit|0f!x7cD&5lQ|x!v@w;HNsH_^7ZFvROhr*Thv-^3C%XY5?vwAI<)dy=_ zwk0*#5<8VxOHw18*+Z|CzM9=Han|ma|D2b98^h_Bmu*Rn99Vt2^wnxyb8^M9y+)DK ziiz=^lb?BS%=q4F+%6r?%rl?mJQFjo#*9-YDOyvbtHq#CT4QhOogUk-W_gEICRQ`r z4u`n0W_{56ul0VE1~!($`RBu#?>NpIOUbC7r8keqOUBtH{47y?lhw@k>gmKq_NhDc`~rE)|PP`u)YR8U~B#}PQAf= zHX$_3rL3Y-$T$@%H-Mz45~p?mwo!j#PAfx9jh5A{XH%Q!T5EG$QxCW^`zNTB6H^bS zmD&OE_t`qsL6X~u+Hs{mFIZRaPu1jkz z*XQfj=j%4*E7rgLK84{l8?z5<6iIVVyN%|>S#xH)DTi!L>5FANKl}mTOMOXNGOhXE zTU%kM1p0GMI`LfhN1UHH4iiUVEZwHA>Hvg{og59LI`NNr$_NrqZ?4X~1|chkD!&<1M7t7_U)lOyG19 zxGoa7781A?8gMO`noRNB0^_;ds&XyFb1lSk+l%LtH?^JK=6v?#ob=?p^=00W@8z$| zYcv0WxuqAyKDCrQT)^M<-4}DwK%t?wpLvQC4g0D z&^(iIcX|P9F?!j@%Q^9KPHJ%;yehRHFJFZokt*v@zFdW3JQsT&I>69k=Z&Tqbc`CUKma zsZkfloJ@MgYVST4m5RS8nSa9@8@ zW+9zfN_pU0+!}NE?s7P84&Pl4x92uogK_Gk2w8>J{y~@YC?Q|hlvJjMVr%v>^)fr~ zwOVuQZE3ytqLjPqb>3`O!?yvVhGjdP-LPBhMm#_dYu{LtDYtWe@|NMznp zc7l(osnmnpRU(&Fq7{auN@*R(L7bD$oQL0W+4kg|4B+qsxNHw_$`6<&$r_V!cc|~j zK?@NroKvXJDbzQ6<(*Xcvo;o1S5Xel6-S^#hI9<33vib$cMV6|6@lMIx+GblQ?lo+7|iLI>cP=J^jeXb=6H$CrspsD1=@Iw-Ig&+)lVXaMT`-z#WG> z1$P$i0vvSC*j>ZXcBBbR^>Y>uVNeexVJW`lP!C?QLaY@9Vzbx|N5%C`t$Yie(C=08Y=^ zIJz#7o+!=fy0W%{UIHH)m&8%};7DIj(1#&4yY@0r!Zcc)1~&tao;vg4^5K@it$Elq~|CZ4s1XTLkyBErJNPMG(cd2%^~*K@GM= zP?K#Dv}Riby;QPA@EF@77^sphg5hk7V5Ca62u86jg3)Y?;Ayr+kioVHa@ZEZi?BuT zo>+$580toKlMrl`KxL~0p=_1FV5_f9NCAERXD52-aXd3Giy~eX9*uS2iL$NiDpTb!IYCZEdFILGa)aC^_sXNC(v`6EP)*dr z_9?B!!{RY9M2r*9i@D+r@wWJb_z1Q7XYq~rJKidEdB5~xWZV?nsC1K0pyr;I&&n6& zYw|7muKZAbiZwwR@@eLmY(_+4ERulL`(3bhf2bHQri*#pVm66guqAQ=tK_eUThb*f zNS}jHXUbeTM=q4BpV{}8{(P#J;VR3AO51A5Ou^qObTd*;eF zn{3krN8|l;x}1j|xLR(KyU_biSgDe&4!@`?nnM=!z?M5B#6&Sg{9e2Ysk2^e z6@L_;i@%8P#ZTf6jM-F?H6Q~Xl$~U6`J@~pC&}N*m*rx)27UZv`6v0Ml`7f&h=E;# z7NR|DJPZ&cMW&c4W{EtpTx<~A#9nb!oQ7S6>*!KpkSR4~Bbg*S%RX|j94oUSJ?3M3 zpS5zc+>I@MPFksw4Uy`izGw-V^{5yqMu}&{v*JbZns`gRD?SvT!lKPz#Sh|VDX~>h zHOQ>SvbB6zJ|>4i4o#LX$XDc>@*T*cPvl|wm6a;lr-;R9x)sLJycwMX%?};6-!&4}}7Jq{!E>+$qqaa0_$Tsp3`8Z^0Iwa}u z<*Ra;TraoEKg!SLU*zc_L&jzr@r((Ktr*)ec46$r*pG22<7mbV#vI1!Pffh29>xQV zM;MPYo*G8!oMpVoc$Kk;u$*8FVJy#Bl`)2~4r3$67Q@D;k1N-fu_I$w#$Jr6jDr}5 zF^*=Oz?j82m2rAn#*nAV&0@@BT+Xov{yNzp>+=85cg7F^zF7V+Lb3<21$@jI$Z%Gv+fcVO%jb{i(6x zYZ(g|H#2T$+|9U;@et!NTs9;8B;y&z^Ng1muQJ|XEGE29VGLmmXRO2+nU2GKF^sht z>oF!UwqQ(VY|q$*u_t3+#sQ2&(=&#Rzi%Yt1jcN}T*g_9^BEU1E@xcJxRG%i_faVm!-uf$=iqHO3;w+l1vcM$g2FjT)CP&sdo;nlYBK4r2qxM8;N(Z5cZ< zc4h1ZOemkqIEZl=<7mbSj9H9R8K*POVw}gA$G8aCr2KNm)r=b$H!*Hw+{L(;@gU<- z#uJRE8P5R|%U@)?!g!tW7UBJZ(Zv|XSb?!BqmQvBV?40w{f!u#F(xs#W9-b>ov{yN zKgPj~X^dkTGl0$R&t{y)ID>IE<9x<^#wCm^7}qiuFm7ht4s3q^ZpM9#hZv7Bo@6}3 zc%JbR<5k8RjKzc%6kv-AA&lXSl^7!#V;E~Q)?-XyY{8h!*q*Tquw{jwjC~mgFb-uL z$vB=dlQD-emvJWJ9L5C`CpKde@ku@7TE#=(qfjAI!y7_%9tG0tF| z4QyU%K4U)P62=vbYZ(g|H#2T$+|9U;@et!NV2es88P71DXS~FCmGK5+F=2$l7{VCN zScx$b*fJu9u{L8p#stO|jLD4c8M`p{WbDg0fN|)=i3yD&Mlz0P%w)`A%w?R(IEQfo z<3h%zjH?*eO?+nP#E6ZITNrmT?qNK@c!cpd<0;0oj29R$GhUnc%!G*%MU1xzD{G7% z#`27n8KW6v8S5}MU`%9el{qSNY~{9$9T~ea_F_zB9K<+`aWvxu#w^CEjMFpm45~bf zaUNqH<08i8jH?+pFm7Vp#<+`dFXO?S;Th?bk20QMJk5BH@gn0D#_No?2&)K27h@P> z1-!N@aP&_8KXsJ+ef<9fx;UrziYne=y?8go=b^hWm*wZj8(H;#5$103{!>4R_3KpL z_lmZ?#s8_J#G51K^IlQIyJACpy&+o!{~I{INAZ2#J0@~E_l~SvfG=EB+VPC^)l1?V z5@9XII*dFoYuPWX$3FLh%{Ya7MFn5FnjoeBsiVZV7Al8(M>nT)?-;`A+&hMHI`@tq zPUqf{H4k8KlS=zuFpN{UcPz*0+&hMII`@vS=8KZKcP!88+&jXOF!FQnNMis=)`;&B zVZ|6J+&fm{bnYEl2L<0iDOdM`l{tlb$10r8y<=5Q=iZSuWibar<#R6>$tm1Bw%~N` z9b0lb_l~ig&b?z(PUqf{H5c)foa*cUE4IeG!VJuw;|u;Go}=1=xsiSNihn|$kr(6@ zc|+b-TuQi76*H3YN`lf#X{U5idMW*2i;J`#kaJ@-M%_4uwct53DM{A|zhV8J=FlWG z^UNbOpZWK8^QgvJN7C9rI29VQe9W_s3#{X6^QhIfj-(re@R}7*qgiqKJ=HqezxP_d zNkfCqpSF%x{6atettSFe_Xb zYCVi2^$ZaeCL#Jl{3oMh&L8-!ZG(7 zixIEHnJ-a>?=brcaeroZ+F^e+8FHZDN}X{>;kcS*NnP)TRu`kaulK-lA&%W~T#O_9 zl_J>yN53ShjkJ#*w*dGeT*ejZ9?`Z3^_1XsQU2U`Wfwo24 zs%_Ig)IQR7X&-9`wLfWxw8Pp@dU^eRy@Fm*ucSxlmGvrmRlS-XsYmHvJzDppO(p0} z^hCWW+EjDBh2Bzcr9Y@A>8qIm;oE{J(Qlx zqv*H2l|Jy9E-Imp`YMkrsmc@R%l(xB%0Ok1@}x3Y8G@eulrjv~2G6K#)V1i}S(vR; zjBH^jBVa9YlrmZwql{I?DdVA^@D1u!L5PRMFVJ^h+sZACTcP2?-D!>-~R z$|7YkbSK_~mBwYta^)>$g|bpvrM#`IR@NwMm3NeN(5cv;)rkid!Nmiq(hI z_G$-MTKqyirXJT|f6jA7gkWAY8k!0|=~q+Kcl3eqMIYB(T4n74t)(_h8x5aJ@NsKZ zv^rWVZMZfDJ{RE=qE*%6vKTyIIHQy9ftfvvrj<}4%3lOeW;$!KGL(`o?;KibwW^g zd3p|VlFCx1UWruGVY3w1dX287L&HiRqrVQEP%6X%4)F?yK>Z`cs~kdlJpTt%Q$qKO zWavo5LieC1=Jac0uDlMkSK^`JQ4g9E4WN_Z15To>&Tt*h2e>#p_CdTNhqy|muwBg4@LMxc$4(q6@N zI*U*|fp&>AtgF)*x*^l#COpUJnN)gzjx=cbkRHTL^znSLR6|?SR;$0L#khB7D39lb z3cuUhbUxnEX3$X++Dtx5Z8jej4OS=NuWEDosA;e8QP-a4qf5)+qg%sn3J4dXP3B{$ zHieHK4ZG#Q-#}T0Ag5Ke-_dXNj`n-@m)a0My4j9qC4B9yDe7r2u%FOoSz+e!QPYO< zF+_Wck2G4*v=_}V+_Eb`*C|$eiG399Wj<w^!P-~<$ z))KTPTB6oeYo;~VT0ri%f?Q96+)nD49=cr9pmj74dUZ>o&$R&>UEA>9vKLxiN1=bD$m+^@ zU_-1NQ6K})qt0GaK9!I;kb2*V5XiU&kSSfzm%XTqhLCL!LBc$u{-XI%bB*x39pp_{ z^lU%su`zz9K>Bn;i;h99Cg68_$fEA(>D5ukP4K$|BvTJfYO$#CMEvduIn`5Bv>K@Y zruf|nQtMGjfSPCx&G5T3WLPgv(`un@G{^6UA<=qkx>g%41tWGyw_#{8Ey2b>epSI* zK@HL_4K3(FuyK%Q)wCqA@sMkgT5GU$$hRmh8LU3;i|$Ir{m|X0xDUDu75S&!tH?Ly zT19>-w<_{Uxr8Zc&Yg;UQLa?vhjOEEP880C!Z}d*>Iz?3;j1cC>$r9v>Xn!R)&=auis{U=f#!%kXN zped)6zhLD04X6JOvuvOhL?gYI-Wy-ap^*!X zz#b3;z2p;|@AJZ|c2YYDpE_He1MNcS;-bAmqCj_b4)R7Z^5k3iy;82iDDxEFpvvPt z>72MMVJLN!IN??YsZWYf^$m3~zAfW9j_(8Y zS!OC?jl2#YH*z11(6pmnv>K1Ki(cq(Zxk`=c6Ga`4vp;tkZ@G zElAB?+5rAdExI;jXm4#GeoMUzp?r`~`;ri~22_T#KYfWP2W{vf7zYm1)ASMgNPUz( z8sofi`UKju4lOl9pNK8ep3$GBGQbmpLgGBfUkOwYYZQ*ja9*^0+6SyX^f$XggL5$C z-Y{rCk4H@|M9nQ0%ke(^2WavhhR$W7IE{AIkG0E3Lw9o~G)?oM6}t{CVk@+XKZP#& zMfn4@acGTKfeyJJ^%JiaW0Z0mC54WKo=v@3$F4z4Wl>v$LLNGA9DzhoDDDrQCPZ2{D#y(|H1nPMg)}FYQ|g&f$I!J zh|zeL@IW5qnelM_-tZl!je_s17?V+I5~-W>r{G*4ipZ9qQd%e-_(4+B>{&@ZyqpgS zjqHD2s&<*KM5%5lQ?6x8wWLh#61|EtEh=5Acwe4RQWd%X?Xlb+0_yPB&acSzfs|F$h<9WGZBP}%@HGYBP`DrcIweZOEMIz7 Nq(f>^`Qi)v{{yFAbiV)q literal 0 HcmV?d00001 diff --git a/src/assets/svg/not-found-illustration.svg b/src/assets/svg/not-found-illustration.svg new file mode 100644 index 000000000..7340bc549 --- /dev/null +++ b/src/assets/svg/not-found-illustration.svg @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/Page404/index.scss b/src/components/Page404/index.scss index 299fcbe01..c42cb9ace 100644 --- a/src/components/Page404/index.scss +++ b/src/components/Page404/index.scss @@ -1,6 +1,19 @@ @import '../../assets/styles/variables.scss'; +// BB Roller Mono is the large "404" numerals in the design. +@font-face { + font-family: 'BB-Roller-Mono'; + src: url('../../assets/fonts/BB-Roller-Mono-Regular.otf') format('opentype'); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +$not-found-text: var(--rd-sys-color-content-primary, #1d232f); +$not-found-brand: #2770ef; + .pageStyles { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -8,22 +21,136 @@ text-align: center; height: auto; min-height: 100vh; - padding: 96px; - color: var(--rd-sys-color-content-primary); - background-color: var(--rd-sys-color-background-base); + padding: 96px 24px; + overflow: hidden; + color: $not-found-text; + background-color: var(--rd-sys-color-background-base, #fff); font-family: 'Optimo-Plain', sans-serif; } +// Radiance top wash across the top of the page, faded out into the page background. +// Size is the container's (RULES.md ADJUST-6), so only the box is set here. +.radianceTop { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 320px; + pointer-events: none; + -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 100%); + mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 100%); + + .radiance-local { + position: absolute; + inset: 0; + overflow: hidden; + } +} + +.pageMissing, +.docMissing { + position: relative; + z-index: 1; +} + +.pageMissing { + display: flex; + flex-direction: column; + align-items: center; +} + +.codeStyles { + margin: 0 0 40px; + font-family: 'BB-Roller-Mono', 'Roboto Mono', monospace; + font-size: 96px; + font-weight: 400; + line-height: 1; + letter-spacing: 0; +} + .headingStyles { - margin-top: 0; - margin-bottom: 64px; - font-size: 28px; + margin: 0 0 8px; + font-size: 24px; + font-weight: $font-weight-bold; + line-height: 32px; } .paragraphStyles { - margin: 0 0 16px; + margin: 0; + font-size: 16px; + line-height: 24px; +} + +.homeButton { + display: inline-flex; + align-items: center; + justify-content: center; + margin-top: 24px; + padding: 4px 12px; + border-radius: 999px; + background: #eaedf2; + color: $not-found-text; + font-size: 13px; + line-height: 16px; + text-decoration: none; + transition: background-color 0.15s ease; + + &:hover { + background: #dde2ea; + text-decoration: none; + } +} + +// In-product presentation: illustration beside the text, primary button. +.pageStyles--embedded { + .docMissing { + display: flex; + align-items: center; + gap: 48px; + text-align: left; + } + + .docMissingIllustration { + flex: none; + width: 140px; + height: 118px; + } + + .docMissingText { + max-width: 440px; + } + + .paragraphStyles { + max-width: 420px; + } + + .homeButton { + padding: 8px 16px; + background: $not-found-brand; + color: #fff; + font-size: 14px; + line-height: 16px; + + &:hover { + background: #1f5fd6; + } + } +} + +@media (max-width: 560px) { + .codeStyles { + font-size: 72px; + } + + .pageStyles--embedded .docMissing { + flex-direction: column; + gap: 24px; + text-align: center; - a { - color: var(--rd-sys-color-content-brand); + .docMissingText { + display: flex; + flex-direction: column; + align-items: center; + } } } diff --git a/src/components/Page404/index.tsx b/src/components/Page404/index.tsx index 40e563eba..7820160d0 100644 --- a/src/components/Page404/index.tsx +++ b/src/components/Page404/index.tsx @@ -2,22 +2,71 @@ import * as React from 'react'; import { Link } from 'gatsby'; import { NOT_FOUND_GO_HOME_PAGE_ID } from '../../configs/doc-configs'; import t from '../../utils/lang-utils'; +import { isPublicSite } from '../../utils/app-utils'; +import { useRadianceTheme } from '../../utils/radiance'; +import notFoundIllustration from '../../assets/svg/not-found-illustration.svg'; import './index.scss'; const Page404 = () => { + // The page is prerendered once (404.html), so it always starts with the public layout and + // switches to the in-product one on mount when it is opened inside the product iframe. + const [isEmbedded, setIsEmbedded] = React.useState(false); + React.useEffect(() => { + setIsEmbedded(!isPublicSite(window.location.search)); + }, []); + + // The 404 designs only have a light variant. + useRadianceTheme(false); + + const homeLink = ( + + {t('404_GO_HOME_LINK_TEXT')} + + ); + return ( -

+
{t('404_PAGE_TITLE')} -

{t('404_PAGE_HEADING')}

-

- {t('404_PAGE_MSG_PRETEXT')} {t('404_PAGE_MSG')} -

-

- - {t('404_GO_HOME_LINK_TEXT')} - - . -

+
); }; diff --git a/src/intl/en.json b/src/intl/en.json index 77f58424b..3354e0ec3 100644 --- a/src/intl/en.json +++ b/src/intl/en.json @@ -3,10 +3,13 @@ "DOCS": "DOCS", "NAV_BACK_BTN_TEXT": "Back", "404_PAGE_TITLE": "Not found", - "404_PAGE_HEADING": "404 - Page Not Found", - "404_PAGE_MSG_PRETEXT": "Sorry,", - "404_PAGE_MSG": "we couldn’t find the page you’re looking for. It may have been removed, renamed, or is temporarily unavailable.", - "404_GO_HOME_LINK_TEXT": "Go to Homepage", + "404_PAGE_CODE": "404", + "404_PAGE_HEADING": "Page not found", + "404_PAGE_MSG": "Sorry, we couldn’t find the page you’re looking for.", + "404_PAGE_MSG_DETAIL": "It may have been removed, renamed, or is temporarily unavailable.", + "404_DOC_HEADING": "This document doesn’t exist", + "404_DOC_MSG": "We couldn’t find this document. It may have been moved, renamed, or the URL is incorrect.", + "404_GO_HOME_LINK_TEXT": "Go to home", "SEARCH_PLACEHOLDER": "Search", "CODE_COPY_BTN_TEXT": "Copy", "CODE_COPY_BTN_HOVER_TEXT": "Copy to clipboard", From 2245f248cbce8a2d27ab7d057dfe84578746e665 Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Wed, 14 Oct 2026 19:03:35 +0530 Subject: [PATCH 18/57] nav edits --- src/components/DevDocTemplate/index.tsx | 88 ++++++++++++++++++++----- src/components/LeftSidebar/index.tsx | 30 ++++++--- 2 files changed, 92 insertions(+), 26 deletions(-) diff --git a/src/components/DevDocTemplate/index.tsx b/src/components/DevDocTemplate/index.tsx index 0af542a6c..cc3209413 100644 --- a/src/components/DevDocTemplate/index.tsx +++ b/src/components/DevDocTemplate/index.tsx @@ -59,6 +59,25 @@ import VersionIframe from '../VersionIframe'; // Not a real DocCategory/tab — used only to pick the left sidebar content when embedded in-product. const IN_PRODUCT_NAV_KEY = 'in-product-help'; +// Session key remembering which sidebar category the visitor was last in. Pages like the +// changelogs are listed in several category navs, and every in-site navigation is a full page +// reload, so this is how the next page knows which sidebar the link was clicked from. +const ACTIVE_CATEGORY_STORAGE_KEY = 'docsActiveCategory'; + +/* The sidebar category the visitor was in on the page they just came from, or null when the + * page was opened directly (typed URL, bookmark, search engine, other site). Only trusted for a + * same-origin referrer, so a direct visit keeps the default category for the page. */ +const readPreviousCategory = (): DocCategory | null => { + if (typeof window === 'undefined') return null; + try { + if (!document.referrer) return null; + if (new URL(document.referrer).origin !== window.location.origin) return null; + return window.sessionStorage.getItem(ACTIVE_CATEGORY_STORAGE_KEY) as DocCategory | null; + } catch (e) { + return null; + } +}; + const DevDocTemplate: FC = (props) => { const { data, @@ -99,6 +118,15 @@ const DevDocTemplate: FC = (props) => { const [navContent, setNavContent] = useState(initialNavContentData || ''); // breadcrumsData is derived after processedNavMap is built (see useMemo below) const [activeCategory, setActiveCategory] = useState('guides'); + // Read once on mount, before the persistence effect below overwrites the stored value. + const [previousCategory] = useState(readPreviousCategory); + useEffect(() => { + try { + window.sessionStorage.setItem(ACTIVE_CATEGORY_STORAGE_KEY, activeCategory); + } catch (e) { + // storage unavailable (private mode etc.): the default category is used next time + } + }, [activeCategory]); const [showSearch, setShowSearch] = useState(false); const [leftNavOpen, setLeftNavOpen] = useState(false); const [keyword, updateKeyword] = useState(''); @@ -205,24 +233,34 @@ const isVersionedIframe = VERSION_DROPDOWN.some( const isAskDocsPage = params[TS_PAGE_ID_PARAM] === CUSTOM_PAGE_ID.ASK_DOCS; - /* Build pageId → category map by parsing hrefs from each category's nav HTML. + /* Build pageId → categories map by parsing hrefs from each category's nav HTML. * This means writers only need to update nav-*.adoc — no TypeScript changes needed. - * Skips any merged nav key that isn't a real tab/category (e.g. the in-product nav, - * or a content-only nav file like nav-release-notes.adoc that a category points to - * via CATEGORY_NAV_ID rather than being a category itself) — otherwise `cat` ends up - * as a bogus DocCategory and CATEGORY_NAV_ID[activeCategory] later resolves to - * undefined, crashing on the `.startsWith` call in activeNavContent below. */ - const pageIdToCategoryMap = React.useMemo(() => { + * A page can be listed in more than one nav (e.g. the changelogs are in both + * nav-release-notes.adoc and their product's nav), so each page maps to a list. + * Nav keys are resolved to categories through CATEGORY_NAV_ID (nav-release-notes.adoc + * belongs to 'whats-new'). Anything that isn't a real tab/category (e.g. the in-product + * nav) is skipped — otherwise `cat` ends up as a bogus DocCategory and + * CATEGORY_NAV_ID[activeCategory] later resolves to undefined, crashing on the + * `.startsWith` call in activeNavContent above. */ + const pageIdToCategories = React.useMemo(() => { if (typeof window === 'undefined') return {}; - const knownCategories = new Set(Object.keys(CATEGORY_NAV_ID)); - const map: Record = {}; - Object.entries(processedNavMap).forEach(([cat, html]) => { - if (cat === IN_PRODUCT_NAV_KEY || !knownCategories.has(cat)) return; + // nav-*.adoc key (pageid minus 'nav-') -> category, e.g. 'release-notes' -> 'whats-new' + const navKeyToCategory: Record = {}; + (Object.entries(CATEGORY_NAV_ID) as [DocCategory, string][]).forEach(([cat, navId]) => { + if (cat !== 'all' && navId.startsWith('nav-')) { + navKeyToCategory[navId.slice(4)] = cat; + } + }); + const map: Record = {}; + Object.entries(processedNavMap).forEach(([navKey, html]) => { + const cat = navKeyToCategory[navKey]; + if (navKey === IN_PRODUCT_NAV_KEY || !cat) return; const doc = new DOMParser().parseFromString(html as string, 'text/html'); doc.querySelectorAll('a[href]').forEach((a) => { const pageId = getPageIdFromHref(a.getAttribute('href')); if (!pageId) return; - map[pageId] = cat as DocCategory; + if (!map[pageId]) map[pageId] = []; + if (!map[pageId].includes(cat)) map[pageId].push(cat); }); }); return map; @@ -245,8 +283,18 @@ const isVersionedIframe = VERSION_DROPDOWN.some( return; } // First try the auto-derived map from nav files - if (pageIdToCategoryMap[currentPageId]) { - setActiveCategory(pageIdToCategoryMap[currentPageId]); + const categories = pageIdToCategories[currentPageId]; + if (categories?.length) { + // A page listed in several category navs (e.g. the changelogs, which sit in both + // "What's new" and their product's nav) stays in the sidebar the visitor came from. + // A direct visit has no previous category and gets the product nav, not "What's new". + const cameFrom = previousCategory && categories.includes(previousCategory) + ? previousCategory + : undefined; + const productNav = categories.filter((cat) => cat !== 'whats-new'); + setActiveCategory( + cameFrom || productNav[productNav.length - 1] || categories[0], + ); return; } // Fall back to the static CATEGORY_PAGEIDS for any pages not yet in a nav file @@ -254,7 +302,7 @@ const isVersionedIframe = VERSION_DROPDOWN.some( ([, pageIds]) => pageIds.includes(currentPageId), ); if (found) setActiveCategory(found[0]); - }, [curPageNode.pageAttributes.pageid, pageIdToCategoryMap, isVersionedIframe, location.search]); + }, [curPageNode.pageAttributes.pageid, pageIdToCategories, previousCategory, isVersionedIframe, location.search]); useEffect(() => { // based on query params set if public site is open or not @@ -414,7 +462,15 @@ const isVersionedIframe = VERSION_DROPDOWN.some( const found = (Object.entries(CATEGORY_PAGEIDS) as [DocCategory, string[]][]).find( ([, pageIds]) => pageIds.includes(e.data.params.pageid), ); - if (found) setActiveCategory(found[0]); + // Keep the current tab when it already lists the page (e.g. a changelog opened + // from "What's new"), instead of jumping to the first category that does. + if (found) { + setActiveCategory((current) => + CATEGORY_PAGEIDS[current]?.includes(e.data.params.pageid) + ? current + : found[0], + ); + } } } }; diff --git a/src/components/LeftSidebar/index.tsx b/src/components/LeftSidebar/index.tsx index 891fb64b6..50eef1abe 100644 --- a/src/components/LeftSidebar/index.tsx +++ b/src/components/LeftSidebar/index.tsx @@ -101,17 +101,27 @@ const LeftSideBar = (props: { // marking this navigation as handled. const isAsideVisible = isMaxMobileResolution || props.leftNavOpen; if (lastScrolledPageIdRef.current !== props.curPageid && isAsideVisible) { - const activeLink = (ref.current as HTMLDivElement)?.querySelector( - 'a.active', - ); - if (activeLink) { + const aside = ref.current as HTMLDivElement; + const activeLink = aside?.querySelector('a.active'); + if (aside && activeLink) { lastScrolledPageIdRef.current = props.curPageid; - // 'nearest' is a no-op once the link is already visible. 'center' isn't — - // it nudges toward exact-center even when already in view, and since this - // sidebar has no scroll room to spare on most pages (content fits exactly), - // that nudge has nowhere to go but the window itself, which is what was - // sliding the fixed header out of view on some walkthrough pages. - activeLink.scrollIntoView({ block: 'nearest' }); + // Scroll only the sidebar itself, never the window: scrollIntoView() also + // scrolls the window, which slid the fixed header out of view on some + // walkthrough pages. 'nearest' avoided that but parks a link that starts + // below the fold flush against the bottom edge, so the user lands on a nav + // that looks jumped. Instead, leave the nav alone when the link is already + // comfortably in the upper part of it, and otherwise bring it to about a + // third of the way down (scrollTop clamps at the ends). + const asideRect = aside.getBoundingClientRect(); + const linkRect = activeLink.getBoundingClientRect(); + const linkTop = linkRect.top - asideRect.top; + const isComfortablyVisible = + linkTop >= 0 && + linkRect.bottom <= asideRect.bottom && + linkTop <= aside.clientHeight * 0.6; + if (!isComfortablyVisible) { + aside.scrollTop += linkTop - aside.clientHeight * 0.3; + } } } }, [props.curPageid, isMaxMobileResolution, navContent, props.leftNavOpen]); From dfbb55b66060692cd1508ee537283e39866e281e Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Thu, 15 Oct 2026 16:39:25 +0530 Subject: [PATCH 19/57] SDK reference fixes for SCAL-251659, SCAL-297673, and SCAL-337086 --- .gitignore | 3 + modules/ROOT/pages/common/nav-embedding.adoc | 17 - .../pages/common/nav-in-product-help.adoc | 18 +- .../ROOT/pages/common/nav-sdk-reference.adoc | 10 + modules/ROOT/pages/embed-action-ref.adoc | 15 +- scripts/Converter/index.ts | 1118 +++++++++++++++-- scripts/Converter/sdk-reference-config.json | 190 +++ src/assets/styles/index.scss | 114 +- src/components/DevDocTemplate/index.tsx | 9 +- src/components/Document/index.tsx | 8 + src/components/EmbedTypeFilter/index.scss | 164 +++ src/components/EmbedTypeFilter/index.tsx | 203 +++ src/components/LeftSidebar/helper.tsx | 14 +- src/components/LeftSidebar/index.tsx | 7 + src/components/SecondaryHeader/index.tsx | 21 +- 15 files changed, 1739 insertions(+), 172 deletions(-) create mode 100644 modules/ROOT/pages/common/nav-sdk-reference.adoc create mode 100644 scripts/Converter/sdk-reference-config.json create mode 100644 src/components/EmbedTypeFilter/index.scss create mode 100644 src/components/EmbedTypeFilter/index.tsx diff --git a/.gitignore b/.gitignore index 366f88c39..26bc5a08c 100644 --- a/.gitignore +++ b/.gitignore @@ -126,3 +126,6 @@ modules/**/generated/ !.idea/dictionaries/*.xml .DS_store *.swp + +# Local report for the docs writers, written by scripts/Converter/index.ts +scripts/Converter/reports/ diff --git a/modules/ROOT/pages/common/nav-embedding.adoc b/modules/ROOT/pages/common/nav-embedding.adoc index e9ea19007..8b6bac6c7 100644 --- a/modules/ROOT/pages/common/nav-embedding.adoc +++ b/modules/ROOT/pages/common/nav-embedding.adoc @@ -165,23 +165,6 @@ Customize and integrate Reference guides and changelog * link:{{navprefix}}/VisualEmbedSdk[Visual Embed SDK Reference] -include::generated/typedoc/CustomSideNav.adoc[] -** Custom styles -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomStyles[CustomStyles]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomisationsInterface[CustomisationsInterface]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_customCssInterface[customCssInterface]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomCssVariables[customCssVariables]# -** Runtime filters -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_RuntimeFilter[RuntimeFilter]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_RuntimeFilterOp[RuntimeFilterOp]# -** Others -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_Action[Action]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_ContextMenuTriggerOptions[ContextMenuTriggerOptions]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_DataSourceVisualMode[DataSourceVisualMode]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_Page[Page]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_PrefetchFeatures[PrefetchFeatures]# -*** [.typedoc-Function]#link:{{navprefix}}/Function_executeTML[executeTML]# -*** [.typedoc-Function]#link:{{navprefix}}/Function_exportTML[exportTML]# * link:{{navprefix}}/embed-sdk-changelog[Changelog] ** link:{{navprefix}}/embed-sdk-changelog[Visual Embed SDK] ** link:{{navprefix}}/mobile-sdk-changelog[Mobile Embed SDK] diff --git a/modules/ROOT/pages/common/nav-in-product-help.adoc b/modules/ROOT/pages/common/nav-in-product-help.adoc index 86cc82b95..be7f7993a 100644 --- a/modules/ROOT/pages/common/nav-in-product-help.adoc +++ b/modules/ROOT/pages/common/nav-in-product-help.adoc @@ -149,23 +149,7 @@ Customize and integrate Visual Embed SDK reference guide * link:{{navprefix}}/VisualEmbedSdk[Visual Embed SDK Reference] -include::generated/typedoc/CustomSideNav.adoc[] -** Custom styles -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomStyles[CustomStyles]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomisationsInterface[CustomisationsInterface]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_customCssInterface[customCssInterface]# -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_CustomCssVariables[customCssVariables]# -** Runtime filters -*** [.typedoc-Interface]#link:{{navprefix}}/Interface_RuntimeFilter[RuntimeFilter]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_RuntimeFilterOp[RuntimeFilterOp]# -** Others -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_Action[Action]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_ContextMenuTriggerOptions[ContextMenuTriggerOptions]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_DataSourceVisualMode[DataSourceVisualMode]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_Page[Page]# -*** [.typedoc-Enumeration]#link:{{navprefix}}/Enumeration_PrefetchFeatures[PrefetchFeatures]# -*** [.typedoc-Function]#link:{{navprefix}}/Function_executeTML[executeTML]# -*** [.typedoc-Function]#link:{{navprefix}}/Function_exportTML[exportTML]# +include::generated/typedoc/SdkReferenceNav.adoc[] [.sidebar-title] diff --git a/modules/ROOT/pages/common/nav-sdk-reference.adoc b/modules/ROOT/pages/common/nav-sdk-reference.adoc new file mode 100644 index 000000000..fb456b000 --- /dev/null +++ b/modules/ROOT/pages/common/nav-sdk-reference.adoc @@ -0,0 +1,10 @@ +:page-pageid: nav-sdk-reference +:page-description: Visual Embed SDK reference navigation + +[navSection] + +[.sidebar-title] +SDK Reference Guide + +* link:{{navprefix}}/VisualEmbedSdk[Visual Embed SDK] +include::generated/typedoc/SdkReferenceNav.adoc[] diff --git a/modules/ROOT/pages/embed-action-ref.adoc b/modules/ROOT/pages/embed-action-ref.adoc index 64335416c..0abf2461b 100644 --- a/modules/ROOT/pages/embed-action-ref.adoc +++ b/modules/ROOT/pages/embed-action-ref.adoc @@ -8,7 +8,7 @@ ThoughtSpot application components such as Liveboards, Answers, visualizations, Search and Spotter interface, and application pages support several actions and menu commands for user-initiated operations. To allow developers to customize the menu options and feature availability for their embedded application users, Visual Embed SDK provides action IDs for the actions in the ThoughtSpot application interface. Developers can use these action IDs in the `visibleActions`, `disabledActions`, or `hiddenActions` arrays to show, disable, or hide specific actions in the embedded view. -The availability of these actions may vary based on user privileges and the SDK library used for embedding. For information about the action IDs available for embed components, browse through the lists in the following sections. +The availability of these actions may vary based on user privileges and the SDK library used for embedding. For information about the action IDs available for embed components, browse through the lists in the following sections. To see only the actions that apply to a specific embed type, such as `LiveboardEmbed` or `SpotterEmbed`, use the filter on the xref:Action.adoc[Action] page. [#liveboardv2-action] == Liveboard @@ -100,14 +100,6 @@ Controls the visibility of the Liveboard style panel. `AppEmbed` |The Liveboard verified banner text. + Indicates the Liveboard is verified. + See also, xref:LiveboardViewConfig.adoc#_showliveboardverifiedbadge[showLiveboardVerifiedBadge]. -|xref:Action.adoc#_personalisedviewsdropdown[`Action.PersonalisedViewsDropdown`]| `LiveboardEmbed` + -`AppEmbed` | The Liveboard personalized views drop-down. + -Available if personalized views are saved on the Liveboard. + -Allows switching between the saved personalized views of a Liveboard. + -[NOTE] -==== -This action is deprecated. Use `Action.PersonalizedViewsDropdown` instead. -==== |xref:Action.adoc#_personalizedviewsdropdown[`Action.PersonalizedViewsDropdown`]| `LiveboardEmbed` + `AppEmbed` | The Liveboard personalized views drop-down. + Allows switching between the saved personalized views of a Liveboard. @@ -632,11 +624,6 @@ a|xref:Action.adoc#_addtowatchlist[`Action.AddToWatchlist`] a| `AppEmbed` | The Adds a KPI chart to the watchlist on the Home page. a|xref:Action.adoc#_removefromwatchlist[`Action.RemoveFromWatchlist`] a| `AppEmbed` | The *Remove from watchlist* menu action on KPI watchlist. + Removes a KPI chart from the watchlist on the Home page. -a|xref:Action.adoc#_organisefavourites[`Action.OrganiseFavourites`] a| `AppEmbed` | The *Organize Favourites* action on Homepage Favorites module. + -[NOTE] -==== -This action is deprecated. Use `Action.OrganizeFavorites` instead. -==== a|xref:Action.adoc#_organizefavorites[`Action.OrganizeFavorites`] a| `AppEmbed` | The *Organize Favorites* action on Homepage Favorites module. |xref:Action.adoc#_copylink[`Action.CopyLink`] a|`AppEmbed`|*Copy link* + Allows users to copy a link from the *Watchlist* on the Homepage. diff --git a/scripts/Converter/index.ts b/scripts/Converter/index.ts index 937460d01..98d40c9ca 100644 --- a/scripts/Converter/index.ts +++ b/scripts/Converter/index.ts @@ -2,6 +2,44 @@ const path = require('path'); const fs = require('fs'); const nodeFetch = require('node-fetch'); +interface SdkReferenceConfig { + groupOrder: string[]; + fallbackGroup: string; + // Leave the deprecated top-level items out of the docs. + excludeDeprecated: boolean; + // Corrections to the wording of an SDK comment, until the comment itself is fixed. + commentFixes: { node: string; reason: string; find: string; replace: string }[]; + // Enumerations whose deprecated members are left out as well (e.g. Action). + excludeDeprecatedMembersOf: string[]; + kindFallbackGroups: Record; + groupOverrides: Record; + groupPatterns: { group: string; pattern: string }[]; + descriptions: Record; + landingPage: { + description: string; + intro: string[]; + groupDescriptions: Record; + typeDescriptions: Record; + }; + // Groups listed as a page, under a parent group, instead of in the menu. + childGroups: Record; + actionEmbedTypes: { + sourcePage: string; + // Embed types that a writer has confirmed for Actions not in the table yet. + confirmed: Record; + }; +} + +// Internal report for the docs writers. It is outside the folders that Gatsby reads +// (src/page and modules/), so it can never be part of the site. +// It is local only (git ignored) and written only for this branch of the SDK. +const ACTION_REPORT_PATH = 'scripts/Converter/reports/action-embed-types.md'; +const ACTION_REPORT_BRANCH = 'main'; + +const sdkReferenceConfig: SdkReferenceConfig = JSON.parse( + fs.readFileSync(path.join(__dirname, 'sdk-reference-config.json'), 'utf8'), +); + enum TypeDocReflectionKind { Project = 'Project', Module = 'Module', @@ -161,23 +199,128 @@ class TypeDocInternalParser { static GITHUB_LINK = 'https://github.com/thoughtspot/visual-embed-sdk/blob/main/src'; - static covertTypeDocText = (text: string) => { + // In AsciiDoc a list must be preceded by a blank line, otherwise its first item + // runs on into the paragraph above it. SDK comments are written as Markdown, + // where that is not needed, so add the blank line. Code blocks are left alone. + static separateListsFromParagraphs = (text: string) => { + const isBullet = (line: string) => /^\s*[-*]\s+\S/.test(line); + let inCodeBlock = false; + const lines = text.split('\n'); + const result: string[] = []; + lines.forEach((line, index) => { + if (line.trimStart().startsWith('```')) inCodeBlock = !inCodeBlock; + const previous = lines[index - 1]; + if ( + !inCodeBlock && + index > 0 && + isBullet(line) && + previous.trim() && + !isBullet(previous) && + !previous.trimStart().startsWith('```') + ) { + result.push(''); + } + result.push(line); + }); + return result.join('\n'); + }; + + // Resolves the target of a {@link Target} to an AsciiDoc link, relative to the page + // being written. Set by TypedocConverter. + static resolveLinkTarget: ( + target: string, + label?: string, + ) => string | undefined; + + // `bareLinks` leaves out the typedoc style around a link. It is needed inside + // the #...# marks of the deprecated and version tags, which it would end early. + static covertTypeDocText = (text: string, bareLinks = false) => { // 1) Convert Markdown links -> AsciiDoc links - const updated = text.replace( + const updated = TypeDocInternalParser.separateListsFromParagraphs( + text, + ).replace( /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, 'link:$2[$1]', ); - // 2) Existing logic: convert {@link Name.hash} -> xref:... - const matches = updated.match(/{@link\s[^{]+}/g); - if (!matches) return updated; + // 2) {@link Target} and {@link Target text} (also {@linkcode}, {@linkplain}). + // A target that is not documented, such as a type the SDK does not export, is + // shown as plain code, never as the braces of the tag. + return updated.replace( + /\{@link(?:code|plain)?\s+([^\s}|]+)(?:[\s|]+([^}]*))?\}/g, + (_match, target: string, label?: string) => { + const shown = label?.trim(); + if (/^https?:\/\//.test(target)) { + return `link:${target}[${shown || target}]`; + } + const link = TypeDocInternalParser.resolveLinkTarget(target, shown); + if (!link) return `\`${shown || target}\``; + return bareLinks ? link.replace(/^\[[^\]]*\]#(.*)#$/, '$1') : link; + }, + ); + }; - return matches.reduce((prevUpdatedText, curLinkText) => { - const linkTo = curLinkText.split(/\s/)[1].replace(/}/g, ''); - const newLinkText = this.convertNameToLink(linkTo, true); - if (!newLinkText) return prevUpdatedText; - return prevUpdatedText.replace(curLinkText, newLinkText); - }, updated); + // Plain text of a comment, for the description in the page metadata and search + // results, where no markup is shown. + static plainText = (text: string | undefined) => + (text || '') + .replace( + /\{@link(?:code|plain)?\s+([^\s}|]+)(?:[\s|]+([^}]*))?\}/g, + (_match, target: string, label?: string) => + (label || target).trim(), + ) + .replace(/\s+/g, ' ') + .trim(); + + // An @example is mostly code, which must stay as it is, but the SDK also writes prose + // in it, after the code. Convert the prose (its {@link}s and lists), not the code. + static convertOutsideCodeBlocks = (text: string) => { + const parts: { code: boolean; lines: string[] }[] = []; + let inCode = false; + text.split('\n').forEach((line) => { + const isFence = line.trimStart().startsWith('```'); + const code = inCode || isFence; + if (isFence) inCode = !inCode; + const last = parts[parts.length - 1]; + if (last && last.code === code) last.lines.push(line); + else parts.push({ code, lines: [line] }); + }); + return parts + .map(({ code, lines }) => + code + ? lines.join('\n') + : TypeDocInternalParser.covertTypeDocText(lines.join('\n')), + ) + .join('\n'); + }; + + // Repairs the tags of a comment that the SDK source wrote wrongly: a closing code + // fence repeated after an example, and a @version line written after the example so + // that it became part of the example text. + static repairTags = (tags: TypeDocCommentTag[]): TypeDocCommentTag[] => { + const repaired: TypeDocCommentTag[] = []; + const extra: TypeDocCommentTag[] = []; + tags.forEach((tag) => { + if (tag.tag !== 'example') { + repaired.push(tag); + return; + } + let { text } = tag; + const version = text.match(/^[ \t]*@version[ \t]+(.+)$/m); + if (version) { + text = text.replace(version[0], ''); + if (!tags.some((other) => other.tag === 'version')) { + extra.push({ tag: 'version', text: `${version[1].trim()}\n` }); + } + } + text = text.replace(/^(```)[ \t]*\n(?:[ \t]*\n)*```[ \t]*$/gm, '$1'); + const fences = text + .split('\n') + .filter((line) => line.trimStart().startsWith('```')).length; + if (fences % 2) text = `${text.trimEnd()}\n\`\`\``; + repaired.push({ ...tag, text }); + }); + return [...repaired, ...extra]; }; // function to parse a tag @@ -188,16 +331,18 @@ class TypeDocInternalParser { } if (tag.tag === 'group') return ''; if (tag.tag === 'version') - return `[version]#Version : ${tag.text.replace(/\n/g, '')}#\n`; - if (tag.tag === 'example') return `${tag.text}\n`; + return `[version]#*Version* : ${tag.text.replace(/\n/g, '')}#\n`; + if (tag.tag === 'example') { + return `${this.convertOutsideCodeBlocks(tag.text)}\n`; + } if (tag.tag === 'param') { return `\nParameter::\n${this.covertTypeDocText(tag.text)}\n`; } if (tag.tag === 'deprecated') { - return `[deprecated]#Deprecated : ${tag.text.replace( - /\n/g, - '', + return `[deprecated]#*Deprecated* : ${this.covertTypeDocText( + tag.text.replace(/\s*\n\s*/g, ' ').trim(), + true, )}#\n`; } if (tag.tag === 'tryItOut') { @@ -220,7 +365,36 @@ class TypeDocInternalParser { return content; } - static parseTags(tags: TypeDocCommentTag[] | undefined) { + // Where the Version and Deprecated blocks of an item go: + // - an item that is not deprecated has its Version block right under the signature, + // before the description; + // - a deprecated item has its description first, then the Deprecated block, then + // the Version block, and then the other tags (default value, examples). + // The two are never in one row. Use parseTags(tags, false) for the other tags. + private static metaBlocks(tags: TypeDocCommentTag[] | undefined) { + const repaired = this.repairTags(tags || []); + const render = (name: string) => + repaired + .filter((tag) => tag.tag === name) + .map((tag) => this.parseTag(tag).trim()) + .filter(Boolean); + return { + version: render('version'), + deprecated: render('deprecated'), + }; + } + + static metaBeforeDescription(tags: TypeDocCommentTag[] | undefined) { + const { version, deprecated } = this.metaBlocks(tags); + return deprecated.length ? '' : version.join('\n\n'); + } + + static metaAfterDescription(tags: TypeDocCommentTag[] | undefined) { + const { version, deprecated } = this.metaBlocks(tags); + return deprecated.length ? [...deprecated, ...version].join('\n\n') : ''; + } + + static parseTags(tags: TypeDocCommentTag[] | undefined, includeMeta = true) { // process tags let content = ''; @@ -231,16 +405,19 @@ class TypeDocInternalParser { }; if (tags) { - const versionTags = tags.filter((tag) => tag.tag === 'version'); - const deprecatedTags = tags.filter( + const repairedTags = this.repairTags(tags); + const versionTags = repairedTags.filter((tag) => tag.tag === 'version'); + const deprecatedTags = repairedTags.filter( (tag) => tag.tag === 'deprecated', ); - const restTags = tags.filter( + const restTags = repairedTags.filter( (tag) => tag.tag !== 'version' && tag.tag !== 'deprecated', ); - addTagsToContent(versionTags); - addTagsToContent(deprecatedTags); + if (includeMeta) { + addTagsToContent(versionTags); + addTagsToContent(deprecatedTags); + } addTagsToContent(restTags); } @@ -384,8 +561,33 @@ class TypeDocParser { private childrenNameMap: Record = {}; + // The top-level items by name, and the one whose page is being written. + private topLevelByName: Record = {}; + + private currentTopNode?: TypeDocLinkingNode; + private groupMap: Record = {}; + private actionEmbedTypes: Record = {}; + + // The link for the Target of a {@link Target}: Item, Item.member, or just a member + // name, which is looked for on the page being written first (so that a property + // that refers to another property of the same interface finds it). Nothing when the + // target is not documented. + public resolveLinkTarget = (target: string, label?: string) => { + const [name, member] = target.split(/[.#]/); + const node = member + ? this.topLevelByName[name]?.children?.find( + (child) => child.name === member, + ) + : this.currentTopNode?.children?.find((child) => child.name === name) || + this.topLevelByName[name]; + if (!node) return undefined; + const link = this.convertNodeToLink(node, Boolean(member)); + if (!link || link === node.name) return undefined; + return label ? link.replace(/\[[^\]]*\]#$/, `[${label}]#`) : link; + }; + public convertNameToLink = ( linkTo: string | undefined, includeParent = false, @@ -497,32 +699,89 @@ class TypeDocParser { ].join('\n'); }; - private createTypeDocTable = ( - data: string[], - noOfColumns: number, - ): string => { - let content = `[cols="${'1,'.repeat(noOfColumns - 1)}1"]\n|===\n`; - data.forEach((str) => { - content += `| ${str}\n`; - }); + // An index of links as a list. The page styles lay it out in columns, so + // long names wrap instead of overflowing the fixed-width cells of a table. + // A long camelCase name gets a line break opportunity () at each word + // boundary, so it wraps between words and never leaves the icon on a line of + // its own. adds no text, so copying and searching are not affected. + private createTypeDocIndex = (items: string[]): string => { + if (!items.length) return ''; + const breakable = (item: string) => + item.replace(/\[([^\]]+)\]#$/, (_match, name: string) => { + const withBreaks = name + .replace(/([a-z0-9])(?=[A-Z])/g, '$1++++++') + .replace(/([A-Z])(?=[A-Z][a-z])/g, '$1++++++'); + return `[${withBreaks}]#`; + }); + return `[.typedoc-index]\n${items + .map((item) => `* ${breakable(item)}`) + .join('\n')}\n`; + }; - for (let i = 0; i < noOfColumns - 1; i++) { - content += '| \n'; - } - content += '|===\n'; - return content; + private addToGroup = (groupName: string, node: TypeDocLinkingNode) => { + if (!this.groupMap[groupName]) this.groupMap[groupName] = []; + this.groupMap[groupName].push(node); }; - private generateMap = (node: TypeDocLinkingNode) => { - const groupTag = + private getGroupTag = (node: TypeDocLinkingNode) => + ( node.comment?.tags?.filter((e) => e.tag === 'group')[0] || node?.signatures?.[0].comment?.tags?.filter( (e) => e.tag === 'group', - )[0]; - if (groupTag) { - const groupName = groupTag.text.trim(); - if (!this.groupMap[groupName]) this.groupMap[groupName] = []; - this.groupMap[groupName].push(node); + )[0] + )?.text.trim(); + + // Sidebar group for a top-level node. Precedence: the docs-side override, + // then the SDK's @group tag, then a name pattern for ungrouped nodes, then + // the fallback group, so every generated page is reachable from the nav. + private resolveTopLevelGroup = (node: TypeDocLinkingNode): string => { + const { + groupOverrides, + groupPatterns, + fallbackGroup, + kindFallbackGroups, + } = sdkReferenceConfig; + const override = Object.keys(groupOverrides).find((group) => + groupOverrides[group].includes(node.name), + ); + if (override) return override; + + const tagGroup = this.getGroupTag(node); + if (tagGroup) return tagGroup; + + const isTypeShape = [ + TypeDocReflectionKind.Interface, + TypeDocReflectionKind.TypeAlias, + TypeDocReflectionKind.Enumeration, + ].includes(node.kindString); + const patternGroup = isTypeShape + ? groupPatterns.find(({ pattern }) => + new RegExp(pattern).test(node.name), + )?.group + : undefined; + return ( + patternGroup || kindFallbackGroups[node.kindString] || fallbackGroup + ); + }; + + // The SDK comment if it has one, else the docs-side description (if any). + private getDescription = (node: TypeDocNode): string | undefined => { + const hasSdkComment = + node.comment?.shortText?.trim() || node.comment?.text?.trim(); + if (hasSdkComment) return undefined; + return sdkReferenceConfig.descriptions[node.name]; + }; + + private generateMap = (node: TypeDocLinkingNode) => { + const isTopLevel = + this.childrenIdMap[node.parentId as number]?.kindString === + TypeDocReflectionKind.Project; + if (isTopLevel) { + this.topLevelByName[node.name] = node; + this.addToGroup(this.resolveTopLevelGroup(node), node); + } else { + const groupName = this.getGroupTag(node); + if (groupName) this.addToGroup(groupName, node); } if (!this.childrenIdMap[node.id]) this.childrenIdMap[node.id] = node; if (!this.childrenNameMap[node.name]) @@ -597,6 +856,14 @@ class TypeDocParser { return ''; }; + private parseNodeComment = (node: TypeDocNode): string => { + const description = this.getDescription(node); + if (description) { + return `${TypeDocInternalParser.covertTypeDocText(description)}\n\n`; + } + return TypeDocInternalParser.parseComment(node.comment); + }; + // handles the Main page nodes (Enum, Class, Interface, Type Alias) private handleMainNode = (node: TypeDocNode) => { const pageTitle = `= ${node.name}`; @@ -605,12 +872,11 @@ class TypeDocParser { let enumIndexContent = '\n\n[div boxDiv boxFullWidth]\n--\n'; // Special handling if (['Enumeration', 'Class', 'Interface'].includes(node.kindString)) { - enumIndexContent += `${this.createTypeDocTable( + enumIndexContent += this.createTypeDocIndex( node.children?.map((childNode) => this.convertNodeToLink(childNode), ) || [], - 3, - )}`; + ); } enumIndexContent += '\n--\n\n'; @@ -629,33 +895,418 @@ class TypeDocParser { return [ pageTitle, - TypeDocInternalParser.parseComment(node.comment), - TypeDocInternalParser.parseTags(node.comment?.tags), + TypeDocInternalParser.metaBeforeDescription(node.comment?.tags), + this.parseNodeComment(node), + TypeDocInternalParser.metaAfterDescription(node.comment?.tags), + TypeDocInternalParser.parseTags(node.comment?.tags, false), '== Index', + node.name === 'Action' ? this.buildEmbedTypeFilter() : '', enumIndexContent, mainPageContent, groupContent, ].join('\n\n'); }; + // The SDK does not record which embed types support each Action, so the + // mapping is read from the "Required SDK library" column of the hand + // written Action IDs page. Rows look like: + // |xref:Action.adoc#_share[`Action.Share`]| `LiveboardEmbed` + `AppEmbed` | label + // A leading `a` (a|xref:...) marks an AsciiDoc-style cell and is also a row start. + // Items that are left out of the docs: anything deprecated, and anything the + // SDK marks @hidden or @internal (the typedoc normally drops those already). + private isExcludedFromDocs = (node: TypeDocNode, includeDeprecated = true) => + [ + ...(node.comment?.tags || []), + ...((node as FunctionNode).signatures?.[0]?.comment?.tags || []), + ].some( + (tag) => + ['hidden', 'internal'].includes(tag.tag) || + (includeDeprecated && tag.tag === 'deprecated'), + ); + + // Removes the excluded top-level items (see excludeDeprecated in the config), and + // the deprecated members of the enumerations in excludeDeprecatedMembersOf, + // before anything is built. They get no page, no nav entry or index entry, and + // a reference to one renders as plain text, not as a broken link. + private excludeDeprecatedNodes = (project: TypeDocNode) => { + const removeFrom = ( + parent: TypeDocNode, + excluded: TypeDocNode[], + label: string, + ) => { + if (!excluded.length) return; + const excludedIds = new Set(excluded.map((node) => node.id)); + // eslint-disable-next-line no-param-reassign + parent.children = parent.children?.filter( + (node) => !excludedIds.has(node.id), + ); + parent.groups?.forEach((group) => { + // eslint-disable-next-line no-param-reassign + group.children = group.children.filter( + (id) => !excludedIds.has(id), + ); + }); + // eslint-disable-next-line no-param-reassign + parent.groups = parent.groups?.filter((group) => group.children.length); + console.info( + `Excluded ${excluded.length} ${label} : ${excluded + .map((node) => node.name) + .join(', ')}`, + ); + }; + + removeFrom( + project, + (project.children || []).filter((node) => + this.isExcludedFromDocs(node, sdkReferenceConfig.excludeDeprecated), + ), + 'deprecated or hidden items', + ); + sdkReferenceConfig.excludeDeprecatedMembersOf.forEach((name) => { + const parent = (project.children || []).find( + (node) => node.name === name, + ); + if (!parent) return; + removeFrom( + parent, + (parent.children || []).filter((member) => + this.isExcludedFromDocs(member), + ), + `deprecated or hidden members of ${name}`, + ); + }); + }; + + // Applies the corrections in commentFixes to the comments of top-level items. The + // match ignores how the text is wrapped. A fix that no longer matches (the SDK + // fixed its comment) is reported, so that it can be removed. + private applyCommentFixes = (project: TypeDocNode) => { + sdkReferenceConfig.commentFixes.forEach(({ node, find, replace }) => { + const target = (project.children || []).find( + (child) => child.name === node, + ); + const pattern = new RegExp( + find + .trim() + .split(/\s+/) + .map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('\\s+'), + ); + let applied = false; + (['shortText', 'text'] as const).forEach((field) => { + const current = target?.comment?.[field]; + if (target?.comment && current && pattern.test(current)) { + target.comment[field] = current.replace(pattern, () => replace); + applied = true; + } + }); + if (!applied) { + console.warn( + `Comment fix for ${node} no longer matches the SDK comment, so it can be removed from commentFixes in sdk-reference-config.json`, + ); + } + }); + }; + + // Warns about top-level items that this converter has no page for, which + // would otherwise be missing from the docs without any sign of it. + private warnAboutUnsupportedNodes = (project: TypeDocNode) => { + const supported: TypeDocReflectionKind[] = [ + TypeDocReflectionKind.Enumeration, + TypeDocReflectionKind.Class, + TypeDocReflectionKind.Interface, + TypeDocReflectionKind.TypeAlias, + TypeDocReflectionKind.Function, + ]; + const unsupported = (project.children || []).filter( + (node) => !supported.includes(node.kindString), + ); + if (unsupported.length) { + console.warn( + `Not documented, no page for this kind of item : ${unsupported + .map((node) => `${node.name} (${node.kindString})`) + .join(', ')}`, + ); + } + console.info( + `Documented ${(project.children || []).length - unsupported.length} items, ${ + unsupported.length + } not supported`, + ); + }; + + private loadActionEmbedTypes = () => { + const { sourcePage } = sdkReferenceConfig.actionEmbedTypes; + this.actionEmbedTypes = {}; + if (!fs.existsSync(sourcePage)) { + console.warn(`Action embed types skipped, not found : ${sourcePage}`); + return; + } + const rows = fs + .readFileSync(sourcePage, 'utf8') + .split(/\n(?=a?\|xref:Action\.adoc)/) + .slice(1); + rows.forEach((row) => { + const nameMatch = row.match( + /^a?\|xref:Action\.adoc#_[a-z0-9]+\[`Action\.([A-Za-z0-9]+)`\]/, + ); + if (!nameMatch) return; + const libraryCell = row + .slice(nameMatch[0].length) + .split(/\n?\|/)[1]; + const embedTypes = (libraryCell?.match(/`[A-Za-z]+Embed`/g) || []).map( + (embed) => embed.replace(/`/g, ''), + ); + const known = this.actionEmbedTypes[nameMatch[1]] || []; + this.actionEmbedTypes[nameMatch[1]] = [ + ...known, + ...embedTypes.filter((embed) => !known.includes(embed)), + ]; + }); + console.info( + `Embed types found for ${ + Object.keys(this.actionEmbedTypes).length + } actions`, + ); + + this.addConfirmedEmbedTypes(sourcePage); + + // A row for an Action that is not documented (excluded or removed) links to an + // anchor that no longer exists on the Action page. + const documented = new Set( + (this.childrenNameMap.Action?.children || []).map((member) => member.name), + ); + const staleRows = Object.keys(this.actionEmbedTypes).filter( + (name) => !documented.has(name), + ); + if (staleRows.length) { + console.warn( + `Action page : ${staleRows.length} rows in ${sourcePage} are for Actions that are not documented (excluded or removed), so their links are dead : ${staleRows.join( + ', ', + )}`, + ); + } + + // An Action ID that is not in the table has no "Supported in" line. It is + // still on the Action page and in the Index, so it shows when no embed type + // is selected ("All") and under "Other" in the filter. The writers get the list, + // with a suggestion to check, in the report (see buildActionReport). + this.uncategorizedActions = ( + this.childrenNameMap.Action?.children || [] + ).filter((member) => !this.actionEmbedTypes[member.name]?.length); + if (this.uncategorizedActions.length) { + console.warn( + `Action page : ${this.uncategorizedActions.length} Action IDs have no embed type yet. They show under "All" and "Other" on the Action page. The list, with suggestions for a writer to check, is in ${ACTION_REPORT_PATH}`, + ); + } + }; + + // The Actions with no embed type yet, in the order of the Action page. + private uncategorizedActions: TypeDocNode[] = []; + + // An internal report for the writers: which Actions have no embed type yet, and a + // suggestion for each to check. It is written outside the folders that the site + // is built from, and nothing in it is used by the pages or the filter. + public buildActionReport = (): string => { + const { sourcePage } = sdkReferenceConfig.actionEmbedTypes; + const sdkVersion = (member: TypeDocNode) => + member.comment?.tags + ?.find((tag) => tag.tag === 'version') + ?.text.match(/SDK:\s*([0-9.]+)/)?.[1] || ''; + const oneLine = (member: TypeDocNode) => + `${member.comment?.shortText || ''}` + .replace(/\*\*|`/g, '') + .replace(/\s+/g, ' ') + .replace(/\|/g, '\\|') + .trim(); + + const rows = this.uncategorizedActions.map((member) => ({ + member, + suggestion: this.suggestEmbedTypes(member), + })); + const withSuggestion = rows.filter((row) => row.suggestion); + const jsonEntry = (name: string, embedTypes: string[]) => + ` "${name}": [${embedTypes.map((embed) => `"${embed}"`).join(', ')}]`; + + const table = rows + .map(({ member, suggestion }) => + [ + `\`${member.name}\``, + sdkVersion(member) || '-', + suggestion ? suggestion.embedTypes.join(' + ') : 'no suggestion', + suggestion ? suggestion.reason : '-', + oneLine(member) || '(no description)', + ].join(' | '), + ) + .map((row) => `| ${row} |`); + + const confirmed = Object.keys(sdkReferenceConfig.actionEmbedTypes.confirmed || {}); + const staleRows = Object.keys(this.actionEmbedTypes).filter( + (name) => !(this.childrenNameMap.Action?.children || []).some((m) => m.name === name), + ); + + return [ + '# Actions with no embed type yet', + `INTERNAL. For the docs writers. Generated locally by \`npm run generate-typedoc\` (scripts/Converter/index.ts) from the ${ACTION_REPORT_BRANCH} branch of the SDK. Do not edit it by hand and do not commit it: it is git ignored. It is not part of the site, and nothing in it is shown to readers.`, + `${rows.length} Action IDs have no embed type. They show under "All" and under "Other" in the filter on the Action page, with no "Supported in" line. ${withSuggestion.length} have a suggestion below. A suggestion is inferred from the description only, so check it (the SDK pull request that added the Action can help) before you use it.`, + '## How to categorize an Action', + [ + `1. Add a row for it to \`${sourcePage}\`, in the section it belongs to, with its embed types in the "Required SDK library" column. This is the full reference entry.`, + '2. Or, to categorize it before that row is written, add its name and embed types under `actionEmbedTypes.confirmed` in `scripts/Converter/sdk-reference-config.json`. Remove the entry once the row is on the page; the row wins if both exist.', + 'On the next `npm run generate-typedoc` the Action gets its "Supported in" line and moves out of "Other" into its embed types.', + ].join('\n'), + '## Actions to categorize', + [ + '| Action | Since SDK | Suggested embed types | Why | Description |', + '| --- | --- | --- | --- | --- |', + ...table, + ].join('\n'), + '## Suggestions as config entries', + withSuggestion.length + ? `NOT confirmed. Check each one, delete the ones that are wrong, then paste the rest into \`actionEmbedTypes.confirmed\`:\n\n\`\`\`json\n${withSuggestion + .map(({ member, suggestion }) => + jsonEntry(member.name, suggestion?.embedTypes || []), + ) + .join(',\n')}\n\`\`\`` + : 'None.', + '## To tidy up', + [ + `- Entries under \`actionEmbedTypes.confirmed\` in the config: ${ + confirmed.length ? confirmed.join(', ') : 'none' + }.`, + `- Rows in \`${sourcePage}\` for Actions that are not documented (excluded or removed), whose links are dead: ${ + staleRows.length ? staleRows.join(', ') : 'none' + }.`, + ].join('\n'), + ].join('\n\n') + '\n'; + }; + + // Embed types that a writer confirmed in the config, for Actions that are not in the + // table yet. The table wins when an Action is in both, and a mistake in an entry + // (a name that does not exist) is reported, not silently ignored. + private addConfirmedEmbedTypes = (sourcePage: string) => { + const confirmed = sdkReferenceConfig.actionEmbedTypes.confirmed || {}; + const documented = new Set( + (this.childrenNameMap.Action?.children || []).map((member) => member.name), + ); + Object.entries(confirmed).forEach(([name, embedTypes]) => { + const unknownEmbeds = embedTypes.filter( + (embed) => + this.childrenNameMap[embed]?.kindString !== + TypeDocReflectionKind.Class, + ); + if (!documented.has(name)) { + console.warn( + `Action page : confirmed embed types for ${name} are ignored, there is no such documented Action`, + ); + } else if (unknownEmbeds.length) { + console.warn( + `Action page : confirmed embed types for ${name} are ignored, not embed classes : ${unknownEmbeds.join( + ', ', + )}`, + ); + } else if (this.actionEmbedTypes[name]?.length) { + console.warn( + `Action page : ${name} is in ${sourcePage} as well, so the confirmed entry in the config is not needed and can be removed`, + ); + } else { + this.actionEmbedTypes[name] = this.sortEmbedTypes(embedTypes); + } + }); + }; + + // A cautious guess at the embed types of an Action from its description. It only + // answers when the description is unambiguous, and returns nothing otherwise. It + // is a hint for the writers: it is never put on a page or used by the filter. + private suggestEmbedTypes = ( + member: TypeDocNode, + ): { embedTypes: string[]; reason: string } | undefined => { + const description = `${member.comment?.shortText || ''} ${ + member.comment?.text || '' + }`.replace(/\s+/g, ' '); + const lower = description.toLowerCase(); + + const named = [ + ...new Set( + description.match( + /\b(?:App|Liveboard|Search|SearchBar|Spotter|SpotterAgent)Embed\b/g, + ), + ), + ]; + if (named.length) { + return { + embedTypes: this.sortEmbedTypes(named), + reason: 'the description names the embed type', + }; + } + + // Pages that only the full application has + if ( + /homepage|home page|liveboard schedules|liveboards page|schedules page/.test( + lower, + ) + ) { + return { + embedTypes: ['AppEmbed'], + reason: 'the description is about a page of the full application', + }; + } + const mentions = (word: string) => new RegExp(`\\b${word}`).test(lower); + const others = (...words: string[]) => words.some(mentions); + if (mentions('liveboard') && !others('answer', 'search', 'spotter')) { + return { + embedTypes: ['AppEmbed', 'LiveboardEmbed'], + reason: 'the description mentions only Liveboard', + }; + } + if (mentions('spotter') && !others('liveboard', 'answer', 'search')) { + return { + embedTypes: ['AppEmbed', 'SpotterEmbed'], + reason: 'the description mentions only Spotter', + }; + } + return undefined; + }; + + private embedTypeLink = (embedType: string) => { + const embedNode = this.childrenNameMap[embedType]; + return embedNode ? this.convertNodeToLink(embedNode) : embedType; + }; + private handleEnumMember = (enumMember: EnumerationMemberNode) => { // debugger; const sourceContent = TypeDocInternalParser.parseSources( enumMember.sources, ); + const parentName = this.childrenIdMap[ + (this.childrenIdMap[enumMember.id] as TypeDocLinkingNode) + ?.parentId as number + ]?.name; + const embedTypes = + parentName === 'Action' + ? this.actionEmbedTypes[enumMember.name] + : undefined; + const supportedIn = embedTypes?.length + ? `[.supported-in]\n**Supported in:** ${embedTypes + .map(this.embedTypeLink) + .join(', ')}\n` + : ''; return [ `=== ${enumMember.name}`, '[div typeDocBlock boxFullWidth]\n--', `\`${enumMember.name}:= ${enumMember.defaultValue}\`\n`, + TypeDocInternalParser.metaBeforeDescription(enumMember.comment?.tags), TypeDocInternalParser.parseComment(enumMember.comment), + TypeDocInternalParser.metaAfterDescription(enumMember.comment?.tags), + supportedIn, sourceContent, - TypeDocInternalParser.parseTags(enumMember.comment?.tags), + TypeDocInternalParser.parseTags(enumMember.comment?.tags, false), '--', ].join('\n'); }; private handleFunctionNode = (node: FunctionNode) => { - const name = node.name !== 'constructor' ? `=== ${node.name}` : ''; + const name = `=== ${node.name}`; let overwrites = ''; if (node.overwrites) { @@ -677,13 +1328,15 @@ class TypeDocParser { return [ name, + TypeDocInternalParser.metaBeforeDescription(node.comment?.tags), TypeDocInternalParser.parseComment(node.comment), + TypeDocInternalParser.metaAfterDescription(node.comment?.tags), '[div typeDocBlock boxFullWidth]\n--', signatureContent, sources, overwrites, inheritedFrom, - TypeDocInternalParser.parseTags(node.comment?.tags), + TypeDocInternalParser.parseTags(node.comment?.tags, false), '--', ].join('\n\n'); }; @@ -694,9 +1347,11 @@ class TypeDocParser { `* ${node.name}: ${this.parseTypeDocType(node.type, true)}${ node?.defaultValue ? ` = ${node.defaultValue}` : '' }`, + TypeDocInternalParser.metaBeforeDescription(node.comment?.tags), TypeDocInternalParser.parseComment(node.comment), + TypeDocInternalParser.metaAfterDescription(node.comment?.tags), this.handleTypeNode(node.type), - TypeDocInternalParser.parseTags(node.comment?.tags), + TypeDocInternalParser.parseTags(node.comment?.tags, false), ].join('\n\n'); }; @@ -708,12 +1363,16 @@ class TypeDocParser { return [ `=== ${node.name}`, '[div typeDocBlock boxFullWidth]\n--', - sig, - // TODO : move this iwth aobve line - node.flags.isOptional ? '_Optional_' : '', + // Optional is on the same line as the signature + // A role, not _underscores_: a link target in the signature can contain an underscore + // (an enumeration member such as SPOTTER_2026_11), which AsciiDoc would pair + // with the closing underscore of _Optional_ and turn into broken italics. + node.flags.isOptional ? `${sig} [.typedoc-optional]#Optional#` : sig, + TypeDocInternalParser.metaBeforeDescription(node.comment?.tags), TypeDocInternalParser.parseComment(node.comment), + TypeDocInternalParser.metaAfterDescription(node.comment?.tags), this.handleTypeNode(node.type), - TypeDocInternalParser.parseTags(node.comment?.tags), + TypeDocInternalParser.parseTags(node.comment?.tags, false), '--\n', ].join('\n\n'); }; @@ -724,14 +1383,16 @@ class TypeDocParser { .join('\n\n'); return [ + TypeDocInternalParser.metaBeforeDescription(node.comment?.tags), TypeDocInternalParser.parseComment(node.comment), + TypeDocInternalParser.metaAfterDescription(node.comment?.tags), node.parameters?.length ? '**Function Parameters**' : '', parmContent, '**Returns**', TypeDocInternalParser.parseTypeDocType(node.type, true), // SCAL-182339 // this.handleTypeNode(node.type), - TypeDocInternalParser.parseTags(node.comment?.tags), + TypeDocInternalParser.parseTags(node.comment?.tags, false), ].join('\n\n'); }; @@ -783,8 +1444,14 @@ class TypeDocParser { private handleTypeNode = (node: TypeDocType | undefined) => { const content = [ + TypeDocInternalParser.metaBeforeDescription( + node?.declaration?.comment?.tags, + ), TypeDocInternalParser.parseComment(node?.declaration?.comment), - TypeDocInternalParser.parseTags(node?.declaration?.comment?.tags), + TypeDocInternalParser.metaAfterDescription( + node?.declaration?.comment?.tags, + ), + TypeDocInternalParser.parseTags(node?.declaration?.comment?.tags, false), this.handleTypeLiteralNode(node?.declaration), ].join('\n\n'); @@ -795,7 +1462,7 @@ class TypeDocParser { return [ `= ${node.name}`, `\`${node.name} : ${this.parseTypeDocType(node.type, true)}\``, - TypeDocInternalParser.parseComment(node.comment), + this.parseNodeComment(node), TypeDocInternalParser.parseSources(node.sources), `${this.handleTypeNode(node.type)}`, ].join('\n\n'); @@ -861,15 +1528,21 @@ class TypeDocParser { callBack: (pageId: string, content: string) => void, ) => { const projectNode = node; + this.excludeDeprecatedNodes(projectNode); + this.applyCommentFixes(projectNode); + this.warnAboutUnsupportedNodes(projectNode); this.generateMap(projectNode); - // creating an index page - let indexPageContent = '= Visual Embed SDK\n\n'; - /* const indexPageHeading = ''; - */ + this.loadActionEmbedTypes(); + // Pages that list every item of one kind (Enumeration, Class, ...), + // linked from the landing page. + const typePages: { title: string; pageId: string }[] = []; const indexPageHeading = this.getHeadingString({ title: indexPageId, pageId: indexPageId, - description: node?.comment?.shortText, + description: TypeDocInternalParser.plainText( + node?.comment?.shortText || + sdkReferenceConfig.landingPage.description, + ), }); let sideNavContent = `* link:{{navprefix}}/${encodePageId( @@ -877,13 +1550,21 @@ class TypeDocParser { )}[Visual Embed SDK Reference]\n`; projectNode?.groups?.forEach((group) => { - // create table group content - let groupContent = `== ${group.title}\n\n[div boxDiv boxFullWidth]\n--\n`; - groupContent += this.createTypeDocTable( + // create index group content + const typeDescription = + sdkReferenceConfig.landingPage.typeDescriptions[group.title]; + if (!typeDescription) { + console.warn( + `Type page : no description in sdk-reference-config.json for ${group.title}`, + ); + } + let groupContent = `== ${group.title}\n\n${ + typeDescription ? `${typeDescription}\n\n` : '' + }[div boxDiv boxFullWidth]\n--\n`; + groupContent += this.createTypeDocIndex( group.children.map((id) => this.convertNodeToLink(this.childrenIdMap[id]), ), - 3, ); groupContent += '--\n\n'; @@ -905,52 +1586,263 @@ class TypeDocParser { const heading = this.getHeadingString({ title: child.name, pageId, - description: child?.comment?.shortText, + description: TypeDocInternalParser.plainText( + child?.comment?.shortText || this.getDescription(child), + ), }); sideNavContent += `*** link:{{navprefix}}/${encodePageId( pageId, )}[${child.name}]\n`; + this.currentTopNode = child; const content = this.convertTypeDocNode(child); callBack(pageId, `${heading}\n\n${content}`); }); callBack(groupPageId, `${groupHeading}\n\n${groupContent}`); - - indexPageContent += groupContent; + typePages.push({ title: group.title, pageId: groupPageId }); }); callBack('VisualEmbedSdkNavLinks', sideNavContent); - callBack(indexPageId, `${indexPageHeading}\n\n${indexPageContent}`); + callBack( + indexPageId, + `${indexPageHeading}\n\n${this.buildLandingPage(typePages)}`, + ); - let customSideNavContent = ''; - Object.keys(this.groupMap).forEach((k) => { - customSideNavContent += `*** ${k}\n`; + Object.entries(sdkReferenceConfig.childGroups).forEach( + ([group, { pageId }]) => { + const content = this.buildChildGroupPage(group, pageId); + if (content) callBack(pageId, content); + }, + ); - this.groupMap[k].forEach((toLinkNode) => { - const linkToNode = `link:{{navprefix}}/${this.getPageId( - toLinkNode, - )}`; + // Included under a "Visual Embed SDK Reference" entry by both + // nav-sdk-reference.adoc and the merged in-product nav. + callBack('SdkReferenceNav', this.buildSdkReferenceNav(typePages)); + }; - customSideNavContent += `**** [.${this.getTypeCSSClass( - toLinkNode, - )}]#${linkToNode}[${toLinkNode.name}]#\n`; + // Landing page: an intro, then one section per sidebar category with its + // description and an index of its items. The text is kept in + // sdk-reference-config.json, not in the generated file, so it is still + // there after every regeneration. A category with no text still gets its + // heading and index, so a group added to the SDK shows up on its own. + private buildLandingPage = ( + typePages: { title: string; pageId: string }[], + ): string => { + const { intro, groupDescriptions } = sdkReferenceConfig.landingPage; + const groups = this.getOrderedGroupNames(); + + const withoutText = groups.filter((group) => !groupDescriptions[group]); + if (withoutText.length) { + console.warn( + `Landing page : no description in sdk-reference-config.json for ${withoutText.join( + ', ', + )}`, + ); + } + const unused = Object.keys(groupDescriptions).filter( + (group) => + !groups.includes(group) && + group !== sdkReferenceConfig.fallbackGroup, + ); + if (unused.length) { + console.warn( + `Landing page : description for a category that no longer exists : ${unused.join( + ', ', + )}`, + ); + } + + const browseByType = typePages + .map( + ({ title, pageId }) => + `link:{{navprefix}}/${encodePageId(pageId)}[${title}]`, + ) + .join(', '); + + const sections = groups.map((group) => + [ + `== ${group}`, + groupDescriptions[group] || '', + `[div boxDiv boxFullWidth]\n--\n${this.createTypeDocIndex( + this.getGroupItems(group).map((groupNode) => + this.convertNodeToLink(groupNode), + ), + )}--`, + ].join('\n\n'), + ); + + return [ + '= Visual Embed SDK', + ...intro, + `To list every item of one kind, browse by type: ${browseByType}.`, + ...sections, + ].join('\n\n'); + }; + + // The items of a group. A group that the config lists in full (groupOverrides) + // keeps the order of that list, so the embed class and its view config come + // first. Any other group keeps the order of the SDK, enumerations first. + private getGroupItems = (group: string): TypeDocLinkingNode[] => { + const items = this.groupMap[group] || []; + const listed = sdkReferenceConfig.groupOverrides[group]; + if (!listed || !items.every((item) => listed.includes(item.name))) { + return items; + } + return [...items].sort( + (a, b) => listed.indexOf(a.name) - listed.indexOf(b.name), + ); + }; + + // Groups in the order set in the config, then any other group A-Z. + private getOrderedGroupNames = () => { + const { groupOrder } = sdkReferenceConfig; + const rank = (group: string) => { + const index = groupOrder.indexOf(group); + return index === -1 ? groupOrder.length : index; + }; + return Object.keys(this.groupMap).sort( + (a, b) => rank(a) - rank(b) || a.localeCompare(b), + ); + }; + + // A child group (see childGroups in the config) appears as one entry under its + // parent group, linking to a page that lists its items. Its items are still in + // the nav, in a list that the sidebar does not show (`nav-hidden-children`), so + // their pages keep the right sidebar and breadcrumbs. + private getChildGroups = (parent: string) => + Object.entries(sdkReferenceConfig.childGroups) + .filter( + ([group, config]) => + config.parent === parent && + this.groupMap[group] && + this.groupMap[config.parent], + ) + .map(([group, config]) => ({ group, pageId: config.pageId })); + + private isNestedChildGroup = (group: string) => { + const config = sdkReferenceConfig.childGroups[group]; + return Boolean(config && this.groupMap[config.parent]); + }; + + private buildNavItem = (toLinkNode: TypeDocLinkingNode, bullet: string) => + `${bullet} [.${this.getTypeCSSClass( + toLinkNode, + )}]#link:{{navprefix}}/${this.getPageId(toLinkNode)}[${ + toLinkNode.name + }]#\n`; + + private buildSdkReferenceNav = ( + typePages: { title: string; pageId: string }[], + ) => { + const groupBullet = '**'; + const itemBullet = '***'; + const subItemBullet = '****'; + + let content = ''; + this.getOrderedGroupNames() + .filter((group) => !this.isNestedChildGroup(group)) + .forEach((group) => { + content += `${groupBullet} ${group}\n`; + + this.getGroupItems(group).forEach((toLinkNode) => { + content += this.buildNavItem(toLinkNode, itemBullet); + }); + + this.getChildGroups(group).forEach((child) => { + content += `${itemBullet} link:{{navprefix}}/${child.pageId}[${child.group}]\n`; + content += '[.nav-hidden-children]\n'; + this.getGroupItems(child.group).forEach((toLinkNode) => { + content += this.buildNavItem(toLinkNode, subItemBullet); + }); + }); + + content += '\n'; + }); + + // The pages that list every item of one kind. They are plain links: the + // items themselves are already in the categories above. + if (typePages.length) { + content += `${groupBullet} Browse by type\n`; + typePages.forEach(({ title, pageId }) => { + content += `${itemBullet} link:{{navprefix}}/${encodePageId( + pageId, + )}[${title}]\n`; }); + content += '\n'; + } - customSideNavContent += '\n'; + return content; + }; + + // The page of a child group: its description and an index of its items. + private buildChildGroupPage = (group: string, pageId: string): string => { + if (!this.groupMap[group]) return ''; + const description = + sdkReferenceConfig.landingPage.groupDescriptions[group] || ''; + const heading = this.getHeadingString({ + toc: false, + title: group, + pageId, + description: TypeDocInternalParser.plainText( + description.replace(/`/g, ''), + ), }); + return [ + heading, + `= ${group}`, + description, + `[div boxDiv boxFullWidth]\n--\n${this.createTypeDocIndex( + this.getGroupItems(group).map((groupNode) => + this.convertNodeToLink(groupNode), + ), + )}--`, + ].join('\n\n'); + }; - callBack('CustomSideNav', customSideNavContent); + private sortEmbedTypes = (embedTypes: string[]) => { + const preferredOrder = [ + 'AppEmbed', + 'LiveboardEmbed', + 'SearchEmbed', + 'SearchBarEmbed', + 'SpotterEmbed', + ]; + const rank = (embed: string) => { + const index = preferredOrder.indexOf(embed); + return index === -1 ? preferredOrder.length : index; + }; + return [...embedTypes].sort( + (a, b) => rank(a) - rank(b) || a.localeCompare(b), + ); + }; + + // Placeholder that the Document component swaps for the EmbedTypeFilter + // component, which filters the Action sections by their "Supported in" line. + private buildEmbedTypeFilter = (): string => { + const embedTypes = this.sortEmbedTypes([ + ...new Set(Object.values(this.actionEmbedTypes).flat()), + ]); + if (!embedTypes.length) return ''; + return `++++\n
\n++++\n`; }; + } class TypedocConverter { private typedDocParser = new TypeDocParser(); + // The SDK branch that the typedoc is read from. + private branch: string; + constructor(branch: string) { + this.branch = branch; TypeDocInternalParser.convertNameToLink = this.typedDocParser.convertNameToLink; + TypeDocInternalParser.resolveLinkTarget = this.typedDocParser.resolveLinkTarget; TypeDocInternalParser.GITHUB_LINK = `https://github.com/thoughtspot/visual-embed-sdk/blob/${branch}/src`; console.info('Source link : ', TypeDocInternalParser.GITHUB_LINK); @@ -973,19 +1865,67 @@ class TypedocConverter { return; } const indexPageId = 'VisualEmbedSdk'; + const pagesDir = 'modules/ROOT/pages/generated/typedoc'; + const navDir = 'modules/ROOT/pages/common/generated/typedoc'; + const navPageIds = ['VisualEmbedSdkNavLinks', 'SdkReferenceNav']; + + // Build every file first, so that a failure part way leaves the previous + // output in place instead of a half written one. + const files: { filePath: string; content: string }[] = []; this.typedDocParser.handleProjectNode( typedocNode, indexPageId, (pageId, content) => { const updatedPageId = pageId.replace('_', '/'); - const filePath = - pageId === 'VisualEmbedSdkNavLinks' || - pageId === 'CustomSideNav' - ? `modules/ROOT/pages/common/generated/typedoc/${updatedPageId}.adoc` - : `modules/ROOT/pages/generated/typedoc/${updatedPageId}.adoc`; - this.writeFile(filePath, content); + files.push({ + filePath: `${ + navPageIds.includes(pageId) ? navDir : pagesDir + }/${updatedPageId}.adoc`, + content, + }); }, ); + + // Two pages whose file names differ only by letter case (the SDK has DrillDownRequest + // and DrilldownRequest) are two files on Linux, but on macOS and Windows the second + // overwrites the first, so a page and every link to it is missing there. + const byLowerCase: Record = {}; + files.forEach(({ filePath }) => { + const key = filePath.toLowerCase(); + byLowerCase[key] = [...(byLowerCase[key] || []), path.basename(filePath, '.adoc')]; + }); + const collisions = Object.values(byLowerCase).filter((names) => names.length > 1); + if (collisions.length) { + console.warn( + `File names that differ only by letter case overwrite each other on case-insensitive file systems (macOS, Windows), so one page of each pair is missing there : ${collisions + .map((names) => names.join(' and ')) + .join(', ')}`, + ); + } + + // Both folders hold only generated files (they are git ignored). Clear + // them so that a page for an item that was removed, or excluded, from + // the SDK docs does not stay behind from an earlier run. + [pagesDir, navDir].forEach((dir) => + fs.rmSync(dir, { recursive: true, force: true }), + ); + files.forEach(({ filePath, content }) => + this.writeFile(filePath, content), + ); + + // The report for the writers is local only (the folder is git ignored) and it is + // written only for the main branch of the SDK. Another branch has other + // Actions, and its list would only get in the way of the one for main. + if (this.branch === ACTION_REPORT_BRANCH) { + this.writeFile( + ACTION_REPORT_PATH, + this.typedDocParser.buildActionReport(), + ); + } else { + console.info( + `Action report skipped : it is written only for the ${ACTION_REPORT_BRANCH} branch of the SDK, this run reads ${this.branch}`, + ); + } }; } diff --git a/scripts/Converter/sdk-reference-config.json b/scripts/Converter/sdk-reference-config.json new file mode 100644 index 000000000..5327f6fe1 --- /dev/null +++ b/scripts/Converter/sdk-reference-config.json @@ -0,0 +1,190 @@ +{ + "_comment": "Docs-side overrides for the Visual Embed SDK reference generated by scripts/converter/index.ts. The SDK source comments stay the source of truth; an entry here only applies when the SDK has no value of its own (descriptions) or when the docs need a different sidebar grouping (groups).", + "groupOrder": [ + "Authentication / Init", + "Full application embed", + "Liveboard embed", + "Search embed", + "Spotter embed", + "Shared embed options", + "Events", + "Host event payloads", + "Actions", + "Visual Overrides", + "Custom styles", + "Runtime filters", + "Error Handling", + "Global methods", + "Others" + ], + "fallbackGroup": "Others", + "kindFallbackGroups": { + "Function": "Global methods" + }, + "groupOverrides": { + "Authentication / Init": [ + "LogLevel" + ], + "Full application embed": [ + "AppEmbed", + "AppViewConfig", + "HomeLeftNavItem", + "HomePage", + "HomePageSearchBarMode", + "HomepageModule", + "ListPage", + "ListPageColumns", + "Page", + "PrimaryNavbarVersion" + ], + "Liveboard embed": [ + "LiveboardEmbed", + "LiveboardViewConfig", + "LiveboardOverride", + "LiveboardDataSourceOverride", + "LiveboardFilterQuery", + "LiveboardParameterQuery" + ], + "Search embed": [ + "SearchEmbed", + "SearchViewConfig", + "SearchBarEmbed", + "SearchBarViewConfig", + "DataSourceVisualMode", + "DataPanelCustomColumnGroupsAccordionState" + ], + "Spotter embed": [ + "SpotterEmbed", + "SpotterEmbedViewConfig", + "SpotterAgentEmbed", + "SpotterAgentEmbedViewConfig", + "SpotterAnalystConfig", + "SpotterChatViewConfig", + "SpotterShareConversationConfig", + "SpotterSidebarViewConfig", + "SpotterVizConfig", + "SpotterVizLoaderTip", + "SpotterVizStarterPrompt", + "SpotterExperienceVersion", + "SpotterQueryMode", + "SpotterSearchRequest", + "StarterPreviewDataCategory", + "StarterPromptCategory", + "StarterPromptQuestion", + "StarterPromptsConfig" + ], + "Shared embed options": [ + "FrameParams", + "DOMSelector", + "InterceptedApiType" + ], + "Events": [ + "SessionInterface", + "UnderlyingDataPoint", + "VizPoint", + "MessageCallback", + "SortOptions" + ], + "Host event payloads": [ + "CrossFilterCondition", + "UpdateFiltersFilterParam" + ], + "Global methods": [ + "executeTMLInput", + "exportTMLInput", + "PrefetchFeatures", + "AutoMCPFrameRendererViewConfig" + ], + "Custom styles": [ + "CustomStyles", + "CustomisationsInterface", + "customCssInterface", + "CustomCssVariables" + ], + "Runtime filters": [ + "RuntimeFilter", + "RuntimeFilterOp", + "RuntimeParameter", + "Applicability", + "ApplicabilityLevel" + ], + "Actions": [ + "Action", + "CustomAction", + "CustomActionTarget", + "CustomActionsPosition", + "ContextMenuTriggerOptions" + ] + }, + "groupPatterns": [ + { + "group": "Host event payloads", + "pattern": "(Request|Response|Payload)$|^(UIPassthrough|HostEvent|Trigger|FilterUpdate|HostFilterUpdate|Liveboard(Filter|Group|Tab|Parameter)$)" + } + ], + "descriptions": { + "executeTMLInput": "Input for the `executeTML` function. Specifies the TML objects to import, the import policy, and whether to create new objects.", + "exportTMLInput": "Input for the `exportTML` function. Specifies the objects to export and the format and options for the exported TML.", + "SessionInterface": "Identifies an Answer session on the ThoughtSpot server. Used by `AnswerService` and returned in the `session` property of Answer-related payloads.", + "UnderlyingDataPoint": "A data point (a column ID and its value) used to fetch the underlying data of a selected point in a chart or table. Passed to `AnswerService.getUnderlyingDataForPoint`.", + "PrefetchFeatures": "The embed features whose static resources can be prefetched. Pass these values in the `prefetchFeatures` parameter of the `prefetch` function.", + "DOMSelector": "A CSS selector string or an HTMLElement that identifies the DOM node in which to render an embedded component. Used by the constructors of the embed components, such as `AppEmbed`, `LiveboardEmbed`, and `SearchEmbed`.", + "HomePageSearchBarMode": "Modes for the search bar on the home page of an embedded ThoughtSpot application. Set this value in the `homePageSearchBarMode` property of `AppViewConfig`.", + "VizPoint": "A selected point in a visualization, described by its selected attributes and measures. Used by `AnswerService` and in the context menu payload of a custom action.", + "UIPassthroughEvent": "Identifiers of the UI passthrough requests. Each value maps to a request and response pair, as described in `UIPassthroughContractBase`." + }, + "landingPage": { + "description": "Reference for the classes, interfaces, enumerations, types, and functions of the Visual Embed SDK.", + "intro": [ + "The Visual Embed SDK reference describes the classes, interfaces, enumerations, types, and functions of the `@thoughtspot/visual-embed-sdk` package. It is generated from the comments in the SDK source code.", + "Browse the categories on this page, or use the navigation menu on the left. For an overview of how to embed ThoughtSpot with the SDK, see xref:getting-started.adoc[Embed with Visual Embed SDK]." + ], + "groupDescriptions": { + "Authentication / Init": "Initialize the SDK and authenticate users. Call `init` with an `EmbedConfig` object before you embed any ThoughtSpot component. This category also includes the enumerations for the authentication type, status, and log level, and the event emitter that `init` returns.", + "Full application embed": "Embed the full ThoughtSpot application with `AppEmbed` and configure it with `AppViewConfig`. This category also includes the enumerations for the options of the full application: the pages to show, the versions of the home page, list page, and primary navigation bar, and the home page modules, left navigation items, and search bar modes.", + "Liveboard embed": "Embed a Liveboard with `LiveboardEmbed` and configure it with `LiveboardViewConfig`. This category also includes the interfaces for the filters, Parameters, and data source overrides that you apply when a Liveboard loads.", + "Search embed": "Embed the ThoughtSpot Search page with `SearchEmbed`, or only the search bar with `SearchBarEmbed`, and configure them with `SearchViewConfig` and `SearchBarViewConfig`. This category also includes the options for the data source panel and the data panel on the Search page.", + "Spotter embed": "Classes and configuration types for embedding Spotter, the ThoughtSpot AI analytics experience. Use `SpotterEmbed` for the Spotter interface and `SpotterAgentEmbed` for chatbots and other conversational interfaces. This category also includes the interfaces for customizing the chat, the sidebar, conversation sharing, and starter prompts.", + "Shared embed options": "Types that all embed components use: the iframe settings (`FrameParams`), the element in which a component renders (`DOMSelector`), and the groups of ThoughtSpot APIs that you can intercept (`InterceptedApiType`).", + "Events": "Events and callbacks for communication between your app and an embedded component. Use `EmbedEvent` to listen to the events that the embedded application emits, `HostEvent` to trigger actions in the embedded application, and `AnswerService` to work with Answers. This category also includes the types that these events and `AnswerService` use.", + "Host event payloads": "Types for the data that you send to, or receive from, the embedded application: the payloads that `trigger` accepts for host events, and the request and response shapes of UI passthrough events. This category also includes the Liveboard filter, parameter, group, and tab types that these payloads use.", + "Actions": "Action IDs and custom action types. Use the `Action` IDs to show, hide, or disable the menu actions of an embedded component, and `CustomAction` to add your own code-based actions. On the `Action` page, you can filter the actions by embed type.", + "Visual Overrides": "Enumerations and an interface for customizing how charts and tables render in embedded visualizations, such as conditional formatting, data labels, legend position, and table theme and density.", + "Custom styles": "Interfaces for customizing the look and feel of embedded ThoughtSpot content, such as CSS variables and custom CSS rules. Set them in the `customizations` property of `EmbedConfig`.", + "Runtime filters": "Filters and parameters that you apply to embedded content when it loads: the `RuntimeFilter` and `RuntimeParameter` interfaces, the `RuntimeFilterOp` operators that you use in runtime filters, and the types that set the level at which a filter or parameter applies.", + "Error Handling": "Error codes, severity levels, and event types for handling the errors that embedded components emit.", + "Global methods": "Functions that you call directly, without an embed component: log out, prefetch static resources, reload the embedded frame, call ThoughtSpot APIs with an authenticated request (`tokenizedFetch`), get session and initialization information, import and export TML (`executeTML` and `exportTML`), create a Liveboard from Answers (`createLiveboardWithAnswers`), get an Answer from a natural language query (`getAnswerFromQuery`), convert a captured filter state (`convertFilterChangedToUpdateFiltersPayload`), and automatically render the ThoughtSpot MCP app frames on a page (`startAutoMCPFrameRenderer`). This category also includes the types that these functions use.", + "Others": "Additional enumerations, interfaces, and types that the SDK uses and that do not belong to another category, such as the log level, the levels at which a filter applies, and the groups of APIs that you can intercept." + }, + "typeDescriptions": { + "Enumerations": "Enumerations define fixed sets of values that you use to configure the SDK or to identify an event, an action, or an error. For example, `AuthType` sets the authentication method, `HostEvent` and `EmbedEvent` name the events that you trigger and listen to, and `Action` lists the action IDs of the embedded ThoughtSpot UI.", + "Classes": "Classes are the building blocks that you create in your app. The embed classes, such as `AppEmbed`, `LiveboardEmbed`, `SearchEmbed`, and `SpotterEmbed`, render ThoughtSpot content in your page. `AnswerService` works with the data of an Answer.", + "Interfaces": "Interfaces describe the shape of the objects that you pass to, or receive from, the SDK: the configuration that you pass to `init` (`EmbedConfig`), the view configuration of each embed component, and the payloads of events and callbacks.", + "Type aliases": "Type aliases name the union and helper types that the SDK uses, such as the types of event payloads, callbacks, and the selector that identifies the element in which to render an embedded component.", + "Functions": "Functions that you call directly, without an embed component: `init` to initialize the SDK and authenticate users, `logout`, `prefetch`, `executeTML` and `exportTML` to import and export TML, and other utilities." + } + }, + "actionEmbedTypes": { + "sourcePage": "modules/ROOT/pages/embed-action-ref.adoc", + "_confirmed_help": "For an Action that is not in the table on the page above yet: once a writer has confirmed its embed types, add \"ActionName\": [\"LiveboardEmbed\", \"AppEmbed\"] to this list. It gets a 'Supported in' line and shows under those embed types in the filter. The table wins if an Action is in both, so remove the entry here once the row is on the page.", + "confirmed": {} + }, + "childGroups": { + "Host event payloads": { + "parent": "Events", + "pageId": "HostEventPayloads" + } + }, + "excludeDeprecated": true, + "excludeDeprecatedMembersOf": [ + "Action" + ], + "commentFixes": [ + { + "node": "Action", + "reason": "The SDK comment lists three bullets, but the second and third are wrapped into the middle of a line, so they show as one run-on paragraph. Remove this entry once the comment in the SDK source is fixed (the converter says when the match no longer applies).", + "find": "To control actions in the embedded view: - disabledActions — the action is grayed out and still visible, but non-interactive (user can see but not click). - hiddenActions — the action is completely removed from the UI (user cannot see it at all). - visibleActions — allowlist, only these actions are shown; all others are hidden.", + "replace": "To control actions in the embedded view:\n\n* disabledActions — the action is grayed out and still visible, but non-interactive (user can see but not click).\n* hiddenActions — the action is completely removed from the UI (user cannot see it at all).\n* visibleActions — allowlist, only these actions are shown; all others are hidden." + } + ] +} diff --git a/src/assets/styles/index.scss b/src/assets/styles/index.scss index 32e85f14d..69af435d3 100644 --- a/src/assets/styles/index.scss +++ b/src/assets/styles/index.scss @@ -257,18 +257,39 @@ button { padding: 9px 5px; } -.version { +/* The Version and Deprecated blocks on the reference pages: the same size, and the + background of the buttons. They are one line high, but grow when a message wraps. */ +.version, +.deprecated { display: inline-block; font-size: $font-size-small; - height: 25px; - line-height: $line-height-doc; + min-height: 25px; + line-height: 23px; text-align: center; margin: 2px; min-width: $tag-width; - padding: $tag-padding; + // Less padding on the left than on the right, as asked: the bold label starts closer + // to the edge of the block. + padding: 0 10px 0 6px; color: var(--primary-color); - border: solid 1px; - border-color: $disabledcolor; + background: var(--copy-btn-bg); +} + +/* The same border as the div blocks and the horizontal lines (a Radiant token, so it follows + the theme). The Deprecated block keeps its orange border in both themes. */ +.version { + border: 1px solid var(--border-color); +} + +/* Code in these blocks (text in backticks in the SDK comment) keeps the monospace font + but not the chip that code has in the page text, which would sit on the block's own + background. */ +.documentView .version code, +.documentView .deprecated code { + padding: 0; + font-size: inherit; + background: none; + border: none; } .version-badge { @@ -309,15 +330,6 @@ button { } .deprecated { - display: inline-block; - font-size: 14px; - height: 25px; - line-height: $line-height-doc; - text-align: center; - margin: 2px; - min-width: $tag-width; - padding: $tag-padding; - color: var(--primary-color); border: solid 1px #fea85f; } @@ -1009,6 +1021,52 @@ a.anchor { /* typedoc styles*/ +/* The "Optional" after a property signature. */ +.documentView .typedoc-optional { + font-style: italic; +} + +/* Link colours use Radiant tokens (radiant-colors.css), which switch with the theme: + blue for classes, methods and constructors, purple for type aliases and functions. + Enumerations are gold and interfaces and properties olive, the colours of their icons + in the sprite (icons.png, a fixed image). Radiant has no equivalent hues, so these two + are fixed values, the same in both themes. */ +:root { + --typedoc-gold: #cc9900; + --typedoc-olive: #7da01f; +} + +/* Index of a typedoc page (see createTypeDocIndex in scripts/Converter/index.ts): + a list in columns that read top to bottom. The typedoc link style below sets + `white-space: nowrap`, which would overflow a narrow column, so links wrap here. */ +.documentView .ulist.typedoc-index { + ul { + margin: 0; + padding: 0; + list-style: none; + column-width: 250px; + column-gap: 24px; + } + + li { + margin: 0; + padding: 3px 0; + break-inside: avoid; + + > p { + // Hanging indent: wrapped lines align with the name, not the icon. + margin: 0; + padding-left: 22px; + text-indent: -22px; + } + } + + a { + white-space: normal; + overflow-wrap: anywhere; + } +} + .typedoc-Enumeration, .typedoc-Enumeration_member, .typedoc-Class, @@ -1037,7 +1095,7 @@ a.anchor { .typedoc-Enumeration { a { - color: #bf982a; + color: var(--typedoc-gold); } :before { background-position: 0px -136px; @@ -1047,7 +1105,7 @@ a.anchor { .typedoc-Enumeration_member { a { - color: #937210; + color: var(--typedoc-gold); } :before { background-position: 0px -136px; @@ -1057,7 +1115,7 @@ a.anchor { .typedoc-Constructor { a { - color: #0672de; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: -51px -102px; @@ -1067,7 +1125,7 @@ a.anchor { .typedoc-Method { a { - color: #0672de; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: -68px -68px; @@ -1077,7 +1135,7 @@ a.anchor { .typedoc-Property { a { - color: #647f1b; + color: var(--typedoc-olive); } :before { background-position: -221px 0px; @@ -1087,7 +1145,7 @@ a.anchor { .typedoc-signature { a { - color: #937210; + color: var(--rd-sys-color-content-primary); } :before { background-position: 0px -153px; @@ -1096,7 +1154,7 @@ a.anchor { .typedoc-Class { a { - color: #0550ae; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: 0px -34px; @@ -1105,7 +1163,7 @@ a.anchor { .typedoc-Class_member { a { - color: #0550ae; + color: var(--rd-sys-color-content-accent-blue); } :before { background-position: -51px -102px; @@ -1114,7 +1172,7 @@ a.anchor { .typedoc-Interface { a { - color: #647f1b; + color: var(--typedoc-olive); } :before { background-position: 0px -68px; @@ -1125,7 +1183,7 @@ a.anchor { .typedoc-Type_alias { a { - color: rgb(144, 10, 227); + color: var(--rd-sys-color-content-accent-purple); } :before { background-position: 0px -170px; @@ -1134,7 +1192,7 @@ a.anchor { .typedoc-Function { a { - color: rgb(144, 10, 227); + color: var(--rd-sys-color-content-accent-purple); } :before { background-position: -136px -68px; @@ -1145,12 +1203,12 @@ a.anchor { #docsModal[data-theme='dark'] { .typedoc-Type_alias a, .typedoc-Function a { - color: #71a1f4; + color: var(--rd-sys-color-content-accent-purple); } .typedoc-Type_alias :before, .typedoc-Function :before { background-image: none; - background-color: #71a1f4; + background-color: var(--rd-sys-color-content-accent-purple); -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat; -webkit-mask-image: url(../icons/icons.png); diff --git a/src/components/DevDocTemplate/index.tsx b/src/components/DevDocTemplate/index.tsx index e3760503f..6c305dade 100644 --- a/src/components/DevDocTemplate/index.tsx +++ b/src/components/DevDocTemplate/index.tsx @@ -56,6 +56,8 @@ import VersionIframe from '../VersionIframe'; // Key of the merged nav-in-product-help.adoc entry in processedNavMap (pageid minus 'nav-' prefix). // Not a real DocCategory/tab — used only to pick the left sidebar content when embedded in-product. const IN_PRODUCT_NAV_KEY = 'in-product-help'; +// Key of nav-sdk-reference.adoc in processedNavMap; also the 'sdk-reference' DocCategory. +const SDK_REFERENCE_NAV_KEY = 'sdk-reference'; const DevDocTemplate: FC = (props) => { const { @@ -211,7 +213,12 @@ const isVersionedIframe = VERSION_DROPDOWN.some( if (typeof window === 'undefined') return {}; const knownCategories = new Set(Object.keys(CATEGORY_NAV_ID)); const map: Record = {}; - Object.entries(processedNavMap).forEach(([cat, html]) => { + // Entries are applied in order and the last one wins, so the SDK reference nav goes last: + // it claims the pages (e.g. VisualEmbedSdk) that the Embedding nav also links to. + const orderedNavs = Object.entries(processedNavMap).sort( + ([a], [b]) => Number(a === SDK_REFERENCE_NAV_KEY) - Number(b === SDK_REFERENCE_NAV_KEY), + ); + orderedNavs.forEach(([cat, html]) => { if (cat === IN_PRODUCT_NAV_KEY || !knownCategories.has(cat)) return; const doc = new DOMParser().parseFromString(html as string, 'text/html'); doc.querySelectorAll('a[href]').forEach((a) => { diff --git a/src/components/Document/index.tsx b/src/components/Document/index.tsx index 56f4a54df..e1a857a19 100644 --- a/src/components/Document/index.tsx +++ b/src/components/Document/index.tsx @@ -6,6 +6,7 @@ import Breadcrums from '../Breadcrums'; import LinkableHeader from '../LinkableHeader'; import WasThisHelpful from '../WasThisHelpful'; import CopyPageDropdown from '../CopyPageDropdown'; +import EmbedTypeFilter from '../EmbedTypeFilter'; import { HOME_PAGE_ID } from '../../configs/doc-configs'; import { isTutorialsPath } from '../../utils/app-utils'; import parse, { HTMLReactParserOptions, domToReact, attributesToProps } from 'html-react-parser'; @@ -176,6 +177,13 @@ const Document = (props: { const options: HTMLReactParserOptions = { replace: (domNode: any) => { + // Placeholder emitted by the typedoc converter on the Action reference page. + if (domNode.type === 'tag' && domNode.attribs?.class === 'embed-type-filter') { + const embedTypes = (domNode.attribs['data-embed-types'] || '') + .split(',') + .filter(Boolean); + return ; + } if (domNode.type === 'tag' && ['h2', 'h3', 'h4'].includes(domNode.name) && !domNode.parent?.attribs?.class?.includes('non-link') diff --git a/src/components/EmbedTypeFilter/index.scss b/src/components/EmbedTypeFilter/index.scss new file mode 100644 index 000000000..497459d4f --- /dev/null +++ b/src/components/EmbedTypeFilter/index.scss @@ -0,0 +1,164 @@ +/* Same pattern and look as the release-notes filter bar (Document/index.scss on + the release-notes-template-update branch): a pill-shaped Filter button that + opens a panel of checkbox sections, plus a pill-shaped search box. */ +.embed-type-filter { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; + margin: 12px 0 20px; + + &__controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + } + + &__dropdown { + position: relative; + } + + &__trigger { + position: relative; + display: inline-flex; + align-items: center; + gap: 5px; + height: 30px; + padding: 0 12px; + font-family: inherit; + font-size: 13px; + font-weight: 500; + color: var(--primary-color); + background: var(--copy-btn-bg); + border: 1px solid var(--rd-sys-color-border-bold); + border-radius: 999px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + border-color: var(--link-text-color); + color: var(--link-text-color); + } + + &.active { + color: var(--link-text-color); + border-color: var(--link-text-color); + } + } + + &__icon { + width: 16px; + height: 16px; + } + + &__menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + min-width: 16rem; + max-height: 360px; + overflow-y: auto; + padding: 6px; + background: var(--copy-page-menu-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + box-shadow: var(--box-shadow-hover); + z-index: 50; + } + + &__option { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 7px 12px; + font-size: 13px; + font-weight: 400; + color: var(--primary-color); + border-radius: 5px; + cursor: pointer; + + &:hover { + background: var(--copy-btn-bg); + } + + input[type='checkbox'] { + flex-shrink: 0; + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--link-text-color); + cursor: pointer; + } + } + + &__clear { + display: block; + width: 100%; + margin-top: 4px; + padding: 7px 12px; + font-family: inherit; + font-size: 12.5px; + font-weight: 500; + text-align: left; + color: var(--link-text-color); + background: transparent; + border: none; + border-top: 1px solid var(--border-color); + cursor: pointer; + + &:hover { + background: var(--copy-btn-bg); + } + } + + &__search { + position: relative; + display: inline-flex; + align-items: center; + + input { + width: 180px; + height: 30px; + padding: 0 10px 0 30px; + font-family: inherit; + font-size: 13px; + color: var(--primary-color); + background: var(--copy-btn-bg); + border: 1px solid var(--rd-sys-color-border-bold); + border-radius: 999px; + transition: border-color 0.15s ease, width 0.15s ease; + + &::placeholder { + color: var(--primary-color); + } + + &:focus-visible { + outline: none; + border-color: var(--link-text-color); + width: 220px; + } + + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + display: none; + } + } + } + + &__search-icon { + position: absolute; + left: 10px; + width: 14px; + height: 14px; + color: var(--primary-color); + pointer-events: none; + } + + &__status { + font-size: 13px; + color: var(--primary-color); + opacity: 0.75; + } +} diff --git a/src/components/EmbedTypeFilter/index.tsx b/src/components/EmbedTypeFilter/index.tsx new file mode 100644 index 000000000..f099da137 --- /dev/null +++ b/src/components/EmbedTypeFilter/index.tsx @@ -0,0 +1,203 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { MdFilterList } from '@react-icons/all-files/md/MdFilterList'; +import { BiSearch } from '@react-icons/all-files/bi/BiSearch'; +import './index.scss'; + +// "A", "A and B", "A, B, and C" +const joinWithAnd = (items: string[]) => { + if (items.length <= 2) return items.join(' and '); + return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`; +}; + +// The option for the actions that are not categorized by embed type yet. +const OTHER = 'Other'; + +/* + * Filter for the generated Action reference page, using the same pattern as + * the release-notes filter bar: a "Filter" button that opens a panel of + * multi-select checkboxes, plus a search box. + * + * The converter (scripts/Converter/index.ts) writes a "Supported in" paragraph + * with the class `supported-in` under each action, and a placeholder for this + * component with the embed types as a comma separated `data-embed-types` + * attribute. An action shows when its "Supported in" line lists at least one + * checked embed type (OR) and, if there is a search term, its text contains it + * (AND). An action with no "Supported in" line is not categorized by embed type + * yet. It shows under "All", in a search, and when "Other" is checked, which is + * only offered while such actions exist. + * + * Hidden actions are also removed from the Index and from the right-hand + * "on this page" list. + */ +const EmbedTypeFilter = (props: { embedTypes: string[] }) => { + const { embedTypes } = props; + const [selected, setSelected] = useState([]); + const [query, setQuery] = useState(''); + const [open, setOpen] = useState(false); + // How many actions have no embed type yet; "Other" is only offered while there are some. + const [uncategorized, setUncategorized] = useState(0); + const rootRef = useRef(null); + + const toggle = (embedType: string) => { + setSelected((current) => + current.includes(embedType) + ? current.filter((value) => value !== embedType) + : [...current, embedType], + ); + }; + + // What is being shown, in the order of the panel: "for SpotterEmbed", "for SpotterEmbed + // and ungrouped Action IDs", or just "ungrouped Action IDs" when only Other is checked. + const checkedEmbedTypes = embedTypes.filter((embedType) => + selected.includes(embedType), + ); + const checkedOther = selected.includes(OTHER); + let statusText = ''; + if (checkedEmbedTypes.length) { + statusText = `Showing Action IDs for ${joinWithAnd([ + ...checkedEmbedTypes, + ...(checkedOther ? ['ungrouped Action IDs'] : []), + ])}.`; + } else if (checkedOther) { + statusText = 'Showing ungrouped Action IDs.'; + } + + // Close the panel on an outside click or Escape. + useEffect(() => { + if (!open) return undefined; + const onClick = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + }; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setOpen(false); + }; + document.addEventListener('click', onClick); + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('click', onClick); + document.removeEventListener('keydown', onKeyDown); + }; + }, [open]); + + useEffect(() => { + const view = rootRef.current?.closest('.documentView'); + if (!view) return undefined; + + const hiddenEntries: HTMLElement[] = []; + const hide = (el: HTMLElement | null) => { + if (!el) return; + el.style.display = 'none'; + hiddenEntries.push(el); + }; + + const search = query.trim().toLowerCase(); + let uncategorizedCount = 0; + const actionSections = Array.from( + view.querySelectorAll('.sect2'), + ); + actionSections.forEach((section) => { + const supportedIn = Array.from( + section.querySelectorAll('.supported-in a'), + ).map((link) => link.textContent?.trim() || ''); + if (!supportedIn.length) uncategorizedCount += 1; + const matchesEmbedType = + !selected.length || + selected.some((option) => + option === OTHER + ? !supportedIn.length + : supportedIn.includes(option), + ); + const matchesSearch = + !search || (section.textContent || '').toLowerCase().includes(search); + if (matchesEmbedType && matchesSearch) return; + hide(section); + const id = section.querySelector('h3')?.id; + if (!id) return; + // The Index links end in "#id" after the page path; the right-hand + // "on this page" list links to "#id" directly. + document.querySelectorAll(`a[href$="#${id}"]`).forEach((link) => { + hide(link.closest('li')); + }); + }); + setUncategorized(uncategorizedCount); + + return () => { + hiddenEntries.forEach((el) => { + // eslint-disable-next-line no-param-reassign + el.style.display = ''; + }); + }; + }, [selected, query]); + + return ( +
+
+
+ + {open && ( +
+ {[ + ...embedTypes, + ...(uncategorized > 0 ? [OTHER] : []), + ].map((embedType) => ( + + ))} + +
+ )} +
+ +
+ + {statusText} + +
+ ); +}; + +export default EmbedTypeFilter; diff --git a/src/components/LeftSidebar/helper.tsx b/src/components/LeftSidebar/helper.tsx index c0d1651bf..08b0a1d23 100644 --- a/src/components/LeftSidebar/helper.tsx +++ b/src/components/LeftSidebar/helper.tsx @@ -28,6 +28,15 @@ export const addExpandCollapseImages = ( nav.querySelectorAll('li').forEach((el, index) => { if (el.children.length === 2) { + // A nested list marked `nav-hidden-children` stays out of the menu: + // no arrow, never expanded. Its items are reached from the page of + // the entry itself. They stay in the HTML so their breadcrumbs and + // sidebar category still resolve. + if (el.children[1].classList.contains('nav-hidden-children')) { + el.children[1].classList.add('displayNone'); + el.children[0].classList.add('node-child'); + return; + } const paragraphElement = el.children[0]; if (paragraphElement.childNodes.length < 2) { paragraphElement.classList.add('linkTitle'); @@ -181,7 +190,10 @@ export const collapseAndExpandLeftNav = ( }); doc?.querySelectorAll('li').forEach((el, i) => { - if (el.children.length === 2) { + if ( + el.children.length === 2 && + !el.children[1].classList.contains('nav-hidden-children') + ) { const spanElement = el.children[0].children.length === 2 ? el.children[0].children[1] diff --git a/src/components/LeftSidebar/index.tsx b/src/components/LeftSidebar/index.tsx index 891fb64b6..ffb2216db 100644 --- a/src/components/LeftSidebar/index.tsx +++ b/src/components/LeftSidebar/index.tsx @@ -60,6 +60,13 @@ const LeftSideBar = (props: { }); if (tag) { tag.classList.add('active'); + // On a page that is only reachable from a hidden sub-menu, mark the + // visible parent entry as the active one instead. + divElement + .querySelector('.nav-hidden-children a.active') + ?.closest('.nav-hidden-children') + ?.parentElement?.querySelector(':scope > p a') + ?.classList.add('active'); } const updatedHTML = addExpandCollapseImages( diff --git a/src/components/SecondaryHeader/index.tsx b/src/components/SecondaryHeader/index.tsx index b21551f12..0dfcc5513 100644 --- a/src/components/SecondaryHeader/index.tsx +++ b/src/components/SecondaryHeader/index.tsx @@ -11,6 +11,7 @@ export type DocCategory = | 'guides' | 'walkthroughs' | 'embedding' + | 'sdk-reference' | 'rest-api' | 'mcp-server' | 'spottercode' @@ -22,6 +23,7 @@ export const CATEGORY_LABELS: Record = { guides: 'Developer guides', walkthroughs: 'Quickstarts', embedding: 'Embedding', + 'sdk-reference': 'Visual Embed SDK reference', 'rest-api': 'REST APIs', 'mcp-server': 'MCP server', spottercode: 'SpotterCode', @@ -37,6 +39,7 @@ export const CATEGORY_LANDING: Record = { guides: '/introduction', walkthroughs: '/tutorials/quickstarts', embedding: '/getting-started', + 'sdk-reference': '/VisualEmbedSdk', 'rest-api': '/rest-apis', 'mcp-server': '/mcp-integration', spottercode: '/SpotterCode', @@ -53,6 +56,7 @@ export const CATEGORY_NAV_ID: Record = { guides: 'nav', walkthroughs: 'nav-walkthroughs', embedding: 'nav-embedding', + 'sdk-reference': 'nav-sdk-reference', 'rest-api': 'nav-rest-api', 'mcp-server': 'nav-mcp-server', spottercode: 'nav-spottercode', @@ -125,6 +129,11 @@ export const CATEGORY_PAGEIDS: Record = { 'SpotterCode', 'integrate-SpotterCode', 'spottercode-prompting-guide', ], tutorials: [], + // The pages that list every item of one kind are linked from the landing page. + 'sdk-reference': [ + 'VisualEmbedSdk', + 'Enumeration', 'Class', 'Interface', 'Type alias', 'Function', + ], 'whats-new': [ 'whats-new', 'fixed-issues', 'known-issues', 'deprecated-features', 'embed-sdk-changelog', 'mobile-sdk-changelog', @@ -151,6 +160,8 @@ const SecondaryHeader = (props: { isVersionedIframe, } = props; const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + // The SDK reference has its own sidebar but no tab of its own: it is part of Embedding. + const activeTab: DocCategory = activeCategory === 'sdk-reference' ? 'embedding' : activeCategory; const menuRef = useRef(null); // 'tutorials' and 'walkthroughs' intentionally excluded — reachable from links @@ -227,8 +238,8 @@ const SecondaryHeader = (props: {
  • - {open && ( -
    - {[ - ...embedTypes, - ...(uncategorized > 0 ? [OTHER] : []), - ].map((embedType) => ( - - ))} - -
    - )} -
  • +
    + + {selected.length > 0 && ( + + )} + {open && ( +
    + +
    + {visibleOptions.map((embedType) => ( + + ))} + {!visibleOptions.length && ( +
    + No matches +
    + )} +
    +
    + )} +
    {statusText} From b125555cd7c5f0202f31eb03f353bef0403f8c66 Mon Sep 17 00:00:00 2001 From: ShashiSubramanya Date: Mon, 26 Oct 2026 13:22:14 +0530 Subject: [PATCH 40/57] filter ui css and other fixes --- src/components/EmbedTypeFilter/index.scss | 34 +++++++++++++++++++++-- src/components/EmbedTypeFilter/index.tsx | 23 +++++++++++++-- 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/components/EmbedTypeFilter/index.scss b/src/components/EmbedTypeFilter/index.scss index a1400437f..67a1f4df3 100644 --- a/src/components/EmbedTypeFilter/index.scss +++ b/src/components/EmbedTypeFilter/index.scss @@ -67,21 +67,40 @@ display: inline-flex; align-items: center; gap: 4px; + // As wide with the icon as with "Select", so the chip does not change size when it opens. + // The text stays at the left, so "Action" does not move when the chip opens. + justify-content: flex-start; + min-width: 120px; height: 30px; padding: 0 16px; font-family: inherit; font-size: 14px; - color: var(--primary-color); - background: var(--copy-btn-bg); + // The text is the link color, like the icon, until the panel is open; then it is the + // normal text color (see the aria-expanded rule below). + color: var(--link-text-color); + // The fill of the button in the product UI: the subtle surface in the light theme, + // the secondary action surface in the dark theme (see the override at the end). + background: var(--rd-sys-color-background-on-base); border: none; border-radius: 999px; cursor: pointer; transition: background 0.15s ease; + &[aria-expanded='true'] { + color: var(--primary-color); + } + strong { font-weight: 700; } + // The filter icon at rest, which "Select" replaces while the panel is open. + .embed-type-filter__chip-icon { + width: 16px; + height: 16px; + color: var(--link-text-color); + } + &:hover { background: var(--copy-btn-hover); } @@ -251,3 +270,14 @@ opacity: 0.75; } } + +#wrapper[data-theme='dark'], +#docsModal[data-theme='dark'] { + .embed-type-filter__chip { + background: var(--rd-sys-color-background-secondary-action); + + &:hover { + background: var(--copy-btn-hover); + } + } +} diff --git a/src/components/EmbedTypeFilter/index.tsx b/src/components/EmbedTypeFilter/index.tsx index bb5825349..69a02d1f0 100644 --- a/src/components/EmbedTypeFilter/index.tsx +++ b/src/components/EmbedTypeFilter/index.tsx @@ -1,5 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { MdClear } from '@react-icons/all-files/md/MdClear'; +import { MdFilterList } from '@react-icons/all-files/md/MdFilterList'; import { BiSearch } from '@react-icons/all-files/bi/BiSearch'; import { dispatchHiddenIds } from '../../utils/doc-filter-events'; import './index.scss'; @@ -13,7 +14,8 @@ const joinWithAnd = (items: string[]) => { // The option for the actions that are not categorized by embed type yet. const OTHER = 'Other'; -// The name on the filter chip: "Action Select". +// The name on the filter chip: "Action" with the filter icon, and "Action Select" while the +// panel is open. const COMPONENT_NAME = 'Action'; /* @@ -82,7 +84,14 @@ const EmbedTypeFilter = (props: { embedTypes: string[] }) => { return undefined; } const onClick = (event: MouseEvent) => { - if (!rootRef.current?.contains(event.target as Node)) setOpen(false); + // Use the path of the click, not event.target: opening the panel replaces the filter + // icon on the chip with "Select", so the element that was clicked is no longer on the + // page when this listener runs, and "contains" would call the click an outside one + // and close the panel again straight away. + const clickedInside = rootRef.current + ? event.composedPath().includes(rootRef.current) + : false; + if (!clickedInside) setOpen(false); }; const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false); @@ -177,7 +186,15 @@ const EmbedTypeFilter = (props: { embedTypes: string[] }) => { aria-label="Filter actions by embed type" onClick={() => setOpen((isOpen) => !isOpen)} > - {COMPONENT_NAME} Select + {COMPONENT_NAME} + {open ? ( + Select + ) : ( +