A dashboard laid out for a desktop screen is too wide for a phone. In view mode, Studio can lay the same widgets out differently as the canvas gets narrower. Use the responsiveLayoutBreakpoints property to define those layouts.
Single Column Layout Copy Link
The simplest mobile view stacks every widget in a single column. The example below starts out responsive, so on a small screen it already shows the single column layout. Click Mobile or Desktop to fix the canvas at that width, and Reset to return to the responsive layout.
import type {
AgDataSourcesDefinition,
AgExpressionFieldDefinition,
AgFieldDefinition,
AgPageLayoutState,
AgPanelConfig,
AgReportState,
AgResponsiveLayoutBreakpoint,
AgStudioTheme,
} from "ag-studio";
import {
enableStudioDevValidations,
singleColumnLayout,
studioTheme,
} from "ag-studio";
import { AgStudio } from "ag-studio-vue3";
import { createApp, defineComponent, ref, shallowRef } from "vue";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const BASE_URL = "https://www.ag-grid.com/studio/example-assets/main-demo";
async function loadJson(filename: string): Promise<any[]> {
const response = await fetch(`${BASE_URL}/${filename}`);
if (!response.ok) {
console.error(`Failed to load ${filename}: ${response.status}`);
return [];
}
return response.json();
}
const productsFields: AgFieldDefinition[] = [
{ id: "product_id", name: "Product ID", format: "textFormat" },
{ id: "product_name", name: "Product", format: "textFormat" },
{ id: "category", name: "Category", format: "textFormat" },
{ id: "subcategory", name: "Subcategory", format: "textFormat" },
{ id: "brand", name: "Brand", format: "textFormat" },
{ id: "launch_date", name: "Launch Date", format: "dateFormat" },
{ id: "list_price", name: "List Price", format: "currencyFormat" },
{ id: "unit_cost", name: "Unit Cost", format: "currencyFormat" },
{ id: "is_discontinued", name: "Discontinued", format: "booleanFormat" },
];
const orderItemsFields: AgFieldDefinition[] = [
{ id: "order_item_id", name: "Order Item ID", format: "textFormat" },
{ id: "order_id", name: "Order ID", format: "textFormat" },
{ id: "product_id", name: "Product ID", format: "textFormat" },
{ id: "quantity", name: "Qty", format: "integerFormat" },
{ id: "unit_price", name: "Unit Price", format: "currencyFormat" },
{ id: "discount_pct", name: "Discount", format: "percentageFormat" },
{ id: "tax_rate", name: "Tax Rate", format: "percentageFormat" },
{ id: "returned", name: "Returned", format: "booleanFormat" },
{ id: "return_reason", name: "Return Reason", format: "textFormat" },
];
const customersFields: AgFieldDefinition[] = [
{ id: "customer_id", name: "Customer ID", format: "textFormat" },
{ id: "customer_name", name: "Customer", format: "textFormat" },
{ id: "region", name: "Region", format: "textFormat" },
{ id: "segment", name: "Segment", format: "textFormat" },
{ id: "industry", name: "Industry", format: "textFormat" },
];
const ordersFields: AgFieldDefinition[] = [
{
id: "order_id",
name: "Order ID",
format: "textFormat",
cardinality: "high",
notBlank: true,
},
{ id: "customer_id", name: "Customer ID", format: "textFormat" },
{ id: "order_datetime", name: "Order Date/Time", format: "dateTimeFormat" },
{ id: "channel", name: "Channel", format: "textFormat" },
{ id: "status", name: "Status", format: "textFormat" },
{ id: "payment_method", name: "Payment Method", format: "textFormat" },
{ id: "currency", name: "Currency", format: "textFormat" },
];
const expressions: AgExpressionFieldDefinition[] = [
{
id: "line_gross",
name: "Line Gross",
isMeasure: false,
format: "currencyFormat",
formatOptions: { format: "$#,##0.0,K" },
expression: {
operator: "multiply",
inputs: [
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
],
},
},
{
id: "margin",
name: "Margin",
isMeasure: false,
format: "currencyFormat",
expression: {
operator: "subtract",
inputs: [{ id: "products.list_price" }, { id: "order_items.unit_price" }],
},
},
{
id: "line_net",
name: "Line Net",
isMeasure: false,
format: "currencyFormat",
expression: {
operator: "subtract",
inputs: [
{
operator: "multiply",
inputs: [
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
],
},
{
operator: "multiply",
inputs: [
{
operator: "multiply",
inputs: [
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
],
},
{ id: "order_items.discount_pct" },
],
},
],
},
},
];
const initialState: AgReportState = {
pages: [
{
id: "overview",
widgets: {
"kpi-gross-sales": {
type: "value",
dataMapping: { value: [{ id: "line_gross", aggregation: "sum" }] },
format: { style: { label: { enabled: true, text: "Gross Sales" } } },
},
"kpi-order-count": {
type: "value",
dataMapping: {
value: [{ id: "orders.order_id", aggregation: "countd" }],
},
format: { style: { label: { enabled: true, text: "Order Count" } } },
},
"kpi-avg-qty": {
type: "value",
dataMapping: {
value: [{ id: "order_items.quantity", aggregation: "avg" }],
},
format: {
style: { label: { enabled: true, text: "Avg Qty per Line" } },
},
},
"sales-by-category": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "products.subcategory" }],
valueKey: [{ id: "line_gross", aggregation: "sum" }],
},
sort: [
{
field: { id: "line_gross", aggregation: "sum" },
direction: "desc",
},
],
format: {
title: { enabled: true, text: "Gross Sales by Subcategory" },
},
},
"sales-by-brand": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "products.brand" }],
valueKey: [{ id: "line_gross", aggregation: "sum" }],
},
sort: [
{
field: { id: "line_gross", aggregation: "sum" },
direction: "desc",
},
],
format: { title: { enabled: true, text: "Gross Sales by Brand" } },
},
"top-products": {
type: "grid",
dataMapping: {
cols: [
{ id: "products.product_name" },
{ id: "products.category" },
{ id: "order_items.quantity", aggregation: "sum" },
{ id: "line_gross", aggregation: "sum" },
{ id: "margin", aggregation: "avg" },
],
},
sort: [
{
field: { id: "line_gross", aggregation: "sum" },
direction: "desc",
},
],
format: { title: { enabled: true, text: "Top Products" } },
},
},
widgetLayout: {
"kpi-gross-sales": { xTrack: 0, yTrack: 0, xSpan: 8, ySpan: 6 },
"kpi-order-count": { xTrack: 8, yTrack: 0, xSpan: 8, ySpan: 6 },
"kpi-avg-qty": { xTrack: 16, yTrack: 0, xSpan: 8, ySpan: 6 },
"sales-by-category": { xTrack: 0, yTrack: 6, xSpan: 12, ySpan: 16 },
"sales-by-brand": { xTrack: 12, yTrack: 6, xSpan: 12, ySpan: 16 },
"top-products": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
},
},
{
id: "detail",
widgets: {
"subcategory-filter": {
type: "list-filter",
dataMapping: {
value: [{ id: "products.subcategory" }],
},
format: {
title: { enabled: true, text: "Subcategory" },
},
},
"order-grid": {
type: "grid",
dataMapping: {
cols: [
{ id: "products.product_name" },
{ id: "products.subcategory" },
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
{ id: "line_gross" },
{ id: "line_net" },
],
},
format: {
title: { enabled: true, text: "Order Details" },
},
},
},
widgetLayout: {
"subcategory-filter": { xTrack: 0, yTrack: 0, xSpan: 6, ySpan: 32 },
"order-grid": { xTrack: 6, yTrack: 0, xSpan: 18, ySpan: 32 },
},
},
{
id: "customers",
widgets: {
"kpi-unique-customers": {
type: "value",
dataMapping: {
value: [{ id: "customers.customer_id", aggregation: "countd" }],
},
format: {
style: { label: { enabled: true, text: "Unique Customers" } },
},
},
"revenue-by-region": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "customers.region" }],
valueKey: [{ id: "line_net", aggregation: "sum" }],
},
sort: [
{
field: { id: "line_net", aggregation: "sum" },
direction: "desc",
},
],
format: { title: { enabled: true, text: "Revenue by Region" } },
},
"customer-grid": {
type: "grid",
dataMapping: {
cols: [
{ id: "customers.customer_name" },
{ id: "customers.region" },
{ id: "customers.segment" },
{ id: "customers.industry" },
{ id: "orders.order_id", aggregation: "countd" },
{ id: "line_net", aggregation: "sum" },
],
},
sort: [
{
field: { id: "line_net", aggregation: "sum" },
direction: "desc",
},
],
format: { title: { enabled: true, text: "Customer Details" } },
},
},
widgetLayout: {
"kpi-unique-customers": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 6 },
"revenue-by-region": { xTrack: 0, yTrack: 6, xSpan: 24, ySpan: 16 },
"customer-grid": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
},
},
],
selectedPageId: "overview",
panels: {
filters: {
collapsed: true,
},
edit: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
function loadData(): Promise<AgDataSourcesDefinition> {
return Promise.all([
loadJson("products.json"),
loadJson("order_items.json"),
loadJson("customers.json"),
loadJson("orders.json"),
]).then(([productData, orderItemData, customerData, orderData]) => ({
sources: [
{
id: "products",
name: "Products",
data: productData,
fields: productsFields,
},
{
id: "order_items",
name: "Order Items",
data: orderItemData,
fields: orderItemsFields,
},
{
id: "customers",
name: "Customers",
data: customerData,
fields: customersFields,
},
{ id: "orders", name: "Orders", data: orderData, fields: ordersFields },
],
relationships: [
{
id: "order-item-product",
source: "order_items.product_id",
target: "products.product_id",
type: "many-to-one",
acceptFanout: true,
},
{
id: "order-item-order",
source: "order_items.order_id",
target: "orders.order_id",
type: "many-to-one",
},
{
id: "order-customer",
source: "orders.customer_id",
target: "customers.customer_id",
type: "many-to-one",
},
],
expressions,
}));
}
const PAGE_MIN_WIDTH = 250;
const MOBILE_MAX_WIDTH = 500;
const singleColumnBreakpoint: AgResponsiveLayoutBreakpoint = {
maxWidth: MOBILE_MAX_WIDTH,
layout: singleColumnLayout,
};
type LayoutMode = "Responsive" | "Desktop" | "Mobile";
// Canvas width forced by each mode. Responsive follows the width of the screen.
const LAYOUT_MODE_WIDTHS: Record<LayoutMode, number | undefined> = {
Responsive: undefined,
Desktop: 1024,
Mobile: 375,
};
function setStudioWidth(width: number | undefined) {
const studioDiv = document.querySelector<HTMLElement>(
".ag-studio-layout-canvas",
)!;
studioDiv.style.width = width != null ? `${width}px` : "";
}
const VueExample = defineComponent({
template: `
<div style="display: flex; flex-direction: column; height: 100%">
<div class="example-controls">
<div class="controls-row">
<button :aria-pressed="layoutMode === 'Desktop'" v-on:click="showLayout('Desktop')">Desktop</button>
<button :aria-pressed="layoutMode === 'Mobile'" v-on:click="showLayout('Mobile')">Mobile</button>
<button v-on:click="showLayout('Responsive')">Reset</button>
<span>Layout: {{ layoutMode }}</span>
</div>
</div>
<ag-studio
style="height: 100%; width: 100%"
class="my-studio-container"
mode="view"
:initialState="initialState"
:data="data"
:theme="theme"
:layout="pageLayout"
:responsiveLayoutBreakpoints="breakpoints"
:panels="panels"></ag-studio>
</div>
`,
components: {
"ag-studio": AgStudio,
},
setup() {
const theme: AgStudioTheme = studioTheme.withParams({ studioMinWidth: 0 });
const pageLayout: Partial<AgPageLayoutState> = { minWidth: PAGE_MIN_WIDTH };
const breakpoints: AgResponsiveLayoutBreakpoint[] = [
singleColumnBreakpoint,
];
const panels: AgPanelConfig = {
view: {
left: [],
right: [],
},
};
const layoutMode = ref<LayoutMode>("Responsive");
const showLayout = (mode: LayoutMode) => {
layoutMode.value = mode;
setStudioWidth(LAYOUT_MODE_WIDTHS[mode]);
};
const data = shallowRef<AgDataSourcesDefinition>();
loadData().then((loaded) => (data.value = loaded));
return {
initialState,
theme,
pageLayout,
breakpoints,
panels,
layoutMode,
showLayout,
data,
};
},
});
const app = createApp(VueExample);
app.mount("#app");
Import the singleColumnLayout function from ag-studio to convert a desktop layout into a single column:
- A heuristic orders the widgets by their visual position within the desktop layout.
- Each widget spans the full width of the canvas.
- Each widget keeps its desktop height.
<ag-studio
:mode="mode"
:theme="theme"
:layout="layout"
:responsiveLayoutBreakpoints="responsiveLayoutBreakpoints"
/* other studio properties ... */>
</ag-studio>
this.mode = 'view';
this.theme = studioTheme.withParams({ studioMinWidth: 0 });
this.layout = {
minWidth: 250
};
this.responsiveLayoutBreakpoints = [
{
maxWidth: 500,
layout: singleColumnLayout,
},
];Two minimum widths limit how narrow things can get, and a mobile layout needs both lowered, as in the example above.
The outer Studio component has its own Min Width, which defaults to 600px and limits how narrow Studio itself can be. The page's Min Width limits how narrow the canvas inside it can be drawn, and a breakpoint below that width is never used.
Breakpoints Copy Link
Each breakpoint covers a range of canvas widths:
minWidthis the narrowest canvas width the breakpoint applies to. Omit it to leave the range open below.maxWidthis the widest canvas width the breakpoint applies to. Omit it to leave the range open above.layoutis a function that returns the position and size of each widget for that range.
Studio uses the first breakpoint whose range contains the canvas width, so list narrower ranges before wider ones that also contain them. Where two ranges meet on the same pixel, the breakpoint listed first takes it.
Canvas widths that no breakpoint covers keep the desktop layout, so a range that should use it needs no breakpoint at all. Omitting layout does the same for a range you declare anyway, which is how to stop a later, wider breakpoint from covering it.
The canvas is never drawn narrower than the page's Min Width, so lowering that minimum is what brings a narrow breakpoint into range. Cover the widths it opens up: any width above the page minimum that no breakpoint covers renders the desktop layout at that width, however narrow it is.
Breakpoints that lay widgets out differently on narrower canvases while in view mode.
The first breakpoint whose range contains the canvas width is the one used, so list narrower ranges
before wider ones that also contain them. Either bound can be omitted to leave that end of the range
open, and canvas widths no breakpoint covers keep the layout the dashboard was authored with.
The canvas is never drawn narrower than the page's own minimum width, so a breakpoint below that
width is never used. Lower the page's minimum width to bring it into range. |
The layout function is called with two arguments:
- A list of widgets, each with its desktop
layout(xTrack,yTrack,xSpanandySpan) and itsminSize(xSpanandySpan). The minimum size is converted to columns and rows at the narrowest canvas width that reaches the breakpoint, so one layout holds across the whole range. - The layout of the page at that width, including the number of
columns, androwsif there is a height constraint.
The function returns one layout for each widget, in the same order as the list it was given. Positions and sizes are in columns and rows, as in the desktop layout.
Custom Layout Copy Link
Write your own layout function for anything other than a single column. In the example below, Mobile uses a single column, Tablet uses two columns, Desktop uses the desktop layout and Ultra Wide uses four columns. As in the previous example, Studio starts out responsive and Reset returns to that.
import type {
AgDataSourcesDefinition,
AgExpressionFieldDefinition,
AgFieldDefinition,
AgPageLayoutState,
AgPanelConfig,
AgReportState,
AgResponsiveLayoutBreakpoint,
AgResponsiveLayoutCandidate,
AgResponsiveLayoutState,
AgStudioTheme,
AgWidgetLayoutState,
} from "ag-studio";
import {
enableStudioDevValidations,
singleColumnLayout,
studioTheme,
} from "ag-studio";
import { AgStudio } from "ag-studio-vue3";
import { createApp, defineComponent, ref, shallowRef } from "vue";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const BASE_URL = "https://www.ag-grid.com/studio/example-assets/main-demo";
async function loadJson(filename: string): Promise<any[]> {
const response = await fetch(`${BASE_URL}/${filename}`);
if (!response.ok) {
console.error(`Failed to load ${filename}: ${response.status}`);
return [];
}
return response.json();
}
const productsFields: AgFieldDefinition[] = [
{ id: "product_id", name: "Product ID", format: "textFormat" },
{ id: "product_name", name: "Product", format: "textFormat" },
{ id: "category", name: "Category", format: "textFormat" },
{ id: "subcategory", name: "Subcategory", format: "textFormat" },
{ id: "brand", name: "Brand", format: "textFormat" },
{ id: "launch_date", name: "Launch Date", format: "dateFormat" },
{ id: "list_price", name: "List Price", format: "currencyFormat" },
{ id: "unit_cost", name: "Unit Cost", format: "currencyFormat" },
{ id: "is_discontinued", name: "Discontinued", format: "booleanFormat" },
];
const orderItemsFields: AgFieldDefinition[] = [
{ id: "order_item_id", name: "Order Item ID", format: "textFormat" },
{ id: "order_id", name: "Order ID", format: "textFormat" },
{ id: "product_id", name: "Product ID", format: "textFormat" },
{ id: "quantity", name: "Qty", format: "integerFormat" },
{ id: "unit_price", name: "Unit Price", format: "currencyFormat" },
{ id: "discount_pct", name: "Discount", format: "percentageFormat" },
{ id: "tax_rate", name: "Tax Rate", format: "percentageFormat" },
{ id: "returned", name: "Returned", format: "booleanFormat" },
{ id: "return_reason", name: "Return Reason", format: "textFormat" },
];
const customersFields: AgFieldDefinition[] = [
{ id: "customer_id", name: "Customer ID", format: "textFormat" },
{ id: "customer_name", name: "Customer", format: "textFormat" },
{ id: "region", name: "Region", format: "textFormat" },
{ id: "segment", name: "Segment", format: "textFormat" },
{ id: "industry", name: "Industry", format: "textFormat" },
];
const ordersFields: AgFieldDefinition[] = [
{
id: "order_id",
name: "Order ID",
format: "textFormat",
cardinality: "high",
notBlank: true,
},
{ id: "customer_id", name: "Customer ID", format: "textFormat" },
{ id: "order_datetime", name: "Order Date/Time", format: "dateTimeFormat" },
{ id: "channel", name: "Channel", format: "textFormat" },
{ id: "status", name: "Status", format: "textFormat" },
{ id: "payment_method", name: "Payment Method", format: "textFormat" },
{ id: "currency", name: "Currency", format: "textFormat" },
];
const expressions: AgExpressionFieldDefinition[] = [
{
id: "line_gross",
name: "Line Gross",
isMeasure: false,
format: "currencyFormat",
formatOptions: { format: "$#,##0.0,K" },
expression: {
operator: "multiply",
inputs: [
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
],
},
},
{
id: "margin",
name: "Margin",
isMeasure: false,
format: "currencyFormat",
expression: {
operator: "subtract",
inputs: [{ id: "products.list_price" }, { id: "order_items.unit_price" }],
},
},
{
id: "line_net",
name: "Line Net",
isMeasure: false,
format: "currencyFormat",
expression: {
operator: "subtract",
inputs: [
{
operator: "multiply",
inputs: [
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
],
},
{
operator: "multiply",
inputs: [
{
operator: "multiply",
inputs: [
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
],
},
{ id: "order_items.discount_pct" },
],
},
],
},
},
];
const initialState: AgReportState = {
pages: [
{
id: "overview",
widgets: {
"kpi-gross-sales": {
type: "value",
dataMapping: { value: [{ id: "line_gross", aggregation: "sum" }] },
format: { style: { label: { enabled: true, text: "Gross Sales" } } },
},
"kpi-order-count": {
type: "value",
dataMapping: {
value: [{ id: "orders.order_id", aggregation: "countd" }],
},
format: { style: { label: { enabled: true, text: "Order Count" } } },
},
"kpi-avg-qty": {
type: "value",
dataMapping: {
value: [{ id: "order_items.quantity", aggregation: "avg" }],
},
format: {
style: { label: { enabled: true, text: "Avg Qty per Line" } },
},
},
"kpi-net-sales": {
type: "value",
dataMapping: { value: [{ id: "line_net", aggregation: "sum" }] },
format: { style: { label: { enabled: true, text: "Net Sales" } } },
},
"kpi-avg-discount": {
type: "value",
dataMapping: {
value: [{ id: "order_items.discount_pct", aggregation: "avg" }],
},
format: { style: { label: { enabled: true, text: "Avg Discount" } } },
},
"kpi-avg-unit-price": {
type: "value",
dataMapping: {
value: [{ id: "order_items.unit_price", aggregation: "avg" }],
},
format: {
style: { label: { enabled: true, text: "Avg Unit Price" } },
},
},
"sales-by-category": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "products.subcategory" }],
valueKey: [{ id: "line_gross", aggregation: "sum" }],
},
sort: [
{
field: { id: "line_gross", aggregation: "sum" },
direction: "desc",
},
],
format: {
title: { enabled: true, text: "Gross Sales by Subcategory" },
},
},
"sales-by-brand": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "products.brand" }],
valueKey: [{ id: "line_gross", aggregation: "sum" }],
},
sort: [
{
field: { id: "line_gross", aggregation: "sum" },
direction: "desc",
},
],
format: { title: { enabled: true, text: "Gross Sales by Brand" } },
},
"top-products": {
type: "grid",
dataMapping: {
cols: [
{ id: "products.product_name" },
{ id: "products.category" },
{ id: "order_items.quantity", aggregation: "sum" },
{ id: "line_gross", aggregation: "sum" },
{ id: "margin", aggregation: "avg" },
],
},
sort: [
{
field: { id: "line_gross", aggregation: "sum" },
direction: "desc",
},
],
format: { title: { enabled: true, text: "Top Products" } },
},
},
widgetLayout: {
"kpi-gross-sales": { xTrack: 0, yTrack: 0, xSpan: 8, ySpan: 6 },
"kpi-order-count": { xTrack: 8, yTrack: 0, xSpan: 8, ySpan: 6 },
"kpi-avg-qty": { xTrack: 16, yTrack: 0, xSpan: 8, ySpan: 6 },
"kpi-net-sales": { xTrack: 0, yTrack: 6, xSpan: 8, ySpan: 6 },
"kpi-avg-discount": { xTrack: 8, yTrack: 6, xSpan: 8, ySpan: 6 },
"kpi-avg-unit-price": { xTrack: 16, yTrack: 6, xSpan: 8, ySpan: 6 },
"sales-by-category": { xTrack: 0, yTrack: 12, xSpan: 12, ySpan: 16 },
"sales-by-brand": { xTrack: 12, yTrack: 12, xSpan: 12, ySpan: 16 },
"top-products": { xTrack: 0, yTrack: 28, xSpan: 24, ySpan: 16 },
},
},
{
id: "detail",
widgets: {
"subcategory-filter": {
type: "list-filter",
dataMapping: {
value: [{ id: "products.subcategory" }],
},
format: {
title: { enabled: true, text: "Subcategory" },
},
},
"order-grid": {
type: "grid",
dataMapping: {
cols: [
{ id: "products.product_name" },
{ id: "products.subcategory" },
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
{ id: "line_gross" },
{ id: "line_net" },
],
},
format: {
title: { enabled: true, text: "Order Details" },
},
},
},
widgetLayout: {
"subcategory-filter": { xTrack: 0, yTrack: 0, xSpan: 6, ySpan: 32 },
"order-grid": { xTrack: 6, yTrack: 0, xSpan: 18, ySpan: 32 },
},
},
{
id: "customers",
widgets: {
"kpi-unique-customers": {
type: "value",
dataMapping: {
value: [{ id: "customers.customer_id", aggregation: "countd" }],
},
format: {
style: { label: { enabled: true, text: "Unique Customers" } },
},
},
"revenue-by-region": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "customers.region" }],
valueKey: [{ id: "line_net", aggregation: "sum" }],
},
sort: [
{
field: { id: "line_net", aggregation: "sum" },
direction: "desc",
},
],
format: { title: { enabled: true, text: "Revenue by Region" } },
},
"customer-grid": {
type: "grid",
dataMapping: {
cols: [
{ id: "customers.customer_name" },
{ id: "customers.region" },
{ id: "customers.segment" },
{ id: "customers.industry" },
{ id: "orders.order_id", aggregation: "countd" },
{ id: "line_net", aggregation: "sum" },
],
},
sort: [
{
field: { id: "line_net", aggregation: "sum" },
direction: "desc",
},
],
format: { title: { enabled: true, text: "Customer Details" } },
},
},
widgetLayout: {
"kpi-unique-customers": { xTrack: 0, yTrack: 0, xSpan: 24, ySpan: 6 },
"revenue-by-region": { xTrack: 0, yTrack: 6, xSpan: 24, ySpan: 16 },
"customer-grid": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
},
},
],
selectedPageId: "overview",
panels: {
filters: {
collapsed: true,
},
edit: {
collapsed: true,
},
data: {
collapsed: true,
},
},
};
function loadData(): Promise<AgDataSourcesDefinition> {
return Promise.all([
loadJson("products.json"),
loadJson("order_items.json"),
loadJson("customers.json"),
loadJson("orders.json"),
]).then(([productData, orderItemData, customerData, orderData]) => ({
sources: [
{
id: "products",
name: "Products",
data: productData,
fields: productsFields,
},
{
id: "order_items",
name: "Order Items",
data: orderItemData,
fields: orderItemsFields,
},
{
id: "customers",
name: "Customers",
data: customerData,
fields: customersFields,
},
{ id: "orders", name: "Orders", data: orderData, fields: ordersFields },
],
relationships: [
{
id: "order-item-product",
source: "order_items.product_id",
target: "products.product_id",
type: "many-to-one",
acceptFanout: true,
},
{
id: "order-item-order",
source: "order_items.order_id",
target: "orders.order_id",
type: "many-to-one",
},
{
id: "order-customer",
source: "orders.customer_id",
target: "customers.customer_id",
type: "many-to-one",
},
],
expressions,
}));
}
function masonryLayout(
horizontalColumns: number,
layouts: AgResponsiveLayoutCandidate[],
state: AgResponsiveLayoutState,
): AgWidgetLayoutState[] {
const order = Array.from(layouts.keys()).sort((aIndex, bIndex) => {
const a = layouts[aIndex].layout;
const b = layouts[bIndex].layout;
return a.yTrack - b.yTrack || a.xTrack - b.xTrack;
});
const columnWidth = state.columns / horizontalColumns;
const result = new Array<AgWidgetLayoutState>(layouts.length);
const columnTrailingWidgets = new Array<AgWidgetLayoutState | null>(
horizontalColumns,
).fill(null);
for (const index of order) {
const { layout, minSize } = layouts[index];
const horizontalSpan = Math.max(
Math.round(layout.xSpan / columnWidth),
Math.ceil(minSize.xSpan / columnWidth),
);
let horizontalIndex = 0;
let yTrack: number | undefined;
for (let i = 0; i <= horizontalColumns - horizontalSpan; i += 1) {
const testYTrack = columnTrailingWidgets
.slice(i, i + horizontalSpan)
.reduce(
(max, widget) =>
Math.max(max, (widget?.yTrack ?? 0) + (widget?.ySpan ?? 0)),
0,
);
if (yTrack == null || testYTrack < yTrack) {
yTrack = testYTrack;
horizontalIndex = i;
}
}
const nextLayout: AgWidgetLayoutState = {
xTrack: horizontalIndex * columnWidth,
yTrack: yTrack ?? 0,
xSpan: horizontalSpan * columnWidth,
ySpan: layout.ySpan,
};
// Update the column trailing widgets with the new layout
for (
let i = horizontalIndex;
i < horizontalIndex + horizontalSpan;
i += 1
) {
columnTrailingWidgets[i] = nextLayout;
}
result[index] = nextLayout;
}
for (const layout of result) {
const otherLayouts = result.filter((other) => other !== layout);
// Extend down
const nextYTrack = otherLayouts
.filter(
(other) =>
overlapsHorizontally(layout, other) &&
other.yTrack >= layout.yTrack + layout.ySpan,
)
.reduce((min, other) => Math.min(min, other.yTrack), Infinity);
if (Number.isFinite(nextYTrack)) {
layout.ySpan = nextYTrack - layout.yTrack;
}
const { yTrack, ySpan } = layout;
// Extend left
while (
layout.xTrack > 0 &&
isAreaFree(
otherLayouts,
layout.xTrack - columnWidth,
columnWidth,
yTrack,
ySpan,
)
) {
layout.xTrack -= columnWidth;
layout.xSpan += columnWidth;
}
// Extend right
while (
layout.xTrack + layout.xSpan < state.columns &&
isAreaFree(
otherLayouts,
layout.xTrack + layout.xSpan,
columnWidth,
yTrack,
ySpan,
)
) {
layout.xSpan += columnWidth;
}
}
return result;
}
function overlapsHorizontally(
a: AgWidgetLayoutState,
b: AgWidgetLayoutState,
): boolean {
return a.xTrack < b.xTrack + b.xSpan && b.xTrack < a.xTrack + a.xSpan;
}
function isAreaFree(
layouts: AgWidgetLayoutState[],
xTrack: number,
xSpan: number,
yTrack: number,
ySpan: number,
): boolean {
return layouts.every((other) => {
return (
other.xTrack + other.xSpan <= xTrack ||
other.xTrack >= xTrack + xSpan ||
other.yTrack + other.ySpan <= yTrack ||
other.yTrack >= yTrack + ySpan
);
});
}
type LayoutMode = "Responsive" | "Mobile" | "Tablet" | "Desktop" | "Ultra Wide";
// Canvas width forced by each mode. Responsive follows the width of the screen.
const LAYOUT_MODE_WIDTHS: Record<LayoutMode, number | undefined> = {
Responsive: undefined,
Mobile: 375,
Tablet: 600,
Desktop: 1024,
"Ultra Wide": 1440,
};
const responsiveLayoutBreakpoints: AgResponsiveLayoutBreakpoint[] = [
{ maxWidth: 480, layout: singleColumnLayout },
{ maxWidth: 720, layout: (...args) => masonryLayout(2, ...args) },
// Widths from 720 to 1200 match no breakpoint, so they keep the desktop layout.
{ minWidth: 1200, layout: (...args) => masonryLayout(4, ...args) },
];
function setStudioWidth(width: number | undefined) {
const studioDiv = document.querySelector<HTMLElement>(
".ag-studio-layout-canvas",
)!;
studioDiv.style.width = width != null ? `${width}px` : "";
}
const VueExample = defineComponent({
template: `
<div style="display: flex; flex-direction: column; height: 100%">
<div class="example-controls">
<div class="controls-row">
<button :aria-pressed="layoutMode === 'Mobile'" v-on:click="showLayout('Mobile')">Mobile</button>
<button :aria-pressed="layoutMode === 'Tablet'" v-on:click="showLayout('Tablet')">Tablet</button>
<button :aria-pressed="layoutMode === 'Desktop'" v-on:click="showLayout('Desktop')">Desktop</button>
<button :aria-pressed="layoutMode === 'Ultra Wide'" v-on:click="showLayout('Ultra Wide')">Ultra Wide</button>
<button v-on:click="showLayout('Responsive')">Reset</button>
<span>Layout: {{ layoutMode }}</span>
</div>
</div>
<ag-studio
style="height: 100%; width: 100%"
class="my-studio-container"
mode="view"
:initialState="initialState"
:data="data"
:theme="theme"
:layout="pageLayout"
:responsiveLayoutBreakpoints="breakpoints"
:panels="panels"></ag-studio>
</div>
`,
components: {
"ag-studio": AgStudio,
},
setup() {
const theme: AgStudioTheme = studioTheme.withParams({ studioMinWidth: 0 });
const pageLayout: Partial<AgPageLayoutState> = { minWidth: 250 };
const breakpoints: AgResponsiveLayoutBreakpoint[] =
responsiveLayoutBreakpoints;
const panels: AgPanelConfig = {
view: {
left: [],
right: [],
},
};
const layoutMode = ref<LayoutMode>("Responsive");
const showLayout = (mode: LayoutMode) => {
layoutMode.value = mode;
setStudioWidth(LAYOUT_MODE_WIDTHS[mode]);
};
const data = shallowRef<AgDataSourcesDefinition>();
loadData().then((loaded) => (data.value = loaded));
return {
initialState,
theme,
pageLayout,
breakpoints,
panels,
layoutMode,
showLayout,
data,
};
},
});
const app = createApp(VueExample);
app.mount("#app");
The example defines three breakpoints:
- Canvases up to 480px wide use
singleColumnLayout. - Canvases from 480px to 720px use a custom
masonryLayoutfunction with two columns. - Canvases wider than 1200px use
masonryLayoutwith four columns. - Canvases between 720px and 1200px match no breakpoint, so they use the desktop layout.
<ag-studio
:layout="layout"
:responsiveLayoutBreakpoints="responsiveLayoutBreakpoints"
/* other studio properties ... */>
</ag-studio>
this.layout = { minWidth: 250 };
this.responsiveLayoutBreakpoints = [
{ maxWidth: 480, layout: singleColumnLayout },
{ maxWidth: 720, layout: (...args) => masonryLayout(2, ...args) },
{ minWidth: 1200, layout: (...args) => masonryLayout(4, ...args) },
];masonryLayout places the widgets in desktop order. Each widget spans as many columns as its desktop width needs, and goes wherever it can sit highest. Once every widget is placed, each one stretches down and sideways to fill any empty space next to it. It is a worked example of a layout function rather than part of Studio, so its implementation lives in the example above: open the example's code to read it alongside the breakpoints that call it.
Switching Between Desktop and Mobile Copy Link
Studio chooses the layout from the current width of the canvas and updates it whenever the canvas is resized. There is no API call to switch between the desktop and mobile layouts. The buttons in the examples above only change the width of the canvas inside Studio.
Studio must be in view mode. In edit mode, it always uses the desktop layout, whatever the width.
Widget Minimum Sizes Copy Link
Every widget has a minimum size. A widget's minimum width must fit within the width of the canvas, so widget minimum sizes need to align with the widths your breakpoints support.
The built-in widgets have small minimum sizes, so they fit the breakpoints shown on this page. If you define Custom Widgets, keep each widget's minSize width no wider than the page's Min Width, which is the narrowest the canvas is ever drawn. A custom layout function also receives each widget's minimum size, so it can give a wide widget more room, as masonryLayout does.
Mobile Overlay Copy Link
The example below shows one way to preview the mobile layout while editing a dashboard. Click Toggle Mobile Overlay to open a resizable popup that shows the dashboard in a single column. Edit the dashboard and the preview updates.
import {
createApp,
defineComponent,
onBeforeMount,
ref,
shallowRef,
} from "vue";
import { AgStudio } from "ag-studio-vue3";
import {
AgDataEngine,
AgDataSourcesDefinition,
AgExpressionFieldDefinition,
AgFieldDefinition,
AgPopupInstance,
AgReportHistory,
AgReportState,
AgStudioApi,
AgStudioApiReadyEvent,
AgStudioMode,
AgStudioProperties,
enableStudioDevValidations,
} from "ag-studio";
import MobileOverlay from "./mobileOverlayVue.ts";
import { MobileOverlayParams } from "./interfaces.ts";
if (process.env.NODE_ENV !== "production") {
// Enable extended validations only for development
enableStudioDevValidations();
}
const BASE_URL = "https://www.ag-grid.com/studio/example-assets/main-demo";
async function loadJson(filename: string): Promise<any[]> {
const response = await fetch(`${BASE_URL}/${filename}`);
if (!response.ok) {
console.error(`Failed to load ${filename}: ${response.status}`);
return [];
}
return response.json();
}
const productsFields: AgFieldDefinition[] = [
{ id: "product_id", name: "Product ID", format: "textFormat" },
{ id: "product_name", name: "Product", format: "textFormat" },
{ id: "category", name: "Category", format: "textFormat" },
{ id: "subcategory", name: "Subcategory", format: "textFormat" },
{ id: "brand", name: "Brand", format: "textFormat" },
{ id: "launch_date", name: "Launch Date", format: "dateFormat" },
{ id: "list_price", name: "List Price", format: "currencyFormat" },
{ id: "unit_cost", name: "Unit Cost", format: "currencyFormat" },
{ id: "is_discontinued", name: "Discontinued", format: "booleanFormat" },
];
const orderItemsFields: AgFieldDefinition[] = [
{ id: "order_item_id", name: "Order Item ID", format: "textFormat" },
{ id: "order_id", name: "Order ID", format: "textFormat" },
{ id: "product_id", name: "Product ID", format: "textFormat" },
{ id: "quantity", name: "Qty", format: "integerFormat" },
{ id: "unit_price", name: "Unit Price", format: "currencyFormat" },
{ id: "discount_pct", name: "Discount", format: "percentageFormat" },
{ id: "tax_rate", name: "Tax Rate", format: "percentageFormat" },
{ id: "returned", name: "Returned", format: "booleanFormat" },
{ id: "return_reason", name: "Return Reason", format: "textFormat" },
];
const customersFields: AgFieldDefinition[] = [
{ id: "customer_id", name: "Customer ID", format: "textFormat" },
{ id: "customer_name", name: "Customer", format: "textFormat" },
{ id: "region", name: "Region", format: "textFormat" },
{ id: "segment", name: "Segment", format: "textFormat" },
{ id: "industry", name: "Industry", format: "textFormat" },
];
const ordersFields: AgFieldDefinition[] = [
{
id: "order_id",
name: "Order ID",
format: "textFormat",
cardinality: "high",
notBlank: true,
},
{ id: "customer_id", name: "Customer ID", format: "textFormat" },
{ id: "order_datetime", name: "Order Date/Time", format: "dateTimeFormat" },
{ id: "channel", name: "Channel", format: "textFormat" },
{ id: "status", name: "Status", format: "textFormat" },
{ id: "payment_method", name: "Payment Method", format: "textFormat" },
{ id: "currency", name: "Currency", format: "textFormat" },
];
const expressions: AgExpressionFieldDefinition[] = [
{
id: "line_gross",
name: "Line Gross",
isMeasure: false,
format: "currencyFormat",
formatOptions: { format: "$#,##0.0,K" },
expression: {
operator: "multiply",
inputs: [
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
],
},
},
{
id: "margin",
name: "Margin",
isMeasure: false,
format: "currencyFormat",
expression: {
operator: "subtract",
inputs: [{ id: "products.list_price" }, { id: "order_items.unit_price" }],
},
},
{
id: "line_net",
name: "Line Net",
isMeasure: false,
format: "currencyFormat",
expression: {
operator: "subtract",
inputs: [
{
operator: "multiply",
inputs: [
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
],
},
{
operator: "multiply",
inputs: [
{
operator: "multiply",
inputs: [
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
],
},
{ id: "order_items.discount_pct" },
],
},
],
},
},
];
let mobileOverlay: AgPopupInstance<MobileOverlayParams> | undefined;
let dataSources: AgDataSourcesDefinition | undefined;
const VueExample = defineComponent({
template: `
<div style="height: 100%">
<div style="display: flex; flex-direction: column; height: 100%">
<div class="example-controls">
<div class="controls-row">
<button v-on:click="toggleMobileOverlay()">Toggle Mobile Overlay</button>
</div>
</div>
<ag-studio
style="width: 100%; height: 100%;"
class="my-studio-container"
@api-ready="onApiReady"
:initialState="initialState"
:mode="mode"
:data="data"
@state-updated="onStateUpdated"></ag-studio>
</div>
</div>
`,
components: {
"ag-studio": AgStudio,
MobileOverlay,
},
setup(props) {
const studioApi = shallowRef<AgStudioApi | null>(null);
const initialState = ref<AgReportState | AgReportHistory>({
pages: [
{
id: "overview",
widgets: {
"kpi-gross-sales": {
type: "value",
dataMapping: {
value: [{ id: "line_gross", aggregation: "sum" }],
},
format: {
style: { label: { enabled: true, text: "Gross Sales" } },
},
},
"kpi-order-count": {
type: "value",
dataMapping: {
value: [{ id: "orders.order_id", aggregation: "countd" }],
},
format: {
style: { label: { enabled: true, text: "Order Count" } },
},
},
"kpi-avg-qty": {
type: "value",
dataMapping: {
value: [{ id: "order_items.quantity", aggregation: "avg" }],
},
format: {
style: { label: { enabled: true, text: "Avg Qty per Line" } },
},
},
"sales-by-category": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "products.subcategory" }],
valueKey: [{ id: "line_gross", aggregation: "sum" }],
},
sort: [
{
field: { id: "line_gross", aggregation: "sum" },
direction: "desc",
},
],
format: {
title: { enabled: true, text: "Gross Sales by Subcategory" },
},
},
"sales-by-brand": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "products.brand" }],
valueKey: [{ id: "line_gross", aggregation: "sum" }],
},
sort: [
{
field: { id: "line_gross", aggregation: "sum" },
direction: "desc",
},
],
format: {
title: { enabled: true, text: "Gross Sales by Brand" },
},
},
"top-products": {
type: "grid",
dataMapping: {
cols: [
{ id: "products.product_name" },
{ id: "products.category" },
{ id: "order_items.quantity", aggregation: "sum" },
{ id: "line_gross", aggregation: "sum" },
{ id: "margin", aggregation: "avg" },
],
},
sort: [
{
field: { id: "line_gross", aggregation: "sum" },
direction: "desc",
},
],
format: { title: { enabled: true, text: "Top Products" } },
},
},
widgetLayout: {
"kpi-gross-sales": { xTrack: 0, yTrack: 0, xSpan: 8, ySpan: 6 },
"kpi-order-count": { xTrack: 8, yTrack: 0, xSpan: 8, ySpan: 6 },
"kpi-avg-qty": { xTrack: 16, yTrack: 0, xSpan: 8, ySpan: 6 },
"sales-by-category": { xTrack: 0, yTrack: 6, xSpan: 12, ySpan: 16 },
"sales-by-brand": { xTrack: 12, yTrack: 6, xSpan: 12, ySpan: 16 },
"top-products": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
},
},
{
id: "detail",
widgets: {
"subcategory-filter": {
type: "list-filter",
dataMapping: {
value: [{ id: "products.subcategory" }],
},
format: {
title: { enabled: true, text: "Subcategory" },
},
},
"order-grid": {
type: "grid",
dataMapping: {
cols: [
{ id: "products.product_name" },
{ id: "products.subcategory" },
{ id: "order_items.quantity" },
{ id: "order_items.unit_price" },
{ id: "line_gross" },
{ id: "line_net" },
],
},
format: {
title: { enabled: true, text: "Order Details" },
},
},
},
widgetLayout: {
"subcategory-filter": { xTrack: 0, yTrack: 0, xSpan: 6, ySpan: 32 },
"order-grid": { xTrack: 6, yTrack: 0, xSpan: 18, ySpan: 32 },
},
},
{
id: "customers",
widgets: {
"kpi-unique-customers": {
type: "value",
dataMapping: {
value: [{ id: "customers.customer_id", aggregation: "countd" }],
},
format: {
style: { label: { enabled: true, text: "Unique Customers" } },
},
},
"revenue-by-region": {
type: "bar-chart-grouped",
dataMapping: {
categoryKey: [{ id: "customers.region" }],
valueKey: [{ id: "line_net", aggregation: "sum" }],
},
sort: [
{
field: { id: "line_net", aggregation: "sum" },
direction: "desc",
},
],
format: { title: { enabled: true, text: "Revenue by Region" } },
},
"customer-grid": {
type: "grid",
dataMapping: {
cols: [
{ id: "customers.customer_name" },
{ id: "customers.region" },
{ id: "customers.segment" },
{ id: "customers.industry" },
{ id: "orders.order_id", aggregation: "countd" },
{ id: "line_net", aggregation: "sum" },
],
},
sort: [
{
field: { id: "line_net", aggregation: "sum" },
direction: "desc",
},
],
format: { title: { enabled: true, text: "Customer Details" } },
},
},
widgetLayout: {
"kpi-unique-customers": {
xTrack: 0,
yTrack: 0,
xSpan: 24,
ySpan: 6,
},
"revenue-by-region": { xTrack: 0, yTrack: 6, xSpan: 24, ySpan: 16 },
"customer-grid": { xTrack: 0, yTrack: 22, xSpan: 24, ySpan: 16 },
},
},
],
selectedPageId: "overview",
panels: {
filters: {
collapsed: true,
},
edit: {
collapsed: true,
},
data: {
collapsed: true,
},
},
});
const mode = ref<AgStudioMode>("edit");
const data = ref<AgDataSourcesDefinition | AgDataEngine>(null);
function onStateUpdated(e) {
mobileOverlay?.refresh({ state: e.state, data: dataSources });
}
function toggleMobileOverlay() {
if (mobileOverlay != null) {
mobileOverlay.closePopup();
} else {
mobileOverlay = studioApi.value.createPopup<MobileOverlayParams>({
comp: MobileOverlay,
compParams: { state: studioApi.value.getState(), data: dataSources },
title: "Preview",
resizable: true,
movable: true,
width: 300,
height: 400,
minWidth: 300,
closedCallback: () => {
mobileOverlay = undefined;
},
});
}
}
const onApiReady = (params: AgStudioApiReadyEvent) => {
studioApi.value = params.api;
Promise.all([
loadJson("products.json"),
loadJson("order_items.json"),
loadJson("customers.json"),
loadJson("orders.json"),
]).then(([productData, orderItemData, customerData, orderData]) => {
dataSources = {
sources: [
{
id: "products",
name: "Products",
data: productData,
fields: productsFields,
},
{
id: "order_items",
name: "Order Items",
data: orderItemData,
fields: orderItemsFields,
},
{
id: "customers",
name: "Customers",
data: customerData,
fields: customersFields,
},
{
id: "orders",
name: "Orders",
data: orderData,
fields: ordersFields,
},
],
relationships: [
{
id: "order-item-product",
source: "order_items.product_id",
target: "products.product_id",
type: "many-to-one",
acceptFanout: true,
},
{
id: "order-item-order",
source: "order_items.order_id",
target: "orders.order_id",
type: "many-to-one",
},
{
id: "order-customer",
source: "orders.customer_id",
target: "customers.customer_id",
type: "many-to-one",
},
],
expressions,
};
params.api.setProperty("data", dataSources);
// A preview opened while the data was loading has none, so give it the data now.
mobileOverlay?.refresh({
state: params.api.getState(),
data: dataSources,
});
});
};
return {
studioApi,
initialState,
mode,
data,
onApiReady,
onStateUpdated,
toggleMobileOverlay,
};
},
});
const app = createApp(VueExample);
app.mount("#app");
The popup contains a second Studio in view mode, with a breakpoint that uses singleColumnLayout. The main Studio's onStateUpdated event passes each change to the preview with setState. The preview sets the --ag-studio-min-width and --ag-studio-min-height CSS variables to 0px on its container, because it would otherwise inherit the main Studio's minimum size and could not shrink to the width of the popup.
Limitations Copy Link
- Responsive layouts are view only. In edit mode, Studio always shows and edits the desktop layout.
- Widget order follows the widget's position in the desktop layout. To change the order of widgets in a mobile layout, rearrange the desktop layout, or write a custom
layoutfunction.