mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-17 13:52:27 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bf5833262 | |||
| 8c9da19935 | |||
| 4b1eeb0eb1 | |||
| f7ffdabe5d |
@@ -3,9 +3,6 @@ contact_links:
|
||||
- name: Request a feature for the UI / Dashboards
|
||||
url: https://github.com/orgs/home-assistant/discussions
|
||||
about: Request a new feature for the Home Assistant frontend.
|
||||
- name: Discuss UI or UX design
|
||||
url: https://github.com/OpenHomeFoundation/ux-design/discussions
|
||||
about: Share design feedback and discuss visual or UX changes with the design team.
|
||||
- name: Report a bug that is NOT related to the UI / Dashboards
|
||||
url: https://github.com/home-assistant/core/issues
|
||||
about: This is the issue tracker for our frontend. Please report other issues in the backend ("core") repository.
|
||||
|
||||
@@ -21,14 +21,6 @@
|
||||
-->
|
||||
|
||||
|
||||
## Screenshots
|
||||
<!--
|
||||
If your PR includes visual changes, please add screenshots or a short video
|
||||
showing the before and after. This helps reviewers understand the impact of
|
||||
your changes.
|
||||
Note: Remove this section if this PR has no visual changes.
|
||||
-->
|
||||
|
||||
## Type of change
|
||||
<!--
|
||||
What type of change does your PR introduce to the Home Assistant frontend?
|
||||
@@ -43,6 +35,16 @@
|
||||
- [ ] Breaking change (fix/feature causing existing functionality to break)
|
||||
- [ ] Code quality improvements to existing code or addition of tests
|
||||
|
||||
## Example configuration
|
||||
<!--
|
||||
Supplying a configuration snippet, makes it easier for a maintainer to test
|
||||
your PR.
|
||||
-->
|
||||
|
||||
```yaml
|
||||
|
||||
```
|
||||
|
||||
## Additional information
|
||||
<!--
|
||||
Details are important, and help maintainers processing your PR.
|
||||
@@ -52,8 +54,6 @@
|
||||
- This PR fixes or closes issue: fixes #
|
||||
- This PR is related to issue or discussion:
|
||||
- Link to documentation pull request:
|
||||
- Link to developer documentation pull request:
|
||||
- Link to backend pull request:
|
||||
|
||||
## Checklist
|
||||
<!--
|
||||
@@ -61,48 +61,18 @@
|
||||
creating the PR. If you're unsure about any of them, don't hesitate to ask.
|
||||
We're here to help! This is simply a reminder of what we are going to look
|
||||
for before merging your code.
|
||||
|
||||
AI tools are welcome, but contributors are responsible for *fully*
|
||||
understanding the code before submitting a PR.
|
||||
-->
|
||||
|
||||
- [ ] I understand the code I am submitting and can explain how it works.
|
||||
- [ ] The code change is tested and works locally.
|
||||
- [ ] There is no commented out code in this PR.
|
||||
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
|
||||
- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
|
||||
- [ ] Tests have been added to verify that the new code works.
|
||||
|
||||
If user exposed functionality or configuration variables are added/changed:
|
||||
|
||||
- [ ] Documentation added/updated for [www.home-assistant.io][docs-repository]
|
||||
|
||||
<!--
|
||||
This project is very active and we have a high turnover of pull requests.
|
||||
|
||||
Unfortunately, the number of incoming pull requests is higher than what our
|
||||
reviewers can review and merge so there is a long backlog of pull requests
|
||||
waiting for review. You can help here!
|
||||
|
||||
By reviewing another pull request, you will help raise the code quality of
|
||||
that pull request and the final review will be faster. This way the general
|
||||
pace of pull request reviews will go up and your wait time will go down.
|
||||
|
||||
When picking a pull request to review, try to choose one that hasn't yet
|
||||
been reviewed.
|
||||
|
||||
Thanks for helping out!
|
||||
-->
|
||||
|
||||
To help with the load of incoming pull requests:
|
||||
|
||||
- [ ] I have reviewed two other [open pull requests][prs] in this repository.
|
||||
|
||||
[prs]: https://github.com/home-assistant/frontend/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+sort%3Acreated-desc+review%3Anone+-status%3Afailure
|
||||
|
||||
<!--
|
||||
Thank you for contributing <3
|
||||
|
||||
Below, some useful links you could explore:
|
||||
-->
|
||||
|
||||
[docs-repository]: https://github.com/home-assistant/home-assistant.io
|
||||
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
|
||||
|
||||
+184
-107
@@ -2,13 +2,12 @@
|
||||
|
||||
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
|
||||
|
||||
**Note**: This file contains high-level guidelines and references to implementation patterns. For gallery-specific documentation, demos, page structure, and usage examples, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
**Note**: This file contains high-level guidelines and references to implementation patterns. For detailed component documentation, API references, and usage examples, refer to the `gallery/` directory.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Quick Reference](#quick-reference)
|
||||
- [Core Architecture](#core-architecture)
|
||||
- [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- [Development Standards](#development-standards)
|
||||
- [Component Library](#component-library)
|
||||
- [Common Patterns](#common-patterns)
|
||||
@@ -41,7 +40,7 @@ script/develop # Development server
|
||||
```typescript
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-alert-dialog";
|
||||
```
|
||||
|
||||
## Core Architecture
|
||||
@@ -53,64 +52,13 @@ The Home Assistant frontend is a modern web application that:
|
||||
- Communicates with the backend via WebSocket API
|
||||
- Provides comprehensive theming and internationalization
|
||||
|
||||
## State Access: Contexts Instead of `hass`
|
||||
|
||||
Every component used to take the whole `hass: HomeAssistant` object — a god-object that re-renders on any unrelated `hass` change, forces tests to mock everything, and hides what a component actually reads. We're moving leaf components to **fine-grained [Lit context](https://lit.dev/docs/data/context/)**: consume only the slice you need and re-render only when it changes.
|
||||
|
||||
For new code, consume the matching context instead of adding a `hass` property. `hass` stays for container components that own it and feed the providers; the canonical migration is [`hui-button-card.ts`](src/panels/lovelace/cards/hui-button-card.ts). Infrastructure: contexts in [`src/data/context/index.ts`](src/data/context/index.ts), the `consume…` helpers in [`src/common/decorators/consume-context-entry.ts`](src/common/decorators/consume-context-entry.ts), and `@transform` in [`src/common/decorators/transform.ts`](src/common/decorators/transform.ts). Providers are wired automatically by `contextMixin` on `HassBaseEl` — you only consume.
|
||||
|
||||
### Contexts
|
||||
|
||||
Consume the narrowest context that covers your reads:
|
||||
|
||||
| Context | Replaces |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| `statesContext` | `hass.states` |
|
||||
| `entitiesContext` / `devicesContext` / `areasContext` / `floorsContext` | `hass.entities` / `.devices` / `.areas` / `.floors` (or `registriesContext` for all four) |
|
||||
| `servicesContext` | `hass.services` |
|
||||
| `internationalizationContext` | `hass.localize`, `hass.locale`, `hass.language` |
|
||||
| `formattersContext` | `hass.formatEntityName`, `hass.formatEntityState`, `hass.formatEntityAttributeName`, … |
|
||||
| `configContext` | `hass.config`, `hass.user`, `hass.auth`, `hass.userData` |
|
||||
| `connectionContext` | `hass.connection`, `hass.connected`, `hass.hassUrl` |
|
||||
| `apiContext` | `hass.callService`, `hass.callApi`, `hass.callWS`, `hass.sendWS`, `hass.fetchWithAuth` |
|
||||
| `uiContext` | `hass.themes`, `hass.selectedTheme`, `hass.panels`, `hass.dockedSidebar`, … |
|
||||
| `narrowViewportContext` | narrow-layout boolean |
|
||||
|
||||
Lazy contexts (subscribe on first consumer, tear down after the last): `labelsContext`, `fullEntitiesContext`, `configEntriesContext`, `manifestsContext`. The single-field contexts (`localizeContext`, `themesContext`, `userContext`, …) are **deprecated** — use the grouped ones above.
|
||||
|
||||
### Consuming
|
||||
|
||||
Use the `consume…` helpers for entity-scoped and `localize` reads. `entityIdPath` is resolved against `this`, so these watch `this._config.entity`:
|
||||
|
||||
```ts
|
||||
@state() @consumeEntityState({ entityIdPath: ["_config", "entity"] })
|
||||
private _stateObj?: HassEntity; // consumeEntityStates(...) for a record of several
|
||||
|
||||
@state() @consumeEntityRegistryEntry({ entityIdPath: ["_config", "entity"] })
|
||||
private _entity?: EntityRegistryDisplayEntry;
|
||||
|
||||
@state() @consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
```
|
||||
|
||||
For any other single field, pair `@consume` with `@transform`:
|
||||
|
||||
```ts
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<HomeAssistantUI, Themes>({ transformer: ({ themes }) => themes })
|
||||
private _themes!: Themes;
|
||||
```
|
||||
|
||||
`@transform`'s `watch` option re-runs the transformer when a host prop changes — needed when an entity id is computed, since `consumeEntityState` only watches the first path segment. To consume a whole group untransformed, drop `@transform` and type it `ContextType<typeof statesContext>`.
|
||||
|
||||
## Development Standards
|
||||
|
||||
### Code Quality Requirements
|
||||
|
||||
**Linting and Formatting (Enforced by Tools)**
|
||||
|
||||
- ESLint config (flat config) extends TypeScript strict, Lit, Web Components, Accessibility (lit-a11y), and import-x
|
||||
- ESLint config extends Airbnb, TypeScript strict, Lit, Web Components, Accessibility
|
||||
- Prettier with ES5 trailing commas enforced
|
||||
- No console statements (`no-console: "error"`) - use proper logging
|
||||
- Import organization: No unused imports, consistent type imports
|
||||
@@ -188,7 +136,6 @@ export class HaMyComponent extends LitElement {
|
||||
### Data Management
|
||||
|
||||
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
|
||||
- **Prefer contexts over `hass`**: For state reads, consume the relevant Lit context instead of taking the whole `hass` object — see [State Access: Contexts Instead of `hass`](#state-access-contexts-instead-of-hass)
|
||||
- **Cache appropriately**: Use collections and caching for frequently accessed data
|
||||
- **Handle errors gracefully**: All API calls should have error handling
|
||||
- **Update real-time**: Subscribe to state changes for live updates
|
||||
@@ -213,7 +160,7 @@ try {
|
||||
- Defined in `src/resources/theme/core.globals.ts`
|
||||
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
|
||||
- **Mobile-first responsive**: Design for mobile, enhance for desktop
|
||||
- **Prefer `ha-*` components**: Build on the Home Assistant component library (many now wrap Web Awesome components); avoid new use of legacy Material Web Components (`mwc-*`), which are being phased out
|
||||
- **Follow Material Design**: Use Material Web Components where appropriate
|
||||
- **Support RTL**: Ensure all layouts work in RTL languages
|
||||
|
||||
```typescript
|
||||
@@ -247,13 +194,13 @@ The View Transitions API creates smooth animations between DOM state changes. Wh
|
||||
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
|
||||
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
|
||||
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
|
||||
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-duration-fast` (150ms), `--ha-animation-duration-normal` (250ms), `--ha-animation-duration-slow` (350ms) (all respect `prefers-reduced-motion`)
|
||||
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-base-duration` (350ms, respects `prefers-reduced-motion`)
|
||||
|
||||
**Implementation Guidelines:**
|
||||
|
||||
1. Always use `withViewTransition()` wrapper for automatic fallback
|
||||
2. Keep transitions simple (subtle crossfades and fades work best)
|
||||
3. Use `--ha-animation-duration-*` CSS variables for consistent timing (`fast`, `normal`, `slow`)
|
||||
3. Use `--ha-animation-base-duration` CSS variable for consistent timing
|
||||
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
|
||||
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
|
||||
|
||||
@@ -267,6 +214,13 @@ By default, `:root` receives `view-transition-name: root`, creating a full-page
|
||||
- Only one view transition can run at a time
|
||||
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
|
||||
|
||||
**Current Usage & Planned Applications:**
|
||||
|
||||
- Launch screen fade out (implemented)
|
||||
- Automation sidebar transitions (planned - #27238)
|
||||
- More info dialog content changes (planned - #27672)
|
||||
- Toolbar navigation, ha-spinner transitions (planned)
|
||||
|
||||
**Specification & Documentation:**
|
||||
|
||||
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
|
||||
@@ -289,11 +243,15 @@ For browser support, API details, and current specifications, refer to these aut
|
||||
- **Test with Vitest**: Use the established test framework
|
||||
- **Mock appropriately**: Mock WebSocket connections and API calls
|
||||
- **Test accessibility**: Ensure components are accessible
|
||||
- **Optimizing chart data processing**: When optimizing chart data transforms (history, statistics, energy, downsampling), follow the playbook in [`test/benchmarks/README.md`](test/benchmarks/README.md) — it has seeded fixtures, characterization (snapshot) tests that pin current output, and `vitest bench` benchmarks (`yarn test:bench`) for before/after comparison. Optimizations must keep output bit-identical.
|
||||
|
||||
## Component Library
|
||||
|
||||
### Dialog Component
|
||||
### Dialog Components
|
||||
|
||||
**Available Dialog Types:**
|
||||
|
||||
- `ha-wa-dialog` - Preferred for new dialogs (Web Awesome based)
|
||||
- `ha-dialog` - Legacy component (still widely used)
|
||||
|
||||
**Opening Dialogs (Fire Event Pattern - Recommended):**
|
||||
|
||||
@@ -307,7 +265,6 @@ fireEvent(this, "show-dialog", {
|
||||
|
||||
**Dialog Implementation Requirements:**
|
||||
|
||||
- Use `ha-dialog` component
|
||||
- Implement `HassDialog<T>` interface
|
||||
- Use `@state() private _open = false` to control dialog visibility
|
||||
- Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
|
||||
@@ -321,29 +278,37 @@ fireEvent(this, "show-dialog", {
|
||||
|
||||
**Dialog Sizing:**
|
||||
|
||||
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (580px - default), `"large"` (1024px), or `"full"`
|
||||
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (560px - default), `"large"` (720px), or `"full"`
|
||||
- Custom sizing is NOT recommended - use the standard width presets
|
||||
- Example: `<ha-wa-dialog width="small">` for alert/confirmation dialogs
|
||||
|
||||
**Button Appearance Guidelines:**
|
||||
|
||||
`ha-button` (wraps the Web Awesome button — see `src/components/ha-button.ts`) has two independent axes plus size:
|
||||
|
||||
- **`variant`** (color): `"brand"` (default), `"neutral"`, `"danger"`, `"warning"`, `"success"`
|
||||
- **`appearance`** (fill style): `"accent"`, `"filled"`, `"outlined"`, `"plain"`
|
||||
- **`size`**: `"xs"` (extra small, 40px), `"s"` (small, 32px), `"m"` (medium, 40px - default), `"l"` (large, 48px), `"xl"` (extra large, 40px)
|
||||
|
||||
Common patterns:
|
||||
|
||||
- **Primary action**: `appearance="filled"` for emphasis (or the default appearance for a lighter look)
|
||||
- **Secondary action**: `appearance="plain"` for cancel/dismiss actions
|
||||
- **Destructive actions**: `variant="danger"` for delete/remove operations (the generic confirmation dialog uses `variant="danger"` for its confirm button — see `src/dialogs/generic/dialog-box.ts`)
|
||||
- **Primary action buttons**: Default appearance (no appearance attribute) or omit for standard styling
|
||||
- **Secondary action buttons**: Use `appearance="plain"` for cancel/dismiss actions
|
||||
- **Destructive actions**: Use `appearance="filled"` for delete/remove operations (combined with appropriate semantic styling)
|
||||
- **Button sizes**: Use `size="small"` (32px height) or default/medium (40px height)
|
||||
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
|
||||
|
||||
**Recent Examples:**
|
||||
|
||||
See these files for current patterns:
|
||||
|
||||
- `src/panels/config/repairs/dialog-repairs-issue.ts`
|
||||
- `src/dialogs/restart/dialog-restart.ts`
|
||||
- `src/panels/config/lovelace/resources/dialog-lovelace-resource-detail.ts`
|
||||
|
||||
**Gallery Documentation:**
|
||||
|
||||
- `gallery/src/pages/components/ha-wa-dialog.markdown`
|
||||
- `gallery/src/pages/components/ha-dialogs.markdown`
|
||||
|
||||
### Form Component (ha-form)
|
||||
|
||||
- Schema-driven using `HaFormSchema[]`
|
||||
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
|
||||
- Built-in validation with error display
|
||||
- Use `dialogInitialFocus` in dialogs
|
||||
- Use `computeLabel`, `computeError`, `computeHelper` for translations
|
||||
|
||||
```typescript
|
||||
@@ -357,11 +322,14 @@ Common patterns:
|
||||
></ha-form>
|
||||
```
|
||||
|
||||
**Gallery Documentation:**
|
||||
|
||||
- `gallery/src/pages/components/ha-form.markdown`
|
||||
|
||||
### Alert Component (ha-alert)
|
||||
|
||||
- Types: `error`, `warning`, `info`, `success`
|
||||
- Properties: `title`, `alert-type`, `dismissable`, `narrow`
|
||||
- Slots: `icon` (override the leading icon), `action` (custom action content)
|
||||
- Properties: `title`, `alert-type`, `dismissable`, `icon`, `action`, `rtl`
|
||||
- Content announced by screen readers when dynamically displayed
|
||||
|
||||
```html
|
||||
@@ -370,6 +338,10 @@ Common patterns:
|
||||
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
|
||||
```
|
||||
|
||||
**Gallery Documentation:**
|
||||
|
||||
- `gallery/src/pages/components/ha-alert.markdown`
|
||||
|
||||
### Keyboard Shortcuts (ShortcutManager)
|
||||
|
||||
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
|
||||
@@ -393,6 +365,7 @@ The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming
|
||||
|
||||
- **Component definition**: `src/components/ha-tooltip.ts`
|
||||
- **Usage example**: `src/components/ha-label.ts`
|
||||
- **Gallery documentation**: `gallery/src/pages/components/ha-tooltip.markdown`
|
||||
|
||||
## Common Patterns
|
||||
|
||||
@@ -420,9 +393,84 @@ export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
```
|
||||
|
||||
### Creating a Dialog
|
||||
|
||||
```typescript
|
||||
@customElement("dialog-my-feature")
|
||||
export class DialogMyFeature
|
||||
extends LitElement
|
||||
implements HassDialog<MyDialogParams>
|
||||
{
|
||||
@property({ attribute: false })
|
||||
hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
private _params?: MyDialogParams;
|
||||
|
||||
@state()
|
||||
private _open = false;
|
||||
|
||||
public async showDialog(params: MyDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._open = true;
|
||||
}
|
||||
|
||||
public closeDialog(): void {
|
||||
this._open = false;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
this._params = undefined;
|
||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this._params) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-wa-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
header-title=${this._params.title}
|
||||
header-subtitle=${this._params.subtitle}
|
||||
@closed=${this._dialogClosed}
|
||||
>
|
||||
<p>Dialog content</p>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button
|
||||
slot="secondaryAction"
|
||||
appearance="plain"
|
||||
@click=${this.closeDialog}
|
||||
>
|
||||
${this.hass.localize("ui.common.cancel")}
|
||||
</ha-button>
|
||||
<ha-button slot="primaryAction" @click=${this._submit}>
|
||||
${this.hass.localize("ui.common.save")}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-wa-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [haStyleDialog, css``];
|
||||
}
|
||||
```
|
||||
|
||||
### Dialog Design Guidelines
|
||||
|
||||
- Max width: 560px (Alert/confirmation: 320px fixed width)
|
||||
- Close X-icon on top left (all screen sizes)
|
||||
- Submit button grouped with cancel at bottom right
|
||||
- Keep button labels short: "Save", "Delete", "Enable"
|
||||
- Destructive actions use red warning button
|
||||
- Always use a title (best practice)
|
||||
- Strive for minimalism
|
||||
|
||||
#### Creating a Lovelace Card
|
||||
|
||||
**Purpose**: Cards allow users to tell different stories about their house.
|
||||
**Purpose**: Cards allow users to tell different stories about their house (based on gallery)
|
||||
|
||||
```typescript
|
||||
@customElement("hui-my-card")
|
||||
@@ -495,13 +543,9 @@ this.hass.localize("ui.panel.config.updates.update_available", {
|
||||
4. **Test**: `yarn test` - Add and run tests
|
||||
5. **Build**: `script/build_frontend` - Test production build
|
||||
|
||||
### Gallery
|
||||
|
||||
For Gallery-specific structure, page/demo naming, sidebar behavior, content standards, and commands, see [`gallery/AGENTS.md`](gallery/AGENTS.md).
|
||||
|
||||
### Common Pitfalls to Avoid
|
||||
|
||||
- Don't manually query the DOM with `querySelector` - use the `@query`/`@queryAll` decorators or component properties
|
||||
- Don't use `querySelector` - Use refs or component properties
|
||||
- Don't manipulate DOM directly - Let Lit handle rendering
|
||||
- Don't use global styles - Scope styles to components
|
||||
- Don't block the main thread - Use web workers for heavy computation
|
||||
@@ -514,22 +558,11 @@ For Gallery-specific structure, page/demo naming, sidebar behavior, content stan
|
||||
- Use HTTPS - All external resources must use HTTPS
|
||||
- CSP compliance - Ensure code works with Content Security Policy
|
||||
|
||||
### Pull Requests
|
||||
|
||||
When creating a pull request, you **must** use the PR template located at `.github/PULL_REQUEST_TEMPLATE.md`. Read the template file and use its full content as the PR body, filling in each section appropriately.
|
||||
|
||||
- Do not omit, reorder, or rewrite the template sections
|
||||
- Check the appropriate "Type of change" box based on the changes
|
||||
- Do not check the checklist items on behalf of the user — those are the user's responsibility to review and check
|
||||
- If the PR includes UI changes, remind the user to add screenshots or a short video to the PR after creating it
|
||||
- Be simple and user friendly — explain what the change does, not implementation details
|
||||
- Use markdown so the user can copy it
|
||||
|
||||
### Text and Copy Guidelines
|
||||
|
||||
#### Terminology Standards
|
||||
|
||||
**Delete vs Remove**
|
||||
**Delete vs Remove** (Based on gallery/src/pages/Text/remove-delete-add-create.markdown)
|
||||
|
||||
- **Use "Remove"** for actions that can be restored or reapplied:
|
||||
- Removing a user's permission
|
||||
@@ -592,24 +625,35 @@ When creating a pull request, you **must** use the PR template located at `.gith
|
||||
|
||||
#### Translation Considerations
|
||||
|
||||
All user-facing text must be translatable — see the **Internationalization** section (under Common Patterns) for the `localize` API and placeholder usage. From a copy perspective:
|
||||
|
||||
- **Add translation keys**: All user-facing text must be translatable
|
||||
- **Use placeholders**: Support dynamic content in translations
|
||||
- **Keep context**: Provide enough context for translators
|
||||
- **Avoid concatenation**: Prefer full localized strings with placeholders over stitching translated fragments together
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
this.hass.localize("ui.panel.config.automation.delete_confirm", {
|
||||
name: automation.alias,
|
||||
});
|
||||
|
||||
// Bad - hardcoded text
|
||||
("Are you sure you want to delete this automation?");
|
||||
```
|
||||
|
||||
### Common Review Issues (From PR Analysis)
|
||||
|
||||
Recurring, easy-to-miss problems surfaced in real PR reviews. These complement the standards above rather than repeating them — items already covered earlier (loading states, error handling, mobile layout, theming, import hygiene) are intentionally not duplicated here.
|
||||
|
||||
#### User Experience and Accessibility
|
||||
|
||||
- **Form validation**: Always provide proper field labels and validation feedback
|
||||
- **Form accessibility**: Prevent password managers from incorrectly identifying fields
|
||||
- **Loading states**: Show clear progress indicators during async operations
|
||||
- **Error handling**: Display meaningful error messages when operations fail
|
||||
- **Mobile responsiveness**: Ensure components work well on small screens
|
||||
- **Hit targets**: Make clickable areas large enough for touch interaction
|
||||
- **Visual feedback**: Provide clear indication of interactive states (hover, active, focus)
|
||||
- **Visual feedback**: Provide clear indication of interactive states
|
||||
|
||||
#### Dialog and Modal Patterns
|
||||
|
||||
- **Dialog width constraints**: Respect minimum and maximum width requirements
|
||||
- **Interview progress**: Show clear progress for multi-step operations
|
||||
- **State persistence**: Handle dialog state properly during background operations
|
||||
- **Cancel behavior**: Ensure cancel/close buttons work consistently
|
||||
@@ -621,12 +665,15 @@ Recurring, easy-to-miss problems surfaced in real PR reviews. These complement t
|
||||
- **Visual hierarchy**: Ensure proper font sizes and spacing ratios
|
||||
- **Grid alignment**: Components should align to the design grid system
|
||||
- **Badge placement**: Position badges and indicators consistently
|
||||
- **Color theming**: Respect theme variables and design system colors
|
||||
|
||||
#### Code Quality Issues
|
||||
|
||||
- **Null checking**: Always check if entities exist before accessing properties
|
||||
- **TypeScript safety**: Handle potentially undefined array/object access
|
||||
- **Event handling and cleanup**: Subscribe/unsubscribe correctly and remove listeners to avoid memory leaks
|
||||
- **Import organization**: Remove unused imports and use proper type imports
|
||||
- **Event handling**: Properly subscribe and unsubscribe from events
|
||||
- **Memory leaks**: Clean up subscriptions and event listeners
|
||||
|
||||
#### Configuration and Props
|
||||
|
||||
@@ -637,12 +684,42 @@ Recurring, easy-to-miss problems surfaced in real PR reviews. These complement t
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
Final pre-submission checklist. Linting and formatting are enforced by tooling, so this focuses on what tools can't catch rather than restating every rule above.
|
||||
### Core Requirements Checklist
|
||||
|
||||
- [ ] `yarn lint` passes (TypeScript, ESLint, Prettier, Lit analyzer) and `yarn test` is green
|
||||
- [ ] Tests added for new data processing/utilities (where applicable)
|
||||
- [ ] All user-facing text is localized and follows the Text and Copy guidelines (sentence case, "Home Assistant" in full, Delete/Remove + Create/Add)
|
||||
- [ ] TypeScript strict mode passes (`yarn lint:types`)
|
||||
- [ ] No ESLint errors or warnings (`yarn lint:eslint`)
|
||||
- [ ] Prettier formatting applied (`yarn lint:prettier`)
|
||||
- [ ] Lit analyzer passes (`yarn lint:lit`)
|
||||
- [ ] Component follows Lit best practices
|
||||
- [ ] Proper error handling implemented
|
||||
- [ ] Loading states handled
|
||||
- [ ] Mobile responsive
|
||||
- [ ] Theme variables used
|
||||
- [ ] Translations added
|
||||
- [ ] Accessible to screen readers
|
||||
- [ ] Tests added (where applicable)
|
||||
- [ ] No console statements (use proper logging)
|
||||
- [ ] Unused imports removed
|
||||
- [ ] Proper naming conventions
|
||||
|
||||
### Text and Copy Checklist
|
||||
|
||||
- [ ] Follows terminology guidelines (Delete vs Remove, Create vs Add)
|
||||
- [ ] Localization keys added for all user-facing text
|
||||
- [ ] Uses "Home Assistant" (never "HA" or "HASS")
|
||||
- [ ] Sentence case for ALL text (titles, headings, buttons, labels)
|
||||
- [ ] American English spelling
|
||||
- [ ] Friendly, informational tone
|
||||
- [ ] Avoids abbreviations and jargon
|
||||
- [ ] Correct terminology (integration not component)
|
||||
|
||||
### Component-Specific Checks
|
||||
|
||||
- [ ] Dialogs implement HassDialog interface
|
||||
- [ ] Dialog styling uses haStyleDialog
|
||||
- [ ] Dialog accessibility includes dialogInitialFocus
|
||||
- [ ] ha-alert used correctly for messages
|
||||
- [ ] ha-form uses proper schema structure
|
||||
- [ ] Components handle all states (loading, error, unavailable)
|
||||
- [ ] Entity existence checked before property access
|
||||
- [ ] Event/subscription listeners cleaned up (no memory leaks)
|
||||
- [ ] Accessible to screen readers and keyboard
|
||||
- [ ] Event subscriptions properly cleaned up
|
||||
|
||||
@@ -5,8 +5,6 @@ updates:
|
||||
schedule:
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- Dependencies
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Blocking labels
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
- unlabeled
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check for labels which block the Pull Request from being merged
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for blocking labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const blockingLabels = [
|
||||
"wait for backend",
|
||||
"Needs UX",
|
||||
"Do Not Review",
|
||||
"Blocked",
|
||||
"has-parent",
|
||||
];
|
||||
const prLabels = context.payload.pull_request.labels.map(
|
||||
(l) => l.name
|
||||
);
|
||||
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
|
||||
if (found.length > 0) {
|
||||
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
|
||||
await core.summary
|
||||
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
|
||||
.addRaw(message)
|
||||
.write();
|
||||
core.setFailed(message);
|
||||
} else {
|
||||
await core.summary
|
||||
.addHeading(":white_check_mark: Pull Request is clear to merge after review", 2)
|
||||
.addRaw("This Pull Request is not blocked by any labels which prevent it from being merged.")
|
||||
.write();
|
||||
}
|
||||
@@ -8,9 +8,6 @@ on:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
@@ -24,13 +21,12 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -46,7 +42,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=cast/dist --alias dev
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --alias dev
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
@@ -60,13 +56,12 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -82,7 +77,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=cast/dist --prod
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=cast/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_CAST_SITE_ID }}
|
||||
|
||||
@@ -18,20 +18,15 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint and check format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -42,7 +37,7 @@ jobs:
|
||||
- name: Build resources
|
||||
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
|
||||
- name: Setup lint cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: |
|
||||
node_modules/.cache/prettier
|
||||
@@ -58,18 +53,14 @@ jobs:
|
||||
run: yarn run lint:lit --quiet
|
||||
- name: Run prettier
|
||||
run: yarn run lint:prettier
|
||||
- name: Check dependency licenses
|
||||
run: yarn run lint:licenses
|
||||
test:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -85,11 +76,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -100,13 +89,13 @@ jobs:
|
||||
env:
|
||||
IS_TEST: "true"
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: frontend-bundle-stats
|
||||
path: build/stats/*.json
|
||||
if-no-files-found: error
|
||||
- name: Upload frontend build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: frontend-build
|
||||
path: hass_frontend/
|
||||
|
||||
@@ -7,10 +7,6 @@ on:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [dev]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
@@ -27,12 +23,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can checkout the head.
|
||||
fetch-depth: 2
|
||||
persist-credentials: false
|
||||
|
||||
# If this run was triggered by a pull request event, then checkout
|
||||
# the head of the pull request instead of the merge commit.
|
||||
@@ -41,14 +36,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/autobuild@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -62,4 +57,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2
|
||||
|
||||
@@ -9,9 +9,6 @@ on:
|
||||
- dev
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
@@ -25,13 +22,12 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: dev
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -47,7 +43,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_DEV_SITE_ID }}
|
||||
@@ -61,13 +57,12 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: master
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -83,7 +78,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=demo/dist --prod
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=demo/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_DEMO_SITE_ID }}
|
||||
|
||||
@@ -5,9 +5,6 @@ on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
@@ -19,12 +16,10 @@ jobs:
|
||||
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -40,7 +35,7 @@ jobs:
|
||||
- name: Deploy to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --prod
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --prod
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_GALLERY_SITE_ID }}
|
||||
|
||||
@@ -10,9 +10,6 @@ on:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
@@ -24,12 +21,10 @@ jobs:
|
||||
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -45,7 +40,7 @@ jobs:
|
||||
- name: Deploy preview to Netlify
|
||||
id: deploy
|
||||
run: |
|
||||
npx -y netlify-cli deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
|
||||
npx -y netlify-cli@23.7.3 deploy --dir=gallery/dist --alias "deploy-preview-${{ github.event.number }}" \
|
||||
--json > deploy_output.json
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
|
||||
@@ -1,308 +0,0 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
- master
|
||||
# BrowserStack runs are gated by the `e2e-browserstack` label or manual
|
||||
# dispatch — see the e2e-browserstack job below. Local Chromium always runs.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
run-browserstack:
|
||||
description: "Run BrowserStack suite"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
env:
|
||||
NODE_OPTIONS: --max_old_space_size=6144
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ── Build the demo once and share it across test jobs via artifact ──────────
|
||||
build-demo:
|
||||
name: Build demo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build demo
|
||||
run: ./node_modules/.bin/gulp build-demo
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Build the e2e test app and share it via artifact ────────────────────────
|
||||
build-e2e-test-app:
|
||||
name: Build e2e test app
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build e2e test app
|
||||
run: ./node_modules/.bin/gulp build-e2e-test-app
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Build the gallery and share it via artifact ─────────────────────────────
|
||||
build-gallery:
|
||||
name: Build gallery
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Build gallery
|
||||
run: ./node_modules/.bin/gulp build-gallery
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
|
||||
# ── Run Playwright tests locally against Chromium ──────────────────────────
|
||||
e2e-local:
|
||||
name: E2E (local Chromium)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
runs-on: ubuntu-latest
|
||||
# Fail fast if anything hangs. The whole suite should take < 15 minutes on
|
||||
# Chromium; anything longer is almost certainly an install or webServer
|
||||
# hang.
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
# Cache the downloaded browser build keyed on the pinned Playwright
|
||||
# version (yarn.lock), so re-runs skip the ~170 MB download.
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: yarn playwright install --with-deps chromium
|
||||
timeout-minutes: 10
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
- name: Run Playwright tests (local)
|
||||
run: yarn test:e2e
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
retention-days: 3
|
||||
|
||||
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
|
||||
# The BrowserStack SDK manages the Local tunnel and uploads results to the
|
||||
# BrowserStack Automate dashboard automatically — no tunnel action needed.
|
||||
#
|
||||
# Gated on:
|
||||
# - manual dispatch with the run-browserstack input enabled, OR
|
||||
# - a PR with the `e2e-browserstack` label applied.
|
||||
# This keeps CI fast on normal PRs while still allowing on-demand runs.
|
||||
e2e-browserstack:
|
||||
name: E2E (BrowserStack)
|
||||
needs: [build-demo, build-e2e-test-app, build-gallery]
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
(github.event_name == 'workflow_dispatch' && inputs.run-browserstack) ||
|
||||
(github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'e2e-browserstack'))
|
||||
environment: browserstack
|
||||
env:
|
||||
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
|
||||
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
|
||||
- name: Run Playwright tests (BrowserStack)
|
||||
run: yarn test:e2e:browserstack
|
||||
|
||||
# ── Merge local blob reports and post PR comment ───────────────────────────
|
||||
# Only depends on the local job — BrowserStack reports live on the
|
||||
# BrowserStack Automate dashboard and don't feed into the local blob report.
|
||||
report:
|
||||
name: Report
|
||||
needs: [e2e-local]
|
||||
runs-on: ubuntu-latest
|
||||
if: always()
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Download blob report (local)
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-local
|
||||
path: test/e2e/reports/
|
||||
|
||||
- name: Stage blobs for merge
|
||||
run: node test/e2e/collect-blob-reports.mjs
|
||||
|
||||
- name: Merge blob reports
|
||||
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
|
||||
|
||||
- name: Upload merged HTML report
|
||||
id: upload-report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: test/e2e/reports/combined/
|
||||
retention-days: 14
|
||||
|
||||
- name: Post report link to PR
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
|
||||
with:
|
||||
script: |
|
||||
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
|
||||
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body,
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
name: "Pull Request Labeler"
|
||||
|
||||
on: pull_request_target # zizmor: ignore[dangerous-triggers] -- safe: only runs actions/labeler, no PR code checkout
|
||||
on: pull_request_target
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -5,19 +5,17 @@ on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
process-only: "issues, prs"
|
||||
issue-inactive-days: "30"
|
||||
issue-lock-inactive-days: "30"
|
||||
issue-exclude-created-before: "2020-10-01T00:00:00Z"
|
||||
issue-lock-reason: ""
|
||||
pr-inactive-days: "1"
|
||||
pr-lock-inactive-days: "1"
|
||||
pr-exclude-created-before: "2020-11-01T00:00:00Z"
|
||||
pr-lock-reason: ""
|
||||
|
||||
@@ -20,9 +20,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
||||
@@ -30,7 +28,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
@@ -59,14 +57,14 @@ jobs:
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: wheels
|
||||
path: dist/home_assistant_frontend*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
name: Pull request standards
|
||||
|
||||
on:
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] -- safe: reads PR metadata from event payload only, no PR code checkout
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- reopened
|
||||
- ready_for_review
|
||||
branches:
|
||||
- dev
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check pull request follows contribution standards
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write # To label and comment on pull requests
|
||||
steps:
|
||||
- name: Check pull request standards
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Exempt bots (Copilot agent, dependabot), drafts, and maintainers.
|
||||
if (pr.user.type === "Bot") {
|
||||
core.info(`Skipping bot author: ${pr.user.login}`);
|
||||
return;
|
||||
}
|
||||
if (pr.draft) {
|
||||
core.info("Skipping draft pull request");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await github.rest.orgs.checkMembershipForUser({
|
||||
org: "home-assistant",
|
||||
username: pr.user.login,
|
||||
});
|
||||
core.info(`Skipping organization member: ${pr.user.login}`);
|
||||
return;
|
||||
} catch (error) {
|
||||
core.info(`${pr.user.login} is not an organization member, checking standards`);
|
||||
}
|
||||
|
||||
const label = "Needs Template";
|
||||
const marker = "<!-- pr-standards-check -->";
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = pr.number;
|
||||
|
||||
const body = (pr.body || "").replace(/<!--[\s\S]*?-->/g, "");
|
||||
const normalized = body.toLowerCase();
|
||||
|
||||
// Ignore 404s from mutations that race manual edits or cancelled runs.
|
||||
const ignoreMissing = async (fn) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
core.info("Target already removed, nothing to do");
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Hide/restore our comment via GraphQL (REST cannot minimize).
|
||||
const setMinimized = async (subjectId, minimized) => {
|
||||
const mutation = minimized
|
||||
? `mutation($id: ID!) {
|
||||
minimizeComment(input: { subjectId: $id, classifier: RESOLVED }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`
|
||||
: `mutation($id: ID!) {
|
||||
unminimizeComment(input: { subjectId: $id }) {
|
||||
clientMutationId
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await github.graphql(mutation, { id: subjectId });
|
||||
} catch (error) {
|
||||
core.info(
|
||||
`Could not ${minimized ? "minimize" : "restore"} comment: ${error.message}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Content of a "## <name>" section, or null when the heading is absent.
|
||||
const section = (name) => {
|
||||
const match = body.match(
|
||||
new RegExp(`##\\s${name}([\\s\\S]*?)(?=\\n##\\s|$)`, "i")
|
||||
);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
const problems = [];
|
||||
|
||||
const requiredHeadings = [
|
||||
"## proposed change",
|
||||
"## type of change",
|
||||
"## checklist",
|
||||
];
|
||||
if (requiredHeadings.some((h) => !normalized.includes(h))) {
|
||||
problems.push(
|
||||
"Use the pull request template without removing its sections."
|
||||
);
|
||||
}
|
||||
|
||||
const typeOfChange = section("type of change");
|
||||
if (typeOfChange !== null) {
|
||||
const ticked = (typeOfChange.match(/-\s*\[[xX]\]/g) || []).length;
|
||||
if (ticked !== 1) {
|
||||
problems.push(
|
||||
'Select exactly one option under "Type of change".'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const proposedChange = section("proposed change");
|
||||
if (proposedChange !== null && proposedChange.trim().length === 0) {
|
||||
problems.push('Describe your changes under "Proposed change".');
|
||||
}
|
||||
|
||||
const isValid = problems.length === 0;
|
||||
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number, per_page: 100 }
|
||||
);
|
||||
const existing = comments.find((c) => c.body.includes(marker));
|
||||
const hasLabel = pr.labels.some((l) => l.name === label);
|
||||
|
||||
if (isValid) {
|
||||
core.info("Pull request standards met");
|
||||
|
||||
if (hasLabel) {
|
||||
await ignoreMissing(() =>
|
||||
github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number, name: label,
|
||||
})
|
||||
);
|
||||
}
|
||||
if (existing) {
|
||||
await setMinimized(existing.node_id, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
core.info(`Pull request standards not met:\n- ${problems.join("\n- ")}`);
|
||||
|
||||
if (!hasLabel) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number, labels: [label],
|
||||
});
|
||||
}
|
||||
|
||||
const message =
|
||||
`${marker}\n` +
|
||||
`Hey @${pr.user.login}!\n\n` +
|
||||
`Thank you for your contribution! To help reviewers, please update ` +
|
||||
`this pull request to follow our pull request standards:\n\n` +
|
||||
problems.map((p) => `- ${p}`).join("\n") +
|
||||
`\n\n` +
|
||||
`Please complete the ` +
|
||||
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
|
||||
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo, comment_id: existing.id, body: message,
|
||||
});
|
||||
await setMinimized(existing.node_id, false);
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number, body: message,
|
||||
});
|
||||
}
|
||||
|
||||
// Fail this check so it can block the PR from being merged
|
||||
core.setFailed(
|
||||
`Pull request standards not met:\n- ${problems.join("\n- ")}`
|
||||
);
|
||||
@@ -1,39 +1,25 @@
|
||||
name: RelativeCI
|
||||
|
||||
on:
|
||||
# zizmor: ignore[dangerous-triggers] -- safe: only downloads artifacts, no PR code checkout
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types:
|
||||
- completed
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
upload-frontend-modern:
|
||||
name: Upload stats (frontend/modern)
|
||||
upload:
|
||||
name: Upload stats
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
strategy:
|
||||
matrix:
|
||||
bundle: [frontend]
|
||||
build: [modern, legacy]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
|
||||
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
|
||||
token: ${{ github.token }}
|
||||
artifactName: frontend-bundle-stats
|
||||
webpackStatsFile: frontend-modern.json
|
||||
|
||||
upload-frontend-legacy:
|
||||
name: Upload stats (frontend/legacy)
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
|
||||
token: ${{ github.token }}
|
||||
artifactName: frontend-bundle-stats
|
||||
webpackStatsFile: frontend-legacy.json
|
||||
artifactName: ${{ format('{0}-bundle-stats', matrix.bundle) }}
|
||||
webpackStatsFile: ${{ format('{0}-{1}.json', matrix.bundle, matrix.build) }}
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
|
||||
- uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6.2.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -26,9 +26,7 @@ jobs:
|
||||
if: github.repository_owner == 'home-assistant'
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -36,12 +34,13 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@e91ad1948e57189485b9c1ad608af0c303946f89 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
@@ -58,15 +57,16 @@ jobs:
|
||||
script/release
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
- name: Upload release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: gh release upload "$TAG_NAME" dist/*.whl dist/*.tar.gz --clobber
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
files: |
|
||||
dist/*.whl
|
||||
dist/*.tar.gz
|
||||
|
||||
wheels-init:
|
||||
name: Init wheels build
|
||||
@@ -74,30 +74,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate requirements.txt
|
||||
env:
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
run: |
|
||||
version=$(echo "$GITHUB_REF" | awk -F"/" '{print $NF}' )
|
||||
# Wait for the package to become available on PyPI
|
||||
echo "Waiting for home-assistant-frontend==$version to appear on PyPI..."
|
||||
for i in $(seq 1 30); do
|
||||
status=$(curl -s -o /dev/null -w "%{http_code}" "https://pypi.org/pypi/home-assistant-frontend/$version/json")
|
||||
if [ "$status" = "200" ]; then
|
||||
echo "Package is available on PyPI!"
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "30" ]; then
|
||||
echo "Timed out waiting for package to appear on PyPI"
|
||||
exit 1
|
||||
fi
|
||||
echo "Not available yet (HTTP $status), retrying in 30 seconds... ($i/30)"
|
||||
sleep 30
|
||||
done
|
||||
# Sleep to give pypi time to populate the new version across mirrors
|
||||
sleep 240
|
||||
version=$(echo "${{ github.ref }}" | awk -F"/" '{print $NF}' )
|
||||
echo "home-assistant-frontend==$version" > ./requirements.txt
|
||||
|
||||
# home-assistant/wheels doesn't support SHA pinning
|
||||
- name: Build wheels
|
||||
uses: home-assistant/wheels@34957438948e0b3dcde73c77750643dadae594f5 # 2026.06.0
|
||||
uses: home-assistant/wheels@2025.12.0
|
||||
with:
|
||||
abi: cp314
|
||||
tag: musllinux_1_2
|
||||
@@ -113,13 +98,12 @@ jobs:
|
||||
contents: write # Required to upload release assets
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
- name: Download Translations
|
||||
@@ -129,11 +113,8 @@ jobs:
|
||||
- name: Build landing-page
|
||||
run: landing-page/script/build_landing_page
|
||||
- name: Tar folder
|
||||
env:
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: tar -czf "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" -C landing-page/dist .
|
||||
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
|
||||
- name: Upload release asset
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: gh release upload "$TAG_NAME" "landing-page/home_assistant_frontend_landingpage-${TAG_NAME}.tar.gz" --clobber
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
|
||||
|
||||
@@ -5,43 +5,14 @@ on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions: {}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
|
||||
jobs:
|
||||
add-no-stale:
|
||||
name: Add no-stale label
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To add labels to issues
|
||||
if: >-
|
||||
github.event.issue.type.name == 'Task'
|
||||
|| github.event.issue.type.name == 'Epic'
|
||||
|| github.event.issue.type.name == 'Opportunity'
|
||||
steps:
|
||||
- name: Add no-stale label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: ['no-stale']
|
||||
});
|
||||
|
||||
check-authorization:
|
||||
name: Check authorization
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write # To comment on, label, and close issues
|
||||
# Only run if this is a Task issue type (from the issue form)
|
||||
if: github.event.issue.type.name == 'Task'
|
||||
steps:
|
||||
- name: Check if user is authorized
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
script: |
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
|
||||
@@ -5,17 +5,12 @@ on:
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
name: Sync numeric device classes
|
||||
|
||||
# Mirrors Home Assistant Core's numeric `SensorDeviceClass` list into the
|
||||
# build-time default in src/data/sensor_numeric_device_classes.ts and opens a PR
|
||||
# when it drifts. Reads homeassistant/generated/sensor.json from core.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 4 * * *" # Daily, 04:00 UTC
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
name: Sync
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Regenerate numeric device classes
|
||||
run: ./script/gen_numeric_device_classes
|
||||
|
||||
- name: Format
|
||||
run: yarn prettier --write src/data/sensor_numeric_device_classes.ts
|
||||
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
branch: chore/sync-numeric-device-classes
|
||||
commit-message: Update numeric sensor device classes
|
||||
title: Update numeric sensor device classes
|
||||
body: |
|
||||
Regenerated `SENSOR_NUMERIC_DEVICE_CLASSES` from Home Assistant Core's
|
||||
`SensorDeviceClass`.
|
||||
|
||||
Automated by `.github/workflows/sync-numeric-device-classes.yaml`.
|
||||
@@ -8,18 +8,13 @@ on:
|
||||
paths:
|
||||
- src/translations/en.json
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
name: Upload
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Upload Translations
|
||||
run: |
|
||||
|
||||
@@ -54,16 +54,7 @@ src/cast/dev_const.ts
|
||||
# test coverage
|
||||
test/coverage/
|
||||
|
||||
# Playwright e2e output
|
||||
test/e2e/reports/
|
||||
test/e2e/test-results/
|
||||
# E2E test app build output
|
||||
test/e2e/app/dist/
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
diff --git a/dist/tinykeys.cjs b/dist/tinykeys.cjs
|
||||
index 08c98b6eff3b8fb4b727fe8e6b096951d6ef6347..9c44f14862f582766ea1733b6dc0e97f962800d8 100644
|
||||
--- a/dist/tinykeys.cjs
|
||||
+++ b/dist/tinykeys.cjs
|
||||
@@ -61,6 +61,18 @@ function defaultKeybindingsHandlerIgnore(event) {
|
||||
function getModifierState(event, mod) {
|
||||
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
|
||||
}
|
||||
+function splitKeybindingPress(press) {
|
||||
+ let parts = [];
|
||||
+ let start = 0;
|
||||
+ for (let index = 0; index < press.length; index++) {
|
||||
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
|
||||
+ parts.push(press.slice(start, index));
|
||||
+ start = index + 1;
|
||||
+ }
|
||||
+ }
|
||||
+ parts.push(press.slice(start));
|
||||
+ return parts;
|
||||
+}
|
||||
/**
|
||||
* Parses a keybinding string into its parts.
|
||||
*
|
||||
@@ -76,10 +88,10 @@ function getModifierState(event, mod) {
|
||||
*/
|
||||
function parseKeybinding(str) {
|
||||
return str.trim().split(" ").map((press) => {
|
||||
- let parts = press.split(/(?<=\w|\])\+/);
|
||||
+ let parts = splitKeybindingPress(press);
|
||||
let last = parts.pop();
|
||||
let regex = last.match(/^\((.+)\)$/);
|
||||
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
|
||||
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
|
||||
let requiredModifiers = [];
|
||||
let optionalModifiers = [];
|
||||
for (const part of parts) {
|
||||
@@ -201,5 +213,3 @@ exports.defaultKeybindingsHandlerIgnore = defaultKeybindingsHandlerIgnore;
|
||||
exports.matchKeybindingPress = matchKeybindingPress;
|
||||
exports.parseKeybinding = parseKeybinding;
|
||||
exports.tinykeys = tinykeys;
|
||||
-
|
||||
-//# sourceMappingURL=tinykeys.cjs.map
|
||||
\ No newline at end of file
|
||||
diff --git a/dist/tinykeys.mjs b/dist/tinykeys.mjs
|
||||
index c289972d2728e03d9b272268c38fd3392e8845bf..e22897b00aae6cdb0dbbb971445227c07be52918 100644
|
||||
--- a/dist/tinykeys.mjs
|
||||
+++ b/dist/tinykeys.mjs
|
||||
@@ -60,6 +60,18 @@ function defaultKeybindingsHandlerIgnore(event) {
|
||||
function getModifierState(event, mod) {
|
||||
return typeof event.getModifierState === "function" ? event.getModifierState(mod) || ALT_GRAPH_ALIASES.includes(mod) && event.getModifierState("AltGraph") : false;
|
||||
}
|
||||
+function splitKeybindingPress(press) {
|
||||
+ let parts = [];
|
||||
+ let start = 0;
|
||||
+ for (let index = 0; index < press.length; index++) {
|
||||
+ if (press[index] === "+" && /[\w\]]/.test(press[index - 1] || "")) {
|
||||
+ parts.push(press.slice(start, index));
|
||||
+ start = index + 1;
|
||||
+ }
|
||||
+ }
|
||||
+ parts.push(press.slice(start));
|
||||
+ return parts;
|
||||
+}
|
||||
/**
|
||||
* Parses a keybinding string into its parts.
|
||||
*
|
||||
@@ -75,10 +87,10 @@ function getModifierState(event, mod) {
|
||||
*/
|
||||
function parseKeybinding(str) {
|
||||
return str.trim().split(" ").map((press) => {
|
||||
- let parts = press.split(/(?<=\w|\])\+/);
|
||||
+ let parts = splitKeybindingPress(press);
|
||||
let last = parts.pop();
|
||||
let regex = last.match(/^\((.+)\)$/);
|
||||
- let key = regex ? new RegExp(`^(?:${regex[1]})$`, "iv") : last;
|
||||
+ let key = regex ? new RegExp(`^(?:${regex[1]})$`, "i") : last;
|
||||
let requiredModifiers = [];
|
||||
let optionalModifiers = [];
|
||||
for (const part of parts) {
|
||||
@@ -196,5 +208,3 @@ function tinykeys(target, keybindingMap, options = {}) {
|
||||
}
|
||||
//#endregion
|
||||
export { createKeybindingsHandler, defaultKeybindingsHandlerIgnore, matchKeybindingPress, parseKeybinding, tinykeys };
|
||||
-
|
||||
-//# sourceMappingURL=tinykeys.mjs.map
|
||||
\ No newline at end of file
|
||||
+1
-1
@@ -31,7 +31,7 @@ index 8795ddcaa77aea7b0356417e4bc4b19e2b3f860c..fcdc68342d9ac53936c9ed40a9ccfc2f
|
||||
@@ -129,7 +129,10 @@ export async function injectManifest(
|
||||
searchString: options.injectionPoint!,
|
||||
});
|
||||
|
||||
|
||||
- filesToWrite[options.swDest] = source;
|
||||
+ filesToWrite[options.swDest] = source.replace(
|
||||
+ url!,
|
||||
+942
File diff suppressed because one or more lines are too long
Vendored
-944
File diff suppressed because one or more lines are too long
+3
-8
@@ -1,16 +1,11 @@
|
||||
approvedGitRepositories:
|
||||
- "**"
|
||||
|
||||
compressionLevel: mixed
|
||||
|
||||
npmMinimalAgeGate: "3d"
|
||||
|
||||
defaultSemverRangePrefix: ""
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableScripts: true
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.16.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.12.0.cjs
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# BrowserStack Automate configuration for Home Assistant frontend e2e tests.
|
||||
# Credentials are read from BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY
|
||||
# environment variables set in GitHub Actions (or locally).
|
||||
# See: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
|
||||
|
||||
userName: ${BROWSERSTACK_USERNAME}
|
||||
accessKey: ${BROWSERSTACK_ACCESS_KEY}
|
||||
|
||||
projectName: Home Assistant Frontend
|
||||
buildName: e2e tests
|
||||
buildIdentifier: "CI #${BUILD_NUMBER}"
|
||||
|
||||
# ── Platforms ────────────────────────────────────────────────────────────────
|
||||
platforms:
|
||||
- os: Windows
|
||||
osVersion: 11
|
||||
browserName: chrome
|
||||
browserVersion: latest
|
||||
- os: OS X
|
||||
osVersion: Ventura
|
||||
browserName: playwright-firefox
|
||||
browserVersion: latest
|
||||
- deviceName: iPad 6th
|
||||
osVersion: 12
|
||||
browserName: playwright-webkit
|
||||
- deviceName: iPhone 12
|
||||
osVersion: 14
|
||||
browserName: playwright-webkit
|
||||
- deviceName: Samsung Galaxy S23
|
||||
osVersion: 13
|
||||
browserName: chrome
|
||||
realMobile: true
|
||||
|
||||
parallelsPerPlatform: 1
|
||||
|
||||
# ── Local tunnel ─────────────────────────────────────────────────────────────
|
||||
# The SDK manages the BrowserStack Local tunnel automatically.
|
||||
browserstackLocal: true
|
||||
|
||||
framework: playwright
|
||||
|
||||
# Pin to the latest Playwright version BrowserStack supports. Our local
|
||||
# @playwright/test is newer (1.59.x) which BrowserStack does not yet support,
|
||||
# causing a "Malformed endpoint" connection error if left unset.
|
||||
# Update this when BrowserStack adds support for a newer version.
|
||||
# Supported versions: https://www.browserstack.com/docs/automate/playwright/browsers-and-os
|
||||
playwrightVersion: 1.latest
|
||||
|
||||
# ── Debugging ────────────────────────────────────────────────────────────────
|
||||
debug: false
|
||||
networkLogs: false
|
||||
consoleLogs: errors
|
||||
testObservability: true
|
||||
@@ -176,14 +176,11 @@ module.exports.babelOptions = ({
|
||||
{
|
||||
// Use unambiguous for dependencies so that require() is correctly injected into CommonJS files
|
||||
// Exclusions are needed in some cases where ES modules have no static imports or exports, such as polyfills
|
||||
// (otherwise babel-plugin-polyfill-corejs3 injects bare require("core-js/modules/...") calls
|
||||
// that rspack does not transform, causing ReferenceError in browsers like Safari 14).
|
||||
sourceType: "unambiguous",
|
||||
include: /\/node_modules\//,
|
||||
exclude: [
|
||||
"element-internals-polyfill",
|
||||
"@?lit(?:-labs|-element|-html)?",
|
||||
"@formatjs/(?:ecma402-abstract|intl-\\w+)",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
},
|
||||
],
|
||||
@@ -320,22 +317,4 @@ module.exports.config = {
|
||||
isLandingPageBuild: true,
|
||||
};
|
||||
},
|
||||
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
|
||||
},
|
||||
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
defineOverlay: {
|
||||
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
|
||||
__DEMO__: true,
|
||||
},
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -31,4 +31,7 @@ module.exports = {
|
||||
isDevContainer() {
|
||||
return isTrue(process.env.DEV_CONTAINER);
|
||||
},
|
||||
jsMinifier() {
|
||||
return (process.env.JS_MINIFIER || "swc").toLowerCase();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
// @ts-check
|
||||
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import rootConfig from "../eslint.config.mjs";
|
||||
|
||||
export default tseslint.config(...rootConfig, {
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"import-x/no-extraneous-dependencies": "off",
|
||||
"import-x/extensions": "off",
|
||||
"import-x/no-dynamic-require": "off",
|
||||
"import/no-extraneous-dependencies": "off",
|
||||
"import/extensions": "off",
|
||||
"import/no-dynamic-require": "off",
|
||||
"global-require": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"prefer-arrow-callback": "off",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
// Browser-only replacement for core-js/internals/get-built-in-node-module.
|
||||
// The original helper evaluates `Function('return require("...")')()`
|
||||
// when it detects a Node environment, which causes a runtime
|
||||
// ReferenceError on browsers (notably Safari 14) if environment
|
||||
// detection mis-classifies the page. Since browser bundles never need to
|
||||
// access Node built-in modules, return undefined unconditionally.
|
||||
//
|
||||
// Wired up via rspack `NormalModuleReplacementPlugin` in build-scripts/rspack.cjs.
|
||||
module.exports = function () {
|
||||
return undefined;
|
||||
};
|
||||
@@ -5,7 +5,6 @@ import "./compress.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./licenses.js";
|
||||
import "./locale-data.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
@@ -37,12 +36,7 @@ gulp.task(
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"build-translations",
|
||||
"build-locale-data",
|
||||
"gen-licenses"
|
||||
),
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-app",
|
||||
"rspack-prod-app",
|
||||
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
|
||||
|
||||
@@ -45,10 +45,3 @@ gulp.task(
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"clean-e2e-test-app",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -99,44 +99,6 @@ const lokaliseProjects = {
|
||||
frontend: "3420425759f6d6d241f598.13594006",
|
||||
};
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
async function pollProcess(lokaliseApi, projectId, processId) {
|
||||
while (true) {
|
||||
const process = await lokaliseApi
|
||||
.queuedProcesses()
|
||||
.get(processId, { project_id: projectId });
|
||||
|
||||
const project =
|
||||
projectId === lokaliseProjects.backend ? "backend" : "frontend";
|
||||
|
||||
if (process.status === "finished") {
|
||||
console.log(`Lokalise export process for ${project} finished`);
|
||||
return process;
|
||||
}
|
||||
|
||||
if (process.status === "failed" || process.status === "cancelled") {
|
||||
throw new Error(
|
||||
`Lokalise export process for ${project} ${process.status}: ${process.message}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Lokalise export process for ${project} in progress...`,
|
||||
process.status,
|
||||
process.details?.items_to_process
|
||||
? `${Math.round(((process.details.items_processed || 0) / process.details.items_to_process) * 100)}% (${process.details.items_processed}/${process.details.items_to_process})`
|
||||
: ""
|
||||
);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, POLL_INTERVAL_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
/* eslint-enable no-await-in-loop */
|
||||
|
||||
gulp.task("fetch-lokalise", async function () {
|
||||
let apiKey;
|
||||
try {
|
||||
@@ -156,60 +118,55 @@ gulp.task("fetch-lokalise", async function () {
|
||||
]);
|
||||
|
||||
await Promise.all(
|
||||
Object.entries(lokaliseProjects).map(async ([project, projectId]) => {
|
||||
try {
|
||||
const exportProcess = await lokaliseApi
|
||||
.files()
|
||||
.async_download(projectId, {
|
||||
format: "json",
|
||||
original_filenames: false,
|
||||
replace_breaks: false,
|
||||
json_unescaped_slashes: true,
|
||||
export_empty_as: "skip",
|
||||
filter_data: ["verified"],
|
||||
});
|
||||
|
||||
const finishedProcess = await pollProcess(
|
||||
lokaliseApi,
|
||||
projectId,
|
||||
exportProcess.process_id
|
||||
);
|
||||
|
||||
const bundleUrl = finishedProcess.details.download_url;
|
||||
|
||||
console.log(`Downloading translations from: ${bundleUrl}`);
|
||||
|
||||
const response = await fetch(bundleUrl);
|
||||
|
||||
if (response.status !== 200 && response.status !== 0) {
|
||||
Object.entries(lokaliseProjects).map(([project, projectId]) =>
|
||||
lokaliseApi
|
||||
.files()
|
||||
.download(projectId, {
|
||||
format: "json",
|
||||
original_filenames: false,
|
||||
replace_breaks: false,
|
||||
json_unescaped_slashes: true,
|
||||
export_empty_as: "skip",
|
||||
filter_data: ["verified"],
|
||||
})
|
||||
.then((download) => fetch(download.bundle_url))
|
||||
.then((response) => {
|
||||
if (response.status === 200 || response.status === 0) {
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
throw new Error(response.statusText);
|
||||
}
|
||||
|
||||
console.log(`Extracting translations...`);
|
||||
|
||||
const contents = await JSZip.loadAsync(await response.arrayBuffer());
|
||||
|
||||
await mkdirPromise;
|
||||
await Promise.all(
|
||||
Object.keys(contents.files).map(async (filename) => {
|
||||
const file = contents.file(filename);
|
||||
if (!file) {
|
||||
// no file, probably a directory
|
||||
return;
|
||||
}
|
||||
const content = await file.async("nodebuffer");
|
||||
await fs.writeFile(
|
||||
path.join(inDir, project, filename.split("/").splice(-1)[0]),
|
||||
content,
|
||||
{ flag: "w", encoding }
|
||||
);
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
})
|
||||
})
|
||||
.then(JSZip.loadAsync)
|
||||
.then(async (contents) => {
|
||||
await mkdirPromise;
|
||||
return Promise.all(
|
||||
Object.keys(contents.files).map(async (filename) => {
|
||||
const file = contents.file(filename);
|
||||
if (!file) {
|
||||
// no file, probably a directory
|
||||
return Promise.resolve();
|
||||
}
|
||||
return file
|
||||
.async("nodebuffer")
|
||||
.then((content) =>
|
||||
fs.writeFile(
|
||||
path.join(
|
||||
inDir,
|
||||
project,
|
||||
filename.split("/").splice(-1)[0]
|
||||
),
|
||||
content,
|
||||
{ flag: "w", encoding }
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import gulp from "gulp";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
|
||||
gulp.task(
|
||||
"develop-e2e-test-app",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
"build-translations",
|
||||
"build-locale-data"
|
||||
),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-dev-server-e2e-test-app"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-e2e-test-app",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-prod-e2e-test-app",
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
@@ -25,7 +25,6 @@ const SAFARI_TO_MACOS = {
|
||||
16: [11, 0, 0],
|
||||
17: [12, 0, 0],
|
||||
18: [13, 0, 0],
|
||||
26: [26, 0, 0],
|
||||
};
|
||||
|
||||
const getCommonTemplateVars = () => {
|
||||
@@ -267,24 +266,3 @@ gulp.task(
|
||||
paths.landingPage_output_es5
|
||||
)
|
||||
);
|
||||
|
||||
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
genPagesDevTask(
|
||||
E2E_TEST_APP_PAGE_ENTRIES,
|
||||
paths.e2eTestApp_dir,
|
||||
paths.e2eTestApp_output_root
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-prod",
|
||||
genPagesProdTask(
|
||||
E2E_TEST_APP_PAGE_ENTRIES,
|
||||
paths.e2eTestApp_dir,
|
||||
paths.e2eTestApp_output_root,
|
||||
paths.e2eTestApp_output_latest
|
||||
)
|
||||
);
|
||||
|
||||
@@ -57,9 +57,7 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
if (descriptionContent === "") {
|
||||
hasDescription = false;
|
||||
} else {
|
||||
descriptionContent = marked(descriptionContent)
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/`/g, "\\`");
|
||||
descriptionContent = marked(descriptionContent).replace(/`/g, "\\`");
|
||||
fs.mkdirSync(path.resolve(galleryBuild, category), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.resolve(galleryBuild, `${pageId}-description.ts`),
|
||||
@@ -103,29 +101,12 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
|
||||
|
||||
if (!toProcess) {
|
||||
console.error("Unknown category", group.category);
|
||||
if (!group.subsections && !group.pages) {
|
||||
if (!group.pages) {
|
||||
group.pages = [];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (group.subsections) {
|
||||
// Listed pages keep their per-subsection order.
|
||||
for (const subsection of group.subsections) {
|
||||
for (const page of subsection.pages) {
|
||||
if (!toProcess.delete(page)) {
|
||||
console.error("Found unreferenced demo", page);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Any remaining pages land in a trailing "Other" subsection.
|
||||
const leftover = Array.from(toProcess).sort();
|
||||
if (leftover.length) {
|
||||
group.subsections.push({ header: "Other", pages: leftover });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Any pre-defined groups will not be sorted.
|
||||
if (group.pages) {
|
||||
for (const page of group.pages) {
|
||||
|
||||
@@ -201,23 +201,3 @@ gulp.task("copy-static-landing-page", async () => {
|
||||
copyFonts(paths.landingPage_output_static);
|
||||
copyTranslations(paths.landingPage_output_static);
|
||||
});
|
||||
|
||||
gulp.task("copy-static-e2e-test-app", async () => {
|
||||
// Copy app static files (icons, polyfills, etc.)
|
||||
fs.copySync(
|
||||
polyPath("public/static"),
|
||||
path.resolve(paths.e2eTestApp_output_root, "static")
|
||||
);
|
||||
// Copy e2e test app public files (manifest, sw stubs)
|
||||
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
|
||||
if (fs.existsSync(e2ePublic)) {
|
||||
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
copyLocaleData(paths.e2eTestApp_output_static);
|
||||
copyMdiIcons(paths.e2eTestApp_output_static);
|
||||
});
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
import gulp from "gulp";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const SOURCE_URL =
|
||||
process.env.SENSOR_METADATA_URL ||
|
||||
"https://raw.githubusercontent.com/home-assistant/core/dev/homeassistant/generated/sensor.json";
|
||||
|
||||
const TARGET = join(
|
||||
paths.root_dir,
|
||||
"src",
|
||||
"data",
|
||||
"sensor_numeric_device_classes.ts"
|
||||
);
|
||||
|
||||
gulp.task("gen-numeric-device-classes", async () => {
|
||||
const response = await fetch(SOURCE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch ${SOURCE_URL}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const classes = [...(data.numeric_device_classes ?? [])].sort();
|
||||
if (!classes.length) {
|
||||
throw new Error(`No numeric_device_classes found in ${SOURCE_URL}`);
|
||||
}
|
||||
|
||||
const content = `// This file is auto-generated from Home Assistant Core's \`SensorDeviceClass\`
|
||||
// (all values minus \`NON_NUMERIC_DEVICE_CLASSES\`). Do not edit by hand.
|
||||
// Regenerate with \`script/gen_numeric_device_classes\`.
|
||||
|
||||
export const SENSOR_NUMERIC_DEVICE_CLASSES: string[] = [
|
||||
${classes.map((deviceClass) => ` "${deviceClass}",`).join("\n")}
|
||||
];
|
||||
`;
|
||||
|
||||
await writeFile(TARGET, content);
|
||||
});
|
||||
@@ -4,13 +4,11 @@ import "./clean.js";
|
||||
import "./compress.js";
|
||||
import "./demo.js";
|
||||
import "./download-translations.js";
|
||||
import "./e2e-test-app.js";
|
||||
import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./gen-numeric-device-classes.js";
|
||||
import "./landing-page.js";
|
||||
import "./locale-data.js";
|
||||
import "./rspack.js";
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
// Gulp task to generate third-party license notices.
|
||||
|
||||
import { readFile, access, readdir } from "fs/promises";
|
||||
import { generateLicenseFile } from "generate-license-file";
|
||||
import gulp from "gulp";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const OUTPUT_FILE = path.join(
|
||||
paths.app_output_static,
|
||||
"third-party-licenses.txt"
|
||||
);
|
||||
|
||||
const NODE_MODULES = path.resolve(paths.root_dir, "node_modules");
|
||||
|
||||
// The echarts package ships an Apache-2.0 NOTICE file that must be
|
||||
// redistributed alongside the compiled output per Apache License §4(d).
|
||||
const NOTICE_FILES = [path.join(NODE_MODULES, "echarts/NOTICE")];
|
||||
|
||||
// Some packages need a manual license override (e.g. they ship multiple
|
||||
// license files and we must pick the right one for the bundled code).
|
||||
//
|
||||
// Each entry is pinned to a specific version. If a package is updated,
|
||||
// this list must be reviewed and the version updated after verifying
|
||||
// that the new version's license still matches. The build will fail if
|
||||
// the pinned version is no longer installed.
|
||||
const LICENSE_OVERRIDES = [
|
||||
{
|
||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||
// We use the MIT license since that covers the bundled code.
|
||||
packageName: "type-fest",
|
||||
version: "5.7.0",
|
||||
licenseFile: "license-mit",
|
||||
},
|
||||
];
|
||||
|
||||
// Locate the directory of an installed package matching an exact version.
|
||||
//
|
||||
// The copy we care about may be hoisted to the top-level node_modules or
|
||||
// nested under a dependency when a different version occupies the hoisted
|
||||
// slot (e.g. a build-only dependency pulling in an older release). Searching
|
||||
// both keeps this check independent of yarn's hoisting decisions, which can
|
||||
// shift when unrelated dependencies are added.
|
||||
async function findPackageDir(packageName, version) {
|
||||
const candidateDirs = [path.join(NODE_MODULES, packageName)];
|
||||
|
||||
// Collect one level of nesting: node_modules/<dep>/node_modules/<pkg> and
|
||||
// node_modules/@scope/<dep>/node_modules/<pkg>.
|
||||
let topLevel = [];
|
||||
try {
|
||||
topLevel = await readdir(NODE_MODULES, { withFileTypes: true });
|
||||
} catch {
|
||||
// node_modules unreadable — fall back to the hoisted candidate only.
|
||||
}
|
||||
for (const entry of topLevel) {
|
||||
if (!entry.isDirectory() || entry.name === packageName) {
|
||||
continue;
|
||||
}
|
||||
if (entry.name.startsWith("@")) {
|
||||
const scopeDir = path.join(NODE_MODULES, entry.name);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const scoped = await readdir(scopeDir, { withFileTypes: true }).catch(
|
||||
() => []
|
||||
);
|
||||
for (const dep of scoped) {
|
||||
if (dep.isDirectory()) {
|
||||
candidateDirs.push(
|
||||
path.join(scopeDir, dep.name, "node_modules", packageName)
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
candidateDirs.push(
|
||||
path.join(NODE_MODULES, entry.name, "node_modules", packageName)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const dir of candidateDirs) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const pkg = await readFile(path.join(dir, "package.json"), "utf-8")
|
||||
.then(JSON.parse)
|
||||
.catch(() => null);
|
||||
if (pkg?.version === version) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
gulp.task("gen-licenses", async () => {
|
||||
const licenseOverrides = {};
|
||||
|
||||
for (const { packageName, version, licenseFile } of LICENSE_OVERRIDES) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const packageDir = await findPackageDir(packageName, version);
|
||||
|
||||
if (!packageDir) {
|
||||
throw new Error(
|
||||
`License override for "${packageName}" is pinned to version ${version}, but that version is not installed. ` +
|
||||
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
|
||||
);
|
||||
}
|
||||
|
||||
const licensePath = path.join(packageDir, licenseFile);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await access(licensePath);
|
||||
} catch {
|
||||
throw new Error(`License file not found or unreadable: ${licensePath}`);
|
||||
}
|
||||
|
||||
licenseOverrides[`${packageName}@${version}`] = licensePath;
|
||||
}
|
||||
|
||||
await generateLicenseFile(
|
||||
path.resolve(paths.root_dir, "package.json"),
|
||||
OUTPUT_FILE,
|
||||
{ append: NOTICE_FILES, replace: licenseOverrides }
|
||||
);
|
||||
});
|
||||
@@ -40,24 +40,18 @@ const convertToJSON = async (
|
||||
throw e;
|
||||
}
|
||||
// Convert to JSON
|
||||
const parts = localeData.split("} else {");
|
||||
const firstBlock = parts[0];
|
||||
const obj = INTL_POLYFILLS[pkg];
|
||||
const dataRegex = new RegExp(
|
||||
`Intl\\.${obj}\\.${addFunc}\\((?<data>.*)\\)`,
|
||||
"s"
|
||||
);
|
||||
localeData = firstBlock.match(dataRegex)?.groups?.data;
|
||||
localeData = localeData.match(dataRegex)?.groups?.data;
|
||||
if (!localeData) {
|
||||
throw Error(`Failed to extract data for language ${lang} from ${pkg}`);
|
||||
}
|
||||
// Parse to validate JSON, then stringify to minify
|
||||
try {
|
||||
localeData = JSON.stringify(JSON.parse(localeData));
|
||||
await writeFile(join(outDir, `${pkg}/${lang}.json`), localeData);
|
||||
} catch (e) {
|
||||
throw Error(`Failed to parse JSON for language ${lang} from ${pkg}: ${e}`);
|
||||
}
|
||||
localeData = JSON.stringify(JSON.parse(localeData));
|
||||
await writeFile(join(outDir, `${pkg}/${lang}.json`), localeData);
|
||||
};
|
||||
|
||||
gulp.task("clean-locale-data", async () => deleteSync([outDir]));
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
createDemoConfig,
|
||||
createGalleryConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
} from "../rspack.cjs";
|
||||
|
||||
const bothBuilds = (createConfigFunc, params) => [
|
||||
@@ -34,9 +33,7 @@ const isWsl =
|
||||
* compiler: import("@rspack/core").Compiler,
|
||||
* contentBase: string,
|
||||
* port: number,
|
||||
* listenHost?: string,
|
||||
* open?: boolean,
|
||||
* logUrlAfterFirstBuild?: boolean,
|
||||
* listenHost?: string
|
||||
* }}
|
||||
*/
|
||||
const runDevServer = async ({
|
||||
@@ -44,43 +41,22 @@ const runDevServer = async ({
|
||||
contentBase,
|
||||
port,
|
||||
listenHost = undefined,
|
||||
open = true,
|
||||
logUrlAfterFirstBuild = false,
|
||||
proxy = undefined,
|
||||
}) => {
|
||||
if (listenHost === undefined) {
|
||||
// For dev container, we need to listen on all hosts
|
||||
listenHost = env.isDevContainer() ? "0.0.0.0" : "localhost";
|
||||
}
|
||||
const url = `http://localhost:${port}`;
|
||||
let loggedUrl = false;
|
||||
if (logUrlAfterFirstBuild) {
|
||||
compiler.hooks.done.tap("log-dev-server-url", () => {
|
||||
if (loggedUrl) {
|
||||
return;
|
||||
}
|
||||
loggedUrl = true;
|
||||
setTimeout(() => {
|
||||
log("[rspack-dev-server]", `Project is running at ${url}`);
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
const server = new RspackDevServer(
|
||||
{
|
||||
hot: false,
|
||||
open,
|
||||
open: true,
|
||||
host: listenHost,
|
||||
port,
|
||||
static: {
|
||||
directory: contentBase,
|
||||
watch: true,
|
||||
},
|
||||
client: {
|
||||
overlay: {
|
||||
runtimeErrors: (error) =>
|
||||
!error?.message?.includes("ResizeObserver loop"),
|
||||
},
|
||||
},
|
||||
proxy,
|
||||
},
|
||||
compiler
|
||||
@@ -88,9 +64,7 @@ const runDevServer = async ({
|
||||
|
||||
await server.start();
|
||||
// Server listening
|
||||
if (!logUrlAfterFirstBuild) {
|
||||
log("[rspack-dev-server]", `Project is running at ${url}`);
|
||||
}
|
||||
log("[rspack-dev-server]", `Project is running at http://localhost:${port}`);
|
||||
};
|
||||
|
||||
const doneHandler = (done) => (err, stats) => {
|
||||
@@ -106,7 +80,13 @@ const doneHandler = (done) => (err, stats) => {
|
||||
console.log(stats.toString("minimal"));
|
||||
}
|
||||
|
||||
log(`Build done @ ${new Date().toLocaleTimeString()}`);
|
||||
const durationMs =
|
||||
stats?.startTime && stats?.endTime ? stats.endTime - stats.startTime : 0;
|
||||
const durationLabel = durationMs
|
||||
? ` (${(durationMs / 1000).toFixed(1)}s, minifier: ${env.jsMinifier()})`
|
||||
: ` (minifier: ${env.jsMinifier()})`;
|
||||
|
||||
log(`Build done @ ${new Date().toLocaleTimeString()}${durationLabel}`);
|
||||
|
||||
if (done) {
|
||||
done();
|
||||
@@ -192,8 +172,6 @@ gulp.task("rspack-dev-server-gallery", () =>
|
||||
contentBase: paths.gallery_output_root,
|
||||
port: 8100,
|
||||
listenHost: "0.0.0.0",
|
||||
open: false,
|
||||
logUrlAfterFirstBuild: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -232,22 +210,3 @@ gulp.task("rspack-prod-landing-page", () =>
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-dev-server-e2e-test-app", () =>
|
||||
runDevServer({
|
||||
compiler: rspack(
|
||||
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
|
||||
),
|
||||
contentBase: paths.e2eTestApp_output_root,
|
||||
port: 8095,
|
||||
})
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-e2e-test-app", () =>
|
||||
prodBuild(
|
||||
bothBuilds(createE2eTestAppConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import presetEnv from "@babel/preset-env";
|
||||
import compilationTargets from "@babel/helper-compilation-targets";
|
||||
import coreJSCompat from "core-js-compat";
|
||||
import { logPlugin } from "@babel/preset-env/lib/debug.js";
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import shippedPolyfills from "../node_modules/babel-plugin-polyfill-corejs3/lib/shipped-proposals.js";
|
||||
import { babelOptions } from "./bundle.cjs";
|
||||
|
||||
|
||||
@@ -50,15 +50,4 @@ module.exports = {
|
||||
),
|
||||
|
||||
translations_src: path.resolve(__dirname, "../src/translations"),
|
||||
|
||||
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
|
||||
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
|
||||
e2eTestApp_output_static: path.resolve(
|
||||
__dirname,
|
||||
"../test/e2e/app/dist/static"
|
||||
),
|
||||
e2eTestApp_output_latest: path.resolve(
|
||||
__dirname,
|
||||
"../test/e2e/app/dist/frontend_latest"
|
||||
),
|
||||
};
|
||||
|
||||
+17
-23
@@ -12,7 +12,8 @@ const TerserPlugin = require("terser-webpack-plugin");
|
||||
const { WebpackManifestPlugin } = require("rspack-manifest-plugin");
|
||||
const log = require("fancy-log");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const SafeWebpackBar = require("./safe-webpackbar.cjs");
|
||||
const WebpackBar = require("webpackbar/rspack");
|
||||
const env = require("./env.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
const bundle = require("./bundle.cjs");
|
||||
|
||||
@@ -100,11 +101,20 @@ const createRspackConfig = ({
|
||||
},
|
||||
optimization: {
|
||||
minimizer: [
|
||||
new TerserPlugin({
|
||||
parallel: true,
|
||||
extractComments: true,
|
||||
terserOptions: bundle.terserOptions({ latestBuild, isTestBuild }),
|
||||
}),
|
||||
env.jsMinifier() === "terser"
|
||||
? new TerserPlugin({
|
||||
parallel: true,
|
||||
extractComments: true,
|
||||
terserOptions: bundle.terserOptions({ latestBuild, isTestBuild }),
|
||||
})
|
||||
: new rspack.SwcJsMinimizerRspackPlugin({
|
||||
extractComments: true,
|
||||
minimizerOptions: {
|
||||
ecma: latestBuild ? 2015 : 5,
|
||||
module: latestBuild,
|
||||
format: { comments: false },
|
||||
},
|
||||
}),
|
||||
],
|
||||
moduleIds: isProdBuild && !isStatsBuild ? "deterministic" : "named",
|
||||
chunkIds: isProdBuild && !isStatsBuild ? "deterministic" : "named",
|
||||
@@ -126,7 +136,7 @@ const createRspackConfig = ({
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
!isStatsBuild && new SafeWebpackBar({ fancy: !isProdBuild }),
|
||||
!isStatsBuild && new WebpackBar({ fancy: !isProdBuild }),
|
||||
new WebpackManifestPlugin({
|
||||
// Only include the JS of entrypoints
|
||||
filter: (file) => file.isInitial && !file.name.endsWith(".map"),
|
||||
@@ -173,16 +183,6 @@ const createRspackConfig = ({
|
||||
path.resolve(paths.root_dir, "src/util/empty.js")
|
||||
)
|
||||
: false,
|
||||
// core-js ships a Node-only helper that evaluates
|
||||
// `Function('return require("...")')()` when its runtime environment
|
||||
// detection mis-classifies the page as Node. That produces a
|
||||
// ReferenceError on browsers (observed on Safari 14). Since browser
|
||||
// bundles never need to access Node built-in modules, replace it with
|
||||
// a CommonJS no-op stub matching the helper's API (returns undefined).
|
||||
new rspack.NormalModuleReplacementPlugin(
|
||||
/core-js[\\/]internals[\\/]get-built-in-node-module(?:\.js)?$/,
|
||||
path.resolve(__dirname, "get-built-in-node-module-shim.cjs")
|
||||
),
|
||||
!isProdBuild && new LogStartCompilePlugin(),
|
||||
isProdBuild &&
|
||||
new StatsWriterPlugin({
|
||||
@@ -337,11 +337,6 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
createAppConfig,
|
||||
createDemoConfig,
|
||||
@@ -349,5 +344,4 @@ module.exports = {
|
||||
createGalleryConfig,
|
||||
createRspackConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
};
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const WebpackBar = require("webpackbar/rspack");
|
||||
|
||||
// Rspack 2's ProgressPlugin passes the third `info` arg as
|
||||
// `{ builtModules, moduleIdentifier? }` instead of the v1 string. webpackbar@7's
|
||||
// parseRequest still expects a string and crashes on `split`. Extract
|
||||
// moduleIdentifier (the v1 equivalent) so progress still shows the active module.
|
||||
class SafeWebpackBar extends WebpackBar {
|
||||
constructor(options) {
|
||||
super(options);
|
||||
const inner = this.webpackbar;
|
||||
const originalUpdate = inner.updateProgress.bind(inner);
|
||||
inner.updateProgress = (percent, message, details = []) =>
|
||||
originalUpdate(
|
||||
percent,
|
||||
message,
|
||||
details.map((d) => {
|
||||
if (typeof d === "string") return d;
|
||||
return d?.moduleIdentifier ?? "";
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SafeWebpackBar;
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ActionDetail } from "@material/mwc-list/mwc-list";
|
||||
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
|
||||
import type { Auth, Connection } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { CastManager } from "../../../../src/cast/cast_manager";
|
||||
@@ -150,7 +150,7 @@ class HcCast extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
protected firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
const llColl = atLeastVersion(this.connection.haVersion, 0, 107)
|
||||
@@ -183,7 +183,7 @@ class HcCast extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
protected updated(changedProps) {
|
||||
super.updated(changedProps);
|
||||
toggleAttribute(
|
||||
this,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
ERR_INVALID_HTTPS_TO_HTTP,
|
||||
getAuth,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import type { CastManager } from "../../../../src/cast/cast_manager";
|
||||
@@ -26,7 +26,7 @@ import "../../../../src/components/ha-svg-icon";
|
||||
import "../../../../src/layouts/hass-loading-screen";
|
||||
import { registerServiceWorker } from "../../../../src/util/register-service-worker";
|
||||
import "./hc-layout";
|
||||
import "../../../../src/components/input/ha-input";
|
||||
import "../../../../src/components/ha-textfield";
|
||||
import "../../../../src/components/ha-button";
|
||||
|
||||
const seeFAQ = (qid) => html`
|
||||
@@ -123,11 +123,11 @@ export class HcConnect extends LitElement {
|
||||
To get started, enter your Home Assistant URL and click authorize.
|
||||
If you want a preview instead, click the show demo button.
|
||||
</p>
|
||||
<ha-input
|
||||
<ha-textfield
|
||||
label="Home Assistant URL"
|
||||
placeholder="https://abcdefghijklmnop.ui.nabu.casa"
|
||||
@keydown=${this._handleInputKeyDown}
|
||||
></ha-input>
|
||||
></ha-textfield>
|
||||
${this.error ? html` <p class="error">${this.error}</p> ` : ""}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
@@ -158,7 +158,7 @@ export class HcConnect extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
protected firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
import("./hc-cast");
|
||||
|
||||
@@ -204,7 +204,7 @@ export class HcConnect extends LitElement {
|
||||
}
|
||||
|
||||
private async _handleConnect() {
|
||||
const inputEl = this.shadowRoot!.querySelector("ha-input")!;
|
||||
const inputEl = this.shadowRoot!.querySelector("ha-textfield")!;
|
||||
const value = inputEl.value || "";
|
||||
this.error = undefined;
|
||||
|
||||
@@ -319,7 +319,7 @@ export class HcConnect extends LitElement {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
ha-input {
|
||||
ha-textfield {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Auth, Connection, HassUser } from "home-assistant-js-websocket";
|
||||
import { getUser } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
@@ -53,7 +53,7 @@ class HcLayout extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
protected firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
|
||||
if (this.connection) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { EntityInput } from "../../../../src/fake_data/entities/types";
|
||||
import type { Entity } from "../../../../src/fake_data/entity";
|
||||
import { convertEntities } from "../../../../src/fake_data/entity";
|
||||
|
||||
export const castDemoEntities: () => EntityInput[] = () =>
|
||||
Object.values({
|
||||
export const castDemoEntities: () => Entity[] = () =>
|
||||
convertEntities({
|
||||
"light.reading_light": {
|
||||
entity_id: "light.reading_light",
|
||||
state: "on",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { mockHistory } from "../../../../demo/src/stubs/history";
|
||||
@@ -30,7 +29,7 @@ class HcDemo extends HassElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
protected firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
this._initializeHass();
|
||||
}
|
||||
@@ -42,7 +41,7 @@ class HcDemo extends HassElement {
|
||||
this._updateHass(hassUpdate),
|
||||
};
|
||||
|
||||
const hass = provideHass(this, initial, true);
|
||||
const hass = (this.hass = provideHass(this, initial));
|
||||
|
||||
mockHistory(hass);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { css, html, LitElement, type TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../src/common/dom/fire_event";
|
||||
import type { LovelaceConfig } from "../../../../src/data/lovelace/config/types";
|
||||
@@ -65,7 +64,7 @@ class HcLovelace extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
protected updated(changedProps) {
|
||||
super.updated(changedProps);
|
||||
|
||||
if (changedProps.has("viewPath") || changedProps.has("lovelaceConfig")) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { createConnection, getAuth } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { CAST_NS } from "../../../../src/cast/const";
|
||||
@@ -106,7 +106,7 @@ export class HcMain extends HassElement {
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
protected firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
import("./hc-lovelace");
|
||||
import("../../../../src/resources/append-ha-style");
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { convertEntities } from "../../../../src/fake_data/entity";
|
||||
import type { DemoConfig } from "../types";
|
||||
|
||||
export const demoEntitiesArsaboo: DemoConfig["entities"] = (localize) =>
|
||||
Object.values({
|
||||
convertEntities({
|
||||
"todo.shopping_list": {
|
||||
entity_id: "todo.shopping_list",
|
||||
state: "2",
|
||||
|
||||
@@ -11,9 +11,9 @@ export const demoConfigs: (() => Promise<DemoConfig>)[] = [
|
||||
() => import("./jimpower").then((mod) => mod.demoJimpower),
|
||||
];
|
||||
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
// eslint-disable-next-line import/no-mutable-exports
|
||||
export let selectedDemoConfigIndex = 0;
|
||||
// eslint-disable-next-line import-x/no-mutable-exports
|
||||
// eslint-disable-next-line import/no-mutable-exports
|
||||
export let selectedDemoConfig: Promise<DemoConfig> =
|
||||
demoConfigs[selectedDemoConfigIndex]();
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { convertEntities } from "../../../../src/fake_data/entity";
|
||||
import type { DemoConfig } from "../types";
|
||||
|
||||
export const demoEntitiesJimpower: DemoConfig["entities"] = () =>
|
||||
Object.values({
|
||||
convertEntities({
|
||||
"todo.shopping_list": {
|
||||
entity_id: "todo.shopping_list",
|
||||
state: "2",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { convertEntities } from "../../../../src/fake_data/entity";
|
||||
import type { DemoConfig } from "../types";
|
||||
|
||||
export const demoEntitiesKernehed: DemoConfig["entities"] = () =>
|
||||
Object.values({
|
||||
convertEntities({
|
||||
"todo.shopping_list": {
|
||||
entity_id: "todo.shopping_list",
|
||||
state: "2",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { convertEntities } from "../../../../src/fake_data/entity";
|
||||
import type { DemoConfig } from "../types";
|
||||
|
||||
export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
|
||||
Object.values({
|
||||
convertEntities({
|
||||
"cover.living_room_garden_shutter": {
|
||||
entity_id: "cover.living_room_garden_shutter",
|
||||
state: "open",
|
||||
@@ -141,7 +142,7 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
|
||||
},
|
||||
},
|
||||
"device_tracker.car": {
|
||||
entity_id: "device_tracker.car",
|
||||
entity_id: "sensor.outdoor_humidity",
|
||||
state: "not_home",
|
||||
attributes: {
|
||||
friendly_name: "Car",
|
||||
@@ -199,7 +200,7 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
|
||||
},
|
||||
},
|
||||
"binary_sensor.kitchen_motion": {
|
||||
entity_id: "binary_sensor.kitchen_motion",
|
||||
entity_id: "light.kitchen_motion",
|
||||
state: "on",
|
||||
attributes: {
|
||||
device_class: "motion",
|
||||
@@ -335,7 +336,7 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
|
||||
},
|
||||
},
|
||||
"sensor.rain": {
|
||||
entity_id: "sensor.rain",
|
||||
entity_id: "sensor.moon_phase",
|
||||
state: "7.2",
|
||||
attributes: {
|
||||
state_class: "total_increasing",
|
||||
@@ -565,7 +566,7 @@ export const demoEntitiesSections: DemoConfig["entities"] = (localize) =>
|
||||
},
|
||||
},
|
||||
"update.home_assistant_core_update": {
|
||||
entity_id: "update.home_assistant_core_update",
|
||||
entity_id: "update.home_assistant_supervisor_update",
|
||||
state: "off",
|
||||
attributes: {
|
||||
auto_update: false,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { convertEntities } from "../../../../src/fake_data/entity";
|
||||
import type { DemoConfig } from "../types";
|
||||
|
||||
export const demoEntitiesTeachingbirds: DemoConfig["entities"] = () =>
|
||||
Object.values({
|
||||
convertEntities({
|
||||
"todo.shopping_list": {
|
||||
entity_id: "todo.shopping_list",
|
||||
state: "2",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceConfig } from "../../../src/data/lovelace/config/types";
|
||||
import type { EntityInput } from "../../../src/fake_data/entities/types";
|
||||
import type { Entity } from "../../../src/fake_data/entity";
|
||||
|
||||
export interface DemoConfig {
|
||||
index?: number;
|
||||
@@ -12,6 +12,6 @@ export interface DemoConfig {
|
||||
| string
|
||||
| ((localize: LocalizeFunc) => string | TemplateResult<1>);
|
||||
lovelace: (localize: LocalizeFunc) => LovelaceConfig;
|
||||
entities: (localize: LocalizeFunc) => EntityInput[];
|
||||
entities: (localize: LocalizeFunc) => Entity[];
|
||||
theme: () => Record<string, string> | null;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/// <reference types="chromecast-caf-sender" />
|
||||
import { mdiTelevision } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import type { CastManager } from "../../../src/cast/cast_manager";
|
||||
@@ -38,7 +36,7 @@ class CastDemoRow extends LitElement implements LovelaceRow {
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
protected firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
import("../../../src/cast/cast_manager").then(({ getCastManager }) =>
|
||||
getCastManager().then((mgr) => {
|
||||
@@ -63,7 +61,7 @@ class CastDemoRow extends LitElement implements LovelaceRow {
|
||||
);
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
protected updated(changedProps) {
|
||||
super.updated(changedProps);
|
||||
this.style.display = this._castManager ? "" : "none";
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
@@ -102,7 +102,7 @@ export class HADemoCard extends LitElement implements LovelaceCard {
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
protected firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
if (this._hidden) {
|
||||
this.style.display = "none";
|
||||
|
||||
+4
-39
@@ -8,7 +8,7 @@ import type { HomeAssistant } from "../../src/types";
|
||||
import { selectedDemoConfig } from "./configs/demo-configs";
|
||||
import { mockAreaRegistry } from "./stubs/area_registry";
|
||||
import { mockAuth } from "./stubs/auth";
|
||||
import { demoDevices } from "./stubs/devices";
|
||||
import { mockConfigEntries } from "./stubs/config_entries";
|
||||
import { mockDeviceRegistry } from "./stubs/device_registry";
|
||||
import { mockEnergy } from "./stubs/energy";
|
||||
import { energyEntities } from "./stubs/entities";
|
||||
@@ -16,7 +16,6 @@ import { mockEntityRegistry } from "./stubs/entity_registry";
|
||||
import { mockEvents } from "./stubs/events";
|
||||
import { mockFloorRegistry } from "./stubs/floor_registry";
|
||||
import { mockFrontend } from "./stubs/frontend";
|
||||
import { mockIntegration } from "./stubs/integration";
|
||||
import { mockLabelRegistry } from "./stubs/label_registry";
|
||||
import { mockIcons } from "./stubs/icons";
|
||||
import { mockHistory } from "./stubs/history";
|
||||
@@ -30,31 +29,6 @@ import { mockTemplate } from "./stubs/template";
|
||||
import { mockTodo } from "./stubs/todo";
|
||||
import { mockTranslations } from "./stubs/translations";
|
||||
|
||||
// WS command / REST path prefixes whose mocks live in the lazily imported
|
||||
// config-panel chunk (see ./stubs/config-panel). Must stay in sync with it.
|
||||
const CONFIG_PANEL_COMMANDS = [
|
||||
"cloud/",
|
||||
"validate_config",
|
||||
"config_entries/",
|
||||
"device_automation/",
|
||||
"entity/source",
|
||||
"blueprint/",
|
||||
"homeassistant/expose",
|
||||
"zone/list",
|
||||
"person/list",
|
||||
"network/url",
|
||||
"application_credentials/",
|
||||
"system_health/",
|
||||
"backup/",
|
||||
"automation/config",
|
||||
"script/config",
|
||||
"config/automation/config",
|
||||
"config/script/config",
|
||||
"config/scene/config",
|
||||
"search/related",
|
||||
"tag/list",
|
||||
];
|
||||
|
||||
@customElement("ha-demo")
|
||||
export class HaDemo extends HomeAssistantAppEl {
|
||||
protected async _initializeHass() {
|
||||
@@ -65,7 +39,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
this._updateHass(hassUpdate),
|
||||
};
|
||||
|
||||
const hass = provideHass(this, initial, true);
|
||||
const hass = (this.hass = provideHass(this, initial));
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
@@ -87,18 +61,9 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockIcons(hass);
|
||||
mockEnergy(hass);
|
||||
mockPersistentNotification(hass);
|
||||
// Consumed app-wide via the lazy manifests context, so register eagerly.
|
||||
mockIntegration(hass);
|
||||
// Config panel mocks are code-split: the loader runs (and the chunk is
|
||||
// dynamically imported) the first time one of these config-only WS/REST
|
||||
// commands is requested, i.e. when the config panel is opened.
|
||||
hass.mockLazyLoad(
|
||||
(command) => CONFIG_PANEL_COMMANDS.some((p) => command.startsWith(p)),
|
||||
() =>
|
||||
import("./stubs/config-panel").then((mod) => mod.mockConfigPanel(hass))
|
||||
);
|
||||
mockConfigEntries(hass);
|
||||
mockAreaRegistry(hass);
|
||||
mockDeviceRegistry(hass, demoDevices);
|
||||
mockDeviceRegistry(hass);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ApplicationCredential } from "../../../src/data/application_credential";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const credentials: ApplicationCredential[] = [
|
||||
{
|
||||
id: "mock-credential",
|
||||
domain: "spotify",
|
||||
client_id: "demo-client-id",
|
||||
client_secret: "demo-client-secret",
|
||||
name: "Spotify",
|
||||
},
|
||||
];
|
||||
|
||||
export const mockApplicationCredentials = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("application_credentials/list", () => credentials);
|
||||
hass.mockWS("application_credentials/config", () => ({
|
||||
integrations: { spotify: { description_placeholders: {} } },
|
||||
}));
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockAssist = (hass: MockHomeAssistant) => {
|
||||
// Stub for assist pipeline list — returns empty so developer tools assist
|
||||
// tab loads without errors.
|
||||
hass.mockWS("assist_pipeline/pipeline/list", () => ({
|
||||
pipelines: [],
|
||||
preferred_pipeline: null,
|
||||
}));
|
||||
|
||||
// Stub for assist pipeline run — immediately sends run-end event so
|
||||
// the UI does not hang waiting for a response.
|
||||
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
|
||||
if (onChange) {
|
||||
onChange({
|
||||
type: "run-end",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
@@ -3,7 +3,4 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
export const mockAuth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("config/auth/list", () => []);
|
||||
hass.mockWS("auth/refresh_tokens", () => []);
|
||||
hass.mockWS("auth/sign_path", (msg: { path: string }) => ({
|
||||
path: msg.path,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { AutomationConfig } from "../../../src/data/automation";
|
||||
import type { ScriptConfig } from "../../../src/data/script";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const demoAutomationConfig = (entityId: string): AutomationConfig => ({
|
||||
id: entityId.split(".")[1],
|
||||
alias: "Demo automation",
|
||||
description: "An example automation shown in the demo.",
|
||||
triggers: [
|
||||
{ trigger: "state", entity_id: "binary_sensor.basement_floor_wet" },
|
||||
],
|
||||
conditions: [],
|
||||
actions: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.bed_light" },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
});
|
||||
|
||||
const demoScriptConfig = (): ScriptConfig => ({
|
||||
alias: "Demo script",
|
||||
description: "An example script shown in the demo.",
|
||||
sequence: [
|
||||
{
|
||||
action: "light.turn_on",
|
||||
target: { entity_id: "light.bed_light" },
|
||||
},
|
||||
],
|
||||
mode: "single",
|
||||
});
|
||||
|
||||
export const mockAutomation = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("automation/config", (msg: { entity_id: string }) => ({
|
||||
config: demoAutomationConfig(msg.entity_id),
|
||||
}));
|
||||
hass.mockWS("script/config", () => ({ config: demoScriptConfig() }));
|
||||
|
||||
hass.mockAPI(/config\/automation\/config\/.+/, () =>
|
||||
demoAutomationConfig("automation.demo")
|
||||
);
|
||||
hass.mockAPI(/config\/script\/config\/.+/, () => demoScriptConfig());
|
||||
|
||||
// Trigger/condition type pickers subscribe for integration-provided
|
||||
// platforms. The demo only uses the built-in ones, so emit empty records.
|
||||
hass.mockWS(
|
||||
"trigger_platforms/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (descriptions: Record<string, unknown>) => void
|
||||
) => {
|
||||
onChange?.({});
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
hass.mockWS(
|
||||
"condition_platforms/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (descriptions: Record<string, unknown>) => void
|
||||
) => {
|
||||
onChange?.({});
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
import type {
|
||||
BackupAgentsInfo,
|
||||
BackupConfig,
|
||||
BackupContent,
|
||||
BackupInfo,
|
||||
} from "../../../src/data/backup";
|
||||
import { BackupScheduleRecurrence } from "../../../src/data/backup";
|
||||
import type { ManagerStateEvent } from "../../../src/data/backup_manager";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const lastBackupDate = new Date(Date.now() - 86400000).toISOString();
|
||||
const nextBackupDate = new Date(Date.now() + 86400000).toISOString();
|
||||
|
||||
const backups: BackupContent[] = [
|
||||
{
|
||||
backup_id: "demo-backup-1",
|
||||
name: "Automatic backup DEMO",
|
||||
date: lastBackupDate,
|
||||
with_automatic_settings: true,
|
||||
agents: {
|
||||
"backup.local": { size: 1024 * 1024 * 512, protected: true },
|
||||
"cloud.cloud": { size: 1024 * 1024 * 512, protected: true },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const backupInfo: BackupInfo = {
|
||||
backups,
|
||||
agent_errors: {},
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
last_action_event: { manager_state: "idle" },
|
||||
next_automatic_backup: nextBackupDate,
|
||||
next_automatic_backup_additional: false,
|
||||
state: "idle",
|
||||
};
|
||||
|
||||
const backupConfig: BackupConfig = {
|
||||
automatic_backups_configured: true,
|
||||
last_attempted_automatic_backup: lastBackupDate,
|
||||
last_completed_automatic_backup: lastBackupDate,
|
||||
next_automatic_backup: nextBackupDate,
|
||||
next_automatic_backup_additional: false,
|
||||
create_backup: {
|
||||
agent_ids: ["backup.local", "cloud.cloud"],
|
||||
include_addons: [],
|
||||
include_all_addons: true,
|
||||
include_database: true,
|
||||
include_folders: [],
|
||||
name: null,
|
||||
password: null,
|
||||
},
|
||||
retention: { copies: 3, days: null },
|
||||
schedule: {
|
||||
recurrence: BackupScheduleRecurrence.DAILY,
|
||||
time: null,
|
||||
days: [],
|
||||
},
|
||||
agents: {
|
||||
"backup.local": { protected: true, retention: null },
|
||||
"cloud.cloud": { protected: true, retention: null },
|
||||
},
|
||||
};
|
||||
|
||||
const agentsInfo: BackupAgentsInfo = {
|
||||
agents: [
|
||||
{ agent_id: "backup.local", name: "This device" },
|
||||
{ agent_id: "cloud.cloud", name: "Home Assistant Cloud" },
|
||||
],
|
||||
};
|
||||
|
||||
export const mockBackup = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("backup/info", () => backupInfo);
|
||||
hass.mockWS("backup/config/info", () => ({ config: backupConfig }));
|
||||
hass.mockWS("backup/agents/info", () => agentsInfo);
|
||||
hass.mockWS(
|
||||
"backup/subscribe_events",
|
||||
(_msg, _hass, onChange?: (event: ManagerStateEvent) => void) => {
|
||||
onChange?.({ manager_state: "idle" });
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { BlueprintDomain, Blueprints } from "../../../src/data/blueprint";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const automationBlueprints: Blueprints = {
|
||||
"homeassistant/motion_light.yaml": {
|
||||
metadata: {
|
||||
domain: "automation",
|
||||
name: "Motion-activated Light",
|
||||
description: "Turn on a light when motion is detected.",
|
||||
author: "Home Assistant",
|
||||
source_url:
|
||||
"https://github.com/home-assistant/core/blob/dev/homeassistant/components/automation/blueprints/motion_light.yaml",
|
||||
input: {
|
||||
motion_entity: { name: "Motion Sensor" },
|
||||
light_target: { name: "Light" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"homeassistant/notify_leaving_zone.yaml": {
|
||||
metadata: {
|
||||
domain: "automation",
|
||||
name: "Send notification when leaving a zone",
|
||||
description: "Get a notification when a person leaves a zone.",
|
||||
author: "Home Assistant",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const scriptBlueprints: Blueprints = {
|
||||
"homeassistant/confirmable_notification.yaml": {
|
||||
metadata: {
|
||||
domain: "script",
|
||||
name: "Confirmable Notification",
|
||||
description:
|
||||
"A script that sends an actionable notification with a confirmation.",
|
||||
author: "Home Assistant",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockBlueprint = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
|
||||
msg.domain === "script" ? scriptBlueprints : automationBlueprints
|
||||
);
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
import type {
|
||||
CloudStatusLoggedIn,
|
||||
SubscriptionInfo,
|
||||
} from "../../../src/data/cloud";
|
||||
import type { CloudTTSInfo } from "../../../src/data/cloud/tts";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const emptyFilter = () => ({
|
||||
include_domains: [],
|
||||
include_entities: [],
|
||||
exclude_domains: [],
|
||||
exclude_entities: [],
|
||||
});
|
||||
|
||||
// A single mutable status object so that preference changes made in the demo
|
||||
// are reflected back in the UI.
|
||||
const cloudStatus: CloudStatusLoggedIn = {
|
||||
logged_in: true,
|
||||
cloud: "connected",
|
||||
cloud_last_disconnect_reason: null,
|
||||
email: "demo@home-assistant.io",
|
||||
google_registered: true,
|
||||
google_entities: emptyFilter(),
|
||||
google_domains: ["light", "switch", "climate", "cover"],
|
||||
alexa_registered: true,
|
||||
alexa_entities: emptyFilter(),
|
||||
remote_domain: "demo-instance.ui.nabu.casa",
|
||||
remote_connected: true,
|
||||
remote_certificate: {
|
||||
common_name: "demo-instance.ui.nabu.casa",
|
||||
expire_date: "2099-01-01T00:00:00+00:00",
|
||||
fingerprint: "demodemodemodemodemodemodemodemodemodemodemodemodemo",
|
||||
alternative_names: ["demo-instance.ui.nabu.casa"],
|
||||
},
|
||||
remote_certificate_status: "ready",
|
||||
http_use_ssl: false,
|
||||
active_subscription: true,
|
||||
prefs: {
|
||||
google_enabled: true,
|
||||
alexa_enabled: true,
|
||||
remote_enabled: true,
|
||||
remote_allow_remote_enable: true,
|
||||
strict_connection: "disabled",
|
||||
google_secure_devices_pin: undefined,
|
||||
cloudhooks: {},
|
||||
alexa_report_state: true,
|
||||
google_report_state: true,
|
||||
tts_default_voice: ["en-US", "JennyNeural"],
|
||||
cloud_ice_servers_enabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
const subscription: SubscriptionInfo = {
|
||||
human_description: "Demo subscription, renews automatically",
|
||||
provider: "Nabu Casa, Inc.",
|
||||
plan_renewal_date: 4102444800,
|
||||
};
|
||||
|
||||
const ttsInfo: CloudTTSInfo = {
|
||||
languages: [
|
||||
["en-US", "JennyNeural", "Jenny"],
|
||||
["en-US", "GuyNeural", "Guy"],
|
||||
["en-GB", "LibbyNeural", "Libby"],
|
||||
["nl-NL", "ColetteNeural", "Colette"],
|
||||
["de-DE", "KatjaNeural", "Katja"],
|
||||
],
|
||||
};
|
||||
|
||||
export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("cloud/status", () => cloudStatus);
|
||||
hass.mockWS("cloud/subscription", () => subscription);
|
||||
hass.mockWS("cloud/tts/info", () => ttsInfo);
|
||||
|
||||
hass.mockWS("cloud/update_prefs", (msg) => {
|
||||
const { type, ...prefs } = msg;
|
||||
cloudStatus.prefs = { ...cloudStatus.prefs, ...prefs };
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/cloudhook/create", (msg) => {
|
||||
const webhook = {
|
||||
webhook_id: msg.webhook_id,
|
||||
cloudhook_id: "demo-cloudhook-id",
|
||||
cloudhook_url: `https://hooks.nabu.casa/demo-${msg.webhook_id}`,
|
||||
managed: false,
|
||||
};
|
||||
cloudStatus.prefs.cloudhooks = {
|
||||
...cloudStatus.prefs.cloudhooks,
|
||||
[msg.webhook_id]: webhook,
|
||||
};
|
||||
return webhook;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/cloudhook/delete", (msg) => {
|
||||
const cloudhooks = { ...cloudStatus.prefs.cloudhooks };
|
||||
delete cloudhooks[msg.webhook_id];
|
||||
cloudStatus.prefs.cloudhooks = cloudhooks;
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remote/connect", () => {
|
||||
cloudStatus.remote_connected = true;
|
||||
return null;
|
||||
});
|
||||
hass.mockWS("cloud/remote/disconnect", () => {
|
||||
cloudStatus.remote_connected = false;
|
||||
return null;
|
||||
});
|
||||
|
||||
hass.mockWS("cloud/remove_data", () => null);
|
||||
hass.mockWS("cloud/google_assistant/entities/update", () => null);
|
||||
hass.mockWS("cloud/alexa/entities", () => []);
|
||||
hass.mockWS("cloud/google_assistant/entities", () => []);
|
||||
|
||||
hass.mockAPI("cloud/logout", () => ({}));
|
||||
hass.mockAPI("cloud/google_actions/sync", () => ({}));
|
||||
hass.mockAPI("cloud/support_package", () => "Demo support package");
|
||||
};
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { mockApplicationCredentials } from "./application_credentials";
|
||||
import { mockAutomation } from "./automation";
|
||||
import { mockBackup } from "./backup";
|
||||
import { mockBlueprint } from "./blueprint";
|
||||
import { mockCloud } from "./cloud";
|
||||
import { mockConfig } from "./config";
|
||||
import { mockConfigEntries } from "./config_entries";
|
||||
import { mockDeviceAutomation } from "./device_automation";
|
||||
import { mockEntitySources } from "./entity_sources";
|
||||
import { mockExpose } from "./expose";
|
||||
import { mockNetwork } from "./network";
|
||||
import { mockPerson } from "./person";
|
||||
import { mockScene } from "./scene";
|
||||
import { mockSearch } from "./search";
|
||||
import { mockSystemHealth } from "./system_health";
|
||||
import { mockTags } from "./tags";
|
||||
import { mockZone } from "./zone";
|
||||
|
||||
// Registers every mock that is only needed once the config panel is opened.
|
||||
// This module is dynamically imported so its data stays out of the main bundle.
|
||||
export const mockConfigPanel = (hass: MockHomeAssistant) => {
|
||||
mockCloud(hass);
|
||||
mockConfig(hass);
|
||||
mockConfigEntries(hass);
|
||||
mockDeviceAutomation(hass);
|
||||
mockEntitySources(hass);
|
||||
mockBlueprint(hass);
|
||||
mockExpose(hass);
|
||||
mockZone(hass);
|
||||
mockPerson(hass);
|
||||
mockNetwork(hass);
|
||||
mockApplicationCredentials(hass);
|
||||
mockSystemHealth(hass);
|
||||
mockBackup(hass);
|
||||
mockAutomation(hass);
|
||||
mockScene(hass);
|
||||
mockSearch(hass);
|
||||
mockTags(hass);
|
||||
};
|
||||
@@ -1,126 +1,26 @@
|
||||
import type {
|
||||
ConfigEntry,
|
||||
ConfigEntryUpdate,
|
||||
} from "../../../src/data/config_entries";
|
||||
import type { ConfigFlowInProgressMessage } from "../../../src/data/config_flow";
|
||||
import type { IntegrationType } from "../../../src/data/integration";
|
||||
import type { getConfigEntries } from "../../../src/data/config_entries";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const baseEntry = {
|
||||
source: "user",
|
||||
state: "loaded" as const,
|
||||
supports_options: false,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: true,
|
||||
supported_subentry_types: {},
|
||||
num_subentries: 0,
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
};
|
||||
|
||||
// Each entry is tagged with its integration type so we can honor the
|
||||
// `type_filter` that the integrations and helpers panels subscribe with.
|
||||
export const demoConfigEntries: {
|
||||
entry: ConfigEntry;
|
||||
type: IntegrationType;
|
||||
}[] = [
|
||||
{
|
||||
type: "service",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "co2signal",
|
||||
export const mockConfigEntries = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS<typeof getConfigEntries>("config_entries/get", () => [
|
||||
{
|
||||
entry_id: "mock-entry-co2signal",
|
||||
domain: "co2signal",
|
||||
title: "Electricity Maps",
|
||||
source: "user",
|
||||
state: "loaded",
|
||||
supports_options: false,
|
||||
supports_remove_device: false,
|
||||
supports_unload: true,
|
||||
supports_reconfigure: true,
|
||||
supported_subentry_types: {},
|
||||
pref_disable_new_entities: false,
|
||||
pref_disable_polling: false,
|
||||
disabled_by: null,
|
||||
reason: null,
|
||||
num_subentries: 0,
|
||||
error_reason_translation_key: null,
|
||||
error_reason_translation_placeholders: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-hue",
|
||||
domain: "hue",
|
||||
title: "Philips Hue",
|
||||
source: "zeroconf",
|
||||
supports_options: true,
|
||||
supports_remove_device: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-sonos",
|
||||
domain: "sonos",
|
||||
title: "Sonos",
|
||||
source: "zeroconf",
|
||||
supports_options: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "service",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-met",
|
||||
domain: "met",
|
||||
title: "Forecast.Home",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "helper",
|
||||
entry: {
|
||||
...baseEntry,
|
||||
entry_id: "mock-template-helper",
|
||||
domain: "template",
|
||||
title: "Comfort level",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterEntries = (filters?: {
|
||||
type_filter?: IntegrationType[];
|
||||
domain?: string;
|
||||
}): ConfigEntry[] =>
|
||||
demoConfigEntries
|
||||
.filter(
|
||||
(e) =>
|
||||
(!filters?.type_filter || filters.type_filter.includes(e.type)) &&
|
||||
(!filters?.domain || filters.domain === e.entry.domain)
|
||||
)
|
||||
.map((e) => e.entry);
|
||||
|
||||
export const mockConfigEntries = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"config_entries/get",
|
||||
(msg: { type_filter?: IntegrationType[]; domain?: string }) =>
|
||||
filterEntries(msg)
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"config_entries/subscribe",
|
||||
(
|
||||
msg: { type_filter?: IntegrationType[]; domain?: string },
|
||||
_hass,
|
||||
onChange?: (updates: ConfigEntryUpdate[]) => void
|
||||
) => {
|
||||
onChange?.(filterEntries(msg).map((entry) => ({ type: null, entry })));
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
|
||||
hass.mockWS(
|
||||
"config_entries/flow/subscribe",
|
||||
(
|
||||
_msg,
|
||||
_hass,
|
||||
onChange?: (updates: ConfigFlowInProgressMessage[]) => void
|
||||
) => {
|
||||
onChange?.([]);
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// The demo's devices don't expose device-specific automations, so report empty
|
||||
// lists and no extra capability fields for the device automation pickers.
|
||||
export const mockDeviceAutomation = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("device_automation/trigger/list", () => []);
|
||||
hass.mockWS("device_automation/condition/list", () => []);
|
||||
hass.mockWS("device_automation/action/list", () => []);
|
||||
hass.mockWS("device_automation/trigger/capabilities", () => ({
|
||||
extra_fields: [],
|
||||
}));
|
||||
hass.mockWS("device_automation/condition/capabilities", () => ({
|
||||
extra_fields: [],
|
||||
}));
|
||||
hass.mockWS("device_automation/action/capabilities", () => ({
|
||||
extra_fields: [],
|
||||
}));
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
|
||||
|
||||
const baseDevice = {
|
||||
config_entries_subentries: {},
|
||||
connections: [] as [string, string][],
|
||||
identifiers: [] as [string, string][],
|
||||
model_id: null,
|
||||
labels: [] as string[],
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
serial_number: null,
|
||||
via_device_id: null,
|
||||
area_id: null,
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
|
||||
export const demoDevices: DeviceRegistryEntry[] = [
|
||||
{
|
||||
...baseDevice,
|
||||
id: "co2signal",
|
||||
name: "Electricity Maps",
|
||||
manufacturer: "Electricity Maps",
|
||||
model: "CO2 Signal",
|
||||
config_entries: ["co2signal"],
|
||||
primary_config_entry: "co2signal",
|
||||
entry_type: "service",
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "hue-bridge",
|
||||
name: "Philips Hue Bridge",
|
||||
manufacturer: "Signify",
|
||||
model: "Hue Bridge (BSB002)",
|
||||
sw_version: "1.50.0",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "sonos-living",
|
||||
name: "Living Room",
|
||||
manufacturer: "Sonos",
|
||||
model: "One",
|
||||
config_entries: ["mock-sonos"],
|
||||
primary_config_entry: "mock-sonos",
|
||||
entry_type: null,
|
||||
},
|
||||
];
|
||||
+34
-22
@@ -14,28 +14,40 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
energy_sources: [
|
||||
{
|
||||
type: "grid",
|
||||
stat_energy_from: "sensor.energy_consumption_tarif_1",
|
||||
stat_energy_to: "sensor.energy_production_tarif_1",
|
||||
stat_cost: "sensor.energy_consumption_tarif_1_cost",
|
||||
stat_compensation: "sensor.energy_production_tarif_1_compensation",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
entity_energy_price_export: null,
|
||||
number_energy_price_export: null,
|
||||
stat_rate: "sensor.power_grid",
|
||||
cost_adjustment_day: 0,
|
||||
},
|
||||
{
|
||||
type: "grid",
|
||||
stat_energy_from: "sensor.energy_consumption_tarif_2",
|
||||
stat_energy_to: "sensor.energy_production_tarif_2",
|
||||
stat_cost: "sensor.energy_consumption_tarif_2_cost",
|
||||
stat_compensation: "sensor.energy_production_tarif_2_compensation",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
entity_energy_price_export: null,
|
||||
number_energy_price_export: null,
|
||||
stat_rate: "sensor.power_grid_return",
|
||||
flow_from: [
|
||||
{
|
||||
stat_energy_from: "sensor.energy_consumption_tarif_1",
|
||||
stat_cost: "sensor.energy_consumption_tarif_1_cost",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
},
|
||||
{
|
||||
stat_energy_from: "sensor.energy_consumption_tarif_2",
|
||||
stat_cost: "sensor.energy_consumption_tarif_2_cost",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
},
|
||||
],
|
||||
flow_to: [
|
||||
{
|
||||
stat_energy_to: "sensor.energy_production_tarif_1",
|
||||
stat_compensation:
|
||||
"sensor.energy_production_tarif_1_compensation",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
},
|
||||
{
|
||||
stat_energy_to: "sensor.energy_production_tarif_2",
|
||||
stat_compensation:
|
||||
"sensor.energy_production_tarif_2_compensation",
|
||||
entity_energy_price: null,
|
||||
number_energy_price: null,
|
||||
},
|
||||
],
|
||||
power: [
|
||||
{ stat_rate: "sensor.power_grid" },
|
||||
{ stat_rate: "sensor.power_grid_return" },
|
||||
],
|
||||
cost_adjustment_day: 0,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { convertEntities } from "../../../src/fake_data/entity";
|
||||
|
||||
export const mapEntities = () =>
|
||||
Object.values({
|
||||
convertEntities({
|
||||
"zone.home": {
|
||||
entity_id: "zone.home",
|
||||
state: "zoning",
|
||||
@@ -49,7 +51,7 @@ export const mapEntities = () =>
|
||||
});
|
||||
|
||||
export const energyEntities = () =>
|
||||
Object.values({
|
||||
convertEntities({
|
||||
"sensor.grid_fossil_fuel_percentage": {
|
||||
entity_id: "sensor.grid_fossil_fuel_percentage",
|
||||
state: "88.6",
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
} from "../../../src/data/entity/entity_registry";
|
||||
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEntityRegistry = (
|
||||
@@ -9,17 +6,4 @@ export const mockEntityRegistry = (
|
||||
data: EntityRegistryEntry[] = []
|
||||
) => {
|
||||
hass.mockWS("config/entity_registry/list", () => data);
|
||||
hass.mockWS(
|
||||
"config/entity_registry/get_entries",
|
||||
(msg: { entity_ids: string[] }) => {
|
||||
const result: Record<string, ExtEntityRegistryEntry> = {};
|
||||
for (const entityId of msg.entity_ids) {
|
||||
const entry = data.find((e) => e.entity_id === entityId);
|
||||
if (entry) {
|
||||
result[entityId] = { ...entry, capabilities: {}, aliases: [] };
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import type { EntitySources } from "../../../src/data/entity/entity_sources";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockEntitySources = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"entity/source",
|
||||
(): EntitySources => ({
|
||||
"sensor.co2_intensity": { domain: "co2signal" },
|
||||
"sensor.grid_fossil_fuel_percentage": { domain: "co2signal" },
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { ExposeEntitySettings } from "../../../src/data/expose";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const exposedEntities: Record<string, ExposeEntitySettings> = {
|
||||
"light.bed_light": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
"light.ceiling_lights": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": false,
|
||||
},
|
||||
"switch.decorative_lights": {
|
||||
conversation: true,
|
||||
"cloud.alexa": false,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
"climate.ecobee": {
|
||||
conversation: true,
|
||||
"cloud.alexa": true,
|
||||
"cloud.google_assistant": true,
|
||||
},
|
||||
};
|
||||
|
||||
export const mockExpose = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("homeassistant/expose_entity/list", () => ({
|
||||
exposed_entities: exposedEntities,
|
||||
}));
|
||||
hass.mockWS(
|
||||
"homeassistant/expose_new_entities/get",
|
||||
(msg: { assistant: string }) => ({
|
||||
expose_new: msg.assistant !== "cloud.google_assistant",
|
||||
})
|
||||
);
|
||||
hass.mockWS("homeassistant/expose_entity", () => null);
|
||||
hass.mockWS("homeassistant/expose_new_entities/set", () => null);
|
||||
};
|
||||
@@ -42,7 +42,6 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
});
|
||||
hass.mockWS("frontend/get_system_data", () => ({ value: null }));
|
||||
hass.mockWS("repairs/list_issues", () => ({ issues: [] }));
|
||||
hass.mockWS("frontend/get_themes", (_msg, currentHass) => currentHass.themes);
|
||||
};
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { IntegrationManifest } from "../../../src/data/integration";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const manifest = (
|
||||
domain: string,
|
||||
name: string,
|
||||
overrides: Partial<IntegrationManifest> = {}
|
||||
): IntegrationManifest => ({
|
||||
is_built_in: true,
|
||||
domain,
|
||||
name,
|
||||
config_flow: true,
|
||||
documentation: `https://www.home-assistant.io/integrations/${domain}/`,
|
||||
iot_class: "local_push",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const manifests: IntegrationManifest[] = [
|
||||
manifest("co2signal", "Electricity Maps", { iot_class: "cloud_polling" }),
|
||||
manifest("hue", "Philips Hue"),
|
||||
manifest("sonos", "Sonos"),
|
||||
manifest("met", "Met.no", { iot_class: "cloud_polling" }),
|
||||
// Helpers
|
||||
manifest("template", "Template", { integration_type: "helper" }),
|
||||
manifest("input_boolean", "Toggle", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_number", "Number", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_select", "Dropdown", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_text", "Text", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("input_datetime", "Date and/or time", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("counter", "Counter", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("timer", "Timer", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("schedule", "Schedule", {
|
||||
config_flow: false,
|
||||
integration_type: "helper",
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
];
|
||||
|
||||
export const mockIntegration = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("manifest/list", () => manifests);
|
||||
hass.mockWS("manifest/get", (msg: { integration: string }) =>
|
||||
manifests.find((m) => m.domain === msg.integration)
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
selectedDemoConfig,
|
||||
@@ -28,9 +27,6 @@ export const mockLovelace = (
|
||||
);
|
||||
});
|
||||
|
||||
hass.mockWS("lovelace/info", () =>
|
||||
Promise.resolve({ resource_mode: "storage" } as LovelaceInfo)
|
||||
);
|
||||
hass.mockWS("lovelace/config/save", () => Promise.resolve());
|
||||
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
|
||||
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { NetworkUrls } from "../../../src/data/network";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockNetwork = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"network/url",
|
||||
(): NetworkUrls => ({
|
||||
internal: "http://homeassistant.local:8123",
|
||||
external: "https://demo-instance.ui.nabu.casa",
|
||||
cloud: "https://demo-instance.ui.nabu.casa",
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { Person } from "../../../src/data/person";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const storage: Person[] = [
|
||||
{
|
||||
id: "demo_user",
|
||||
name: "Demo User",
|
||||
user_id: "abcd",
|
||||
device_trackers: [],
|
||||
},
|
||||
{
|
||||
id: "anne_therese",
|
||||
name: "Anne Therese",
|
||||
device_trackers: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const mockPerson = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("person/list", () => ({ storage, config: [] as Person[] }));
|
||||
};
|
||||
+21
-25
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
addDays,
|
||||
addHours,
|
||||
addMinutes,
|
||||
addMonths,
|
||||
differenceInHours,
|
||||
endOfDay,
|
||||
@@ -13,22 +12,10 @@ import type {
|
||||
} from "../../../src/data/recorder";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const getNextDate = (
|
||||
currentDate: Date,
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
): Date => {
|
||||
return period === "day"
|
||||
? addDays(currentDate, 1)
|
||||
: period === "month"
|
||||
? addMonths(currentDate, 1)
|
||||
: period === "hour"
|
||||
? addHours(currentDate, 1)
|
||||
: addMinutes(currentDate, 5);
|
||||
};
|
||||
|
||||
const generateMeanStatistics = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
// eslint-disable-next-line default-param-last
|
||||
period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
maxDiff: number
|
||||
): StatisticValue[] => {
|
||||
@@ -39,10 +26,9 @@ const generateMeanStatistics = (
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const delta = Math.random() * maxDiff;
|
||||
const mean = delta;
|
||||
const nextDate = getNextDate(currentDate, period);
|
||||
statistics.push({
|
||||
start: currentDate.getTime(),
|
||||
end: nextDate.getTime(),
|
||||
end: currentDate.getTime(),
|
||||
mean,
|
||||
min: mean - Math.random() * maxDiff,
|
||||
max: mean + Math.random() * maxDiff,
|
||||
@@ -50,7 +36,12 @@ const generateMeanStatistics = (
|
||||
state: mean,
|
||||
sum: null,
|
||||
});
|
||||
currentDate = nextDate;
|
||||
currentDate =
|
||||
period === "day"
|
||||
? addDays(currentDate, 1)
|
||||
: period === "month"
|
||||
? addMonths(currentDate, 1)
|
||||
: addHours(currentDate, 1);
|
||||
}
|
||||
return statistics;
|
||||
};
|
||||
@@ -58,6 +49,7 @@ const generateMeanStatistics = (
|
||||
const generateSumStatistics = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
// eslint-disable-next-line default-param-last
|
||||
period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
initValue: number,
|
||||
maxDiff: number
|
||||
@@ -68,12 +60,11 @@ const generateSumStatistics = (
|
||||
let sum = initValue;
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const nextDate = getNextDate(currentDate, period);
|
||||
const add = Math.random() * maxDiff;
|
||||
sum += add;
|
||||
statistics.push({
|
||||
start: currentDate.getTime(),
|
||||
end: nextDate.getTime(),
|
||||
end: currentDate.getTime(),
|
||||
mean: null,
|
||||
min: null,
|
||||
max: null,
|
||||
@@ -82,7 +73,12 @@ const generateSumStatistics = (
|
||||
state: initValue + sum,
|
||||
sum,
|
||||
});
|
||||
currentDate = nextDate;
|
||||
currentDate =
|
||||
period === "day"
|
||||
? addDays(currentDate, 1)
|
||||
: period === "month"
|
||||
? addMonths(currentDate, 1)
|
||||
: addHours(currentDate, 1);
|
||||
}
|
||||
return statistics;
|
||||
};
|
||||
@@ -90,7 +86,8 @@ const generateSumStatistics = (
|
||||
const generateCurvedStatistics = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
// eslint-disable-next-line default-param-last
|
||||
_period: "5minute" | "hour" | "day" | "month" = "hour",
|
||||
initValue: number,
|
||||
maxDiff: number,
|
||||
metered: boolean
|
||||
@@ -104,12 +101,11 @@ const generateCurvedStatistics = (
|
||||
let half = false;
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const nextDate = getNextDate(currentDate, period);
|
||||
const add = i * (Math.random() * maxDiff);
|
||||
sum += add;
|
||||
statistics.push({
|
||||
start: currentDate.getTime(),
|
||||
end: nextDate.getTime(),
|
||||
end: currentDate.getTime(),
|
||||
mean: null,
|
||||
min: null,
|
||||
max: null,
|
||||
@@ -118,7 +114,7 @@ const generateCurvedStatistics = (
|
||||
state: initValue + sum,
|
||||
sum: metered ? sum : null,
|
||||
});
|
||||
currentDate = nextDate;
|
||||
currentDate = addHours(currentDate, 1);
|
||||
if (!half && i > hours / 2) {
|
||||
half = true;
|
||||
}
|
||||
@@ -296,7 +292,7 @@ const statisticsFunctions: Record<
|
||||
end,
|
||||
period,
|
||||
productionFinalVal,
|
||||
0.2
|
||||
2
|
||||
);
|
||||
return [...morning, ...production, ...evening, ...rest];
|
||||
},
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { SceneConfig } from "../../../src/data/scene";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const demoSceneConfig = (id: string): SceneConfig => ({
|
||||
id,
|
||||
name: "Demo scene",
|
||||
entities: {
|
||||
"light.bed_light": { state: "on" },
|
||||
},
|
||||
});
|
||||
|
||||
export const mockScene = (hass: MockHomeAssistant) => {
|
||||
hass.mockAPI(/config\/scene\/config\/.+/, (_hass, method, path) => {
|
||||
const id = path.split("/").pop()!;
|
||||
// GET returns the config; POST/DELETE just acknowledge.
|
||||
return method === "GET" ? demoSceneConfig(id) : {};
|
||||
});
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { RelatedResult } from "../../../src/data/search";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSearch = (hass: MockHomeAssistant) => {
|
||||
// The demo has no relationship graph, so report no related items.
|
||||
hass.mockWS("search/related", (): RelatedResult => ({}));
|
||||
};
|
||||
+54
-52
@@ -1,56 +1,58 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSensor = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("sensor/numeric_device_classes", () => ({
|
||||
numeric_device_classes: [
|
||||
"volume_storage",
|
||||
"gas",
|
||||
"data_size",
|
||||
"irradiance",
|
||||
"wind_speed",
|
||||
"volatile_organic_compounds",
|
||||
"volatile_organic_compounds_parts",
|
||||
"voltage",
|
||||
"frequency",
|
||||
"precipitation_intensity",
|
||||
"volume",
|
||||
"precipitation",
|
||||
"battery",
|
||||
"nitrogen_dioxide",
|
||||
"speed",
|
||||
"signal_strength",
|
||||
"pm1",
|
||||
"nitrous_oxide",
|
||||
"atmospheric_pressure",
|
||||
"data_rate",
|
||||
"temperature",
|
||||
"power_factor",
|
||||
"aqi",
|
||||
"current",
|
||||
"volume_flow_rate",
|
||||
"humidity",
|
||||
"duration",
|
||||
"ozone",
|
||||
"distance",
|
||||
"pressure",
|
||||
"pm25",
|
||||
"weight",
|
||||
"energy",
|
||||
"carbon_monoxide",
|
||||
"apparent_power",
|
||||
"illuminance",
|
||||
"energy_storage",
|
||||
"moisture",
|
||||
"power",
|
||||
"water",
|
||||
"carbon_dioxide",
|
||||
"ph",
|
||||
"reactive_power",
|
||||
"monetary",
|
||||
"nitrogen_monoxide",
|
||||
"pm10",
|
||||
"sound_pressure",
|
||||
"sulphur_dioxide",
|
||||
],
|
||||
}));
|
||||
hass.mockWS("sensor/numeric_device_classes", () => [
|
||||
{
|
||||
numeric_device_classes: [
|
||||
"volume_storage",
|
||||
"gas",
|
||||
"data_size",
|
||||
"irradiance",
|
||||
"wind_speed",
|
||||
"volatile_organic_compounds",
|
||||
"volatile_organic_compounds_parts",
|
||||
"voltage",
|
||||
"frequency",
|
||||
"precipitation_intensity",
|
||||
"volume",
|
||||
"precipitation",
|
||||
"battery",
|
||||
"nitrogen_dioxide",
|
||||
"speed",
|
||||
"signal_strength",
|
||||
"pm1",
|
||||
"nitrous_oxide",
|
||||
"atmospheric_pressure",
|
||||
"data_rate",
|
||||
"temperature",
|
||||
"power_factor",
|
||||
"aqi",
|
||||
"current",
|
||||
"volume_flow_rate",
|
||||
"humidity",
|
||||
"duration",
|
||||
"ozone",
|
||||
"distance",
|
||||
"pressure",
|
||||
"pm25",
|
||||
"weight",
|
||||
"energy",
|
||||
"carbon_monoxide",
|
||||
"apparent_power",
|
||||
"illuminance",
|
||||
"energy_storage",
|
||||
"moisture",
|
||||
"power",
|
||||
"water",
|
||||
"carbon_dioxide",
|
||||
"ph",
|
||||
"reactive_power",
|
||||
"monetary",
|
||||
"nitrogen_monoxide",
|
||||
"pm10",
|
||||
"sound_pressure",
|
||||
"sulphur_dioxide",
|
||||
],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSystemHealth = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"system_health/info",
|
||||
(_msg, _hass, onChange?: (event: any) => void) => {
|
||||
// Defer so the consumer's unsubscribe handle is initialized first
|
||||
// (real WS events arrive asynchronously).
|
||||
setTimeout(() => {
|
||||
onChange?.({
|
||||
type: "initial",
|
||||
data: {
|
||||
homeassistant: {
|
||||
info: {
|
||||
version: "DEMO",
|
||||
installation_type: "Home Assistant OS",
|
||||
dev: false,
|
||||
hassio: true,
|
||||
docker: true,
|
||||
container_arch: "aarch64",
|
||||
user: "root",
|
||||
virtualenv: false,
|
||||
python_version: "3.13.0",
|
||||
os_name: "Linux",
|
||||
os_version: "6.6.0",
|
||||
arch: "aarch64",
|
||||
timezone: "America/Los_Angeles",
|
||||
config_dir: "/config",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
return () => undefined;
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,33 +1,5 @@
|
||||
import type { LoggedError } from "../../../src/data/system_log";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const now = Date.now() / 1000;
|
||||
|
||||
const logs: LoggedError[] = [
|
||||
{
|
||||
name: "homeassistant.components.demo",
|
||||
message: ["Demo integration failed to update sensor data"],
|
||||
level: "warning",
|
||||
source: ["components/demo/sensor.py", 142],
|
||||
exception: "",
|
||||
count: 2,
|
||||
timestamp: now - 120,
|
||||
first_occurred: now - 3600,
|
||||
},
|
||||
{
|
||||
name: "homeassistant.config_entries",
|
||||
message: ["Config entry for met.no could not be set up"],
|
||||
level: "error",
|
||||
source: ["config_entries.py", 512],
|
||||
exception:
|
||||
'Traceback (most recent call last):\n File "config_entries.py", line 512',
|
||||
count: 1,
|
||||
timestamp: now - 600,
|
||||
first_occurred: now - 600,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockSystemLog = (hass: MockHomeAssistant) => {
|
||||
hass.mockAPI("error/all", () => []);
|
||||
hass.mockWS("system_log/list", () => logs);
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUpdate = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("update/list", () => []);
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { Zone } from "../../../src/data/zone";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const zones: Zone[] = [
|
||||
{
|
||||
id: "home",
|
||||
name: "Home",
|
||||
icon: "mdi:home",
|
||||
latitude: 52.3731339,
|
||||
longitude: 4.8903147,
|
||||
radius: 100,
|
||||
passive: false,
|
||||
},
|
||||
{
|
||||
id: "work",
|
||||
name: "Work",
|
||||
icon: "mdi:briefcase",
|
||||
latitude: 52.3909184,
|
||||
longitude: 4.8530821,
|
||||
radius: 200,
|
||||
passive: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockZone = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("zone/list", () => zones);
|
||||
};
|
||||
+48
-103
@@ -1,24 +1,28 @@
|
||||
// @ts-check
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
import globals from "globals";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import js from "@eslint/js";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import tseslint from "typescript-eslint";
|
||||
import eslintConfigPrettier from "eslint-config-prettier";
|
||||
import { configs as litConfigs } from "eslint-plugin-lit";
|
||||
import { configs as wcConfigs } from "eslint-plugin-wc";
|
||||
import { configs as a11yConfigs } from "eslint-plugin-lit-a11y";
|
||||
import html from "@html-eslint/eslint-plugin";
|
||||
import importX from "eslint-plugin-import-x";
|
||||
|
||||
const rspackConfigPath = fileURLToPath(
|
||||
new URL("./rspack.config.cjs", import.meta.url)
|
||||
);
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = path.dirname(_filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: _dirname,
|
||||
recommendedConfig: js.configs.recommended,
|
||||
allConfig: js.configs.all,
|
||||
});
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...compat.extends("airbnb-base"),
|
||||
eslintConfigPrettier,
|
||||
litConfigs["flat/all"],
|
||||
tseslint.configs.recommended,
|
||||
@@ -26,7 +30,6 @@ export default tseslint.config(
|
||||
tseslint.configs.stylistic,
|
||||
wcConfigs["flat/recommended"],
|
||||
a11yConfigs.recommended,
|
||||
importX.flatConfigs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
@@ -54,73 +57,41 @@ export default tseslint.config(
|
||||
},
|
||||
|
||||
settings: {
|
||||
"import-x/resolver": {
|
||||
"import/resolver": {
|
||||
webpack: {
|
||||
config: rspackConfigPath,
|
||||
config: "./rspack.config.cjs",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
rules: {
|
||||
"array-callback-return": ["error", { allowImplicit: true }],
|
||||
"block-scoped-var": "error",
|
||||
"consistent-return": "error",
|
||||
curly: ["error", "multi-line"],
|
||||
"default-case-last": "error",
|
||||
eqeqeq: ["error", "always", { null: "ignore" }],
|
||||
"guard-for-in": "error",
|
||||
"no-await-in-loop": "error",
|
||||
"no-caller": "error",
|
||||
"no-constructor-return": "error",
|
||||
"no-eval": "error",
|
||||
"no-extend-native": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-iterator": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-promise-executor-return": "error",
|
||||
"no-return-assign": ["error", "always"],
|
||||
"no-script-url": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-unreachable-loop": "error",
|
||||
|
||||
"no-else-return": ["error", { allowElseIf: false }],
|
||||
"no-lonely-if": "error",
|
||||
"no-unneeded-ternary": ["error", { defaultAssignment: false }],
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-concat": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"one-var": ["error", "never"],
|
||||
"operator-assignment": ["error", "always"],
|
||||
"prefer-arrow-callback": "error",
|
||||
"prefer-exponentiation-operator": "error",
|
||||
"prefer-object-spread": "error",
|
||||
"prefer-regex-literals": ["error", { disallowRedundantWrapping: true }],
|
||||
"symbol-description": "error",
|
||||
yoda: "error",
|
||||
|
||||
// TODO: Enable once violations are fixed (43 instances as of 2026-04)
|
||||
// "no-useless-assignment": "error",
|
||||
"no-useless-assignment": "error",
|
||||
|
||||
// Project rules
|
||||
"class-methods-use-this": "off",
|
||||
"new-cap": "off",
|
||||
"prefer-template": "off",
|
||||
"object-shorthand": "off",
|
||||
"func-names": "off",
|
||||
"no-underscore-dangle": "off",
|
||||
strict: "off",
|
||||
"no-plusplus": "off",
|
||||
"no-bitwise": "error",
|
||||
"comma-dangle": "off",
|
||||
"vars-on-top": "off",
|
||||
"no-continue": "off",
|
||||
"no-param-reassign": "off",
|
||||
"no-multi-assign": "off",
|
||||
"no-console": "error",
|
||||
radix: "off",
|
||||
"no-alert": "off",
|
||||
"no-nested-ternary": "off",
|
||||
"prefer-destructuring": "off",
|
||||
"no-restricted-globals": [2, "event"],
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"wc/no-self-class": "off",
|
||||
"prefer-promise-reject-errors": "off",
|
||||
"import/prefer-default-export": "off",
|
||||
"import/no-default-export": "off",
|
||||
"import/no-unresolved": "off",
|
||||
"import/no-cycle": "off",
|
||||
|
||||
// import-x rules
|
||||
"import-x/named": "off",
|
||||
"import-x/prefer-default-export": "off",
|
||||
"import-x/no-default-export": "off",
|
||||
"import-x/no-unresolved": "off",
|
||||
"import-x/no-cycle": "off",
|
||||
"import-x/extensions": [
|
||||
"import/extensions": [
|
||||
"error",
|
||||
"ignorePackages",
|
||||
{
|
||||
@@ -128,27 +99,19 @@ export default tseslint.config(
|
||||
js: "never",
|
||||
},
|
||||
],
|
||||
"import-x/no-mutable-exports": "error",
|
||||
"import-x/no-amd": "error",
|
||||
"import-x/first": "error",
|
||||
"import-x/order": [
|
||||
"error",
|
||||
{ groups: [["builtin", "external", "internal"]] },
|
||||
],
|
||||
"import-x/newline-after-import": "error",
|
||||
"import-x/no-absolute-path": "error",
|
||||
"import-x/no-dynamic-require": "error",
|
||||
"import-x/no-webpack-loader-syntax": "error",
|
||||
"import-x/no-named-default": "error",
|
||||
"import-x/no-self-import": "error",
|
||||
"import-x/no-useless-path-segments": ["error", { commonjs: true }],
|
||||
"import-x/no-import-module-exports": ["error", { exceptions: [] }],
|
||||
"import-x/no-relative-packages": "error",
|
||||
|
||||
// TypeScript rules
|
||||
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
|
||||
"object-curly-newline": "off",
|
||||
"default-case": "off",
|
||||
"wc/no-self-class": "off",
|
||||
"no-shadow": "off",
|
||||
"@typescript-eslint/camelcase": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-shadow": ["error"],
|
||||
|
||||
"@typescript-eslint/naming-convention": [
|
||||
@@ -212,6 +175,7 @@ export default tseslint.config(
|
||||
"lit-a11y/role-has-required-aria-attrs": "error",
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
"@typescript-eslint/no-import-type-side-effects": "error",
|
||||
camelcase: "off",
|
||||
"@typescript-eslint/no-dynamic-delete": "off",
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
@@ -220,6 +184,7 @@ export default tseslint.config(
|
||||
allowObjectTypes: "always",
|
||||
},
|
||||
],
|
||||
"no-use-before-define": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -227,25 +192,5 @@ export default tseslint.config(
|
||||
languageOptions: {
|
||||
globals: globals.audioWorklet,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/entrypoints/service-worker.ts"],
|
||||
languageOptions: {
|
||||
globals: globals.serviceworker,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/e2e/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
},
|
||||
rules: {
|
||||
"html/no-invalid-attr-value": "error",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
# Gallery Agent Instructions
|
||||
|
||||
This file applies to all files under `gallery/`. Follow the root `AGENTS.md` for repository-wide Home Assistant frontend, TypeScript, Lit, accessibility, and copy standards. This file adds gallery-specific structure, page, demo, and verification guidance.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
Run commands from the repository root unless noted otherwise:
|
||||
|
||||
```bash
|
||||
gallery/script/develop_gallery # Start the gallery development server
|
||||
gallery/script/build_gallery # Build the static gallery
|
||||
yarn lint # ESLint, Prettier, TypeScript, and Lit checks
|
||||
yarn lint:types # TypeScript compiler, without file arguments
|
||||
```
|
||||
|
||||
Never run `yarn lint:types` or `tsc` with file arguments. See the root `AGENTS.md` for the generated `.js` file risk.
|
||||
|
||||
## Purpose
|
||||
|
||||
The gallery is a developer and designer reference for Home Assistant frontend UI patterns. It documents component APIs, shows realistic Lovelace and more-info states, captures brand and copy guidance, and provides reproducible demos that are safe to inspect outside a running Home Assistant instance.
|
||||
|
||||
- Prefer demonstrating real production components from `src/` instead of creating gallery-only replacements.
|
||||
- Keep fake state, sample data, and demo-only helpers inside `gallery/`.
|
||||
- Do not move gallery stubs or demo data into production code unless a production feature explicitly needs them.
|
||||
- Do not hand-edit generated output under `gallery/build/` or `gallery/dist/`.
|
||||
|
||||
## Structure
|
||||
|
||||
- `sidebar.js`: Defines gallery sections, headers, and explicit page ordering.
|
||||
- `script/develop_gallery`: Wrapper for the `develop-gallery` gulp task.
|
||||
- `script/build_gallery`: Wrapper for the `build-gallery` gulp task.
|
||||
- `src/entrypoint.js`: Creates the `<ha-gallery>` shell.
|
||||
- `src/ha-gallery.ts`: Renders the drawer, page routing, markdown descriptions, demos, edit links, and RTL toggle.
|
||||
- `src/html/index.html.template`: HTML template used by the gallery build.
|
||||
- `src/pages/<category>/<page>.markdown`: Optional page description and frontmatter.
|
||||
- `src/pages/<category>/<page>.ts`: Optional live demo module for the same page id.
|
||||
- `src/components/`: Gallery-only demo wrappers like `demo-card`, `demo-cards`, `demo-more-info`, and `page-description`.
|
||||
- `src/data/`: Fake `hass`, demo states, mock traces, and reusable sample data.
|
||||
- `public/`: Static assets copied into the gallery output.
|
||||
|
||||
## Page Model
|
||||
|
||||
Gallery pages are generated by `gather-gallery-pages` in `build-scripts/gulp/gallery.js`.
|
||||
|
||||
- A page id is the path under `src/pages/` without the extension, like `components/ha-button`.
|
||||
- A `.markdown` file and a `.ts` file with the same page id become one gallery page.
|
||||
- A page may have only markdown, only a TypeScript demo, or both.
|
||||
- Markdown can contain YAML frontmatter with `title` and optional `subtitle`.
|
||||
- Markdown that contains only frontmatter contributes metadata without rendering a description block.
|
||||
- TypeScript demo modules are dynamically imported for side effects when the page is opened.
|
||||
- A demo module must define a custom element named `demo-${category}-${page}` with slashes replaced by hyphens, like `demo-components-ha-button` for `components/ha-button`.
|
||||
- `ha-gallery.ts` renders that element with `dynamicElement()` based on the current page id.
|
||||
|
||||
## Sidebar
|
||||
|
||||
Use `sidebar.js` when a page needs a visible section, section header, or deterministic ordering.
|
||||
|
||||
- `category` must match the first directory name under `src/pages/`.
|
||||
- `header` is the section label shown in the drawer.
|
||||
- `pages` is optional. When present, listed pages keep that exact order.
|
||||
- Pages in a category that are not listed are appended alphabetically after the listed pages.
|
||||
- New categories without a sidebar entry are appended by the generator with their category name as the header.
|
||||
- If a listed page does not exist, the generator logs an error during `gather-gallery-pages`.
|
||||
|
||||
### Subsections
|
||||
|
||||
A section can group its pages under named subsections instead of one flat list. Use this for large categories where related pages should sit together.
|
||||
|
||||
- `subsections` is an array of `{ header, pages }`. It is mutually exclusive with a flat `pages` array on the same group.
|
||||
- Each subsection `header` is a non-collapsible label rendered inside the section's expansion panel; the section stays the only collapsible level.
|
||||
- Listed pages keep their per-subsection order.
|
||||
- Any pages found in the category but not listed in a subsection are collected into a generated `Other` subsection, appended alphabetically. The `Other` subsection is omitted when there are no leftovers.
|
||||
- A listed page that does not exist still logs an error during `gather-gallery-pages`.
|
||||
- Use sentence case for subsection headers and follow the content standards below.
|
||||
|
||||
## Markdown Pages
|
||||
|
||||
Use markdown pages for explanations, design guidance, API notes, and copy standards.
|
||||
|
||||
- Start with frontmatter when the page needs a title or subtitle.
|
||||
- Use sentence case for titles, headings, labels, and UI copy.
|
||||
- Put the live example before the reference API when that makes the page easier to scan.
|
||||
- Use fenced code blocks with a language tag for copyable examples.
|
||||
- Keep examples short and focused on the behavior being documented.
|
||||
- Prefer real component names and attributes over prose-only descriptions.
|
||||
- Use Home Assistant terminology from the root `AGENTS.md`.
|
||||
- For remove/delete and add/create wording, follow `src/pages/misc/remove-delete-add-create.markdown`.
|
||||
|
||||
Gallery markdown is documentation content and is not localized with `localize`. If demo code creates production UI strings, keep those strings aligned with the root localization and copy guidance.
|
||||
|
||||
## Demo Components
|
||||
|
||||
Use TypeScript demo pages for interactive or stateful examples.
|
||||
|
||||
- Import production components from `../../../src/...` or the correct relative path from the demo file.
|
||||
- Import reusable gallery helpers from `gallery/src/components/` when they already model the pattern.
|
||||
- Use `demo-card` and `demo-cards` for Lovelace card examples that render YAML card configs.
|
||||
- Use `demo-more-info` and `demo-more-infos` for more-info dialog examples.
|
||||
- Use shared mock data from `src/data/` instead of repeating large fake state objects inline.
|
||||
- Show meaningful states, such as loading, unavailable, empty, error, active, inactive, and disabled when relevant.
|
||||
- Check responsive behavior and the gallery RTL toggle when layout or direction-sensitive UI changes.
|
||||
- Keep unavoidable casts or loose demo parsing local to the demo helper or demo page.
|
||||
|
||||
The gallery ESLint config allows `console` for gallery diagnostics. Do not copy that exception into production frontend code.
|
||||
|
||||
## Content Standards
|
||||
|
||||
The root copy standards still apply: use American English, sentence case, active voice, inclusive language, direct user-focused wording, and consistent Home Assistant terminology.
|
||||
|
||||
- Use `Home Assistant` in full, not `HA` or `HASS`.
|
||||
- Use `integration` instead of `component` for product concepts.
|
||||
- Use `Remove` for reversible disassociation and `Delete` for permanent deletion.
|
||||
- Use `Add` for existing items and `Create` for something made from scratch.
|
||||
- Avoid Latin abbreviations like `e.g.` and `i.e.` in prose.
|
||||
- Avoid stitching sentence fragments together in production UI examples.
|
||||
|
||||
## Verification
|
||||
|
||||
- For markdown, sidebar, and page-generation changes, run `gallery/script/build_gallery`.
|
||||
- For TypeScript demo or gallery shell changes, run the smallest relevant check plus `yarn lint` when practical.
|
||||
- For type checking, run `yarn lint:types` without file arguments.
|
||||
- For visual changes, run `gallery/script/develop_gallery` and check the affected page on desktop, narrow viewport, and RTL when relevant.
|
||||
- If verification is skipped, state which command was skipped and why.
|
||||
+27
-208
@@ -1,237 +1,56 @@
|
||||
import {
|
||||
mdiAccountGroup,
|
||||
mdiCalendarClock,
|
||||
mdiDotsHorizontal,
|
||||
mdiHome,
|
||||
mdiInformationOutline,
|
||||
mdiPalette,
|
||||
mdiPuzzle,
|
||||
mdiRobot,
|
||||
mdiViewDashboard,
|
||||
} from "@mdi/js";
|
||||
|
||||
// A group may list its pages flat in `pages`, or group them under named
|
||||
// `subsections`. The two are mutually exclusive. Listed pages keep their order;
|
||||
// any pages found in the category but not listed are appended alphabetically
|
||||
// (to a generated "Other" subsection when the group uses subsections).
|
||||
export default [
|
||||
{
|
||||
// This section has no header and so all page links are shown directly in the sidebar
|
||||
category: "concepts",
|
||||
icon: mdiHome,
|
||||
pages: ["home"],
|
||||
},
|
||||
|
||||
{
|
||||
category: "brand",
|
||||
icon: mdiPalette,
|
||||
header: "Brand",
|
||||
},
|
||||
{
|
||||
category: "components",
|
||||
icon: mdiPuzzle,
|
||||
header: "Components",
|
||||
subsections: [
|
||||
{
|
||||
header: "Form and selectors",
|
||||
pages: [
|
||||
"ha-form",
|
||||
"ha-selector",
|
||||
"ha-select-box",
|
||||
"ha-input",
|
||||
"ha-textarea",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Controls and sliders",
|
||||
pages: [
|
||||
"ha-button",
|
||||
"ha-control-button",
|
||||
"ha-progress-button",
|
||||
"ha-switch",
|
||||
"ha-control-switch",
|
||||
"ha-slider",
|
||||
"ha-control-slider",
|
||||
"ha-control-circular-slider",
|
||||
"ha-control-number-buttons",
|
||||
"ha-control-select",
|
||||
"ha-control-select-menu",
|
||||
"ha-hs-color-picker",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Overlays",
|
||||
pages: [
|
||||
"ha-dialog",
|
||||
"ha-dialogs",
|
||||
"ha-adaptive-dialog",
|
||||
"ha-adaptive-popover",
|
||||
"ha-dropdown",
|
||||
"ha-tooltip",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Lists and disclosure",
|
||||
pages: ["ha-list", "ha-expansion-panel", "ha-faded"],
|
||||
},
|
||||
{
|
||||
header: "Feedback and status",
|
||||
pages: ["ha-alert", "ha-spinner", "ha-tip", "ha-bar", "ha-gauge"],
|
||||
},
|
||||
{
|
||||
header: "Labels and text",
|
||||
pages: ["ha-badge", "ha-label-badge", "ha-chips", "ha-marquee-text"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "lovelace",
|
||||
icon: mdiViewDashboard,
|
||||
// Label for in the sidebar
|
||||
header: "Dashboards",
|
||||
subsections: [
|
||||
{
|
||||
header: "Introduction",
|
||||
pages: ["introduction"],
|
||||
},
|
||||
{
|
||||
header: "Entity cards",
|
||||
pages: [
|
||||
"entities-card",
|
||||
"entity-button-card",
|
||||
"entity-filter-card",
|
||||
"glance-card",
|
||||
"tile-card",
|
||||
"area-card",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Picture cards",
|
||||
pages: [
|
||||
"picture-card",
|
||||
"picture-elements-card",
|
||||
"picture-entity-card",
|
||||
"picture-glance-card",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Domain cards",
|
||||
pages: [
|
||||
"light-card",
|
||||
"thermostat-card",
|
||||
"alarm-panel-card",
|
||||
"gauge-card",
|
||||
"plant-card",
|
||||
"map-card",
|
||||
"media-control-card",
|
||||
"media-player-row",
|
||||
],
|
||||
},
|
||||
{
|
||||
header: "Layout and utility",
|
||||
pages: [
|
||||
"grid-and-stack-card",
|
||||
"conditional-card",
|
||||
"iframe-card",
|
||||
"markdown-card",
|
||||
"todo-list-card",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "more-info",
|
||||
icon: mdiInformationOutline,
|
||||
header: "More Info dialogs",
|
||||
subsections: [
|
||||
{
|
||||
header: "Climate and water",
|
||||
pages: ["climate", "humidifier", "water-heater", "fan"],
|
||||
},
|
||||
{
|
||||
header: "Covers and access",
|
||||
pages: ["cover", "lock", "lawn-mower", "vacuum"],
|
||||
},
|
||||
{
|
||||
header: "Lighting",
|
||||
pages: ["light", "scene"],
|
||||
},
|
||||
{
|
||||
header: "Media",
|
||||
pages: ["media-player"],
|
||||
},
|
||||
{
|
||||
header: "Inputs and values",
|
||||
pages: ["input-number", "input-text", "number", "timer"],
|
||||
},
|
||||
{
|
||||
header: "System",
|
||||
pages: ["update"],
|
||||
},
|
||||
],
|
||||
// Specify order of pages. Any pages in the category folder but not listed here will
|
||||
// automatically be added after the pages listed here.
|
||||
pages: ["introduction"],
|
||||
},
|
||||
{
|
||||
category: "automation",
|
||||
icon: mdiRobot,
|
||||
header: "Automation",
|
||||
subsections: [
|
||||
{
|
||||
header: "Editors",
|
||||
pages: ["editor-trigger", "editor-condition", "editor-action"],
|
||||
},
|
||||
{
|
||||
header: "Descriptions",
|
||||
pages: ["describe-trigger", "describe-condition", "describe-action"],
|
||||
},
|
||||
{
|
||||
header: "Traces",
|
||||
pages: ["trace", "trace-timeline"],
|
||||
},
|
||||
pages: [
|
||||
"editor-trigger",
|
||||
"editor-condition",
|
||||
"editor-action",
|
||||
"trace",
|
||||
"trace-timeline",
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "components",
|
||||
header: "Components",
|
||||
},
|
||||
{
|
||||
category: "more-info",
|
||||
header: "More Info dialogs",
|
||||
},
|
||||
{
|
||||
category: "misc",
|
||||
header: "Miscellaneous",
|
||||
},
|
||||
{
|
||||
category: "brand",
|
||||
header: "Brand",
|
||||
},
|
||||
{
|
||||
category: "user-test",
|
||||
icon: mdiAccountGroup,
|
||||
header: "Users",
|
||||
pages: ["user-types", "configuration-menu"],
|
||||
},
|
||||
{
|
||||
category: "date-time",
|
||||
icon: mdiCalendarClock,
|
||||
header: "Date and Time",
|
||||
subsections: [
|
||||
{
|
||||
header: "Date",
|
||||
pages: ["date"],
|
||||
},
|
||||
{
|
||||
header: "Time",
|
||||
pages: ["time", "time-seconds", "time-weekday"],
|
||||
},
|
||||
{
|
||||
header: "Combined",
|
||||
pages: [
|
||||
"date-time",
|
||||
"date-time-numeric",
|
||||
"date-time-seconds",
|
||||
"date-time-short",
|
||||
"date-time-short-year",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
category: "misc",
|
||||
icon: mdiDotsHorizontal,
|
||||
header: "Miscellaneous",
|
||||
pages: [
|
||||
"entity-state",
|
||||
"ha-markdown",
|
||||
"integration-card",
|
||||
"box-shadow",
|
||||
"util-long-press",
|
||||
"remove-delete-add-create",
|
||||
"editing",
|
||||
],
|
||||
category: "design.home-assistant.io",
|
||||
header: "About",
|
||||
},
|
||||
];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user