Skip to main content

Layout and Testing

Understanding how widgets adapt to different screen sizes and ensuring they are accessible and well-tested is crucial for building robust, production-ready widgets. This guide covers responsive design patterns, comprehensive testing strategies, and accessibility best practices.

Layout and Responsive Design

Reference: Layout Calculation

Layout Props

Widgets receive layout information via props:

interface IWidgetProps {
layout?: {
w: number; // Width in grid units
h: number; // Height in grid units
x: number; // X position in grid
y: number; // Y position in grid
};
}

Layout Constraints

Defined in widget-config.json:

{
"layout": {
"minW": 5,
"maxW": 24,
"minH": 5,
"maxH": 12
}
}

React Grid Layout Integration

The Dashboard Composer (Host application) uses a responsive grid system based on viewport breakpoints. The number of grid columns changes based on the current breakpoint.

Grid Configuration:

const rgl = {
cols: {
lg: 36, // Large screens
md: 24, // Medium screens
sm: 12, // Small screens
xs: 6, // Extra small screens
},
};

Viewport Breakpoints:

const VIEWPORT_BREAKPOINTS = {
EXTRA_EXTRA_SMALL: 0,
EXTRA_SMALL: 320,
SMALL: 768,
MEDIUM: 1220,
LARGE: 1920,
};

Breakpoint Examples:

  • Resolution 321px - 767px: Grid has 6 columns (xs)
  • Resolution 768px - 1219px: Grid has 12 columns (sm)
  • Resolution 1220px - 1919px: Grid has 24 columns (md)
  • Resolution 1920px+: Grid has 36 columns (lg)

Dashboard Composer Breakpoint Controls

The Dashboard Composer provides 4 predefined breakpoints for users to switch between during dashboard configuration. When switching to a breakpoint different from the current viewport, the dashboard width is set to a specific value:

  • XS: 390px
  • SM: 1024px
  • MD: 1480px
  • LG: 1921px

This allows users to preview and configure their dashboards for different screen sizes.

Responsive Widget Example

import React from "react";
import styled from "styled-components";
import { getSizeBy } from "@invent/wl-ui-kit";
import type { IWidgetProps } from "../types/widget-props";

const VIEWPORT_BREAKPOINTS = {
EXTRA_EXTRA_SMALL: 0,
EXTRA_SMALL: 320,
SMALL: 768,
MEDIUM: 1220,
LARGE: 1920,
};

const ResponsiveKpiTracker: React.FC<IWidgetProps> = ({ layout }) => {
const isSmall = layout && layout.w < 12;
const isMedium = layout && layout.w >= 12 && layout.w < 24;
const isLarge = layout && layout.w >= 24;

return (
<ResponsiveContainer $isSmall={isSmall}>
<KpiGrid $columns={isSmall ? 1 : isMedium ? 2 : 3}>
<KpiCard>Revenue</KpiCard>
<KpiCard>Users</KpiCard>
<KpiCard>Conversion</KpiCard>
</KpiGrid>
</ResponsiveContainer>
);
};

const ResponsiveContainer = styled.div<{ $isSmall?: boolean }>`
padding: ${(props) => (props.$isSmall ? getSizeBy(1) : getSizeBy(3))};

@media screen and (max-width: ${VIEWPORT_BREAKPOINTS.SMALL}px) {
padding: ${getSizeBy(2)};
}
`;

const KpiGrid = styled.div<{ $columns: number }>`
display: grid;
grid-template-columns: repeat(${(props) => props.$columns}, 1fr);
gap: ${getSizeBy(2)};

@media screen and (max-width: ${VIEWPORT_BREAKPOINTS.SMALL}px) {
grid-template-columns: 1fr;
gap: ${getSizeBy(1)};
}
`;

const KpiCard = styled.div`
padding: ${getSizeBy(2)};
background-color: ${getColor("tertiary")};
border-radius: ${(props) => props.theme.borderRadius}px;
`;

export default ResponsiveKpiTracker;

Testing and Accessibility

Component Testing

Reference: UI-Kit Testing

Test Scenarios:

  1. Testing new components
  2. Testing edge cases
  3. Fixing broken tests
  4. Refactoring tests

RTL Query Priority:

  1. Accessible: getByRole, getByLabelText, getByPlaceholderText, getByText
  2. Semantic: getByAltText, getByTitle
  3. Test ID: getByTestId (last resort)

Example Test

import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "react-query";
import "@testing-library/jest-dom";
import KpiTracker from "./widget";

const mockHttpClient = jest.fn();
const mockShowNotification = jest.fn();
const mockUseShareValue = jest.fn();
const mockUseSelectSharedValue = jest.fn(() => null);

const defaultProps = {
platformMeta: {
storeWidgetsById: {},
platformWidgetsById: {},
currentDashboardId: "dash-123",
currentDashboard: undefined,
},
httpClient: mockHttpClient,
useShareValue: mockUseShareValue,
useSelectSharedValue: mockUseSelectSharedValue,
useDeleteSharedValue: jest.fn(),
title: "Test KPI Tracker",
apiUrl: "https://api.test.com",
refreshInterval: 30,
showTrend: true,
enableNotifications: false,
remoteModule: jest.fn(),
showNotification: mockShowNotification,
useQueryData: jest.fn(),
useDashboardNavigationInfo: jest.fn(),
getExtension: jest.fn(),
installedExtensions: [],
dashboardWidgetInstanceId: "widget-123",
};

const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

const renderWidget = (props = {}) => {
return render(
<QueryClientProvider client={queryClient}>
<KpiTracker {...defaultProps} {...props} />
</QueryClientProvider>
);
};

describe("KpiTracker Widget", () => {
beforeEach(() => {
jest.clearAllMocks();
});

it("renders widget title", () => {
renderWidget();
expect(screen.getByText("Test KPI Tracker")).toBeInTheDocument();
});

it("displays loading state initially", () => {
renderWidget();
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});

it("fetches KPI data on mount", async () => {
mockHttpClient.mockResolvedValueOnce({
revenue: 125000,
users: 5280,
conversion: 3.42,
timestamp: Date.now(),
});

renderWidget();

await waitFor(() => {
expect(mockHttpClient).toHaveBeenCalledWith(
"https://api.test.com/kpi",
expect.objectContaining({
traceId: expect.stringContaining("kpi-tracker"),
})
);
});
});

it("displays KPI data when loaded", async () => {
const kpiData = {
revenue: 125000,
users: 5280,
conversion: 3.42,
timestamp: Date.now(),
};

mockHttpClient.mockResolvedValueOnce(kpiData);

renderWidget();

await waitFor(() => {
expect(screen.getByText(/\$125,000/)).toBeInTheDocument();
expect(screen.getByText(/5,280/)).toBeInTheDocument();
expect(screen.getByText(/3.42%/)).toBeInTheDocument();
});
});

it("shows error notification on API failure", async () => {
mockHttpClient.mockRejectedValueOnce(new Error("API Error"));

renderWidget();

await waitFor(() => {
expect(mockShowNotification).toHaveBeenCalledWith(
"error",
"Failed to load KPI data",
expect.objectContaining({
position: "top-center",
autoClose: 5000,
})
);
});
});

it("shares data via useShareValue when data loads", async () => {
const kpiData = {
revenue: 125000,
users: 5280,
conversion: 3.42,
timestamp: Date.now(),
};

mockHttpClient.mockResolvedValueOnce(kpiData);
const mockShareValue = jest.fn();
mockUseShareValue.mockReturnValue(mockShareValue);

renderWidget();

await waitFor(() => {
expect(mockShareValue).toHaveBeenCalledWith(["kpiData"], kpiData);
});
});

it("respects showTrend setting", () => {
renderWidget({ showTrend: false });

expect(screen.queryByText(//)).not.toBeInTheDocument();
});
});

Accessibility Testing

Reference: A11y Support

Tools:

  • eslint-plugin-jsx-a11y: Linting for accessibility issues
  • jest-axe: Automated accessibility testing
  • storybook-a11y-addon: Accessibility testing in Storybook
  • @react-aria: Accessible React components
  • Browser tools: axe DevTools extension, Lighthouse

Jest-Axe Example

import React from "react";
import { render } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import KpiTracker from "./widget";

expect.extend(toHaveNoViolations);

describe("KpiTracker Accessibility", () => {
it("should have no accessibility violations", async () => {
const { container } = render(<KpiTracker {...defaultProps} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});

Accessible Component Example

import React from "react";
import styled from "styled-components";

const AccessibleKpiCard: React.FC<{ label: string; value: string }> = ({
label,
value,
}) => {
return (
<Card role="region" aria-labelledby="kpi-label">
<Label id="kpi-label">{label}</Label>
<Value aria-live="polite" aria-atomic="true">
{value}
</Value>
</Card>
);
};

const Card = styled.div`
padding: 16px;
border-radius: 4px;
background-color: #f5f5f5;
`;

const Label = styled.div`
font-size: 14px;
color: #666;
`;

const Value = styled.div`
font-size: 24px;
font-weight: bold;
color: #333;
`;

export default AccessibleKpiCard;

Code Style

Reference: Code Style

Tools:

  • wl-linters: Centralized linting configuration
  • eslint: JavaScript/TypeScript linting
  • stylelint: CSS linting
  • prettier-eslint: Code formatting
  • Husky: Pre-commit hooks

Commands:

# Lint JavaScript/TypeScript
npm run lint

# Lint styles
npm run lint:styles

# Format code
npm run format

# Run all checks
npm run lint && npm run lint:styles && npm run format