[Share Modal] Reinstate switch to support generating public urls for embed when supported (#207383)

## Summary

Closes https://github.com/elastic/kibana/issues/194105

This PR aims to resolve a regression with the share embed option, prior
to 8.14.0 there was a functionality that provided the ability for users
to get an embed link that would allow public access to the object of the
share (i.e. dashboards, visualisations) if they had the right
configuration ([see
here](https://www.elastic.co/guide/en/kibana/8.13/kibana-authentication.html#anonymous-access-and-embedding)
on how to).

## How to test
- Attempt to get an embed link from for example the dashboard, the user
shouldn't not be present with an option to create a url with public
access.
- Now configure anonymous login in your `kibana.dev.yml`, like so; 

	```
	xpack.security.authc.providers:
	  basic.basic1:
	    order: 0
	  anonymous.anonymous1:
	    order: 1
	    credentials:
	      username: "elastic"
	      password: "changeme"
	```
- On doing this, you should be presented with the option to create a
public URL using the toggle switch similar to the image below, select
this option.

<img width="602" alt="Screenshot 2025-01-20 at 15 07 03"
src="https://github.com/user-attachments/assets/2af9082b-c44c-4cd0-89ae-de423bc7d18d"
/>

- Click copy code button

- Next, we'll create a dummy html document to verify the code copied
works, in your terminal simply run;
  ```bash
	touch embed.html
	echo "paste embed code content here" >> embed.html
	npx --package=serve@latest -y serve
   ```
- On running the commands above, we can try out the embed by opening up
the URL at `http://localhost:3000/embed`
- Ideally if all the steps were followed we should have a page that
loads up the object which the share URL was generated from.


https://github.com/user-attachments/assets/c5c873a4-5417-4bcf-b0cb-132d9073992f



<!-- 
### Checklist

Check the PR satisfies following conditions. 

Reviewers should verify this PR satisfies this list as well.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/src/platform/packages/shared/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This was checked for breaking HTTP API changes, and any breaking
changes have been approved by the breaking-change committee. The
`release_note:breaking` label should be applied in these situations.
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] The PR description includes the appropriate Release Notes section,
and the correct `release_note:*` label is applied per the
[guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

### Identify risks

Does this PR introduce any risks? For example, consider risks like hard
to test bugs, performance regression, potential of data loss.

Describe the risk, its severity, and mitigation for each identified
risk. Invite stakeholders and evaluate how to proceed before merging.

- [ ] [See some risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx)
- [ ] ...


-->
This commit is contained in:
Eyo O. Eyo 2025-02-10 12:57:22 +01:00 committed by GitHub
parent fd7c7591da
commit 7b2631a21d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 257 additions and 147 deletions

View file

@ -245,6 +245,7 @@ export function ShowShareModal({
: shareModalStrings.getDraftShareWarning('embed')}
</EuiCallOut>
),
computeAnonymousCapabilities: showPublicUrlSwitch,
},
},
},
@ -266,7 +267,6 @@ export function ShowShareModal({
component: EmbedUrlParamExtension,
},
],
showPublicUrlSwitch,
snapshotShareWarning: Boolean(unsavedDashboardState?.panels)
? shareModalStrings.getSnapshotShareWarning()
: undefined,

View file

@ -7,46 +7,81 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { mountWithIntl } from '@kbn/test-jest-helpers';
import React, { type ComponentProps } from 'react';
import { __IntlProvider as IntlProvider } from '@kbn/i18n-react';
import userEvent from '@testing-library/user-event';
import { render, screen, waitFor } from '@testing-library/react';
import { EmbedContent } from './embed_content';
import React from 'react';
import { ReactWrapper } from 'enzyme';
import type { BrowserUrlService } from '../../../types';
import { urlServiceTestSetup } from '../../../../common/url_service/__tests__/setup';
let urlService: BrowserUrlService;
// @ts-expect-error there is a type error because we override the shortUrls implementation
// eslint-disable-next-line prefer-const
({ service: urlService } = urlServiceTestSetup());
const defaultProps: Pick<
ComponentProps<typeof EmbedContent>,
'allowShortUrl' | 'isDirty' | 'shareableUrl' | 'urlService' | 'objectType'
> = {
isDirty: false,
objectType: 'dashboard',
shareableUrl: '/home#/',
urlService,
allowShortUrl: false,
};
const renderComponent = (props: ComponentProps<typeof EmbedContent>) => {
return render(
<IntlProvider locale="en">
<EmbedContent {...props} />
</IntlProvider>
);
};
describe('Share modal embed content tab', () => {
describe('share url embedded', () => {
let component: ReactWrapper;
beforeEach(() => {
component = mountWithIntl(
<EmbedContent isDirty={false} objectType="dashboard" shareableUrl="/home#/" />
);
beforeAll(() => {
Object.defineProperty(document, 'execCommand', {
value: jest.fn(() => true),
});
});
it('works for simple url', async () => {
component.setProps({ shareableUrl: 'http://localhost:5601/app/home#/' });
component.update();
const user = userEvent.setup();
const shareUrl = component
.find('button[data-test-subj="copyEmbedUrlButton"]')
.prop('data-share-url');
expect(shareUrl).toBe(
'<iframe src="http://localhost:5601/app/home#/?embed=true&_g=" height="600" width="800"></iframe>'
);
renderComponent({ ...defaultProps, shareableUrl: 'http://localhost:5601/app/home#/' });
const copyButton = screen.getByTestId('copyEmbedUrlButton');
await user.click(copyButton);
await waitFor(() => {
expect(copyButton.getAttribute('data-share-url')).toBe(
'<iframe src="http://localhost:5601/app/home#/?embed=true&_g=" height="600" width="800"></iframe>'
);
});
});
it('works if the url has a query string', async () => {
component.setProps({
const user = userEvent.setup();
renderComponent({
...defaultProps,
shareableUrl:
'http://localhost:5601/app/dashboards#/create?_g=(refreshInterval%3A(pause%3A!t%2Cvalue%3A60000)%2Ctime%3A(from%3Anow-15m%2Cto%3Anow))',
});
component.update();
const shareUrl = component
.find('button[data-test-subj="copyEmbedUrlButton"]')
.prop('data-share-url');
expect(shareUrl).toBe(
'<iframe src="http://localhost:5601/app/dashboards#/create?embed=true&_g=(refreshInterval%3A(pause%3A!t%2Cvalue%3A60000)%2Ctime%3A(from%3Anow-15m%2Cto%3Anow))" height="600" width="800"></iframe>'
);
const copyButton = screen.getByTestId('copyEmbedUrlButton');
await user.click(copyButton);
await waitFor(() => {
expect(copyButton.getAttribute('data-share-url')).toBe(
'<iframe src="http://localhost:5601/app/dashboards#/create?embed=true&_g=(refreshInterval%3A(pause%3A!t%2Cvalue%3A60000)%2Ctime%3A(from%3Anow-15m%2Cto%3Anow))" height="600" width="800"></iframe>'
);
});
});
});
});

View file

@ -15,11 +15,15 @@ import {
EuiSpacer,
EuiText,
EuiFlexItem,
EuiCopy,
EuiSwitch,
type EuiSwitchEvent,
EuiToolTip,
EuiIcon,
copyToClipboard,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import React, { useCallback, useEffect, useState } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
import React, { useCallback, useEffect, useState, useRef } from 'react';
import { format as formatUrl, parse as parseUrl } from 'url';
import { AnonymousAccessState } from '../../../../common';
@ -33,6 +37,9 @@ type EmbedProps = Pick<
| 'embedUrlParamExtensions'
| 'objectType'
| 'isDirty'
| 'allowShortUrl'
| 'anonymousAccess'
| 'urlService'
> & {
objectConfig?: ShareContextObjectTypeConfig;
};
@ -43,27 +50,55 @@ interface UrlParams {
};
}
export enum ExportUrlAsType {
EXPORT_URL_AS_SAVED_OBJECT = 'savedObject',
EXPORT_URL_AS_SNAPSHOT = 'snapshot',
}
export const EmbedContent = ({
embedUrlParamExtensions: urlParamExtensions,
shareableUrlForSavedObject,
shareableUrl,
shareableUrlLocatorParams,
objectType,
objectConfig = {},
isDirty,
allowShortUrl,
urlService,
anonymousAccess,
}: EmbedProps) => {
const isMounted = useMountedState();
const [urlParams, setUrlParams] = useState<UrlParams | undefined>(undefined);
const [useShortUrl] = useState<boolean>(true);
const [exportUrlAs] = useState<ExportUrlAsType>(ExportUrlAsType.EXPORT_URL_AS_SAVED_OBJECT);
const [url, setUrl] = useState<string>('');
const [shortUrlCache, setShortUrlCache] = useState<string | undefined>(undefined);
const [anonymousAccessParameters] = useState<AnonymousAccessState['accessURLParameters']>(null);
const [usePublicUrl] = useState<boolean>(false);
const urlParamsRef = useRef<UrlParams | undefined>(undefined);
const [isLoading, setIsLoading] = useState(false);
const [snapshotUrl, setSnapshotUrl] = useState<string>('');
const [isTextCopied, setTextCopied] = useState(false);
const urlToCopy = useRef<string | undefined>(undefined);
const [anonymousAccessParameters, setAnonymousAccessParameters] =
useState<AnonymousAccessState['accessURLParameters']>(null);
const [usePublicUrl, setUsePublicUrl] = useState<boolean>(false);
const [showPublicUrlSwitch, setShowPublicUrlSwitch] = useState(false);
const copiedTextToolTipCleanupIdRef = useRef<ReturnType<typeof setTimeout>>();
const { draftModeCallOut: DraftModeCallout, computeAnonymousCapabilities } = objectConfig;
useEffect(() => {
if (computeAnonymousCapabilities && anonymousAccess) {
const resolveAnonymousAccessClaims = async () => {
try {
const [state, capabilities] = await Promise.all([
anonymousAccess.getState(),
anonymousAccess.getCapabilities(),
]);
if (state?.isEnabled) {
setAnonymousAccessParameters(state?.accessURLParameters);
if (capabilities) {
setShowPublicUrlSwitch(computeAnonymousCapabilities?.(capabilities));
}
}
} catch {
//
}
};
resolveAnonymousAccessClaims();
}
}, [anonymousAccess, computeAnonymousCapabilities]);
const makeUrlEmbeddable = useCallback((tempUrl: string): string => {
const embedParam = '?embed=true';
@ -76,55 +111,43 @@ export const EmbedContent = ({
return `${tempUrl}${embedParam}`;
}, []);
const getUrlParamExtensions = useCallback(
(tempUrl: string): string => {
return urlParams
? Object.keys(urlParams).reduce((urlAccumulator, key) => {
const urlParam = urlParams[key];
return urlParam
? Object.keys(urlParam).reduce((queryAccumulator, queryParam) => {
const isQueryParamEnabled = urlParam[queryParam];
return isQueryParamEnabled
? queryAccumulator + `&${queryParam}=true`
: queryAccumulator;
}, urlAccumulator)
: urlAccumulator;
}, tempUrl)
: tempUrl;
},
[urlParams]
);
const getUrlParamExtensions = useCallback((tempUrl: string): string => {
const urlWithUpdatedParams = urlParamsRef.current
? Object.keys(urlParamsRef.current).reduce((urlAccumulator, key) => {
const urlParam = urlParamsRef.current?.[key];
return urlParam
? Object.keys(urlParam).reduce((queryAccumulator, queryParam) => {
const isQueryParamEnabled = urlParam[queryParam];
return isQueryParamEnabled
? queryAccumulator + `&${queryParam}=true`
: queryAccumulator;
}, urlAccumulator)
: urlAccumulator;
}, tempUrl)
: tempUrl;
return urlWithUpdatedParams;
}, []);
const updateUrlParams = useCallback(
(tempUrl: string) => {
tempUrl = makeUrlEmbeddable(tempUrl);
tempUrl = urlParams ? getUrlParamExtensions(tempUrl) : tempUrl;
return tempUrl;
},
[makeUrlEmbeddable, getUrlParamExtensions, urlParams]
(url: string) => getUrlParamExtensions(makeUrlEmbeddable(url)),
[makeUrlEmbeddable, getUrlParamExtensions]
);
const getSnapshotUrl = useCallback(
(forSavedObject?: boolean) => {
let tempUrl = '';
if (forSavedObject && shareableUrlForSavedObject) {
tempUrl = shareableUrlForSavedObject;
}
if (!tempUrl) {
tempUrl = shareableUrl || window.location.href;
}
return updateUrlParams(tempUrl);
},
[shareableUrl, shareableUrlForSavedObject, updateUrlParams]
useEffect(
() => setSnapshotUrl(updateUrlParams(shareableUrl || window.location.href)),
[shareableUrl, updateUrlParams]
);
const getSavedObjectUrl = useCallback(() => {
const tempUrl = getSnapshotUrl(true);
const tempUrl = shareableUrlForSavedObject
? updateUrlParams(shareableUrlForSavedObject)
: snapshotUrl;
const parsedUrl = parseUrl(tempUrl);
if (!parsedUrl || !parsedUrl.hash) {
return;
return tempUrl;
}
// Get the application route, after the hash, and remove the #.
@ -146,7 +169,18 @@ export const EmbedContent = ({
});
return updateUrlParams(formattedUrl);
}, [getSnapshotUrl, updateUrlParams]);
}, [shareableUrlForSavedObject, snapshotUrl, updateUrlParams]);
const createShortUrl = useCallback(async () => {
const shortUrlService = urlService.shortUrls.get(null);
if (shareableUrlLocatorParams) {
const shortUrl = await shortUrlService.createWithLocator(shareableUrlLocatorParams);
return shortUrl.locator.getUrl(shortUrl.params, { absolute: true });
} else {
return (await shortUrlService.createFromLongUrl(snapshotUrl)).url;
}
}, [shareableUrlLocatorParams, snapshotUrl, urlService.shortUrls]);
const addUrlAnonymousAccessParameters = useCallback(
(tempUrl: string): string => {
@ -165,53 +199,41 @@ export const EmbedContent = ({
[anonymousAccessParameters, usePublicUrl]
);
const makeIframeTag = (tempUrl: string) => {
if (!tempUrl) {
return;
}
const getEmbedLink = useCallback(async () => {
const embedUrl = addUrlAnonymousAccessParameters(
!isDirty ? getSavedObjectUrl() : allowShortUrl ? await createShortUrl() : snapshotUrl
);
return `<iframe src="${tempUrl}" height="600" width="800"></iframe>`;
};
const setUrlHelper = useCallback(() => {
let tempUrl: string | undefined;
if (exportUrlAs === ExportUrlAsType.EXPORT_URL_AS_SAVED_OBJECT) {
tempUrl = getSavedObjectUrl();
} else if (useShortUrl && shortUrlCache) {
tempUrl = shortUrlCache;
} else {
tempUrl = getSnapshotUrl();
}
if (tempUrl) {
tempUrl = addUrlAnonymousAccessParameters(tempUrl!);
}
tempUrl = makeIframeTag(tempUrl!);
setUrl(tempUrl!);
return `<iframe src="${embedUrl}" height="600" width="800"></iframe>`;
}, [
addUrlAnonymousAccessParameters,
exportUrlAs,
allowShortUrl,
createShortUrl,
getSavedObjectUrl,
getSnapshotUrl,
shortUrlCache,
useShortUrl,
isDirty,
snapshotUrl,
]);
const resetUrl = useCallback(() => {
if (isMounted()) {
setShortUrlCache(undefined);
setUrlHelper();
}
}, [isMounted, setUrlHelper]);
const copyUrlHelper = useCallback(async () => {
setIsLoading(true);
useEffect(() => {
setUrlHelper();
getUrlParamExtensions(url);
window.addEventListener('hashchange', resetUrl, false);
}, [getUrlParamExtensions, resetUrl, setUrlHelper, url]);
urlToCopy.current = await getEmbedLink();
copyToClipboard(urlToCopy.current!);
setTextCopied(() => {
if (copiedTextToolTipCleanupIdRef.current) {
clearTimeout(copiedTextToolTipCleanupIdRef.current);
}
// set up timer to revert copied state to false after specified duration
copiedTextToolTipCleanupIdRef.current = setTimeout(() => setTextCopied(false), 1000);
// set copied state to true for now
return true;
});
setIsLoading(false);
}, [getEmbedLink]);
const renderUrlParamExtensions = () => {
if (!urlParamExtensions) {
@ -221,8 +243,7 @@ export const EmbedContent = ({
const setParamValue =
(paramName: string) =>
(values: { [queryParam: string]: boolean } = {}): void => {
setUrlParams({ ...urlParams, [paramName]: { ...values } });
setUrlHelper();
urlParamsRef.current = { ...urlParamsRef.current, [paramName]: { ...values } };
};
return (
@ -236,6 +257,38 @@ export const EmbedContent = ({
);
};
const renderPublicUrlOptionsSwitch = () => {
return (
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={false}>
<EuiSwitch
label={
<FormattedMessage
id="share.embed.publicUrlOptionsSwitch.label"
defaultMessage="Allow public access"
/>
}
checked={usePublicUrl}
onChange={(e: EuiSwitchEvent) => setUsePublicUrl(e.target.checked)}
data-test-subj="embedPublicUrlOptionsSwitch"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={
<FormattedMessage
id="share.embed.publicUrlOptionsSwitch.tooltip"
defaultMessage="Enabling public access generates a sharable URL that allows anonymous access without a login prompt."
/>
}
>
<EuiIcon type="questionInCircle" />
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
);
};
const helpText =
objectType === 'dashboard' ? (
<FormattedMessage
@ -250,8 +303,6 @@ export const EmbedContent = ({
/>
);
const { draftModeCallOut: DraftModeCallout } = objectConfig;
return (
<>
<EuiForm>
@ -267,22 +318,30 @@ export const EmbedContent = ({
<EuiSpacer />
</EuiForm>
<EuiFlexGroup justifyContent="flexEnd" responsive={false}>
<React.Fragment>
{showPublicUrlSwitch ? renderPublicUrlOptionsSwitch() : null}
</React.Fragment>
<EuiFlexItem grow={false}>
<EuiCopy textToCopy={url}>
{(copy) => (
<EuiButton
data-test-subj="copyEmbedUrlButton"
onClick={copy}
data-share-url={url}
fill
>
<FormattedMessage
id="share.link.copyEmbedCodeButton"
defaultMessage="Copy embed code"
/>
</EuiButton>
)}
</EuiCopy>
<EuiToolTip
content={
isTextCopied
? i18n.translate('share.embed.copied', { defaultMessage: 'Link copied' })
: null
}
>
<EuiButton
fill
data-test-subj="copyEmbedUrlButton"
onClick={copyUrlHelper}
data-share-url={urlToCopy.current}
isLoading={isLoading}
>
<FormattedMessage
id="share.embed.copyEmbedCodeButton"
defaultMessage="Copy embed code"
/>
</EuiButton>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</>

View file

@ -23,6 +23,10 @@ const EmbedTabContent: NonNullable<IEmbedTab['content']> = ({ state, dispatch })
objectType,
objectTypeMeta,
isDirty,
allowShortUrl,
anonymousAccess,
urlService,
shareableUrlLocatorParams,
} = useShareTabsContext()!;
return (
@ -34,6 +38,10 @@ const EmbedTabContent: NonNullable<IEmbedTab['content']> = ({ state, dispatch })
objectType,
objectConfig: objectTypeMeta?.config?.embed,
isDirty,
anonymousAccess,
allowShortUrl,
urlService,
shareableUrlLocatorParams,
}}
/>
);

View file

@ -44,7 +44,7 @@ describe('LinkContent', () => {
let urlService: BrowserUrlService;
// @ts-expect-error there is a type because we override the shortUrls implementation
// @ts-expect-error there is a type error because we override the shortUrls implementation
// eslint-disable-next-line prefer-const
({ service: urlService } = urlServiceTestSetup({
shortUrls: ({ locators }) =>
@ -102,7 +102,7 @@ describe('LinkContent', () => {
await user.click(copyButton);
waitFor(() => {
await waitFor(() => {
expect(copyButton.getAttribute('data-share-url')).toBe(shareableUrl);
});
});

View file

@ -104,7 +104,7 @@ export const LinkContent = ({
copyToClipboard(urlToCopy.current);
setTextCopied(() => {
if (copiedTextToolTipCleanupIdRef.current) {
clearInterval(copiedTextToolTipCleanupIdRef.current);
clearTimeout(copiedTextToolTipCleanupIdRef.current);
}
// set up timer to revert copied state to false after specified duration

View file

@ -23,6 +23,7 @@ export type BrowserUrlService = UrlService<
export interface ShareContextObjectTypeConfig {
draftModeCallOut?: ReactNode;
computeAnonymousCapabilities?: (anonymousUserCapabilities: Capabilities) => boolean;
}
/**
@ -71,6 +72,9 @@ export interface ShareContext {
sharingData: { [key: string]: unknown };
isDirty: boolean;
onClose: () => void;
/**
* @deprecated use computeAnonymousCapabilities defined on objectTypeMeta config
*/
showPublicUrlSwitch?: (anonymousUserCapabilities: Capabilities) => boolean;
disabledShareUrl?: boolean;
toasts: ToastsSetup;

View file

@ -398,6 +398,11 @@ export const getTopNavConfig = (
title: i18n.translate('visualizations.share.shareModal.title', {
defaultMessage: 'Share this visualization',
}),
config: {
embed: {
computeAnonymousCapabilities: showPublicUrlSwitch,
},
},
},
sharingData: {
title:
@ -413,7 +418,6 @@ export const getTopNavConfig = (
},
},
isDirty: hasUnappliedChanges || hasUnsavedChanges,
showPublicUrlSwitch,
toasts: toastNotifications,
});
}

View file

@ -7511,11 +7511,11 @@
"share.contextMenu.permalinksTab": "Liens",
"share.contextMenuTitle": "Partager ce {objectType}",
"share.dashboard.link.description": "Partagez un lien direct avec cette recherche.",
"share.embed.copyEmbedCodeButton": "Copier le code intégré",
"share.embed.dashboard.helpText": "Intégrez ce tableau de bord dans une autre page web. Sélectionnez les éléments à inclure dans la vue intégrable.",
"share.embed.helpText": "Intégrez ce {objectType} dans une autre page web.",
"share.fileType": "Type de fichier",
"share.link.copied": "Texte copié",
"share.link.copyEmbedCodeButton": "Copier le code intégré",
"share.link.copyLinkButton": "Copier le lien",
"share.link.helpText": "Partager un lien direct vers ce {objectType}.",
"share.modalContent.copyUrlButtonLabel": "Copier l'URL Post",

View file

@ -7388,11 +7388,11 @@
"share.contextMenu.permalinksTab": "リンク",
"share.contextMenuTitle": "この {objectType} を共有",
"share.dashboard.link.description": "この検索への直接リンクを共有します。",
"share.embed.copyEmbedCodeButton": "埋め込みコードをコピー",
"share.embed.dashboard.helpText": "このダッシュボードを別のWebページに埋め込みます。埋め込み可能なビューに含める項目を選択します。",
"share.embed.helpText": "この{objectType}を別のWebページに埋め込みます。",
"share.fileType": "ファイルタイプ",
"share.link.copied": "テキストがコピーされました",
"share.link.copyEmbedCodeButton": "埋め込みコードをコピー",
"share.link.copyLinkButton": "リンクをコピー",
"share.link.helpText": "この{objectType}への直接リンクを共有します。",
"share.modalContent.copyUrlButtonLabel": "POST URLをコピー",

View file

@ -7277,11 +7277,11 @@
"share.contextMenu.permalinksTab": "链接",
"share.contextMenuTitle": "共享此 {objectType}",
"share.dashboard.link.description": "共享指向此搜索的直接链接。",
"share.embed.copyEmbedCodeButton": "复制嵌入代码",
"share.embed.dashboard.helpText": "将此仪表板嵌入到其他网页。选择要在可嵌入视图中包括哪些项目。",
"share.embed.helpText": "将此 {objectType} 嵌入到其他网页。",
"share.fileType": "文件类型",
"share.link.copied": "文本已复制",
"share.link.copyEmbedCodeButton": "复制嵌入代码",
"share.link.copyLinkButton": "复制链接",
"share.link.helpText": "共享指向此 {objectType} 的直接链接。",
"share.modalContent.copyUrlButtonLabel": "复制 Post URL",