Hello Start Page!¶
So far, in Hello World! and Hello CRM!, our components have been shown on the object card. In this tutorial we build one for the start page: a widget called My sales. It shows how much the user has won this month, how close that is to their target, and which deals got them there.
On the start page, the administrator decides how big each widget is. The same widget can be a small square on one start page and fill the whole width of another, and every widget gets smaller on a phone. So we cannot design the widget for a single size. Instead, we use CSS grid to arrange the parts of the widget, and container queries to pick an arrangement that suits the space the widget gets.
By the end, you will know how to:
- name the parts of a layout, and arrange them with
grid-template-areas - change the layout depending on how much space the widget has
- register a widget, so that administrators can find it in Lime Admin
Before you start
This tutorial continues in the Cool Package package from the earlier tutorials. If you gave your package another name, the folder and component names below will have that name instead of cool-package.
Generate the widget¶
In the folder of your package, generate a new web component:
Web components live in the frontend folder of the package, so go there next. The rest of this tutorial runs from that folder:
The component is created in src/components/lwc-limepkg-cool-package-my-sales/. We will work in two of its files:
lwc-limepkg-cool-package-my-sales.tsx, which renders the widgetlwc-limepkg-cool-package-my-sales.scss, which styles it, and is empty for now
Write the content¶
The widget has three parts:
- The figure: how much the user has won this month, out of their target.
- The progress: how close the user is to the target, in percent.
- The deals: the deals the user has won this month.
To keep this tutorial about layout, we use sample data. Where to go next shows how a real widget would get these numbers from the CRM.
Replace the contents of the .tsx file with this:
import {
LimeWebComponent,
LimeWebComponentContext,
LimeWebComponentPlatform,
PlatformServiceName,
} from '@limetech/lime-web-components';
import { Component, h, Prop } from '@stencil/core';
const TARGET = 600000;
const PERCENT = 100;
const WON_DEALS = [
{ company: 'Bistro Nova', value: 180000 },
{ company: 'Hotel Öresund', value: 152000 },
{ company: 'Café Lilla Torg', value: 80000 },
];
@Component({
tag: 'lwc-limepkg-cool-package-my-sales',
shadow: true,
styleUrl: 'lwc-limepkg-cool-package-my-sales.scss',
})
export class MySales implements LimeWebComponent {
/**
* @inherit
*/
@Prop()
public platform: LimeWebComponentPlatform;
/**
* @inherit
*/
@Prop()
public context: LimeWebComponentContext;
public render() {
const won = WON_DEALS.reduce((sum, deal) => sum + deal.value, 0);
const percentOfTarget = Math.round((won / TARGET) * PERCENT);
return (
<limebb-dashboard-widget
heading="My sales"
subheading="This month"
icon="money_bag"
>
<div slot="content">
<p>
<strong>{this.format(won)}</strong> won of{' '}
{this.format(TARGET)}
</p>
<limel-circular-progress value={percentOfTarget} />
<limel-list
items={WON_DEALS.map(deal => ({
text: deal.company,
secondaryText: this.format(deal.value),
}))}
/>
</div>
</limebb-dashboard-widget>
);
}
private format(amount: number) {
const language = this.platform
.get(PlatformServiceName.Application)
.getLanguage();
return amount.toLocaleString(language);
}
}
limebb-dashboard-widget comes with Lime CRM. It gives our widget the same header and background as the other widgets on the start page, and shows whatever we put in its content slot below the header. See its documentation for more.
The three parts are wrapped in a div, which goes in the content slot. That div is going to be our grid.
format writes amounts the way the user's language in Lime CRM does, for example 412 000 in Swedish and 412,000 in English. Plain toLocaleString() would use the language of the browser instead, which is not always the same. The built-in widgets on the start page format their numbers the same way.
Register the widget¶
In Lime Admin, administrators add widgets to a start page by picking them from a list. To be on that list, our widget must be registered, with the tag start-page.
Registration should happen once, when the web client starts, so it belongs in the loader of the package. Open src/components/lwc-limepkg-cool-package-loader/lwc-limepkg-cool-package-loader.tsx, add PlatformServiceName to the imports from @limetech/lime-web-components, and register the widget in componentWillLoad. The loader should then look like this:
import {
LimePluginLoader,
LimeWebComponentContext,
LimeWebComponentPlatform,
PlatformServiceName,
} from '@limetech/lime-web-components';
import { Component, Prop } from '@stencil/core';
// NOTE: Do NOT remove this component, it is required to run the plugin correctly.
// However, if your plugin has any code that should run only once when the application
// starts, you are free to use the component lifecycle methods below to do so.
// The component should never render anything, so do NOT implement a render method.
@Component({
// ⚠️ WARNING! Do not change the tag name of this component unless you also
// change the name of the package. The tag name should be
// lwc-<package-name>-loader, e.g. lwc-limepkg-cool-package-loader
tag: 'lwc-limepkg-cool-package-loader',
shadow: true,
})
export class Loader implements LimePluginLoader {
/**
* @inherit
*/
@Prop()
public platform: LimeWebComponentPlatform;
/**
* @inherit
*/
@Prop()
public context: LimeWebComponentContext;
public connectedCallback() {}
public componentWillLoad() {
this.platform
.get(PlatformServiceName.WebComponentRegistry)
.register('lwc-limepkg-cool-package-my-sales', {
title: 'My sales',
description:
'What you have won this month. Fits Square and bigger.',
icon: 'money_bag',
tags: ['start-page'],
});
}
public componentWillUpdate() {}
public disconnectedCallback() {}
}
The title, description and icon are what administrators see in the list. We will come back to why the description mentions sizes. Using tags lists the other tags that Lime Admin looks for, such as card-widget for the object card.
Add the widget to a start page¶
From the frontend folder, build the component, like in Hello World!:
Then add the widget to a start page:
- Go to
Lime Admin > System > Start Pages, and open a start page. In the New Admin, it is underLook and feel > Start Pages. - Under Widgets (called Web components in older versions), add one, and pick My sales as its name.
- Set the Size to Big square, and save.
Read more about start pages in Configuring the Start Page.
Open the start page in the web client. All three parts are there, but stacked on top of each other. Time to arrange them.
Arrange the parts with grid areas¶
Add this to the .scss file:
[slot='content'] {
display: grid;
grid-template-areas:
'figure progress'
'deals deals';
align-items: center;
gap: 1rem;
padding: 1rem;
}
p {
grid-area: figure;
margin: 0;
}
strong {
display: block;
font-size: 2rem;
}
limel-circular-progress {
grid-area: progress;
}
limel-list {
grid-area: deals;
}
We find the div by its slot, with [slot='content']. display: grid turns it into a grid, and grid-template-areas draws a map of it. You can read the map like a picture of the widget:
- Each string is a row, and each word in it is a cell. This map has two rows, with two cells each.
- The figure and the progress share the top row.
- The deals fill the whole bottom row, because
dealsis written in both of its cells. - Extra spaces do not matter. We use them to line up the columns, so that the map is easier to read.
- To leave a cell empty, write a
.in it.
Each part is then placed in its area with grid-area. The names are up to you; pick ones that say what goes in the area.
We can use short selectors like p and strong because the component uses shadow DOM (shadow: true). Styles in the .scss file only reach the elements of this component, so p cannot affect anything else on the start page.
Build again and reload the start page. The figure and the progress are now side by side, with the deals below them.
Try other sizes¶
In Lime Admin, change the size of the widget to Rectangle. The widget gets shorter, and the deals no longer fit. Change it to Square, and the figure and the progress get squeezed together. On a phone, every widget gets smaller still.
One layout cannot fit every size, so the widget needs to change its layout depending on how much space it has.
Why not use media queries, or @SelectDevice()?
Media queries and the @SelectDevice() decorator tell you about the screen. But the space a widget gets depends much more on the size the administrator picked: on a laptop, a Square widget has less space than a Big square has on a phone. What we need to know is how much space the widget itself has, and that is what container queries tell us.
Make the widget a container¶
Add this to the top of the .scss file:
:host is the element of our widget itself, lwc-limepkg-cool-package-my-sales. container-type: size turns it into a container: an element whose width and height can be asked about with @container, by the elements inside it. A container must be a block, and custom elements are inline unless told otherwise, hence the display: block.
size or inline-size?
container-type: size lets us ask about both the width and the height of the widget. In return, the height of the widget no longer grows with its content, so it must be decided from outside. On the start page it is: the size the administrator picks sets both the width and the height.
For a component whose height should follow its content, such as a web component on the object card, use container-type: inline-size instead. It only lets you ask about the width.
Pick a layout for each size¶
Add these rules to the end of the .scss file:
@container (min-width: 50rem) {
[slot='content'] {
grid-template-areas: 'figure progress deals';
}
}
@container (max-height: 20rem) {
[slot='content'] {
grid-template-areas: 'figure progress';
}
limel-list {
display: none;
}
}
@container (max-width: 20rem) {
[slot='content'] {
grid-template-areas: 'figure';
}
limel-circular-progress,
limel-list {
display: none;
}
}
Read each @container rule as "when the widget is…":
- at least 50rem wide, there is room for all three parts in one row.
- at most 20rem tall, there is no room for the deals, so we keep only the top row.
- at most 20rem wide, there is only room for the figure.
Two things to note:
- Hide the parts you leave out. Taking an area out of the map does not hide the part that was placed in it. Instead, the grid adds an extra row and column to make room for that part. So we hide it ourselves, with
display: none. - The last matching rule wins. A widget can match more than one rule. For example, a Square is both narrow and short. As always in CSS, the rule furthest down then wins. That is why the rules go from the roomiest layout to the most cramped one: when several of them match, the most cramped layout is used, and nothing gets squeezed.
Build, reload, and try the sizes again. On a laptop screen, the widget now looks like this:
| Size | What the widget shows |
|---|---|
| Square | The figure |
| Rectangle | The figure and the progress |
| Big square, Medium rectangle, Tall | The figure and the progress, with the deals below |
| Big rectangle, Full width | All three parts, in one row |
On smaller screens every size is smaller, so the same size may get a more compact layout. That is the point: the widget adapts to the space it actually has, whatever the reason for it.
Not every widget fits every size
Small square and Small rectangle leave room for the header and little else, so they are too small for this widget. When a widget needs a certain size to be useful, say so in its description, like we did when we registered it. Administrators see the description when they pick the widget.
The finished widget¶
import {
LimeWebComponent,
LimeWebComponentContext,
LimeWebComponentPlatform,
PlatformServiceName,
} from '@limetech/lime-web-components';
import { Component, h, Prop } from '@stencil/core';
const TARGET = 600000;
const PERCENT = 100;
const WON_DEALS = [
{ company: 'Bistro Nova', value: 180000 },
{ company: 'Hotel Öresund', value: 152000 },
{ company: 'Café Lilla Torg', value: 80000 },
];
@Component({
tag: 'lwc-limepkg-cool-package-my-sales',
shadow: true,
styleUrl: 'lwc-limepkg-cool-package-my-sales.scss',
})
export class MySales implements LimeWebComponent {
/**
* @inherit
*/
@Prop()
public platform: LimeWebComponentPlatform;
/**
* @inherit
*/
@Prop()
public context: LimeWebComponentContext;
public render() {
const won = WON_DEALS.reduce((sum, deal) => sum + deal.value, 0);
const percentOfTarget = Math.round((won / TARGET) * PERCENT);
return (
<limebb-dashboard-widget
heading="My sales"
subheading="This month"
icon="money_bag"
>
<div slot="content">
<p>
<strong>{this.format(won)}</strong> won of{' '}
{this.format(TARGET)}
</p>
<limel-circular-progress value={percentOfTarget} />
<limel-list
items={WON_DEALS.map(deal => ({
text: deal.company,
secondaryText: this.format(deal.value),
}))}
/>
</div>
</limebb-dashboard-widget>
);
}
private format(amount: number) {
const language = this.platform
.get(PlatformServiceName.Application)
.getLanguage();
return amount.toLocaleString(language);
}
}
:host {
display: block;
container-type: size;
}
[slot='content'] {
display: grid;
grid-template-areas:
'figure progress'
'deals deals';
align-items: center;
gap: 1rem;
padding: 1rem;
}
p {
grid-area: figure;
margin: 0;
}
strong {
display: block;
font-size: 2rem;
}
limel-circular-progress {
grid-area: progress;
}
limel-list {
grid-area: deals;
}
@container (min-width: 50rem) {
[slot='content'] {
grid-template-areas: 'figure progress deals';
}
}
@container (max-height: 20rem) {
[slot='content'] {
grid-template-areas: 'figure progress';
}
limel-list {
display: none;
}
}
@container (max-width: 20rem) {
[slot='content'] {
grid-template-areas: 'figure';
}
limel-circular-progress,
limel-list {
display: none;
}
}
Where to go next¶
- The next tutorial, Hello Command!, shows how to write a command of your own, and run it from a menu in the table view. It registers the command in the same loader, so keep the
registercall for My sales when you get there. - Replace the sample data with real numbers. In the web client,
platform.get(PlatformServiceName.Query)gives you the Query service, which runs the same queries as Lime query. Its aggregates can add up the value of the deals won this month. Ask for only the few most recent deals, with the query's limit: the widget has a fixed size, and cuts off a list that does not fit. - Make the texts translatable. In a package, the code holds translation keys instead of English text, and the web client shows each user the text in their language. See Translations. The
titleanddescriptionyou register the widget with can be translation keys too. - Web components on the object card are placed in a grid too. Web component placement and layout explains how to make them span more columns or rows.
- MDN has good guides to grid template areas and container queries.