[Dashboards] - add iframe widget (#14251)

closes https://github.com/twentyhq/core-team-issues/issues/1415
This commit is contained in:
nitin
2025-09-03 21:01:38 +05:30
committed by GitHub
parent 7ac7f52510
commit 0a6d5479c9
37 changed files with 522 additions and 78 deletions
@@ -4,6 +4,7 @@ import { CommandMenuAskAIPage } from '@/command-menu/pages/ask-ai/components/Com
import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage';
import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage';
import { CommandMenuPageLayoutGraphTypeSelect } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphTypeSelect';
import { CommandMenuPageLayoutIframeConfig } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeConfig';
import { CommandMenuPageLayoutWidgetTypeSelect } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect';
import { CommandMenuMergeRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuMergeRecordPage';
import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage';
@@ -48,4 +49,8 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map<
CommandMenuPages.PageLayoutGraphTypeSelect,
<CommandMenuPageLayoutGraphTypeSelect />,
],
[
CommandMenuPages.PageLayoutIframeConfig,
<CommandMenuPageLayoutIframeConfig />,
],
]);
@@ -0,0 +1,126 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useCreatePageLayoutIframeWidget } from '@/settings/page-layout/hooks/useCreatePageLayoutIframeWidget';
import { usePageLayoutWidgetUpdate } from '@/settings/page-layout/hooks/usePageLayoutWidgetUpdate';
import { pageLayoutEditingWidgetIdState } from '@/settings/page-layout/states/pageLayoutEditingWidgetIdState';
import { pageLayoutWidgetsState } from '@/settings/page-layout/states/pageLayoutWidgetsState';
import styled from '@emotion/styled';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { isValidUrl } from 'twenty-shared/utils';
import { Button } from 'twenty-ui/input';
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(2)};
gap: ${({ theme }) => theme.spacing(3)};
`;
const StyledSectionTitle = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
padding-left: ${({ theme }) => theme.spacing(1)};
`;
const StyledButtonContainer = styled.div`
display: flex;
justify-content: flex-end;
margin-top: ${({ theme }) => theme.spacing(2)};
`;
export const CommandMenuPageLayoutIframeConfig = () => {
const { closeCommandMenu } = useCommandMenu();
const { createPageLayoutIframeWidget } = useCreatePageLayoutIframeWidget();
const { handleUpdateWidget } = usePageLayoutWidgetUpdate();
const [pageLayoutEditingWidgetId, setPageLayoutEditingWidgetId] =
useRecoilState(pageLayoutEditingWidgetIdState);
const pageLayoutWidgets = useRecoilValue(pageLayoutWidgetsState);
const editingWidget = pageLayoutWidgets.find(
(w) => w.id === pageLayoutEditingWidgetId,
);
const isEditMode = !!editingWidget;
const [title, setTitle] = useState(editingWidget?.title || '');
const [url, setUrl] = useState(editingWidget?.configuration?.url || '');
const [urlError, setUrlError] = useState('');
const validateUrl = (urlString: string): boolean => {
const trimmedUrl = urlString.trim();
if (!isValidUrl(trimmedUrl)) {
setUrlError('Please enter a valid URL');
return false;
}
setUrlError('');
return true;
};
const handleUrlChange = (value: string) => {
setUrl(value);
validateUrl(value);
};
const handleSubmit = () => {
if (!title.trim()) {
return;
}
if (!validateUrl(url)) {
return;
}
if (isEditMode && pageLayoutEditingWidgetId !== null) {
handleUpdateWidget(pageLayoutEditingWidgetId, {
title: title.trim(),
configuration: {
...editingWidget?.configuration,
url: url.trim(),
},
});
setPageLayoutEditingWidgetId(null);
} else {
createPageLayoutIframeWidget(title.trim(), url.trim());
}
closeCommandMenu();
};
const isFormValid = title.trim() && url.trim() && !urlError;
return (
<StyledContainer>
<StyledSectionTitle>
{isEditMode ? 'Edit iFrame Widget' : 'Configure iFrame Widget'}
</StyledSectionTitle>
<FormTextFieldInput
label="Widget Title"
placeholder="e.g., Analytics Dashboard"
defaultValue={title}
onChange={setTitle}
/>
<FormTextFieldInput
label="URL to Embed"
placeholder="https://example.com/embed"
defaultValue={url}
onChange={handleUrlChange}
error={urlError}
/>
<StyledButtonContainer>
<Button
title={isEditMode ? 'Save Changes' : 'Create Widget'}
onClick={handleSubmit}
disabled={!isFormValid}
variant="primary"
size="small"
/>
</StyledButtonContainer>
</StyledContainer>
);
};
@@ -45,7 +45,7 @@ const widgetTypeOptions = [
type: WidgetType.IFRAME,
icon: IconFrame,
title: 'Add an iframe',
disabled: true,
disabled: false,
},
];
@@ -56,14 +56,24 @@ export const CommandMenuPageLayoutWidgetTypeSelect = () => {
);
const handleSelectWidget = (widgetType: WidgetType) => {
if (widgetType === WidgetType.GRAPH) {
navigateCommandMenu({
page: CommandMenuPages.PageLayoutGraphTypeSelect,
pageTitle: 'Select Graph Type',
pageIcon: IconChartPie,
});
} else {
setPageLayoutDraggedArea(null);
switch (widgetType) {
case WidgetType.GRAPH:
navigateCommandMenu({
page: CommandMenuPages.PageLayoutGraphTypeSelect,
pageTitle: 'Select Graph Type',
pageIcon: IconChartPie,
});
break;
case WidgetType.IFRAME:
navigateCommandMenu({
page: CommandMenuPages.PageLayoutIframeConfig,
pageTitle: 'Configure iFrame',
pageIcon: IconFrame,
});
break;
default:
setPageLayoutDraggedArea(null);
break;
}
};
@@ -16,4 +16,5 @@ export enum CommandMenuPages {
ViewPreviousAIChats = 'view-previous-ai-chats',
PageLayoutWidgetTypeSelect = 'page-layout-widget-type-select',
PageLayoutGraphTypeSelect = 'page-layout-graph-type-select',
PageLayoutIframeConfig = 'page-layout-iframe-config',
}
@@ -0,0 +1,121 @@
import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.transparent.lighter};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
`;
const StyledIframe = styled.iframe`
border: none;
flex: 1;
height: 100%;
width: 100%;
`;
const StyledLoadingContainer = styled.div`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding-top: ${({ theme }) => theme.spacing(2)};
padding-left: ${({ theme }) => theme.spacing(2)};
background: ${({ theme }) => theme.background.primary};
pointer-events: none;
z-index: 1;
`;
const StyledErrorContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: ${({ theme }) => theme.spacing(4)};
text-align: center;
`;
const StyledErrorMessage = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.md};
margin-top: ${({ theme }) => theme.spacing(2)};
`;
const StyledErrorUrl = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
margin-top: ${({ theme }) => theme.spacing(1)};
word-break: break-all;
`;
export type IframeWidgetProps = {
url: string;
title?: string;
};
export const IframeWidget = ({
url,
title = 'Embedded Content',
}: IframeWidgetProps) => {
const theme = useTheme();
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const handleIframeLoad = () => {
setIsLoading(false);
};
const handleIframeError = () => {
setIsLoading(false);
setHasError(true);
};
if (hasError) {
return (
<StyledContainer>
<StyledErrorContainer>
<StyledErrorMessage>Failed to load content</StyledErrorMessage>
<StyledErrorUrl>{url}</StyledErrorUrl>
</StyledErrorContainer>
</StyledContainer>
);
}
return (
<StyledContainer>
{isLoading && (
<StyledLoadingContainer>
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<Skeleton
width={120}
height={SKELETON_LOADER_HEIGHT_SIZES.standard.m}
/>
</SkeletonTheme>
</StyledLoadingContainer>
)}
<StyledIframe
src={url}
title={title}
onLoad={handleIframeLoad}
onError={handleIframeError}
sandbox="allow-scripts allow-forms allow-popups"
allow="encrypted-media"
allowFullScreen
/>
</StyledContainer>
);
};
@@ -1,6 +1,6 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { IconGripVertical, IconX } from 'twenty-ui/display';
import { IconGripVertical, IconPencil, IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import {
AnimatedPlaceholder,
@@ -66,6 +66,7 @@ const StyledContent = styled.div`
type PageLayoutWidgetPlaceholderProps = {
title?: string;
onRemove?: () => void;
onEdit?: () => void;
children?: ReactNode;
isEmpty?: boolean;
};
@@ -73,6 +74,7 @@ type PageLayoutWidgetPlaceholderProps = {
export const PageLayoutWidgetPlaceholder = ({
title = 'Graph Title',
onRemove,
onEdit,
children,
isEmpty = false,
}: PageLayoutWidgetPlaceholderProps) => {
@@ -116,6 +118,14 @@ export const PageLayoutWidgetPlaceholder = ({
size="small"
/>
<StyledTitle>{title}</StyledTitle>
{onEdit && (
<IconButton
onClick={onEdit}
Icon={IconPencil}
variant="tertiary"
size="small"
/>
)}
{onRemove && (
<IconButton
onClick={onRemove}
@@ -1,6 +1,6 @@
import { GraphWidgetGaugeChart } from '@/dashboards/graphs/components/GraphWidgetGaugeChart';
import { GraphWidgetNumberChart } from '@/dashboards/graphs/components/GraphWidgetNumberChart';
import { GraphWidgetPieChart } from '@/dashboards/graphs/components/GraphWidgetPieChart';
import { GraphWidgetGaugeChart } from '@/dashboards/widgets/graph/components/GraphWidgetGaugeChart';
import { GraphWidgetNumberChart } from '@/dashboards/widgets/graph/components/GraphWidgetNumberChart';
import { GraphWidgetPieChart } from '@/dashboards/widgets/graph/components/GraphWidgetPieChart';
import { type Meta, type StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui/testing';
import { PageLayoutWidgetPlaceholder } from '../PageLayoutWidgetPlaceholder';
@@ -0,0 +1,80 @@
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { type Widget, WidgetType } from '../mocks/mockWidgets';
import { pageLayoutCurrentLayoutsState } from '../states/pageLayoutCurrentLayoutsState';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutDraggedAreaState } from '../states/pageLayoutDraggedAreaState';
import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState';
import { getDefaultWidgetPosition } from '../utils/getDefaultWidgetPosition';
export const useCreatePageLayoutIframeWidget = () => {
const createPageLayoutIframeWidget = useRecoilCallback(
({ snapshot, set }) =>
(title: string, url: string) => {
const pageLayoutWidgets = snapshot
.getLoadable(pageLayoutWidgetsState)
.getValue();
const pageLayoutCurrentLayouts = snapshot
.getLoadable(pageLayoutCurrentLayoutsState)
.getValue();
const pageLayoutDraggedArea = snapshot
.getLoadable(pageLayoutDraggedAreaState)
.getValue();
const newWidget: Widget = {
id: `widget-${uuidv4()}`,
type: WidgetType.IFRAME,
title,
configuration: {
url,
},
};
const defaultSize = { w: 6, h: 6 };
const position = getDefaultWidgetPosition(
pageLayoutDraggedArea,
defaultSize,
);
const newLayout = {
i: newWidget.id,
x: position.x,
y: position.y,
w: position.w,
h: position.h,
};
const updatedWidgets = [...pageLayoutWidgets, newWidget];
set(pageLayoutWidgetsState, updatedWidgets);
const updatedLayouts = {
desktop: [...(pageLayoutCurrentLayouts.desktop || []), newLayout],
mobile: [
...(pageLayoutCurrentLayouts.mobile || []),
{ ...newLayout, w: 1, x: 0 },
],
};
set(pageLayoutCurrentLayoutsState, updatedLayouts);
const widgetWithPosition = {
...newWidget,
gridPosition: {
row: position.y,
column: position.x,
rowSpan: position.h,
columnSpan: position.w,
},
};
set(pageLayoutDraftState, (prev) => ({
...prev,
widgets: [...prev.widgets, widgetWithPosition],
}));
set(pageLayoutDraggedAreaState, null);
},
[],
);
return { createPageLayoutIframeWidget };
};
@@ -0,0 +1,35 @@
import { useRecoilCallback } from 'recoil';
import { type Widget } from '../mocks/mockWidgets';
import { pageLayoutDraftState } from '../states/pageLayoutDraftState';
import { pageLayoutWidgetsState } from '../states/pageLayoutWidgetsState';
export const usePageLayoutWidgetUpdate = () => {
const handleUpdateWidget = useRecoilCallback(
({ snapshot, set }) =>
(widgetId: string, updates: Partial<Widget>) => {
const pageLayoutWidgets = snapshot
.getLoadable(pageLayoutWidgetsState)
.getValue();
const pageLayoutDraft = snapshot
.getLoadable(pageLayoutDraftState)
.getValue();
const updatedWidgets = pageLayoutWidgets.map((widget) =>
widget.id === widgetId ? { ...widget, ...updates } : widget,
);
set(pageLayoutWidgetsState, updatedWidgets);
const updatedDraftWidgets = pageLayoutDraft.widgets.map((widget) =>
widget.id === widgetId ? { ...widget, ...updates } : widget,
);
set(pageLayoutDraftState, {
...pageLayoutDraft,
widgets: updatedDraftWidgets,
});
},
[],
);
return { handleUpdateWidget };
};
@@ -18,7 +18,7 @@ export type Widget = {
id: string;
type: WidgetType;
title: string;
configuration?: Record<string, unknown>;
configuration?: Record<string, string>;
data?: any;
};
@@ -0,0 +1,6 @@
import { createState } from 'twenty-ui/utilities';
export const pageLayoutEditingWidgetIdState = createState<string | null>({
key: 'pageLayoutEditingWidgetIdState',
defaultValue: null,
});
@@ -23,7 +23,7 @@ export type SavedPageLayout = {
rowSpan: number;
columnSpan: number;
};
configuration?: Record<string, unknown>;
configuration?: Record<string, string>;
data?: any; // TODO: Remove when backend connected - data will be fetched dynamically
}>;
};
@@ -0,0 +1,66 @@
import { GraphWidgetBarChart } from '@/dashboards/widgets/graph/components/GraphWidgetBarChart';
import { GraphWidgetGaugeChart } from '@/dashboards/widgets/graph/components/GraphWidgetGaugeChart';
import { GraphWidgetNumberChart } from '@/dashboards/widgets/graph/components/GraphWidgetNumberChart';
import { GraphWidgetPieChart } from '@/dashboards/widgets/graph/components/GraphWidgetPieChart';
import { type ReactNode } from 'react';
import { GraphSubType, type Widget } from '../mocks/mockWidgets';
type GraphRenderer = (widget: Widget) => ReactNode;
const graphRenderers: Record<GraphSubType, GraphRenderer> = {
[GraphSubType.NUMBER]: (widget) => (
<GraphWidgetNumberChart
value={widget.data.value}
trendPercentage={widget.data.trendPercentage}
/>
),
[GraphSubType.GAUGE]: (widget) => (
<GraphWidgetGaugeChart
data={{
value: widget.data.value,
min: widget.data.min,
max: widget.data.max,
label: widget.data.label,
}}
displayType="percentage"
showValue
id={`gauge-chart-${widget.id}`}
/>
),
[GraphSubType.PIE]: (widget) => (
<GraphWidgetPieChart
data={widget.data.items}
showLegend
displayType="percentage"
id={`pie-chart-${widget.id}`}
/>
),
[GraphSubType.BAR]: (widget) => (
<GraphWidgetBarChart
data={widget.data.items}
indexBy={widget.data.indexBy}
keys={widget.data.keys}
seriesLabels={widget.data.seriesLabels}
layout={widget.data.layout}
showLegend
showGrid
displayType="number"
id={`bar-chart-${widget.id}`}
/>
),
};
export const renderGraphWidget = (widget: Widget): ReactNode => {
const graphType = widget.configuration?.graphType as GraphSubType | undefined;
if (!graphType) {
return null;
}
const renderer = graphRenderers[graphType];
if (!renderer) {
return null;
}
return renderer(widget);
};
@@ -1,69 +1,28 @@
import { GraphWidgetBarChart } from '@/dashboards/graphs/components/GraphWidgetBarChart';
import { GraphWidgetGaugeChart } from '@/dashboards/graphs/components/GraphWidgetGaugeChart';
import { GraphWidgetNumberChart } from '@/dashboards/graphs/components/GraphWidgetNumberChart';
import { GraphWidgetPieChart } from '@/dashboards/graphs/components/GraphWidgetPieChart';
import { IframeWidget } from '@/dashboards/widgets/iframe/components/IframeWidget';
import { type ReactNode } from 'react';
import { GraphSubType, WidgetType, type Widget } from '../mocks/mockWidgets';
type WidgetRenderer = (widget: Widget) => ReactNode;
const widgetRenderers: Record<GraphSubType, WidgetRenderer> = {
[GraphSubType.NUMBER]: (widget) => (
<GraphWidgetNumberChart
value={widget.data.value}
trendPercentage={widget.data.trendPercentage}
/>
),
[GraphSubType.GAUGE]: (widget) => (
<GraphWidgetGaugeChart
data={{
value: widget.data.value,
min: widget.data.min,
max: widget.data.max,
label: widget.data.label,
}}
displayType="percentage"
showValue
id={`gauge-chart-${widget.id}`}
/>
),
[GraphSubType.PIE]: (widget) => (
<GraphWidgetPieChart
data={widget.data.items}
showLegend
displayType="percentage"
id={`pie-chart-${widget.id}`}
/>
),
[GraphSubType.BAR]: (widget) => (
<GraphWidgetBarChart
data={widget.data.items}
indexBy={widget.data.indexBy}
keys={widget.data.keys}
seriesLabels={widget.data.seriesLabels}
layout={widget.data.layout}
showLegend
showGrid
displayType="number"
id={`bar-chart-${widget.id}`}
/>
),
};
import { WidgetType, type Widget } from '../mocks/mockWidgets';
import { renderGraphWidget } from './graphRegistry';
export const renderWidget = (widget: Widget): ReactNode => {
if (widget.type !== WidgetType.GRAPH) {
return null;
}
switch (widget.type) {
case WidgetType.GRAPH:
return renderGraphWidget(widget);
const graphType = widget.configuration?.graphType as GraphSubType | undefined;
if (!graphType) {
return null;
}
case WidgetType.IFRAME:
return (
<IframeWidget
url={widget.configuration?.url ?? ''}
title={widget.title}
/>
);
const renderer = widgetRenderers[graphType];
if (!renderer) {
return null;
}
case WidgetType.VIEW:
return null;
return renderer(widget);
case WidgetType.FIELDS:
return null;
default:
return null;
}
};
@@ -14,8 +14,10 @@ import { usePageLayoutDragSelection } from '@/settings/page-layout/hooks/usePage
import { usePageLayoutHandleLayoutChange } from '@/settings/page-layout/hooks/usePageLayoutHandleLayoutChange';
import { usePageLayoutSaveHandler } from '@/settings/page-layout/hooks/usePageLayoutSaveHandler';
import { usePageLayoutWidgetDelete } from '@/settings/page-layout/hooks/usePageLayoutWidgetDelete';
import { WidgetType } from '@/settings/page-layout/mocks/mockWidgets';
import { pageLayoutCurrentBreakpointState } from '@/settings/page-layout/states/pageLayoutCurrentBreakpointState';
import { pageLayoutCurrentLayoutsState } from '@/settings/page-layout/states/pageLayoutCurrentLayoutsState';
import { pageLayoutEditingWidgetIdState } from '@/settings/page-layout/states/pageLayoutEditingWidgetIdState';
import { pageLayoutSelectedCellsState } from '@/settings/page-layout/states/pageLayoutSelectedCellsState';
import { pageLayoutWidgetsState } from '@/settings/page-layout/states/pageLayoutWidgetsState';
import { calculateTotalGridRows } from '@/settings/page-layout/utils/calculateTotalGridRows';
@@ -36,7 +38,7 @@ import {
import 'react-grid-layout/css/styles.css';
import 'react-resizable/css/styles.css';
import { useParams } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { IconAppWindow, IconPlus } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { useNavigateSettings } from '~/hooks/useNavigateSettings';
@@ -134,6 +136,9 @@ export const SettingsPageLayoutEdit = () => {
);
const pageLayoutWidgets = useRecoilValue(pageLayoutWidgetsState);
const { navigateCommandMenu } = useNavigateCommandMenu();
const setPageLayoutEditingWidgetId = useSetRecoilState(
pageLayoutEditingWidgetIdState,
);
const {
handleDragSelectionStart,
@@ -153,6 +158,25 @@ export const SettingsPageLayoutEdit = () => {
const { handleRemoveWidget } = usePageLayoutWidgetDelete();
const { handleLayoutChange } = usePageLayoutHandleLayoutChange();
const handleEditWidget = useCallback(
(widgetId: string) => {
const widget = pageLayoutWidgets.find((w) => w.id === widgetId);
if (!widget) return;
setPageLayoutEditingWidgetId(widgetId);
if (widget.type === WidgetType.IFRAME) {
navigateCommandMenu({
page: CommandMenuPages.PageLayoutIframeConfig,
pageTitle: 'Edit iFrame',
pageIcon: IconAppWindow,
resetNavigationStack: true,
});
}
},
[pageLayoutWidgets, setPageLayoutEditingWidgetId, navigateCommandMenu],
);
const gridContainerRef = useRef<HTMLDivElement>(null);
const isEmptyState = pageLayoutWidgets.length === 0;
@@ -289,6 +313,7 @@ export const SettingsPageLayoutEdit = () => {
<PageLayoutWidgetPlaceholder
title={widget.title}
onRemove={() => handleRemoveWidget(widget.id)}
onEdit={() => handleEditWidget(widget.id)}
>
{renderWidget(widget)}
</PageLayoutWidgetPlaceholder>