From af4ef0bd3c20e7776f0d563fd0abde770c8ebb99 Mon Sep 17 00:00:00 2001 From: Tom Owers Date: Wed, 31 Jul 2024 12:21:32 +0100 Subject: [PATCH] feat(BI): Series settings (#24082) Co-authored-by: Eric Duong Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> --- .../lib/lemon-ui/LemonButton/LemonButton.tsx | 9 +- frontend/src/queries/examples.ts | 5 +- .../Components/Charts/LineGraph.tsx | 6 +- .../Components/SeriesTab.tsx | 110 ++++++++++++----- .../Components/ySeriesLogic.ts | 63 ++++++++++ .../dataVisualizationLogic.ts | 114 +++++++++++++----- frontend/src/queries/schema.json | 66 +++++++--- frontend/src/queries/schema.ts | 11 +- package.json | 2 + pnpm-lock.yaml | 13 +- posthog/schema.py | 55 ++++++--- 11 files changed, 354 insertions(+), 100 deletions(-) create mode 100644 frontend/src/queries/nodes/DataVisualization/Components/ySeriesLogic.ts diff --git a/frontend/src/lib/lemon-ui/LemonButton/LemonButton.tsx b/frontend/src/lib/lemon-ui/LemonButton/LemonButton.tsx index 11e44d7a5e4..cefc0e536df 100644 --- a/frontend/src/lib/lemon-ui/LemonButton/LemonButton.tsx +++ b/frontend/src/lib/lemon-ui/LemonButton/LemonButton.tsx @@ -63,6 +63,8 @@ export interface LemonButtonPropsBase disabled?: boolean /** Like plain `disabled`, except we enforce a reason to be shown in the tooltip. */ disabledReason?: string | null | false + /** Class for the wrapping div when the button is disabled */ + disabledReasonWrapperClass?: string noPadding?: boolean size?: 'xsmall' | 'small' | 'medium' | 'large' 'data-attr'?: string @@ -116,6 +118,7 @@ export const LemonButton: React.FunctionComponent {/* If the button is a `button` element and disabled, wrap it in a div so that the tooltip works */} - {disabled && ButtonComponent === 'button' ?
{workingButton}
: workingButton} + {disabled && ButtonComponent === 'button' ? ( +
{workingButton}
+ ) : ( + workingButton + )} ) } diff --git a/frontend/src/queries/examples.ts b/frontend/src/queries/examples.ts index 7cbad0eccc4..26046b44356 100644 --- a/frontend/src/queries/examples.ts +++ b/frontend/src/queries/examples.ts @@ -289,8 +289,9 @@ limit 100`, const HogQLForDataWarehouse: HogQLQuery = { kind: NodeKind.HogQLQuery, - query: `select toDate(timestamp) as timestamp, event as event - from events + query: `select toDate(timestamp) as timestamp, count() +from events +group by timestamp limit 100`, explain: true, } diff --git a/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx b/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx index e719e99616f..b82c00737bf 100644 --- a/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx +++ b/frontend/src/queries/nodes/DataVisualization/Components/Charts/LineGraph.tsx @@ -19,7 +19,7 @@ import { ensureTooltip } from 'scenes/insights/views/LineGraph/LineGraph' import { themeLogic } from '~/layout/navigation-3000/themeLogic' import { ChartDisplayType, GraphType } from '~/types' -import { dataVisualizationLogic } from '../../dataVisualizationLogic' +import { dataVisualizationLogic, formatDataWithSettings } from '../../dataVisualizationLogic' import { displayLogic } from '../../displayLogic' Chart.register(annotationPlugin) @@ -179,9 +179,9 @@ export const LineGraph = (): JSX.Element => { tooltipRoot.render(
({ + dataSource={yData.map(({ data, column, settings }) => ({ series: column.name, - data: data[referenceDataPoint.dataIndex], + data: formatDataWithSettings(data[referenceDataPoint.dataIndex], settings), }))} columns={[ { diff --git a/frontend/src/queries/nodes/DataVisualization/Components/SeriesTab.tsx b/frontend/src/queries/nodes/DataVisualization/Components/SeriesTab.tsx index d26e0b23faa..867b10ad1ab 100644 --- a/frontend/src/queries/nodes/DataVisualization/Components/SeriesTab.tsx +++ b/frontend/src/queries/nodes/DataVisualization/Components/SeriesTab.tsx @@ -1,12 +1,15 @@ -import { IconPlusSmall, IconTrash } from '@posthog/icons' -import { LemonButton, LemonLabel, LemonSelect } from '@posthog/lemon-ui' +import { IconGear, IconPlusSmall, IconTrash } from '@posthog/icons' +import { LemonButton, LemonInput, LemonLabel, LemonSelect, Popover } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' +import { Form } from 'kea-forms' +import { LemonField } from 'lib/lemon-ui/LemonField' -import { dataVisualizationLogic } from '../dataVisualizationLogic' +import { AxisSeries, dataVisualizationLogic } from '../dataVisualizationLogic' +import { ySeriesLogic } from './ySeriesLogic' export const SeriesTab = (): JSX.Element => { const { columns, xData, yData, responseLoading } = useValues(dataVisualizationLogic) - const { updateXSeries, updateYSeries, addYSeries, deleteYSeries } = useActions(dataVisualizationLogic) + const { updateXSeries, addYSeries } = useActions(dataVisualizationLogic) const options = columns.map(({ name, label }) => ({ value: name, @@ -29,29 +32,8 @@ export const SeriesTab = (): JSX.Element => { }} /> Y-axis - {(yData ?? [null]).map((series, index) => ( -
- { - const column = columns.find((n) => n.name === value) - if (column) { - updateYSeries(index, column.name) - } - }} - /> - } - status="danger" - title="Delete Y-series" - noPadding - onClick={() => deleteYSeries(index)} - /> -
+ {yData.map((series, index) => ( + ))} {
) } + +const YSeries = ({ series, index }: { series: AxisSeries; index: number }): JSX.Element => { + const { columns, responseLoading, dataVisualizationProps } = useValues(dataVisualizationLogic) + const { updateYSeries, deleteYSeries } = useActions(dataVisualizationLogic) + + const seriesLogicProps = { series, seriesIndex: index, dataVisualizationProps } + const seriesLogic = ySeriesLogic(seriesLogicProps) + + const { isSettingsOpen, canOpenSettings } = useValues(seriesLogic) + const { setSettingsOpen, submitFormatting } = useActions(seriesLogic) + + const options = columns.map(({ name, label }) => ({ + value: name, + label, + })) + + return ( +
+ { + const column = columns.find((n) => n.name === value) + if (column) { + updateYSeries(index, column.name) + } + }} + /> + + + + + + + + + + + + } + visible={isSettingsOpen} + placement="bottom" + onClickOutside={() => submitFormatting()} + > + } + noPadding + onClick={() => setSettingsOpen(true)} + disabledReason={!canOpenSettings && 'Select a column first'} + disabledReasonWrapperClass="flex" + /> + + } + status="danger" + title="Delete Y-series" + noPadding + onClick={() => deleteYSeries(index)} + /> +
+ ) +} diff --git a/frontend/src/queries/nodes/DataVisualization/Components/ySeriesLogic.ts b/frontend/src/queries/nodes/DataVisualization/Components/ySeriesLogic.ts new file mode 100644 index 00000000000..46b3313e6b6 --- /dev/null +++ b/frontend/src/queries/nodes/DataVisualization/Components/ySeriesLogic.ts @@ -0,0 +1,63 @@ +import { actions, connect, kea, key, path, props, reducers, selectors } from 'kea' +import { forms } from 'kea-forms' + +import { + AxisSeries, + dataVisualizationLogic, + DataVisualizationLogicProps, + EmptyYAxisSeries, +} from '../dataVisualizationLogic' +import type { ySeriesLogicType } from './ySeriesLogicType' + +export interface YSeriesLogicProps { + series: AxisSeries + seriesIndex: number + dataVisualizationProps: DataVisualizationLogicProps +} + +export const ySeriesLogic = kea([ + path(['queries', 'nodes', 'DataVisualization', 'Components', 'ySeriesLogic']), + key((props) => props.series?.column?.name ?? `new-${props.seriesIndex}`), + connect((props: YSeriesLogicProps) => ({ + actions: [dataVisualizationLogic(props.dataVisualizationProps), ['updateYSeries']], + })), + props({ series: EmptyYAxisSeries } as YSeriesLogicProps), + actions({ + setSettingsOpen: (open: boolean) => ({ open }), + }), + reducers({ + isSettingsOpen: [ + false as boolean, + { + setSettingsOpen: (_, { open }) => open, + }, + ], + }), + selectors({ + canOpenSettings: [ + (_s, p) => [p.series], + (series) => { + return series !== EmptyYAxisSeries + }, + ], + }), + forms(({ actions, props }) => ({ + formatting: { + defaults: { + prefix: props.series?.settings?.formatting?.prefix ?? '', + suffix: props.series?.settings?.formatting?.suffix ?? '', + style: props.series?.settings?.formatting?.style ?? 'none', + }, + submit: async (format) => { + actions.updateYSeries(props.seriesIndex, props.series.column.name, { + formatting: { + prefix: format.prefix, + suffix: format.suffix, + style: format.style, + }, + }) + actions.setSettingsOpen(false) + }, + }, + })), +]) diff --git a/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts b/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts index 6d952e01fb8..4251694763b 100644 --- a/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts +++ b/frontend/src/queries/nodes/DataVisualization/dataVisualizationLogic.ts @@ -1,10 +1,11 @@ import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' import { subscriptions } from 'kea-subscriptions' +import mergeObject from 'lodash.merge' import { insightSceneLogic } from 'scenes/insights/insightSceneLogic' import { teamLogic } from 'scenes/teamLogic' import { insightVizDataCollectionId } from '~/queries/nodes/InsightViz/InsightViz' -import { AnyResponseType, ChartAxis, DataVisualizationNode } from '~/queries/schema' +import { AnyResponseType, ChartAxis, ChartSettingsFormatting, DataVisualizationNode } from '~/queries/schema' import { QueryContext } from '~/queries/types' import { ChartDisplayType, InsightLogicProps, ItemMode } from '~/types' @@ -24,9 +25,14 @@ export interface Column { dataIndex: number } +export interface AxisSeriesSettings { + formatting?: ChartSettingsFormatting +} + export interface AxisSeries { column: Column data: T[] + settings?: AxisSeriesSettings } export interface DataVisualizationLogicProps { @@ -38,6 +44,50 @@ export interface DataVisualizationLogicProps { cachedResults?: AnyResponseType } +export interface SelectedYAxis { + name: string + settings: AxisSeriesSettings +} + +export const EmptyYAxisSeries: AxisSeries = { + column: { + name: 'None', + type: 'None', + label: 'None', + dataIndex: -1, + }, + data: [], +} + +const DefaultAxisSettings: AxisSeriesSettings = { + formatting: { + prefix: '', + suffix: '', + }, +} + +export const formatDataWithSettings = (data: number, settings?: AxisSeriesSettings): string => { + let dataAsString = `${data}` + + if (settings?.formatting?.style === 'number') { + dataAsString = data.toLocaleString() + } + + if (settings?.formatting?.style === 'percent') { + dataAsString = `${(data * 100).toLocaleString()}%` + } + + if (settings?.formatting?.prefix) { + dataAsString = `${settings.formatting.prefix}${dataAsString}` + } + + if (settings?.formatting?.suffix) { + dataAsString = `${dataAsString}${settings.formatting.suffix}` + } + + return dataAsString +} + export const dataVisualizationLogic = kea([ key((props) => props.key), path(['queries', 'nodes', 'DataVisualization', 'dataVisualizationLogic']), @@ -63,11 +113,12 @@ export const dataVisualizationLogic = kea([ updateXSeries: (columnName: string) => ({ columnName, }), - updateYSeries: (seriesIndex: number, columnName: string) => ({ + updateYSeries: (seriesIndex: number, columnName: string, settings?: AxisSeriesSettings) => ({ seriesIndex, columnName, + settings, }), - addYSeries: (columnName?: string) => ({ columnName }), + addYSeries: (columnName?: string, settings?: AxisSeriesSettings) => ({ columnName, settings }), deleteYSeries: (seriesIndex: number) => ({ seriesIndex }), clearAxis: true, setQuery: (node: DataVisualizationNode) => ({ node }), @@ -88,28 +139,36 @@ export const dataVisualizationLogic = kea([ }, ], selectedYAxis: [ - null as (string | null)[] | null, + null as (SelectedYAxis | null)[] | null, { clearAxis: () => null, - addYSeries: (state, { columnName }) => { + addYSeries: (state, { columnName, settings }) => { if (!state && columnName !== undefined) { - return [columnName] + return [{ name: columnName, settings: settings ?? DefaultAxisSettings }] } if (!state) { return [null] } - return [...state, columnName === undefined ? null : columnName] + return [ + ...state, + columnName === undefined + ? null + : { name: columnName, settings: settings ?? DefaultAxisSettings }, + ] }, - updateYSeries: (state, { seriesIndex, columnName }) => { + updateYSeries: (state, { seriesIndex, columnName, settings }) => { if (!state) { return null } const ySeries = [...state] - ySeries[seriesIndex] = columnName + ySeries[seriesIndex] = { + name: columnName, + settings: mergeObject(ySeries[seriesIndex]?.settings ?? {}, settings), + } return ySeries }, deleteYSeries: (state, { seriesIndex }) => { @@ -192,30 +251,22 @@ export const dataVisualizationLogic = kea([ ], yData: [ (state) => [state.selectedYAxis, state.response, state.columns], - (ySeries, response, columns): null | AxisSeries[] => { + (ySeries, response, columns): AxisSeries[] => { if (!response || ySeries === null || ySeries.length === 0) { - return null + return [EmptyYAxisSeries] } const data: any[] = response?.['results'] ?? response?.['result'] ?? [] return ySeries - .map((name): AxisSeries | null => { - if (!name) { - return { - column: { - name: 'None', - type: 'None', - label: 'None', - dataIndex: -1, - }, - data: [], - } + .map((series): AxisSeries | null => { + if (!series) { + return EmptyYAxisSeries } - const column = columns.find((n) => n.name === name) + const column = columns.find((n) => n.name === series.name) if (!column) { - return null + return EmptyYAxisSeries } return { @@ -227,6 +278,7 @@ export const dataVisualizationLogic = kea([ return 0 } }), + settings: series.settings, } }) .filter((series): series is AxisSeries => Boolean(series)) @@ -260,6 +312,7 @@ export const dataVisualizationLogic = kea([ } }, ], + dataVisualizationProps: [() => [(_, props) => props], (props): DataVisualizationLogicProps => props], }), listeners(({ props }) => ({ setQuery: ({ node }) => { @@ -290,7 +343,7 @@ export const dataVisualizationLogic = kea([ if (yAxis && yAxis.length) { yAxis.forEach((axis) => { - actions.addYSeries(axis.column) + actions.addYSeries(axis.column, axis.settings) }) } } @@ -325,22 +378,23 @@ export const dataVisualizationLogic = kea([ }, selectedXAxis: (value: string | null) => { if (props.setQuery) { - const yColumns = values.selectedYAxis?.filter((n: string | null): n is string => Boolean(n)) ?? [] + const yColumns = + values.selectedYAxis?.filter((n: SelectedYAxis | null): n is SelectedYAxis => Boolean(n)) ?? [] const xColumn: ChartAxis | undefined = value !== null ? { column: value } : undefined props.setQuery({ ...props.query, chartSettings: { ...(props.query.chartSettings ?? {}), - yAxis: yColumns.map((n) => ({ column: n })), + yAxis: yColumns.map((n) => ({ column: n.name, settings: n.settings })), xAxis: xColumn, }, }) } }, - selectedYAxis: (value: (string | null)[] | null) => { + selectedYAxis: (value: (SelectedYAxis | null)[] | null) => { if (props.setQuery) { - const yColumns = value?.filter((n: string | null): n is string => Boolean(n)) ?? [] + const yColumns = value?.filter((n: SelectedYAxis | null): n is SelectedYAxis => Boolean(n)) ?? [] const xColumn: ChartAxis | undefined = values.selectedXAxis !== null ? { column: values.selectedXAxis } : undefined @@ -348,7 +402,7 @@ export const dataVisualizationLogic = kea([ ...props.query, chartSettings: { ...(props.query.chartSettings ?? {}), - yAxis: yColumns.map((n) => ({ column: n })), + yAxis: yColumns.map((n) => ({ column: n.name, settings: n.settings })), xAxis: xColumn, }, }) diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 798d89e51aa..9da3bf34f3a 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -1993,6 +1993,15 @@ "properties": { "column": { "type": "string" + }, + "settings": { + "additionalProperties": false, + "properties": { + "formatting": { + "$ref": "#/definitions/ChartSettingsFormatting" + } + }, + "type": "object" } }, "required": ["column"], @@ -2013,6 +2022,43 @@ ], "type": "string" }, + "ChartSettings": { + "additionalProperties": false, + "properties": { + "goalLines": { + "items": { + "$ref": "#/definitions/GoalLine" + }, + "type": "array" + }, + "xAxis": { + "$ref": "#/definitions/ChartAxis" + }, + "yAxis": { + "items": { + "$ref": "#/definitions/ChartAxis" + }, + "type": "array" + } + }, + "type": "object" + }, + "ChartSettingsFormatting": { + "additionalProperties": false, + "properties": { + "prefix": { + "type": "string" + }, + "style": { + "enum": ["none", "number", "percent"], + "type": "string" + }, + "suffix": { + "type": "string" + } + }, + "type": "object" + }, "ClickhouseQueryProgress": { "additionalProperties": false, "properties": { @@ -2670,25 +2716,7 @@ "additionalProperties": false, "properties": { "chartSettings": { - "additionalProperties": false, - "properties": { - "goalLines": { - "items": { - "$ref": "#/definitions/GoalLine" - }, - "type": "array" - }, - "xAxis": { - "$ref": "#/definitions/ChartAxis" - }, - "yAxis": { - "items": { - "$ref": "#/definitions/ChartAxis" - }, - "type": "array" - } - }, - "type": "object" + "$ref": "#/definitions/ChartSettings" }, "display": { "$ref": "#/definitions/ChartDisplayType" diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index a3e982f83fd..7db4e7aff08 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -551,9 +551,18 @@ export interface GoalLine { export interface ChartAxis { column: string + settings?: { + formatting?: ChartSettingsFormatting + } } -interface ChartSettings { +export interface ChartSettingsFormatting { + prefix?: string + suffix?: string + style?: 'none' | 'number' | 'percent' +} + +export interface ChartSettings { xAxis?: ChartAxis yAxis?: ChartAxis[] goalLines?: GoalLine[] diff --git a/package.json b/package.json index 2ec48a1059f..955a69b5db8 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "kea-test-utils": "^0.2.4", "kea-waitfor": "^0.2.1", "kea-window-values": "^3.0.0", + "lodash.merge": "^4.6.2", "maplibre-gl": "^3.5.1", "md5": "^2.3.0", "monaco-editor": "^0.49.0", @@ -223,6 +224,7 @@ "@types/image-blob-reduce": "^4.1.1", "@types/jest": "^29.5.12", "@types/jest-image-snapshot": "^6.1.0", + "@types/lodash.merge": "^4.6.9", "@types/md5": "^2.3.0", "@types/node": "^18.11.9", "@types/papaparse": "^5.3.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5432acec6a6..08a040e5b16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -238,6 +238,9 @@ dependencies: kea-window-values: specifier: ^3.0.0 version: 3.0.0(kea@3.1.5) + lodash.merge: + specifier: ^4.6.2 + version: 4.6.2 maplibre-gl: specifier: ^3.5.1 version: 3.5.1 @@ -488,6 +491,9 @@ devDependencies: '@types/jest-image-snapshot': specifier: ^6.1.0 version: 6.1.0 + '@types/lodash.merge': + specifier: ^4.6.9 + version: 4.6.9 '@types/node': specifier: ^18.11.9 version: 18.11.9 @@ -8355,6 +8361,12 @@ packages: resolution: {integrity: sha512-PecSzorDGdabF57OBeQO/xFbAkYWo88g4Xvnsx7LRwqLC17I7OoKtA3bQB9uXkY6UkMWCOsA8HSVpaoitscdXw==} dev: false + /@types/lodash.merge@4.6.9: + resolution: {integrity: sha512-23sHDPmzd59kUgWyKGiOMO2Qb9YtqRO/x4IhkgNUiPQ1+5MUVqi6bCZeq9nBJ17msjIMbEIO5u+XW4Kz6aGUhQ==} + dependencies: + '@types/lodash': 4.14.188 + dev: true + /@types/lodash@4.14.188: resolution: {integrity: sha512-zmEmF5OIM3rb7SbLCFYoQhO4dGt2FRM9AMkxvA3LaADOF1n8in/zGJlWji9fmafLoNyz+FoL6FE0SLtGIArD7w==} dev: true @@ -15659,7 +15671,6 @@ packages: /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - dev: true /lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} diff --git a/posthog/schema.py b/posthog/schema.py index 68a009d5e91..7f08557d62f 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -146,13 +146,6 @@ class StatusItem(BaseModel): value: str -class ChartAxis(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - column: str - - class ChartDisplayType(StrEnum): ACTIONS_LINE_GRAPH = "ActionsLineGraph" ACTIONS_BAR = "ActionsBar" @@ -166,6 +159,21 @@ class ChartDisplayType(StrEnum): WORLD_MAP = "WorldMap" +class Style(StrEnum): + NONE = "none" + NUMBER = "number" + PERCENT = "percent" + + +class ChartSettingsFormatting(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + prefix: Optional[str] = None + style: Optional[Style] = None + suffix: Optional[str] = None + + class ClickhouseQueryProgress(BaseModel): model_config = ConfigDict( extra="forbid", @@ -1851,6 +1859,30 @@ class CachedWebTopClicksQueryResponse(BaseModel): types: Optional[list] = None +class Settings(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + formatting: Optional[ChartSettingsFormatting] = None + + +class ChartAxis(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + column: str + settings: Optional[Settings] = None + + +class ChartSettings(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + goalLines: Optional[list[GoalLine]] = None + xAxis: Optional[ChartAxis] = None + yAxis: Optional[list[ChartAxis]] = None + + class Response(BaseModel): model_config = ConfigDict( extra="forbid", @@ -2009,15 +2041,6 @@ class Response7(BaseModel): ) -class ChartSettings(BaseModel): - model_config = ConfigDict( - extra="forbid", - ) - goalLines: Optional[list[GoalLine]] = None - xAxis: Optional[ChartAxis] = None - yAxis: Optional[list[ChartAxis]] = None - - class DataWarehousePersonPropertyFilter(BaseModel): model_config = ConfigDict( extra="forbid",