Dashboard: Redesign row edit panes (#101510)

* add row item changes

* redesign row edit panes and header actions

* clean up after merges

* adjust to sentence casing

* bring back layout selection extra options
This commit is contained in:
Sergej-Vlasov
2025-03-05 11:18:41 +00:00
committed by GitHub
parent 01899d761e
commit b5be6cd4d6
11 changed files with 308 additions and 126 deletions
@@ -25,7 +25,7 @@ export class DashboardEditableElement implements EditableDashboardElement {
const { body } = dashboard.useState();
const dashboardOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
const editPaneHeaderOptions = new OptionsPaneCategoryDescriptor({
title: t('dashboard.options.title', 'Dashboard options'),
id: 'dashboard-options',
isOpenable: false,
@@ -48,6 +48,14 @@ export class DashboardEditableElement implements EditableDashboardElement {
render: () => <DashboardLayoutSelector layoutManager={body} />,
})
);
if (body.getOptions) {
for (const option of body.getOptions()) {
editPaneHeaderOptions.addItem(option);
}
}
return editPaneHeaderOptions;
}, [body, dashboard]);
return [dashboardOptions];
@@ -29,7 +29,7 @@ export class MultiSelectedVizPanelsEditableElement implements MultiSelectedEdita
isOpenable: false,
renderTitle: () => (
<EditPaneHeader
title={t('dashboard.layout.common.panels-title', '{{length}} Panels Selected', {
title={t('dashboard.layout.common.panels-title', '{{length}} panels selected', {
length: this._panels.length,
})}
onDelete={() => this.onDelete()}
@@ -1,16 +1,15 @@
import { ReactNode } from 'react';
import { SceneObjectState, SceneObjectBase, sceneGraph, VariableDependencyConfig, SceneObject } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { getDefaultVizPanel } from '../../utils/utils';
import { ResponsiveGridLayoutManager } from '../layout-responsive-grid/ResponsiveGridLayoutManager';
import { BulkActionElement } from '../types/BulkActionElement';
import { DashboardLayoutManager } from '../types/DashboardLayoutManager';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../types/EditableDashboardElement';
import { LayoutParent } from '../types/LayoutParent';
import { getEditOptions, renderActions } from './RowItemEditor';
import { getEditOptions } from './RowItemEditor';
import { RowItemRenderer } from './RowItemRenderer';
import { RowItemRepeaterBehavior } from './RowItemRepeaterBehavior';
import { RowItems } from './RowItems';
@@ -60,19 +59,42 @@ export class RowItem
return getEditOptions(this);
}
public renderActions(): ReactNode {
return renderActions(this);
}
public onDelete() {
const layout = sceneGraph.getAncestor(this, RowsLayoutManager);
layout.removeRow(this);
this._getParentLayout().removeRow(this);
}
public createMultiSelectedElement(items: SceneObject[]): RowItems {
return new RowItems(items.filter((item) => item instanceof RowItem));
}
public onAddPanel(panel = getDefaultVizPanel()) {
this.getLayout().addPanel(panel);
}
public onAddRowAbove() {
this._getParentLayout().addRowAbove(this);
}
public onAddRowBelow() {
this._getParentLayout().addRowBelow(this);
}
public onMoveUp() {
this._getParentLayout().moveRowUp(this);
}
public onMoveDown() {
this._getParentLayout().moveRowDown(this);
}
public isFirstRow(): boolean {
return this._getParentLayout().isFirstRow(this);
}
public isLastRow(): boolean {
return this._getParentLayout().isLastRow(this);
}
public getRepeatVariable(): string | undefined {
return this._getRepeatBehavior()?.state.variableName;
}
@@ -110,6 +132,10 @@ export class RowItem
this.setState({ isCollapsed: !this.state.isCollapsed });
}
private _getParentLayout(): RowsLayoutManager {
return sceneGraph.getAncestor(this, RowsLayoutManager);
}
private _getRepeatBehavior(): RowItemRepeaterBehavior | undefined {
return this.state.$behaviors?.find((b) => b instanceof RowItemRepeaterBehavior);
}
@@ -1,8 +1,7 @@
import { useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Alert, Button, Input, RadioButtonGroup, Switch, TextLink } from '@grafana/ui';
import { Alert, Input, Switch, TextLink } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@@ -10,18 +9,25 @@ import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSel
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { EditPaneHeader } from '../../edit-pane/EditPaneHeader';
import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { DashboardLayoutSelector } from '../layouts-shared/DashboardLayoutSelector';
import { RowItem } from './RowItem';
export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[] {
const { layout } = model.useState();
const rowOptions = useMemo(() => {
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.rows-layout.row-options.title', 'Row options'),
const dashboard = getDashboardSceneFor(model);
const editPaneHeaderOptions = new OptionsPaneCategoryDescriptor({
title: t('dashboard.rows-layout.row-options.title', 'Row'),
id: 'row-options',
isOpenDefault: true,
isOpenable: false,
renderTitle: () => (
<EditPaneHeader title={t('dashboard.rows-layout.row-options.title', 'Row')} onDelete={() => model.onDelete()} />
),
})
.addItem(
new OptionsPaneItemDescriptor({
@@ -31,8 +37,22 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[]
)
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.row-options.height.title', 'Height'),
render: () => <RowHeightSelect row={model} />,
title: t('dashboard.layout.common.layout', 'Layout'),
render: () => <DashboardLayoutSelector layoutManager={layout} />,
})
);
if (layout.getOptions) {
for (const option of layout.getOptions()) {
editPaneHeaderOptions.addItem(option);
}
}
editPaneHeaderOptions
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.row-options.repeat.variable.title', 'Repeat for'),
render: () => <RowRepeatSelect row={model} dashboard={dashboard} />,
})
)
.addItem(
@@ -41,42 +61,23 @@ export function getEditOptions(model: RowItem): OptionsPaneCategoryDescriptor[]
render: () => <RowHeaderSwitch row={model} />,
})
);
}, [model]);
const rowRepeatOptions = useMemo(() => {
const dashboard = getDashboardSceneFor(model);
return editPaneHeaderOptions;
}, [layout, model]);
return new OptionsPaneCategoryDescriptor({
title: t('dashboard.rows-layout.row-options.repeat.title', 'Repeat options'),
id: 'row-repeat-options',
isOpenDefault: true,
}).addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.rows-layout.row-options.repeat.variable.title', 'Variable'),
render: () => <RowRepeatSelect row={model} dashboard={dashboard} />,
})
);
}, [model]);
const { layout } = model.useState();
const layoutOptions = useLayoutCategory(layout);
return [rowOptions, rowRepeatOptions, layoutOptions];
}
export function renderActions(model: RowItem) {
return (
<>
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={() => model.onDelete()} icon="trash-alt" />
</>
);
return [rowOptions];
}
function RowTitleInput({ row }: { row: RowItem }) {
const { title } = row.useState();
return <Input value={title} onChange={(e) => row.onChangeTitle(e.currentTarget.value)} />;
return (
<Input
title={t('dashboard.rows-layout.row-options.title-option', 'Title')}
value={title}
onChange={(e) => row.onChangeTitle(e.currentTarget.value)}
/>
);
}
function RowHeaderSwitch({ row }: { row: RowItem }) {
@@ -85,17 +86,6 @@ function RowHeaderSwitch({ row }: { row: RowItem }) {
return <Switch value={isHeaderHidden} onChange={() => row.onHeaderHiddenToggle()} />;
}
function RowHeightSelect({ row }: { row: RowItem }) {
const { height = 'expand' } = row.useState();
const options: Array<SelectableValue<'expand' | 'min'>> = [
{ label: t('dashboard.rows-layout.row-options.height.expand', 'Expand'), value: 'expand' },
{ label: t('dashboard.rows-layout.row-options.height.min', 'Min'), value: 'min' },
];
return <RadioButtonGroup options={options} value={height} onChange={(option) => row.onChangeHeight(option)} />;
}
function RowRepeatSelect({ row, dashboard }: { row: RowItem; dashboard: DashboardScene }) {
const { layout } = row.useState();
@@ -0,0 +1,93 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Dropdown, Menu, ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { RowItem } from './RowItem';
interface RowItemMenuProps {
model: RowItem;
}
export function RowItemMenu({ model }: RowItemMenuProps) {
const styles = useStyles2(getStyles);
return (
<ToolbarButtonRow className={styles.container}>
<Dropdown
placement="bottom-end"
overlay={() => (
<Menu>
<Menu.Item
ariaLabel={t('dashboard.rows-layout.row.menu.add-panel', 'Panel')}
label={t('dashboard.rows-layout.row.menu.add-panel', 'Panel')}
onClick={() => model.onAddPanel()}
/>
<Menu.Divider />
<Menu.Item
ariaLabel={t('dashboard.rows-layout.row.menu.add-row-above', 'Row above')}
label={t('dashboard.rows-layout.row.menu.add-row-above', 'Row above')}
onClick={() => model.onAddRowAbove()}
/>
<Menu.Item
ariaLabel={t('dashboard.rows-layout.row.menu.add-row-below', 'Row below')}
label={t('dashboard.rows-layout.row.menu.add-row-below', 'Row below')}
onClick={() => model.onAddRowBelow()}
/>
</Menu>
)}
>
<ToolbarButton
aria-label={t('dashboard.rows-layout.row.menu.add', 'Add row')}
title={t('dashboard.rows-layout.row.menu.add', 'Add row')}
tooltip={t('dashboard.rows-layout.row.menu.add', 'Add row')}
icon="plus"
iconSize="sm"
variant="default"
>
<Trans i18nKey="grafana-ui.tags-input.add">Add</Trans>
</ToolbarButton>
</Dropdown>
<Dropdown
placement="bottom-end"
overlay={() => (
<Menu>
<Menu.Item
aria-label={t('dashboard.rows-layout.row.menu.move-up', 'Move row up')}
label={t('dashboard.rows-layout.row.menu.move-up', 'Move row up')}
onClick={() => model.onMoveUp()}
disabled={model.isFirstRow()}
/>
<Menu.Divider />
<Menu.Item
aria-label={t('dashboard.rows-layout.row.menu.move-down', 'Move row down')}
label={t('dashboard.rows-layout.row.menu.move-down', 'Move row down')}
onClick={() => model.onMoveDown()}
disabled={model.isLastRow()}
/>
</Menu>
)}
>
<ToolbarButton
aria-label={t('dashboard.rows-layout.row.menu.move-row', 'Move row')}
title={t('dashboard.rows-layout.row.menu.move-row', 'Move row')}
tooltip={t('dashboard.rows-layout.row.menu.move-row', 'Move row')}
icon="arrows-v"
iconSize="md"
variant="default"
/>
</Dropdown>
</ToolbarButtonRow>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
alignItems: 'center',
flex: 1,
justifyContent: 'flex-end',
gap: theme.spacing(1),
}),
});
@@ -4,13 +4,14 @@ import { useMemo, useRef } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { Button, Icon, useElementSelection, useStyles2 } from '@grafana/ui';
import { Checkbox, clearButtonStyles, Icon, useElementSelection, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { isClonedKey } from '../../utils/clone';
import { getDashboardSceneFor } from '../../utils/utils';
import { RowItem } from './RowItem';
import { RowItemMenu } from './RowItemMenu';
export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
const { layout, title, isCollapsed, height = 'expand', isHeaderHidden, key } = model.useState();
@@ -18,6 +19,7 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
const dashboard = getDashboardSceneFor(model);
const { isEditing, showHiddenElements } = dashboard.useState();
const styles = useStyles2(getStyles);
const clearStyles = useStyles2(clearButtonStyles);
const titleInterpolated = sceneGraph.interpolate(model, title, undefined, 'text');
const ref = useRef<HTMLDivElement>(null);
const shouldGrow = !isCollapsed && height === 'expand';
@@ -35,9 +37,14 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
>
{(!isHeaderHidden || (isEditing && showHiddenElements)) && (
<div className={styles.rowHeader}>
{!isClone && isEditing && (
<div className={styles.checkboxWrapper} onPointerDown={onSelect}>
<Checkbox value={!!isSelected} />
</div>
)}
<button
onClick={() => model.onCollapseToggle()}
className={styles.rowTitleButton}
className={cx(clearStyles, styles.rowTitleButton)}
aria-label={
isCollapsed
? t('dashboard.rows-layout.row.expand', 'Expand row')
@@ -50,9 +57,7 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
{titleInterpolated}
</span>
</button>
{!isClone && isEditing && (
<Button icon="pen" variant="secondary" size="sm" fill="text" onPointerDown={(evt) => onSelect?.(evt)} />
)}
{!isClone && isEditing && <RowItemMenu model={model} />}
</div>
)}
{!isCollapsed && <layout.Component model={layout} />}
@@ -69,17 +74,6 @@ function getStyles(theme: GrafanaTheme2) {
padding: theme.spacing(0, 0, 0.5, 0),
margin: theme.spacing(0, 0, 1, 0),
alignItems: 'center',
'&:hover, &:focus-within': {
'& > div': {
opacity: 1,
},
},
'& > div': {
marginBottom: 0,
marginRight: theme.spacing(1),
},
}),
rowTitleButton: css({
display: 'flex',
@@ -118,5 +112,9 @@ function getStyles(theme: GrafanaTheme2) {
display: 'flex',
opacity: 0,
}),
checkboxWrapper: css({
display: 'flex',
alignItems: 'center',
}),
};
}
@@ -1,4 +1,3 @@
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
import { t } from 'app/core/internationalization';
@@ -8,7 +7,7 @@ import { EditableDashboardElementInfo } from '../types/EditableDashboardElement'
import { MultiSelectedEditableDashboardElement } from '../types/MultiSelectedEditableDashboardElement';
import { RowItem } from './RowItem';
import { getEditOptions, renderActions } from './RowItemsEditor';
import { getEditOptions } from './RowItemsEditor';
export class RowItems implements MultiSelectedEditableDashboardElement {
public readonly isMultiSelectedEditableDashboardElement = true;
@@ -26,10 +25,6 @@ export class RowItems implements MultiSelectedEditableDashboardElement {
return getEditOptions(this);
}
public renderActions(): ReactNode {
return renderActions(this);
}
public getRows(): RowItem[] {
return this._rows;
}
@@ -41,4 +36,6 @@ export class RowItems implements MultiSelectedEditableDashboardElement {
public onHeaderHiddenToggle(value: boolean, indeterminate: boolean) {
this._rows.forEach((row) => row.onHeaderHiddenToggle(indeterminate ? true : !value));
}
public getNumberOfRowsSelected = () => this._rows.length;
}
@@ -1,15 +1,25 @@
import { Button, Checkbox, Stack, Text } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { Checkbox } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { EditPaneHeader } from '../../edit-pane/EditPaneHeader';
import { RowItems } from './RowItems';
export function getEditOptions(model: RowItems): OptionsPaneCategoryDescriptor[] {
const options = new OptionsPaneCategoryDescriptor({
title: t('dashboard.edit-pane.row.multi-select.options-header', 'Multi-selected Row options'),
title: '',
id: `ms-row-options-${model.key}`,
isOpenDefault: true,
isOpenable: false,
renderTitle: () => (
<EditPaneHeader
title={t('dashboard.edit-pane.row.multi-select.title', '{{length}} rows selected', {
length: model.getNumberOfRowsSelected(),
})}
onDelete={() => model.onDelete()}
/>
),
}).addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.row.header.title', 'Row header'),
@@ -20,24 +30,6 @@ export function getEditOptions(model: RowItems): OptionsPaneCategoryDescriptor[]
return [options];
}
export function renderActions(model: RowItems) {
const rows = model.getRows();
return (
<Stack direction="column">
<Text>
<Trans i18nKey="dashboard.edit-pane.row.multi-select.selection-number" values={{ length: rows.length }}>
No. of rows selected: {{ length }}
</Trans>
</Text>
<Stack direction="row">
<Button size="sm" variant="secondary" icon="copy" />
<Button size="sm" variant="destructive" fill="outline" onClick={() => model.onDelete()} icon="trash-alt" />
</Stack>
</Stack>
);
}
function RowHeaderCheckboxMulti({ model }: { model: RowItems }) {
const rows = model.getRows();
@@ -41,17 +41,17 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
// Try to add new panels to the selected row
const selectedRows = dashboardSceneGraph.getAllSelectedObjects(this).filter((obj) => obj instanceof RowItem);
if (selectedRows.length > 0) {
return selectedRows.forEach((row) => row.getLayout().addPanel(vizPanel));
return selectedRows.forEach((row) => row.onAddPanel(vizPanel));
}
// If we don't have selected row add it to the first row
if (this.state.rows.length > 0) {
return this.state.rows[0].getLayout().addPanel(vizPanel);
return this.state.rows[0].onAddPanel(vizPanel);
}
// Otherwise fallback to adding a new row and a panel
this.addNewRow();
this.state.rows[this.state.rows.length - 1].getLayout().addPanel(vizPanel);
this.state.rows[this.state.rows.length - 1].onAddPanel(vizPanel);
}
public getVizPanels(): VizPanel[] {
@@ -103,11 +103,81 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
});
}
public addRowAbove(row: RowItem) {
const rows = this.state.rows;
const index = rows.indexOf(row);
rows.splice(index, 0, new RowItem());
this.setState({ rows });
}
public addRowBelow(row: RowItem) {
const rows = this.state.rows;
let index = rows.indexOf(row);
// Be sure we don't add a row between an original row and one of its clones
while (rows[index + 1] && isClonedKey(rows[index + 1].state.key!)) {
index = index + 1;
}
rows.splice(index + 1, 0, new RowItem());
this.setState({ rows });
}
public removeRow(row: RowItem) {
const rows = this.state.rows.filter((r) => r !== row);
this.setState({ rows: rows.length === 0 ? [new RowItem()] : rows });
}
public moveRowUp(row: RowItem) {
const rows = [...this.state.rows];
const originalIndex = rows.indexOf(row);
if (originalIndex === 0) {
return;
}
let moveToIndex = originalIndex - 1;
// Be sure we don't add a row between an original row and one of its clones
while (rows[moveToIndex] && isClonedKey(rows[moveToIndex].state.key!)) {
moveToIndex = moveToIndex - 1;
}
rows.splice(originalIndex, 1);
rows.splice(moveToIndex, 0, row);
this.setState({ rows });
}
public moveRowDown(row: RowItem) {
const rows = [...this.state.rows];
const originalIndex = rows.indexOf(row);
if (originalIndex === rows.length - 1) {
return;
}
let moveToIndex = originalIndex + 1;
// Be sure we don't add a row between an original row and one of its clones
while (rows[moveToIndex] && isClonedKey(rows[moveToIndex].state.key!)) {
moveToIndex = moveToIndex + 1;
}
rows.splice(moveToIndex + 1, 0, row);
rows.splice(originalIndex, 1);
this.setState({ rows });
}
public isFirstRow(row: RowItem): boolean {
return this.state.rows[0] === row;
}
public isLastRow(row: RowItem): boolean {
const filteredRow = this.state.rows.filter((r) => !isClonedKey(r.state.key!));
return filteredRow[filteredRow.length - 1] === row;
}
public static createEmpty(): RowsLayoutManager {
return new RowsLayoutManager({ rows: [new RowItem()] });
}
+14 -10
View File
@@ -1084,8 +1084,7 @@
"title": "Row header"
},
"multi-select": {
"options-header": "Multi-selected Row options",
"selection-number": "No. of rows selected: {{length}}"
"title": "{{length}} rows selected"
}
}
},
@@ -1167,7 +1166,7 @@
"delete": "Delete",
"duplicate": "Duplicate",
"layout": "Layout",
"panels-title": "{{length}} Panels Selected"
"panels-title": "{{length}} panels selected"
}
},
"options": {
@@ -1219,6 +1218,15 @@
"row": {
"collapse": "Collapse row",
"expand": "Expand row",
"menu": {
"add": "Add row",
"add-panel": "Panel",
"add-row-above": "Row above",
"add-row-below": "Row below",
"move-down": "Move row down",
"move-row": "Move row",
"move-up": "Move row up"
},
"new": "New row",
"repeat": {
"learn-more": "Learn more",
@@ -1227,18 +1235,14 @@
},
"row-options": {
"height": {
"expand": "Expand",
"hide-row-header": "Hide row header",
"min": "Min",
"title": "Height"
"hide-row-header": "Hide row header"
},
"repeat": {
"title": "Repeat options",
"variable": {
"title": "Variable"
"title": "Repeat for"
}
},
"title": "Row options",
"title": "Row",
"title-option": "Title"
}
},
+14 -10
View File
@@ -1084,8 +1084,7 @@
"title": "Ŗőŵ ĥęäđęř"
},
"multi-select": {
"options-header": "Mūľŧį-şęľęčŧęđ Ŗőŵ őpŧįőʼnş",
"selection-number": "Ńő. őƒ řőŵş şęľęčŧęđ: {{length}}"
"title": "{{length}} řőŵş şęľęčŧęđ"
}
}
},
@@ -1167,7 +1166,7 @@
"delete": "Đęľęŧę",
"duplicate": "Đūpľįčäŧę",
"layout": "Ŀäyőūŧ",
"panels-title": "{{length}} Päʼnęľş Ŝęľęčŧęđ"
"panels-title": "{{length}} päʼnęľş şęľęčŧęđ"
}
},
"options": {
@@ -1219,6 +1218,15 @@
"row": {
"collapse": "Cőľľäpşę řőŵ",
"expand": "Ēχpäʼnđ řőŵ",
"menu": {
"add": "Åđđ řőŵ",
"add-panel": "Päʼnęľ",
"add-row-above": "Ŗőŵ äþővę",
"add-row-below": "Ŗőŵ þęľőŵ",
"move-down": "Mővę řőŵ đőŵʼn",
"move-row": "Mővę řőŵ",
"move-up": "Mővę řőŵ ūp"
},
"new": "Ńęŵ řőŵ",
"repeat": {
"learn-more": "Ŀęäřʼn mőřę",
@@ -1227,18 +1235,14 @@
},
"row-options": {
"height": {
"expand": "Ēχpäʼnđ",
"hide-row-header": "Ħįđę řőŵ ĥęäđęř",
"min": "Mįʼn",
"title": "Ħęįģĥŧ"
"hide-row-header": "Ħįđę řőŵ ĥęäđęř"
},
"repeat": {
"title": "Ŗępęäŧ őpŧįőʼnş",
"variable": {
"title": "Väřįäþľę"
"title": "Ŗępęäŧ ƒőř"
}
},
"title": "Ŗőŵ őpŧįőʼnş",
"title": "Ŗőŵ",
"title-option": "Ŧįŧľę"
}
},