mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-18 23:06:40 +00:00
Merge branch 'dev' into zwave-graph
This commit is contained in:
commit
4151ce5dba
592
.github/copilot-instructions.md
vendored
Normal file
592
.github/copilot-instructions.md
vendored
Normal file
@ -0,0 +1,592 @@
|
|||||||
|
# GitHub Copilot & Claude Code Instructions
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Quick Reference](#quick-reference)
|
||||||
|
- [Core Architecture](#core-architecture)
|
||||||
|
- [Development Standards](#development-standards)
|
||||||
|
- [Component Library](#component-library)
|
||||||
|
- [Common Patterns](#common-patterns)
|
||||||
|
- [Text and Copy Guidelines](#text-and-copy-guidelines)
|
||||||
|
- [Development Workflow](#development-workflow)
|
||||||
|
- [Review Guidelines](#review-guidelines)
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### Essential Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn lint # ESLint + Prettier + TypeScript + Lit
|
||||||
|
yarn format # Auto-fix ESLint + Prettier
|
||||||
|
yarn lint:types # TypeScript compiler
|
||||||
|
yarn test # Vitest
|
||||||
|
script/develop # Development server
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Prefixes
|
||||||
|
|
||||||
|
- `ha-` - Home Assistant components
|
||||||
|
- `hui-` - Lovelace UI components
|
||||||
|
- `dialog-` - Dialog components
|
||||||
|
|
||||||
|
### Import Patterns
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import type { HomeAssistant } from "../types";
|
||||||
|
import { fireEvent } from "../common/dom/fire_event";
|
||||||
|
import { showAlertDialog } from "../dialogs/generic/show-alert-dialog";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Architecture
|
||||||
|
|
||||||
|
The Home Assistant frontend is a modern web application that:
|
||||||
|
|
||||||
|
- Uses Web Components (custom elements) built with Lit framework
|
||||||
|
- Is written entirely in TypeScript with strict type checking
|
||||||
|
- Communicates with the backend via WebSocket API
|
||||||
|
- Provides comprehensive theming and internationalization
|
||||||
|
|
||||||
|
## Development Standards
|
||||||
|
|
||||||
|
### Code Quality Requirements
|
||||||
|
|
||||||
|
**Linting and Formatting (Enforced by Tools)**
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
**Naming Conventions**
|
||||||
|
|
||||||
|
- PascalCase for types and classes
|
||||||
|
- camelCase for variables, methods
|
||||||
|
- Private methods require leading underscore
|
||||||
|
- Public methods forbid leading underscore
|
||||||
|
|
||||||
|
### TypeScript Usage
|
||||||
|
|
||||||
|
- **Always use strict TypeScript**: Enable all strict flags, avoid `any` types
|
||||||
|
- **Proper type imports**: Use `import type` for type-only imports
|
||||||
|
- **Define interfaces**: Create proper interfaces for data structures
|
||||||
|
- **Type component properties**: All Lit properties must be properly typed
|
||||||
|
- **No unused variables**: Prefix with `_` if intentionally unused
|
||||||
|
- **Consistent imports**: Use `@typescript-eslint/consistent-type-imports`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
import type { HomeAssistant } from "../types";
|
||||||
|
|
||||||
|
interface EntityConfig {
|
||||||
|
entity: string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@property({ type: Object })
|
||||||
|
hass!: HomeAssistant;
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
@property()
|
||||||
|
hass: any;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Components with Lit
|
||||||
|
|
||||||
|
- **Use Lit 3.x patterns**: Follow modern Lit practices
|
||||||
|
- **Extend appropriate base classes**: Use `LitElement`, `SubscribeMixin`, or other mixins as needed
|
||||||
|
- **Define custom element names**: Use `ha-` prefix for components
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@customElement("ha-my-component")
|
||||||
|
export class HaMyComponent extends LitElement {
|
||||||
|
@property({ attribute: false })
|
||||||
|
hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private _config?: MyComponentConfig;
|
||||||
|
|
||||||
|
static get styles() {
|
||||||
|
return css`
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return html`<div>Content</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Guidelines
|
||||||
|
|
||||||
|
- **Use composition**: Prefer composition over inheritance
|
||||||
|
- **Lazy load panels**: Heavy panels should be dynamically imported
|
||||||
|
- **Optimize renders**: Use `@state()` for internal state, `@property()` for public API
|
||||||
|
- **Handle loading states**: Always show appropriate loading indicators
|
||||||
|
- **Support themes**: Use CSS custom properties from theme
|
||||||
|
|
||||||
|
### Data Management
|
||||||
|
|
||||||
|
- **Use WebSocket API**: All backend communication via home-assistant-js-websocket
|
||||||
|
- **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
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Good
|
||||||
|
try {
|
||||||
|
const result = await fetchEntityRegistry(this.hass.connection);
|
||||||
|
this._processResult(result);
|
||||||
|
} catch (err) {
|
||||||
|
showAlertDialog(this, {
|
||||||
|
text: `Failed to load: ${err.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Styling Guidelines
|
||||||
|
|
||||||
|
- **Use CSS custom properties**: Leverage the theme system
|
||||||
|
- **Mobile-first responsive**: Design for mobile, enhance for desktop
|
||||||
|
- **Follow Material Design**: Use Material Web Components where appropriate
|
||||||
|
- **Support RTL**: Ensure all layouts work in RTL languages
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
static get styles() {
|
||||||
|
return css`
|
||||||
|
:host {
|
||||||
|
--spacing: 16px;
|
||||||
|
padding: var(--spacing);
|
||||||
|
color: var(--primary-text-color);
|
||||||
|
background-color: var(--card-background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
:host {
|
||||||
|
--spacing: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Performance Best Practices
|
||||||
|
|
||||||
|
- **Code split**: Split code at the panel/dialog level
|
||||||
|
- **Lazy load**: Use dynamic imports for heavy components
|
||||||
|
- **Optimize bundle**: Keep initial bundle size minimal
|
||||||
|
- **Use virtual scrolling**: For long lists, implement virtual scrolling
|
||||||
|
- **Memoize computations**: Cache expensive calculations
|
||||||
|
|
||||||
|
### Testing Requirements
|
||||||
|
|
||||||
|
- **Write tests**: Add tests for data processing and utilities
|
||||||
|
- **Test with Vitest**: Use the established test framework
|
||||||
|
- **Mock appropriately**: Mock WebSocket connections and API calls
|
||||||
|
- **Test accessibility**: Ensure components are accessible
|
||||||
|
|
||||||
|
## Component Library
|
||||||
|
|
||||||
|
### Dialog Components
|
||||||
|
|
||||||
|
**Available Dialog Types:**
|
||||||
|
|
||||||
|
- `ha-md-dialog` - Preferred for new code (Material Design 3)
|
||||||
|
- `ha-dialog` - Legacy component still widely used
|
||||||
|
|
||||||
|
**Opening Dialogs (Fire Event Pattern - Recommended):**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
fireEvent(this, "show-dialog", {
|
||||||
|
dialogTag: "dialog-example",
|
||||||
|
dialogImport: () => import("./dialog-example"),
|
||||||
|
dialogParams: { title: "Example", data: someData },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Dialog Implementation Requirements:**
|
||||||
|
|
||||||
|
- Implement `HassDialog<T>` interface
|
||||||
|
- Use `createCloseHeading()` for standard headers
|
||||||
|
- Import `haStyleDialog` for consistent styling
|
||||||
|
- Return `nothing` when no params (loading state)
|
||||||
|
- Fire `dialog-closed` event when closing
|
||||||
|
- Add `dialogInitialFocus` for accessibility
|
||||||
|
|
||||||
|
````
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<ha-form
|
||||||
|
.hass=${this.hass}
|
||||||
|
.data=${this._data}
|
||||||
|
.schema=${this._schema}
|
||||||
|
.error=${this._errors}
|
||||||
|
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
|
||||||
|
@value-changed=${this._valueChanged}
|
||||||
|
></ha-form>
|
||||||
|
````
|
||||||
|
|
||||||
|
### Alert Component (ha-alert)
|
||||||
|
|
||||||
|
- Types: `error`, `warning`, `info`, `success`
|
||||||
|
- Properties: `title`, `alert-type`, `dismissable`, `icon`, `action`, `rtl`
|
||||||
|
- Content announced by screen readers when dynamically displayed
|
||||||
|
|
||||||
|
```html
|
||||||
|
<ha-alert alert-type="error">Error message</ha-alert>
|
||||||
|
<ha-alert alert-type="warning" title="Warning">Description</ha-alert>
|
||||||
|
<ha-alert alert-type="success" dismissable>Success message</ha-alert>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Creating a Panel
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@customElement("ha-panel-myfeature")
|
||||||
|
export class HaPanelMyFeature extends SubscribeMixin(LitElement) {
|
||||||
|
@property({ attribute: false })
|
||||||
|
hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true })
|
||||||
|
narrow!: boolean;
|
||||||
|
|
||||||
|
@property()
|
||||||
|
route!: Route;
|
||||||
|
|
||||||
|
hassSubscribe() {
|
||||||
|
return [
|
||||||
|
subscribeEntityRegistry(this.hass.connection, (entities) => {
|
||||||
|
this._entities = entities;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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;
|
||||||
|
|
||||||
|
public async showDialog(params: MyDialogParams): Promise<void> {
|
||||||
|
this._params = params;
|
||||||
|
}
|
||||||
|
|
||||||
|
public closeDialog(): void {
|
||||||
|
this._params = undefined;
|
||||||
|
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
if (!this._params) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ha-dialog
|
||||||
|
open
|
||||||
|
@closed=${this.closeDialog}
|
||||||
|
.heading=${createCloseHeading(this.hass, this._params.title)}
|
||||||
|
>
|
||||||
|
<!-- Dialog content -->
|
||||||
|
<ha-button @click=${this.closeDialog} slot="secondaryAction">
|
||||||
|
${this.hass.localize("ui.common.cancel")}
|
||||||
|
</ha-button>
|
||||||
|
<ha-button @click=${this._submit} slot="primaryAction">
|
||||||
|
${this.hass.localize("ui.common.save")}
|
||||||
|
</ha-button>
|
||||||
|
</ha-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 (based on gallery)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@customElement("hui-my-card")
|
||||||
|
export class HuiMyCard extends LitElement implements LovelaceCard {
|
||||||
|
@property({ attribute: false })
|
||||||
|
hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private _config?: MyCardConfig;
|
||||||
|
|
||||||
|
public setConfig(config: MyCardConfig): void {
|
||||||
|
if (!config.entity) {
|
||||||
|
throw new Error("Entity required");
|
||||||
|
}
|
||||||
|
this._config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCardSize(): number {
|
||||||
|
return 3; // Height in grid units
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Editor for card configuration
|
||||||
|
public static getConfigElement(): LovelaceCardEditor {
|
||||||
|
return document.createElement("hui-my-card-editor");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: Stub config for card picker
|
||||||
|
public static getStubConfig(): object {
|
||||||
|
return { entity: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Card Guidelines:**
|
||||||
|
|
||||||
|
- Cards are highly customizable for different households
|
||||||
|
- Implement `LovelaceCard` interface with `setConfig()` and `getCardSize()`
|
||||||
|
- Use proper error handling in `setConfig()`
|
||||||
|
- Consider all possible states (loading, error, unavailable)
|
||||||
|
- Support different entity types and states
|
||||||
|
- Follow responsive design principles
|
||||||
|
- Add configuration editor when needed
|
||||||
|
|
||||||
|
### Internationalization
|
||||||
|
|
||||||
|
- **Use localize**: Always use the localization system
|
||||||
|
- **Add translation keys**: Add keys to src/translations/en.json
|
||||||
|
- **Support placeholders**: Use proper placeholder syntax
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
this.hass.localize("ui.panel.config.updates.update_available", {
|
||||||
|
count: 5,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
- **ARIA labels**: Add appropriate ARIA labels
|
||||||
|
- **Keyboard navigation**: Ensure all interactions work with keyboard
|
||||||
|
- **Screen reader support**: Test with screen readers
|
||||||
|
- **Color contrast**: Meet WCAG AA standards
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Setup and Commands
|
||||||
|
|
||||||
|
1. **Setup**: `script/setup` - Install dependencies
|
||||||
|
2. **Develop**: `script/develop` - Development server
|
||||||
|
3. **Lint**: `yarn lint` - Run all linting before committing
|
||||||
|
4. **Test**: `yarn test` - Add and run tests
|
||||||
|
5. **Build**: `script/build_frontend` - Test production build
|
||||||
|
|
||||||
|
### Common Pitfalls to Avoid
|
||||||
|
|
||||||
|
- 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
|
||||||
|
- Don't ignore TypeScript errors - Fix all type issues
|
||||||
|
|
||||||
|
### Security Best Practices
|
||||||
|
|
||||||
|
- Sanitize HTML - Never use `unsafeHTML` with user content
|
||||||
|
- Validate inputs - Always validate user inputs
|
||||||
|
- Use HTTPS - All external resources must use HTTPS
|
||||||
|
- CSP compliance - Ensure code works with Content Security Policy
|
||||||
|
|
||||||
|
### Text and Copy Guidelines
|
||||||
|
|
||||||
|
#### Terminology Standards
|
||||||
|
|
||||||
|
**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
|
||||||
|
- Removing a user from a group
|
||||||
|
- Removing links between items
|
||||||
|
- Removing a widget from dashboard
|
||||||
|
- Removing an item from a cart
|
||||||
|
- **Use "Delete"** for permanent, non-recoverable actions:
|
||||||
|
- Deleting a field
|
||||||
|
- Deleting a value in a field
|
||||||
|
- Deleting a task
|
||||||
|
- Deleting a group
|
||||||
|
- Deleting a permission
|
||||||
|
- Deleting a calendar event
|
||||||
|
|
||||||
|
**Create vs Add** (Create pairs with Delete, Add pairs with Remove)
|
||||||
|
|
||||||
|
- **Use "Add"** for already-existing items:
|
||||||
|
- Adding a permission to a user
|
||||||
|
- Adding a user to a group
|
||||||
|
- Adding links between items
|
||||||
|
- Adding a widget to dashboard
|
||||||
|
- Adding an item to a cart
|
||||||
|
- **Use "Create"** for something made from scratch:
|
||||||
|
- Creating a new field
|
||||||
|
- Creating a new task
|
||||||
|
- Creating a new group
|
||||||
|
- Creating a new permission
|
||||||
|
- Creating a new calendar event
|
||||||
|
|
||||||
|
#### Writing Style (Consistent with Home Assistant Documentation)
|
||||||
|
|
||||||
|
- **Use American English**: Standard spelling and terminology
|
||||||
|
- **Friendly, informational tone**: Be inspiring, personal, comforting, engaging
|
||||||
|
- **Address users directly**: Use "you" and "your"
|
||||||
|
- **Be inclusive**: Objective, non-discriminatory language
|
||||||
|
- **Be concise**: Use clear, direct language
|
||||||
|
- **Be consistent**: Follow established terminology patterns
|
||||||
|
- **Use active voice**: "Delete the automation" not "The automation should be deleted"
|
||||||
|
- **Avoid jargon**: Use terms familiar to home automation users
|
||||||
|
|
||||||
|
#### Language Standards
|
||||||
|
|
||||||
|
- **Always use "Home Assistant"** in full, never "HA" or "HASS"
|
||||||
|
- **Avoid abbreviations**: Spell out terms when possible
|
||||||
|
- **Use sentence case everywhere**: Titles, headings, buttons, labels, UI elements
|
||||||
|
- ✅ "Create new automation"
|
||||||
|
- ❌ "Create New Automation"
|
||||||
|
- ✅ "Device settings"
|
||||||
|
- ❌ "Device Settings"
|
||||||
|
- **Oxford comma**: Use in lists (item 1, item 2, and item 3)
|
||||||
|
- **Replace Latin terms**: Use "like" instead of "e.g.", "for example" instead of "i.e."
|
||||||
|
- **Avoid CAPS for emphasis**: Use bold or italics instead
|
||||||
|
- **Write for all skill levels**: Both technical and non-technical users
|
||||||
|
|
||||||
|
#### Key Terminology
|
||||||
|
|
||||||
|
- **"add-on"** (hyphenated, not "addon")
|
||||||
|
- **"integration"** (preferred over "component")
|
||||||
|
- **Technical terms**: Use lowercase (automation, entity, device, service)
|
||||||
|
|
||||||
|
#### Translation Considerations
|
||||||
|
|
||||||
|
- **Add translation keys**: All user-facing text must be translatable
|
||||||
|
- **Use placeholders**: Support dynamic content in translations
|
||||||
|
- **Keep context**: Provide enough context for translators
|
||||||
|
|
||||||
|
```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)
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
- **Form prefilling**: Use smart defaults but allow user override
|
||||||
|
|
||||||
|
#### Component Design Patterns
|
||||||
|
|
||||||
|
- **Terminology consistency**: Use "Join"/"Apply" instead of "Group" when appropriate
|
||||||
|
- **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
|
||||||
|
- **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
|
||||||
|
|
||||||
|
- **Optional parameters**: Make configuration fields optional when sensible
|
||||||
|
- **Smart defaults**: Provide reasonable default values
|
||||||
|
- **Future extensibility**: Design APIs that can be extended later
|
||||||
|
- **Validation**: Validate configuration before applying changes
|
||||||
|
|
||||||
|
## Review Guidelines
|
||||||
|
|
||||||
|
### Core Requirements Checklist
|
||||||
|
|
||||||
|
- [ ] 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 (add-on not addon, 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 subscriptions properly cleaned up
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -53,3 +53,7 @@ src/cast/dev_const.ts
|
|||||||
|
|
||||||
# test coverage
|
# test coverage
|
||||||
test/coverage/
|
test/coverage/
|
||||||
|
|
||||||
|
# AI tooling
|
||||||
|
.claude
|
||||||
|
|
||||||
|
@ -416,6 +416,34 @@ const SCHEMAS: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
items: {
|
||||||
|
name: "Items",
|
||||||
|
selector: {
|
||||||
|
object: {
|
||||||
|
label_field: "name",
|
||||||
|
description_field: "value",
|
||||||
|
multiple: true,
|
||||||
|
fields: {
|
||||||
|
name: {
|
||||||
|
label: "Name",
|
||||||
|
selector: { text: {} },
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
label: "Value",
|
||||||
|
selector: {
|
||||||
|
number: {
|
||||||
|
mode: "slider",
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
unit_of_measurement: "%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
71
package.json
71
package.json
@ -30,11 +30,11 @@
|
|||||||
"@braintree/sanitize-url": "7.1.1",
|
"@braintree/sanitize-url": "7.1.1",
|
||||||
"@codemirror/autocomplete": "6.18.6",
|
"@codemirror/autocomplete": "6.18.6",
|
||||||
"@codemirror/commands": "6.8.1",
|
"@codemirror/commands": "6.8.1",
|
||||||
"@codemirror/language": "6.11.1",
|
"@codemirror/language": "6.11.2",
|
||||||
"@codemirror/legacy-modes": "6.5.1",
|
"@codemirror/legacy-modes": "6.5.1",
|
||||||
"@codemirror/search": "6.5.11",
|
"@codemirror/search": "6.5.11",
|
||||||
"@codemirror/state": "6.5.2",
|
"@codemirror/state": "6.5.2",
|
||||||
"@codemirror/view": "6.37.2",
|
"@codemirror/view": "6.38.0",
|
||||||
"@egjs/hammerjs": "2.0.17",
|
"@egjs/hammerjs": "2.0.17",
|
||||||
"@formatjs/intl-datetimeformat": "6.18.0",
|
"@formatjs/intl-datetimeformat": "6.18.0",
|
||||||
"@formatjs/intl-displaynames": "6.8.11",
|
"@formatjs/intl-displaynames": "6.8.11",
|
||||||
@ -45,12 +45,12 @@
|
|||||||
"@formatjs/intl-numberformat": "8.15.4",
|
"@formatjs/intl-numberformat": "8.15.4",
|
||||||
"@formatjs/intl-pluralrules": "5.4.4",
|
"@formatjs/intl-pluralrules": "5.4.4",
|
||||||
"@formatjs/intl-relativetimeformat": "11.4.11",
|
"@formatjs/intl-relativetimeformat": "11.4.11",
|
||||||
"@fullcalendar/core": "6.1.17",
|
"@fullcalendar/core": "6.1.18",
|
||||||
"@fullcalendar/daygrid": "6.1.17",
|
"@fullcalendar/daygrid": "6.1.18",
|
||||||
"@fullcalendar/interaction": "6.1.17",
|
"@fullcalendar/interaction": "6.1.18",
|
||||||
"@fullcalendar/list": "6.1.17",
|
"@fullcalendar/list": "6.1.18",
|
||||||
"@fullcalendar/luxon3": "6.1.17",
|
"@fullcalendar/luxon3": "6.1.18",
|
||||||
"@fullcalendar/timegrid": "6.1.17",
|
"@fullcalendar/timegrid": "6.1.18",
|
||||||
"@lezer/highlight": "1.2.1",
|
"@lezer/highlight": "1.2.1",
|
||||||
"@lit-labs/motion": "1.0.8",
|
"@lit-labs/motion": "1.0.8",
|
||||||
"@lit-labs/observers": "2.0.5",
|
"@lit-labs/observers": "2.0.5",
|
||||||
@ -89,14 +89,14 @@
|
|||||||
"@thomasloven/round-slider": "0.6.0",
|
"@thomasloven/round-slider": "0.6.0",
|
||||||
"@tsparticles/engine": "3.8.1",
|
"@tsparticles/engine": "3.8.1",
|
||||||
"@tsparticles/preset-links": "3.2.0",
|
"@tsparticles/preset-links": "3.2.0",
|
||||||
"@vaadin/combo-box": "24.7.8",
|
"@vaadin/combo-box": "24.7.9",
|
||||||
"@vaadin/vaadin-themable-mixin": "24.7.8",
|
"@vaadin/vaadin-themable-mixin": "24.7.9",
|
||||||
"@vibrant/color": "4.0.0",
|
"@vibrant/color": "4.0.0",
|
||||||
"@vue/web-component-wrapper": "1.3.0",
|
"@vue/web-component-wrapper": "1.3.0",
|
||||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||||
"app-datepicker": "5.1.1",
|
"app-datepicker": "5.1.1",
|
||||||
"barcode-detector": "3.0.4",
|
"barcode-detector": "3.0.5",
|
||||||
"color-name": "2.0.0",
|
"color-name": "2.0.0",
|
||||||
"comlink": "4.4.2",
|
"comlink": "4.4.2",
|
||||||
"core-js": "3.43.0",
|
"core-js": "3.43.0",
|
||||||
@ -111,7 +111,7 @@
|
|||||||
"fuse.js": "7.1.0",
|
"fuse.js": "7.1.0",
|
||||||
"google-timezones-json": "1.2.0",
|
"google-timezones-json": "1.2.0",
|
||||||
"gulp-zopfli-green": "6.0.2",
|
"gulp-zopfli-green": "6.0.2",
|
||||||
"hls.js": "1.6.5",
|
"hls.js": "1.6.6",
|
||||||
"home-assistant-js-websocket": "9.5.0",
|
"home-assistant-js-websocket": "9.5.0",
|
||||||
"idb-keyval": "6.2.2",
|
"idb-keyval": "6.2.2",
|
||||||
"intl-messageformat": "10.7.16",
|
"intl-messageformat": "10.7.16",
|
||||||
@ -122,7 +122,7 @@
|
|||||||
"lit": "3.3.0",
|
"lit": "3.3.0",
|
||||||
"lit-html": "3.3.0",
|
"lit-html": "3.3.0",
|
||||||
"luxon": "3.6.1",
|
"luxon": "3.6.1",
|
||||||
"marked": "15.0.12",
|
"marked": "16.0.0",
|
||||||
"memoize-one": "6.0.0",
|
"memoize-one": "6.0.0",
|
||||||
"node-vibrant": "4.0.3",
|
"node-vibrant": "4.0.3",
|
||||||
"object-hash": "3.0.0",
|
"object-hash": "3.0.0",
|
||||||
@ -135,7 +135,7 @@
|
|||||||
"stacktrace-js": "2.0.2",
|
"stacktrace-js": "2.0.2",
|
||||||
"superstruct": "2.0.2",
|
"superstruct": "2.0.2",
|
||||||
"tinykeys": "3.0.0",
|
"tinykeys": "3.0.0",
|
||||||
"ua-parser-js": "2.0.3",
|
"ua-parser-js": "2.0.4",
|
||||||
"vis-data": "7.1.9",
|
"vis-data": "7.1.9",
|
||||||
"vue": "2.7.16",
|
"vue": "2.7.16",
|
||||||
"vue2-daterange-picker": "0.6.8",
|
"vue2-daterange-picker": "0.6.8",
|
||||||
@ -149,26 +149,25 @@
|
|||||||
"xss": "1.0.15"
|
"xss": "1.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "7.27.4",
|
"@babel/core": "7.28.0",
|
||||||
"@babel/helper-define-polyfill-provider": "0.6.4",
|
"@babel/helper-define-polyfill-provider": "0.6.5",
|
||||||
"@babel/plugin-transform-runtime": "7.27.4",
|
"@babel/plugin-transform-runtime": "7.28.0",
|
||||||
"@babel/preset-env": "7.27.2",
|
"@babel/preset-env": "7.28.0",
|
||||||
"@bundle-stats/plugin-webpack-filter": "4.20.2",
|
"@bundle-stats/plugin-webpack-filter": "4.21.0",
|
||||||
"@lokalise/node-api": "14.8.0",
|
"@lokalise/node-api": "14.9.1",
|
||||||
"@octokit/auth-oauth-device": "8.0.1",
|
"@octokit/auth-oauth-device": "8.0.1",
|
||||||
"@octokit/plugin-retry": "8.0.1",
|
"@octokit/plugin-retry": "8.0.1",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@rsdoctor/rspack-plugin": "1.1.3",
|
"@rsdoctor/rspack-plugin": "1.1.7",
|
||||||
"@rspack/cli": "1.3.12",
|
"@rspack/cli": "1.4.3",
|
||||||
"@rspack/core": "1.3.12",
|
"@rspack/core": "1.4.3",
|
||||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||||
"@types/chromecast-caf-receiver": "6.0.22",
|
"@types/chromecast-caf-receiver": "6.0.22",
|
||||||
"@types/chromecast-caf-sender": "1.0.11",
|
"@types/chromecast-caf-sender": "1.0.11",
|
||||||
"@types/color-name": "2.0.0",
|
"@types/color-name": "2.0.0",
|
||||||
"@types/glob": "8.1.0",
|
|
||||||
"@types/html-minifier-terser": "7.0.2",
|
"@types/html-minifier-terser": "7.0.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/leaflet": "1.9.18",
|
"@types/leaflet": "1.9.19",
|
||||||
"@types/leaflet-draw": "1.0.12",
|
"@types/leaflet-draw": "1.0.12",
|
||||||
"@types/leaflet.markercluster": "1.5.5",
|
"@types/leaflet.markercluster": "1.5.5",
|
||||||
"@types/lodash.merge": "4.6.9",
|
"@types/lodash.merge": "4.6.9",
|
||||||
@ -179,18 +178,18 @@
|
|||||||
"@types/tar": "6.1.13",
|
"@types/tar": "6.1.13",
|
||||||
"@types/ua-parser-js": "0.7.39",
|
"@types/ua-parser-js": "0.7.39",
|
||||||
"@types/webspeechapi": "0.0.29",
|
"@types/webspeechapi": "0.0.29",
|
||||||
"@vitest/coverage-v8": "3.2.3",
|
"@vitest/coverage-v8": "3.2.4",
|
||||||
"babel-loader": "10.0.0",
|
"babel-loader": "10.0.0",
|
||||||
"babel-plugin-template-html-minifier": "4.1.0",
|
"babel-plugin-template-html-minifier": "4.1.0",
|
||||||
"browserslist-useragent-regexp": "4.1.3",
|
"browserslist-useragent-regexp": "4.1.3",
|
||||||
"del": "8.0.0",
|
"del": "8.0.0",
|
||||||
"eslint": "9.29.0",
|
"eslint": "9.30.1",
|
||||||
"eslint-config-airbnb-base": "15.0.0",
|
"eslint-config-airbnb-base": "15.0.0",
|
||||||
"eslint-config-prettier": "10.1.5",
|
"eslint-config-prettier": "10.1.5",
|
||||||
"eslint-import-resolver-webpack": "0.13.10",
|
"eslint-import-resolver-webpack": "0.13.10",
|
||||||
"eslint-plugin-import": "2.31.0",
|
"eslint-plugin-import": "2.32.0",
|
||||||
"eslint-plugin-lit": "2.1.1",
|
"eslint-plugin-lit": "2.1.1",
|
||||||
"eslint-plugin-lit-a11y": "5.0.1",
|
"eslint-plugin-lit-a11y": "5.1.0",
|
||||||
"eslint-plugin-unused-imports": "4.1.4",
|
"eslint-plugin-unused-imports": "4.1.4",
|
||||||
"eslint-plugin-wc": "3.0.1",
|
"eslint-plugin-wc": "3.0.1",
|
||||||
"fancy-log": "2.0.0",
|
"fancy-log": "2.0.0",
|
||||||
@ -199,18 +198,18 @@
|
|||||||
"gulp": "5.0.1",
|
"gulp": "5.0.1",
|
||||||
"gulp-brotli": "3.0.0",
|
"gulp-brotli": "3.0.0",
|
||||||
"gulp-json-transform": "0.5.0",
|
"gulp-json-transform": "0.5.0",
|
||||||
"gulp-rename": "2.0.0",
|
"gulp-rename": "2.1.0",
|
||||||
"html-minifier-terser": "7.2.0",
|
"html-minifier-terser": "7.2.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"jsdom": "26.1.0",
|
"jsdom": "26.1.0",
|
||||||
"jszip": "3.10.1",
|
"jszip": "3.10.1",
|
||||||
"lint-staged": "16.1.0",
|
"lint-staged": "16.1.2",
|
||||||
"lit-analyzer": "2.0.3",
|
"lit-analyzer": "2.0.3",
|
||||||
"lodash.merge": "4.6.2",
|
"lodash.merge": "4.6.2",
|
||||||
"lodash.template": "4.5.0",
|
"lodash.template": "4.5.0",
|
||||||
"map-stream": "0.0.7",
|
"map-stream": "0.0.7",
|
||||||
"pinst": "3.0.0",
|
"pinst": "3.0.0",
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.6.2",
|
||||||
"rspack-manifest-plugin": "5.0.3",
|
"rspack-manifest-plugin": "5.0.3",
|
||||||
"serve": "14.2.4",
|
"serve": "14.2.4",
|
||||||
"sinon": "21.0.0",
|
"sinon": "21.0.0",
|
||||||
@ -218,9 +217,9 @@
|
|||||||
"terser-webpack-plugin": "5.3.14",
|
"terser-webpack-plugin": "5.3.14",
|
||||||
"ts-lit-plugin": "2.0.2",
|
"ts-lit-plugin": "2.0.2",
|
||||||
"typescript": "5.8.3",
|
"typescript": "5.8.3",
|
||||||
"typescript-eslint": "8.34.0",
|
"typescript-eslint": "8.35.1",
|
||||||
"vite-tsconfig-paths": "5.1.4",
|
"vite-tsconfig-paths": "5.1.4",
|
||||||
"vitest": "3.2.3",
|
"vitest": "3.2.4",
|
||||||
"webpack-stats-plugin": "1.1.3",
|
"webpack-stats-plugin": "1.1.3",
|
||||||
"webpackbar": "7.0.0",
|
"webpackbar": "7.0.0",
|
||||||
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
|
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
|
||||||
@ -231,8 +230,8 @@
|
|||||||
"lit-html": "3.3.0",
|
"lit-html": "3.3.0",
|
||||||
"clean-css": "5.3.3",
|
"clean-css": "5.3.3",
|
||||||
"@lit/reactive-element": "2.1.0",
|
"@lit/reactive-element": "2.1.0",
|
||||||
"@fullcalendar/daygrid": "6.1.17",
|
"@fullcalendar/daygrid": "6.1.18",
|
||||||
"globals": "16.2.0",
|
"globals": "16.3.0",
|
||||||
"tslib": "2.8.1",
|
"tslib": "2.8.1",
|
||||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||||
},
|
},
|
||||||
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "home-assistant-frontend"
|
name = "home-assistant-frontend"
|
||||||
version = "20250430.0"
|
version = "20250625.0"
|
||||||
license = "Apache-2.0"
|
license = "Apache-2.0"
|
||||||
license-files = ["LICENSE*"]
|
license-files = ["LICENSE*"]
|
||||||
description = "The Home Assistant frontend"
|
description = "The Home Assistant frontend"
|
||||||
|
@ -11,7 +11,6 @@ export const COLORS = [
|
|||||||
"#9c6b4e",
|
"#9c6b4e",
|
||||||
"#97bbf5",
|
"#97bbf5",
|
||||||
"#01ab63",
|
"#01ab63",
|
||||||
"#9498a0",
|
|
||||||
"#094bad",
|
"#094bad",
|
||||||
"#c99000",
|
"#c99000",
|
||||||
"#d84f3e",
|
"#d84f3e",
|
||||||
@ -21,7 +20,6 @@ export const COLORS = [
|
|||||||
"#8043ce",
|
"#8043ce",
|
||||||
"#7599d1",
|
"#7599d1",
|
||||||
"#7a4c31",
|
"#7a4c31",
|
||||||
"#74787f",
|
|
||||||
"#6989f4",
|
"#6989f4",
|
||||||
"#ffd444",
|
"#ffd444",
|
||||||
"#ff957c",
|
"#ff957c",
|
||||||
@ -31,7 +29,6 @@ export const COLORS = [
|
|||||||
"#c884ff",
|
"#c884ff",
|
||||||
"#badeff",
|
"#badeff",
|
||||||
"#bf8b6d",
|
"#bf8b6d",
|
||||||
"#b6bac2",
|
|
||||||
"#927acc",
|
"#927acc",
|
||||||
"#97ee3f",
|
"#97ee3f",
|
||||||
"#bf3947",
|
"#bf3947",
|
||||||
@ -44,7 +41,6 @@ export const COLORS = [
|
|||||||
"#d9b100",
|
"#d9b100",
|
||||||
"#9d7a00",
|
"#9d7a00",
|
||||||
"#698cff",
|
"#698cff",
|
||||||
"#d9d9d9",
|
|
||||||
"#00d27e",
|
"#00d27e",
|
||||||
"#d06800",
|
"#d06800",
|
||||||
"#009f82",
|
"#009f82",
|
||||||
|
@ -77,7 +77,7 @@ export const formatDateNumeric = (
|
|||||||
const month = parts.find((value) => value.type === "month")?.value;
|
const month = parts.find((value) => value.type === "month")?.value;
|
||||||
const year = parts.find((value) => value.type === "year")?.value;
|
const year = parts.find((value) => value.type === "year")?.value;
|
||||||
|
|
||||||
const lastPart = parts.at(parts.length - 1);
|
const lastPart = parts[parts.length - 1];
|
||||||
let lastLiteral = lastPart?.type === "literal" ? lastPart?.value : "";
|
let lastLiteral = lastPart?.type === "literal" ? lastPart?.value : "";
|
||||||
|
|
||||||
if (locale.language === "bg" && locale.date_format === DateFormat.YMD) {
|
if (locale.language === "bg" && locale.date_format === DateFormat.YMD) {
|
||||||
|
@ -202,7 +202,6 @@ export function storage(options: {
|
|||||||
// Don't set the initial value if we have a value in localStorage
|
// Don't set the initial value if we have a value in localStorage
|
||||||
if (this.__initialized || getValue() === undefined) {
|
if (this.__initialized || getValue() === undefined) {
|
||||||
setValue(this, value);
|
setValue(this, value);
|
||||||
this.requestUpdate(propertyKey, undefined);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@ -212,11 +211,13 @@ export function storage(options: {
|
|||||||
const oldSetter = descriptor.set;
|
const oldSetter = descriptor.set;
|
||||||
newDescriptor = {
|
newDescriptor = {
|
||||||
...descriptor,
|
...descriptor,
|
||||||
|
get(this: ReactiveStorageElement) {
|
||||||
|
return getValue();
|
||||||
|
},
|
||||||
set(this: ReactiveStorageElement, value) {
|
set(this: ReactiveStorageElement, value) {
|
||||||
// Don't set the initial value if we have a value in localStorage
|
// Don't set the initial value if we have a value in localStorage
|
||||||
if (this.__initialized || getValue() === undefined) {
|
if (this.__initialized || getValue() === undefined) {
|
||||||
setValue(this, value);
|
setValue(this, value);
|
||||||
this.requestUpdate(propertyKey, undefined);
|
|
||||||
}
|
}
|
||||||
oldSetter?.call(this, value);
|
oldSetter?.call(this, value);
|
||||||
},
|
},
|
||||||
|
68
src/common/entity/group_entities.ts
Normal file
68
src/common/entity/group_entities.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import type { HassEntity } from "home-assistant-js-websocket";
|
||||||
|
import { computeStateDomain } from "./compute_state_domain";
|
||||||
|
import { isUnavailableState, UNAVAILABLE } from "../../data/entity";
|
||||||
|
import type { HomeAssistant } from "../../types";
|
||||||
|
|
||||||
|
export const computeGroupEntitiesState = (states: HassEntity[]): string => {
|
||||||
|
if (!states.length) {
|
||||||
|
return UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validState = states.filter((stateObj) => isUnavailableState(stateObj));
|
||||||
|
|
||||||
|
if (!validState) {
|
||||||
|
return UNAVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first state to determine the domain
|
||||||
|
// This assumes all states in the group have the same domain
|
||||||
|
const domain = computeStateDomain(states[0]);
|
||||||
|
|
||||||
|
if (domain === "cover") {
|
||||||
|
for (const s of ["opening", "closing", "open"]) {
|
||||||
|
if (states.some((stateObj) => stateObj.state === s)) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (states.some((stateObj) => stateObj.state === "on")) {
|
||||||
|
return "on";
|
||||||
|
}
|
||||||
|
return "off";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleGroupEntities = (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
states: HassEntity[]
|
||||||
|
) => {
|
||||||
|
if (!states.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the first state to determine the domain
|
||||||
|
// This assumes all states in the group have the same domain
|
||||||
|
const domain = computeStateDomain(states[0]);
|
||||||
|
|
||||||
|
const state = computeGroupEntitiesState(states);
|
||||||
|
|
||||||
|
const isOn = state === "on" || state === "open";
|
||||||
|
|
||||||
|
let service = isOn ? "turn_off" : "turn_on";
|
||||||
|
if (domain === "cover") {
|
||||||
|
if (state === "opening" || state === "closing") {
|
||||||
|
// If the cover is opening or closing, we toggle it to stop it
|
||||||
|
service = "stop_cover";
|
||||||
|
} else {
|
||||||
|
// For covers, we use the open/close service
|
||||||
|
service = isOn ? "close_cover" : "open_cover";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entitiesIds = states.map((stateObj) => stateObj.entity_id);
|
||||||
|
|
||||||
|
hass.callService(domain, service, {
|
||||||
|
entity_id: entitiesIds,
|
||||||
|
});
|
||||||
|
};
|
@ -64,15 +64,27 @@ export const domainStateColorProperties = (
|
|||||||
const compareState = state !== undefined ? state : stateObj.state;
|
const compareState = state !== undefined ? state : stateObj.state;
|
||||||
const active = stateActive(stateObj, state);
|
const active = stateActive(stateObj, state);
|
||||||
|
|
||||||
|
return domainColorProperties(
|
||||||
|
domain,
|
||||||
|
stateObj.attributes.device_class,
|
||||||
|
compareState,
|
||||||
|
active
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const domainColorProperties = (
|
||||||
|
domain: string,
|
||||||
|
deviceClass: string | undefined,
|
||||||
|
state: string,
|
||||||
|
active: boolean
|
||||||
|
) => {
|
||||||
const properties: string[] = [];
|
const properties: string[] = [];
|
||||||
|
|
||||||
const stateKey = slugify(compareState, "_");
|
const stateKey = slugify(state, "_");
|
||||||
const activeKey = active ? "active" : "inactive";
|
const activeKey = active ? "active" : "inactive";
|
||||||
|
|
||||||
const dc = stateObj.attributes.device_class;
|
if (deviceClass) {
|
||||||
|
properties.push(`--state-${domain}-${deviceClass}-${stateKey}-color`);
|
||||||
if (dc) {
|
|
||||||
properties.push(`--state-${domain}-${dc}-${stateKey}-color`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
properties.push(
|
properties.push(
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
|
import { isIPAddress } from "./is_ip_address";
|
||||||
|
|
||||||
const collator = memoizeOne(
|
const collator = memoizeOne(
|
||||||
(language: string | undefined) => new Intl.Collator(language)
|
(language: string | undefined) =>
|
||||||
|
new Intl.Collator(language, { numeric: true })
|
||||||
);
|
);
|
||||||
|
|
||||||
const caseInsensitiveCollator = memoizeOne(
|
const caseInsensitiveCollator = memoizeOne(
|
||||||
(language: string | undefined) =>
|
(language: string | undefined) =>
|
||||||
new Intl.Collator(language, { sensitivity: "accent" })
|
new Intl.Collator(language, { sensitivity: "accent", numeric: true })
|
||||||
);
|
);
|
||||||
|
|
||||||
const fallbackStringCompare = (a: string, b: string) => {
|
const fallbackStringCompare = (a: string, b: string) => {
|
||||||
@ -33,6 +35,19 @@ export const stringCompare = (
|
|||||||
return fallbackStringCompare(a, b);
|
return fallbackStringCompare(a, b);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ipCompare = (a: string, b: string) => {
|
||||||
|
const aIsIpV4 = isIPAddress(a);
|
||||||
|
const bIsIpV4 = isIPAddress(b);
|
||||||
|
|
||||||
|
if (aIsIpV4 && bIsIpV4) {
|
||||||
|
return ipv4Compare(a, b);
|
||||||
|
}
|
||||||
|
if (!aIsIpV4 && !bIsIpV4) {
|
||||||
|
return ipV6Compare(a, b);
|
||||||
|
}
|
||||||
|
return aIsIpV4 ? -1 : 1;
|
||||||
|
};
|
||||||
|
|
||||||
export const caseInsensitiveStringCompare = (
|
export const caseInsensitiveStringCompare = (
|
||||||
a: string,
|
a: string,
|
||||||
b: string,
|
b: string,
|
||||||
@ -64,3 +79,42 @@ export const orderCompare = (order: string[]) => (a: string, b: string) => {
|
|||||||
|
|
||||||
return idxA - idxB;
|
return idxA - idxB;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function ipv4Compare(a: string, b: string) {
|
||||||
|
const num1 = Number(
|
||||||
|
a
|
||||||
|
.split(".")
|
||||||
|
.map((num) => num.padStart(3, "0"))
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
const num2 = Number(
|
||||||
|
b
|
||||||
|
.split(".")
|
||||||
|
.map((num) => num.padStart(3, "0"))
|
||||||
|
.join("")
|
||||||
|
);
|
||||||
|
return num1 - num2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipV6Compare(a: string, b: string) {
|
||||||
|
const ipv6a = normalizeIPv6(a)
|
||||||
|
.split(":")
|
||||||
|
.map((part) => part.padStart(4, "0"))
|
||||||
|
.join("");
|
||||||
|
const ipv6b = normalizeIPv6(b)
|
||||||
|
.split(":")
|
||||||
|
.map((part) => part.padStart(4, "0"))
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
return ipv6a.localeCompare(ipv6b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIPv6(ip) {
|
||||||
|
const parts = ip.split("::");
|
||||||
|
const head = parts[0].split(":");
|
||||||
|
const tail = parts[1] ? parts[1].split(":") : [];
|
||||||
|
const totalParts = 8;
|
||||||
|
const missing = totalParts - (head.length + tail.length);
|
||||||
|
const zeros = new Array(missing).fill("0");
|
||||||
|
return [...head, ...zeros, ...tail].join(":");
|
||||||
|
}
|
||||||
|
@ -9,7 +9,9 @@ const _extractCssVars = (
|
|||||||
cssString.split(";").forEach((rawLine) => {
|
cssString.split(";").forEach((rawLine) => {
|
||||||
const line = rawLine.substring(rawLine.indexOf("--")).trim();
|
const line = rawLine.substring(rawLine.indexOf("--")).trim();
|
||||||
if (line.startsWith("--") && condition(line)) {
|
if (line.startsWith("--") && condition(line)) {
|
||||||
const [name, value] = line.split(":").map((part) => part.trim());
|
const [name, value] = line
|
||||||
|
.split(":")
|
||||||
|
.map((part) => part.replaceAll("}", "").trim());
|
||||||
variables[name.substring(2, name.length)] = value;
|
variables[name.substring(2, name.length)] = value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -25,7 +27,10 @@ export const extractVar = (css: CSSResult, varName: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const endIndex = cssString.indexOf(";", startIndex + search.length);
|
const endIndex = cssString.indexOf(";", startIndex + search.length);
|
||||||
return cssString.substring(startIndex + search.length, endIndex).trim();
|
return cssString
|
||||||
|
.substring(startIndex + search.length, endIndex)
|
||||||
|
.replaceAll("}", "")
|
||||||
|
.trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const extractVars = (css: CSSResult) => {
|
export const extractVars = (css: CSSResult) => {
|
||||||
|
@ -9,6 +9,7 @@ import type {
|
|||||||
LegendComponentOption,
|
LegendComponentOption,
|
||||||
XAXisOption,
|
XAXisOption,
|
||||||
YAXisOption,
|
YAXisOption,
|
||||||
|
LineSeriesOption,
|
||||||
} from "echarts/types/dist/shared";
|
} from "echarts/types/dist/shared";
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
@ -49,6 +50,9 @@ export class HaChartBase extends LitElement {
|
|||||||
@property({ attribute: "expand-legend", type: Boolean })
|
@property({ attribute: "expand-legend", type: Boolean })
|
||||||
public expandLegend?: boolean;
|
public expandLegend?: boolean;
|
||||||
|
|
||||||
|
@property({ attribute: "small-controls", type: Boolean })
|
||||||
|
public smallControls?: boolean;
|
||||||
|
|
||||||
// extraComponents is not reactive and should not trigger updates
|
// extraComponents is not reactive and should not trigger updates
|
||||||
public extraComponents?: any[];
|
public extraComponents?: any[];
|
||||||
|
|
||||||
@ -194,7 +198,7 @@ export class HaChartBase extends LitElement {
|
|||||||
<div class="chart"></div>
|
<div class="chart"></div>
|
||||||
</div>
|
</div>
|
||||||
${this._renderLegend()}
|
${this._renderLegend()}
|
||||||
<div class="chart-controls">
|
<div class="chart-controls ${classMap({ small: this.smallControls })}">
|
||||||
${this._isZoomed
|
${this._isZoomed
|
||||||
? html`<ha-icon-button
|
? html`<ha-icon-button
|
||||||
class="zoom-reset"
|
class="zoom-reset"
|
||||||
@ -386,6 +390,7 @@ export class HaChartBase extends LitElement {
|
|||||||
type: "inside",
|
type: "inside",
|
||||||
orient: "horizontal",
|
orient: "horizontal",
|
||||||
filterMode: "none",
|
filterMode: "none",
|
||||||
|
xAxisIndex: 0,
|
||||||
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
|
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
|
||||||
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
|
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
|
||||||
zoomLock: !this._isTouchDevice && !this._modifierPressed,
|
zoomLock: !this._isTouchDevice && !this._modifierPressed,
|
||||||
@ -639,44 +644,46 @@ export class HaChartBase extends LitElement {
|
|||||||
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
|
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
|
||||||
| YAXisOption
|
| YAXisOption
|
||||||
| undefined;
|
| undefined;
|
||||||
const series = ensureArray(this.data)
|
const series = ensureArray(this.data).map((s) => {
|
||||||
.filter((d) => !this._hiddenDatasets.has(String(d.name ?? d.id)))
|
const data = this._hiddenDatasets.has(String(s.name ?? s.id))
|
||||||
.map((s) => {
|
? undefined
|
||||||
if (s.type === "line") {
|
: s.data;
|
||||||
if (yAxis?.type === "log") {
|
if (data && s.type === "line") {
|
||||||
// set <=0 values to null so they render as gaps on a log graph
|
if (yAxis?.type === "log") {
|
||||||
return {
|
// set <=0 values to null so they render as gaps on a log graph
|
||||||
...s,
|
return {
|
||||||
data: s.data?.map((v) =>
|
...s,
|
||||||
Array.isArray(v)
|
data: (data as LineSeriesOption["data"])!.map((v) =>
|
||||||
? [
|
Array.isArray(v)
|
||||||
v[0],
|
? [
|
||||||
typeof v[1] !== "number" || v[1] > 0 ? v[1] : null,
|
v[0],
|
||||||
...v.slice(2),
|
typeof v[1] !== "number" || v[1] > 0 ? v[1] : null,
|
||||||
]
|
...v.slice(2),
|
||||||
: v
|
]
|
||||||
),
|
: v
|
||||||
};
|
),
|
||||||
}
|
};
|
||||||
if (s.sampling === "minmax") {
|
|
||||||
const minX =
|
|
||||||
xAxis?.min && typeof xAxis.min === "number"
|
|
||||||
? xAxis.min
|
|
||||||
: undefined;
|
|
||||||
const maxX =
|
|
||||||
xAxis?.max && typeof xAxis.max === "number"
|
|
||||||
? xAxis.max
|
|
||||||
: undefined;
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
sampling: undefined,
|
|
||||||
data: downSampleLineData(s.data, this.clientWidth, minX, maxX),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return s;
|
if (s.sampling === "minmax") {
|
||||||
});
|
const minX =
|
||||||
return series;
|
xAxis?.min && typeof xAxis.min === "number" ? xAxis.min : undefined;
|
||||||
|
const maxX =
|
||||||
|
xAxis?.max && typeof xAxis.max === "number" ? xAxis.max : undefined;
|
||||||
|
return {
|
||||||
|
...s,
|
||||||
|
sampling: undefined,
|
||||||
|
data: downSampleLineData(
|
||||||
|
data as LineSeriesOption["data"],
|
||||||
|
this.clientWidth,
|
||||||
|
minX,
|
||||||
|
maxX
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...s, data };
|
||||||
|
});
|
||||||
|
return series as ECOption["series"];
|
||||||
}
|
}
|
||||||
|
|
||||||
private _getDefaultHeight() {
|
private _getDefaultHeight() {
|
||||||
@ -784,6 +791,10 @@ export class HaChartBase extends LitElement {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
.chart-controls.small {
|
||||||
|
top: 0;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
.chart-controls ha-icon-button,
|
.chart-controls ha-icon-button,
|
||||||
.chart-controls ::slotted(ha-icon-button) {
|
.chart-controls ::slotted(ha-icon-button) {
|
||||||
background: var(--card-background-color);
|
background: var(--card-background-color);
|
||||||
@ -792,6 +803,11 @@ export class HaChartBase extends LitElement {
|
|||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
border: 1px solid var(--divider-color);
|
border: 1px solid var(--divider-color);
|
||||||
}
|
}
|
||||||
|
.chart-controls.small ha-icon-button,
|
||||||
|
.chart-controls.small ::slotted(ha-icon-button) {
|
||||||
|
--mdc-icon-button-size: 22px;
|
||||||
|
--mdc-icon-size: 16px;
|
||||||
|
}
|
||||||
.chart-controls ha-icon-button.inactive,
|
.chart-controls ha-icon-button.inactive,
|
||||||
.chart-controls ::slotted(ha-icon-button.inactive) {
|
.chart-controls ::slotted(ha-icon-button.inactive) {
|
||||||
color: var(--state-inactive-color);
|
color: var(--state-inactive-color);
|
||||||
|
@ -226,22 +226,24 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
this.maxYAxis;
|
this.maxYAxis;
|
||||||
if (typeof minYAxis === "number") {
|
if (typeof minYAxis === "number") {
|
||||||
if (this.fitYData) {
|
if (this.fitYData) {
|
||||||
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
|
minYAxis = ({ min }) =>
|
||||||
|
Math.min(this._roundYAxis(min, Math.floor), this.minYAxis!);
|
||||||
}
|
}
|
||||||
} else if (this.logarithmicScale) {
|
} else if (this.logarithmicScale) {
|
||||||
minYAxis = ({ min }) => {
|
minYAxis = ({ min }) => {
|
||||||
const value = min > 0 ? min * 0.95 : min * 1.05;
|
const value = min > 0 ? min * 0.95 : min * 1.05;
|
||||||
return Math.abs(value) < 1 ? value : Math.floor(value);
|
return this._roundYAxis(value, Math.floor);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (typeof maxYAxis === "number") {
|
if (typeof maxYAxis === "number") {
|
||||||
if (this.fitYData) {
|
if (this.fitYData) {
|
||||||
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
|
maxYAxis = ({ max }) =>
|
||||||
|
Math.max(this._roundYAxis(max, Math.ceil), this.maxYAxis!);
|
||||||
}
|
}
|
||||||
} else if (this.logarithmicScale) {
|
} else if (this.logarithmicScale) {
|
||||||
maxYAxis = ({ max }) => {
|
maxYAxis = ({ max }) => {
|
||||||
const value = max > 0 ? max * 1.05 : max * 0.95;
|
const value = max > 0 ? max * 1.05 : max * 0.95;
|
||||||
return Math.abs(value) < 1 ? value : Math.ceil(value);
|
return this._roundYAxis(value, Math.ceil);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
this._chartOptions = {
|
this._chartOptions = {
|
||||||
@ -729,20 +731,17 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _formatYAxisLabel = (value: number) => {
|
private _formatYAxisLabel = (value: number) => {
|
||||||
const formatOptions =
|
// show the first significant digit for tiny values
|
||||||
value >= 1 || value <= -1
|
const maximumFractionDigits = Math.max(
|
||||||
? undefined
|
1,
|
||||||
: {
|
// use the difference to the previous value to determine the number of significant digits #25526
|
||||||
// show the first significant digit for tiny values
|
-Math.floor(
|
||||||
maximumFractionDigits: Math.max(
|
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
|
||||||
2,
|
)
|
||||||
// use the difference to the previous value to determine the number of significant digits #25526
|
);
|
||||||
-Math.floor(
|
const label = formatNumber(value, this.hass.locale, {
|
||||||
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
|
maximumFractionDigits,
|
||||||
)
|
});
|
||||||
),
|
|
||||||
};
|
|
||||||
const label = formatNumber(value, this.hass.locale, formatOptions);
|
|
||||||
const width = measureTextWidth(label, 12) + 5;
|
const width = measureTextWidth(label, 12) + 5;
|
||||||
if (width > this._yWidth) {
|
if (width > this._yWidth) {
|
||||||
this._yWidth = width;
|
this._yWidth = width;
|
||||||
@ -767,6 +766,10 @@ export class StateHistoryChartLine extends LitElement {
|
|||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _roundYAxis(value: number, roundingFn: (value: number) => number) {
|
||||||
|
return Math.abs(value) < 1 ? value : roundingFn(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
customElements.define("state-history-chart-line", StateHistoryChartLine);
|
customElements.define("state-history-chart-line", StateHistoryChartLine);
|
||||||
|
|
||||||
|
@ -66,6 +66,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
|||||||
.options=${this._chartOptions}
|
.options=${this._chartOptions}
|
||||||
.height=${`${this.data.length * 30 + 30}px`}
|
.height=${`${this.data.length * 30 + 30}px`}
|
||||||
.data=${this._chartData as ECOption["series"]}
|
.data=${this._chartData as ECOption["series"]}
|
||||||
|
small-controls
|
||||||
@chart-click=${this._handleChartClick}
|
@chart-click=${this._handleChartClick}
|
||||||
></ha-chart-base>
|
></ha-chart-base>
|
||||||
`;
|
`;
|
||||||
|
@ -238,22 +238,24 @@ export class StatisticsChart extends LitElement {
|
|||||||
this.maxYAxis;
|
this.maxYAxis;
|
||||||
if (typeof minYAxis === "number") {
|
if (typeof minYAxis === "number") {
|
||||||
if (this.fitYData) {
|
if (this.fitYData) {
|
||||||
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
|
minYAxis = ({ min }) =>
|
||||||
|
Math.min(this._roundYAxis(min, Math.floor), this.minYAxis!);
|
||||||
}
|
}
|
||||||
} else if (this.logarithmicScale) {
|
} else if (this.logarithmicScale) {
|
||||||
minYAxis = ({ min }) => {
|
minYAxis = ({ min }) => {
|
||||||
const value = min > 0 ? min * 0.95 : min * 1.05;
|
const value = min > 0 ? min * 0.95 : min * 1.05;
|
||||||
return Math.abs(value) < 1 ? value : Math.floor(value);
|
return this._roundYAxis(value, Math.floor);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (typeof maxYAxis === "number") {
|
if (typeof maxYAxis === "number") {
|
||||||
if (this.fitYData) {
|
if (this.fitYData) {
|
||||||
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
|
maxYAxis = ({ max }) =>
|
||||||
|
Math.max(this._roundYAxis(max, Math.ceil), this.maxYAxis!);
|
||||||
}
|
}
|
||||||
} else if (this.logarithmicScale) {
|
} else if (this.logarithmicScale) {
|
||||||
maxYAxis = ({ max }) => {
|
maxYAxis = ({ max }) => {
|
||||||
const value = max > 0 ? max * 1.05 : max * 0.95;
|
const value = max > 0 ? max * 1.05 : max * 0.95;
|
||||||
return Math.abs(value) < 1 ? value : Math.ceil(value);
|
return this._roundYAxis(value, Math.ceil);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const endTime = this.endTime ?? new Date();
|
const endTime = this.endTime ?? new Date();
|
||||||
@ -634,6 +636,10 @@ export class StatisticsChart extends LitElement {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _roundYAxis(value: number, roundingFn: (value: number) => number) {
|
||||||
|
return Math.abs(value) < 1 ? value : roundingFn(value);
|
||||||
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
@ -72,6 +72,7 @@ export interface DataTableColumnData<T = any> extends DataTableSortColumnData {
|
|||||||
label?: TemplateResult | string;
|
label?: TemplateResult | string;
|
||||||
type?:
|
type?:
|
||||||
| "numeric"
|
| "numeric"
|
||||||
|
| "ip"
|
||||||
| "icon"
|
| "icon"
|
||||||
| "icon-button"
|
| "icon-button"
|
||||||
| "overflow"
|
| "overflow"
|
||||||
@ -506,7 +507,9 @@ export class HaDataTable extends LitElement {
|
|||||||
this.hasFab,
|
this.hasFab,
|
||||||
this.groupColumn,
|
this.groupColumn,
|
||||||
this.groupOrder,
|
this.groupOrder,
|
||||||
this._collapsedGroups
|
this._collapsedGroups,
|
||||||
|
this.sortColumn,
|
||||||
|
this.sortDirection
|
||||||
)}
|
)}
|
||||||
.keyFunction=${this._keyFunction}
|
.keyFunction=${this._keyFunction}
|
||||||
.renderItem=${renderRow}
|
.renderItem=${renderRow}
|
||||||
@ -701,22 +704,37 @@ export class HaDataTable extends LitElement {
|
|||||||
hasFab: boolean,
|
hasFab: boolean,
|
||||||
groupColumn: string | undefined,
|
groupColumn: string | undefined,
|
||||||
groupOrder: string[] | undefined,
|
groupOrder: string[] | undefined,
|
||||||
collapsedGroups: string[]
|
collapsedGroups: string[],
|
||||||
|
sortColumn: string | undefined,
|
||||||
|
sortDirection: SortingDirection
|
||||||
) => {
|
) => {
|
||||||
if (appendRow || hasFab || groupColumn) {
|
if (appendRow || hasFab || groupColumn) {
|
||||||
let items = [...data];
|
let items = [...data];
|
||||||
|
|
||||||
if (groupColumn) {
|
if (groupColumn) {
|
||||||
|
const isGroupSortColumn = sortColumn === groupColumn;
|
||||||
const grouped = groupBy(items, (item) => item[groupColumn]);
|
const grouped = groupBy(items, (item) => item[groupColumn]);
|
||||||
if (grouped.undefined) {
|
if (grouped.undefined) {
|
||||||
// make sure ungrouped items are at the bottom
|
// make sure ungrouped items are at the bottom
|
||||||
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
|
grouped[UNDEFINED_GROUP_KEY] = grouped.undefined;
|
||||||
delete grouped.undefined;
|
delete grouped.undefined;
|
||||||
}
|
}
|
||||||
const sorted: Record<string, DataTableRowData[]> = Object.keys(
|
const sortedEntries: [string, DataTableRowData[]][] = Object.keys(
|
||||||
grouped
|
grouped
|
||||||
)
|
)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
if (!groupOrder && isGroupSortColumn) {
|
||||||
|
const comparison = stringCompare(
|
||||||
|
a,
|
||||||
|
b,
|
||||||
|
this.hass.locale.language
|
||||||
|
);
|
||||||
|
if (sortDirection === "asc") {
|
||||||
|
return comparison;
|
||||||
|
}
|
||||||
|
return comparison * -1;
|
||||||
|
}
|
||||||
|
|
||||||
const orderA = groupOrder?.indexOf(a) ?? -1;
|
const orderA = groupOrder?.indexOf(a) ?? -1;
|
||||||
const orderB = groupOrder?.indexOf(b) ?? -1;
|
const orderB = groupOrder?.indexOf(b) ?? -1;
|
||||||
if (orderA !== orderB) {
|
if (orderA !== orderB) {
|
||||||
@ -734,12 +752,18 @@ export class HaDataTable extends LitElement {
|
|||||||
this.hass.locale.language
|
this.hass.locale.language
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.reduce((obj, key) => {
|
.reduce(
|
||||||
obj[key] = grouped[key];
|
(entries, key) => {
|
||||||
return obj;
|
const entry: [string, DataTableRowData[]] = [key, grouped[key]];
|
||||||
}, {});
|
|
||||||
|
entries.push(entry);
|
||||||
|
return entries;
|
||||||
|
},
|
||||||
|
[] as [string, DataTableRowData[]][]
|
||||||
|
);
|
||||||
|
|
||||||
const groupedItems: DataTableRowData[] = [];
|
const groupedItems: DataTableRowData[] = [];
|
||||||
Object.entries(sorted).forEach(([groupName, rows]) => {
|
sortedEntries.forEach(([groupName, rows]) => {
|
||||||
const collapsed = collapsedGroups.includes(groupName);
|
const collapsed = collapsedGroups.includes(groupName);
|
||||||
groupedItems.push({
|
groupedItems.push({
|
||||||
append: true,
|
append: true,
|
||||||
@ -835,7 +859,9 @@ export class HaDataTable extends LitElement {
|
|||||||
this.hasFab,
|
this.hasFab,
|
||||||
this.groupColumn,
|
this.groupColumn,
|
||||||
this.groupOrder,
|
this.groupOrder,
|
||||||
this._collapsedGroups
|
this._collapsedGroups,
|
||||||
|
this.sortColumn,
|
||||||
|
this.sortDirection
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { expose } from "comlink";
|
import { expose } from "comlink";
|
||||||
import { stringCompare } from "../../common/string/compare";
|
import { stringCompare, ipCompare } from "../../common/string/compare";
|
||||||
import { stripDiacritics } from "../../common/string/strip-diacritics";
|
import { stripDiacritics } from "../../common/string/strip-diacritics";
|
||||||
import type {
|
import type {
|
||||||
ClonedDataTableColumnData,
|
ClonedDataTableColumnData,
|
||||||
@ -57,6 +57,8 @@ const sortData = (
|
|||||||
if (column.type === "numeric") {
|
if (column.type === "numeric") {
|
||||||
valA = isNaN(valA) ? undefined : Number(valA);
|
valA = isNaN(valA) ? undefined : Number(valA);
|
||||||
valB = isNaN(valB) ? undefined : Number(valB);
|
valB = isNaN(valB) ? undefined : Number(valB);
|
||||||
|
} else if (column.type === "ip") {
|
||||||
|
return sort * ipCompare(valA, valB);
|
||||||
} else if (typeof valA === "string" && typeof valB === "string") {
|
} else if (typeof valA === "string" && typeof valB === "string") {
|
||||||
return sort * stringCompare(valA, valB, language);
|
return sort * stringCompare(valA, valB, language);
|
||||||
}
|
}
|
||||||
|
@ -438,10 +438,8 @@ export class HaStatisticPicker extends LitElement {
|
|||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
<span slot="headline">${item.primary} </span>
|
<span slot="headline">${item.primary} </span>
|
||||||
${item.secondary || item.type
|
${item.secondary
|
||||||
? html`<span slot="supporting-text"
|
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||||
>${item.secondary} - ${item.type}</span
|
|
||||||
>`
|
|
||||||
: nothing}
|
: nothing}
|
||||||
${item.statistic_id && showEntityId
|
${item.statistic_id && showEntityId
|
||||||
? html`<span slot="supporting-text" class="code">
|
? html`<span slot="supporting-text" class="code">
|
||||||
|
@ -173,7 +173,6 @@ class HaStatisticsPicker extends LitElement {
|
|||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
:host {
|
:host {
|
||||||
width: 200px;
|
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
ha-statistic-picker {
|
ha-statistic-picker {
|
||||||
|
@ -366,6 +366,7 @@ export class HaAreaPicker extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.autofocus=${this.autofocus}
|
.autofocus=${this.autofocus}
|
||||||
.label=${this.label}
|
.label=${this.label}
|
||||||
|
.helper=${this.helper}
|
||||||
.notFoundLabel=${this.hass.localize(
|
.notFoundLabel=${this.hass.localize(
|
||||||
"ui.components.area-picker.no_match"
|
"ui.components.area-picker.no_match"
|
||||||
)}
|
)}
|
||||||
|
282
src/components/ha-areas-floors-display-editor.ts
Normal file
282
src/components/ha-areas-floors-display-editor.ts
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
import { mdiDrag, mdiTextureBox } from "@mdi/js";
|
||||||
|
import type { TemplateResult } from "lit";
|
||||||
|
import { LitElement, css, html, nothing } from "lit";
|
||||||
|
import { customElement, property } from "lit/decorators";
|
||||||
|
import { repeat } from "lit/directives/repeat";
|
||||||
|
import memoizeOne from "memoize-one";
|
||||||
|
import { fireEvent } from "../common/dom/fire_event";
|
||||||
|
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||||
|
import { getAreaContext } from "../common/entity/context/get_area_context";
|
||||||
|
import { areaCompare } from "../data/area_registry";
|
||||||
|
import type { FloorRegistryEntry } from "../data/floor_registry";
|
||||||
|
import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
|
||||||
|
import type { HomeAssistant } from "../types";
|
||||||
|
import "./ha-expansion-panel";
|
||||||
|
import "./ha-floor-icon";
|
||||||
|
import "./ha-items-display-editor";
|
||||||
|
import type { DisplayItem, DisplayValue } from "./ha-items-display-editor";
|
||||||
|
import "./ha-svg-icon";
|
||||||
|
import "./ha-textfield";
|
||||||
|
|
||||||
|
export interface AreasFloorsDisplayValue {
|
||||||
|
areas_display?: {
|
||||||
|
hidden?: string[];
|
||||||
|
order?: string[];
|
||||||
|
};
|
||||||
|
floors_display?: {
|
||||||
|
order?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNASSIGNED_FLOOR = "__unassigned__";
|
||||||
|
|
||||||
|
@customElement("ha-areas-floors-display-editor")
|
||||||
|
export class HaAreasFloorsDisplayEditor extends LitElement {
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@property() public label?: string;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public value?: AreasFloorsDisplayValue;
|
||||||
|
|
||||||
|
@property() public helper?: string;
|
||||||
|
|
||||||
|
@property({ type: Boolean }) public disabled = false;
|
||||||
|
|
||||||
|
@property({ type: Boolean }) public required = false;
|
||||||
|
|
||||||
|
@property({ type: Boolean, attribute: "show-navigation-button" })
|
||||||
|
public showNavigationButton = false;
|
||||||
|
|
||||||
|
protected render(): TemplateResult {
|
||||||
|
const groupedAreasItems = this._groupedAreasItems(
|
||||||
|
this.hass.areas,
|
||||||
|
this.hass.floors
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredFloors = this._sortedFloors(
|
||||||
|
this.hass.floors,
|
||||||
|
this.value?.floors_display?.order
|
||||||
|
).filter(
|
||||||
|
(floor) =>
|
||||||
|
// Only include floors that have areas assigned to them
|
||||||
|
groupedAreasItems[floor.floor_id]?.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
const value: DisplayValue = {
|
||||||
|
order: this.value?.areas_display?.order ?? [],
|
||||||
|
hidden: this.value?.areas_display?.hidden ?? [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const canReorderFloors =
|
||||||
|
filteredFloors.filter((floor) => floor.floor_id !== UNASSIGNED_FLOOR)
|
||||||
|
.length > 1;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||||
|
<ha-sortable
|
||||||
|
draggable-selector=".draggable"
|
||||||
|
handle-selector=".handle"
|
||||||
|
@item-moved=${this._floorMoved}
|
||||||
|
.disabled=${this.disabled || !canReorderFloors}
|
||||||
|
invert-swap
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
${repeat(
|
||||||
|
filteredFloors,
|
||||||
|
(floor) => floor.floor_id,
|
||||||
|
(floor: FloorRegistryEntry) => html`
|
||||||
|
<ha-expansion-panel
|
||||||
|
outlined
|
||||||
|
.header=${computeFloorName(floor)}
|
||||||
|
left-chevron
|
||||||
|
class=${floor.floor_id === UNASSIGNED_FLOOR ? "" : "draggable"}
|
||||||
|
>
|
||||||
|
<ha-floor-icon
|
||||||
|
slot="leading-icon"
|
||||||
|
.floor=${floor}
|
||||||
|
></ha-floor-icon>
|
||||||
|
${floor.floor_id === UNASSIGNED_FLOOR || !canReorderFloors
|
||||||
|
? nothing
|
||||||
|
: html`
|
||||||
|
<ha-svg-icon
|
||||||
|
class="handle"
|
||||||
|
slot="icons"
|
||||||
|
.path=${mdiDrag}
|
||||||
|
></ha-svg-icon>
|
||||||
|
`}
|
||||||
|
<ha-items-display-editor
|
||||||
|
.hass=${this.hass}
|
||||||
|
.items=${groupedAreasItems[floor.floor_id]}
|
||||||
|
.value=${value}
|
||||||
|
.floorId=${floor.floor_id}
|
||||||
|
@value-changed=${this._areaDisplayChanged}
|
||||||
|
.showNavigationButton=${this.showNavigationButton}
|
||||||
|
></ha-items-display-editor>
|
||||||
|
</ha-expansion-panel>
|
||||||
|
`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ha-sortable>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _groupedAreasItems = memoizeOne(
|
||||||
|
(
|
||||||
|
hassAreas: HomeAssistant["areas"],
|
||||||
|
// update items if floors change
|
||||||
|
_hassFloors: HomeAssistant["floors"]
|
||||||
|
): Record<string, DisplayItem[]> => {
|
||||||
|
const compare = areaCompare(hassAreas);
|
||||||
|
|
||||||
|
const areas = Object.values(hassAreas).sort((areaA, areaB) =>
|
||||||
|
compare(areaA.area_id, areaB.area_id)
|
||||||
|
);
|
||||||
|
const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
|
||||||
|
(acc, area) => {
|
||||||
|
const { floor } = getAreaContext(area, this.hass!);
|
||||||
|
const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR;
|
||||||
|
|
||||||
|
if (!acc[floorId]) {
|
||||||
|
acc[floorId] = [];
|
||||||
|
}
|
||||||
|
acc[floorId].push({
|
||||||
|
value: area.area_id,
|
||||||
|
label: area.name,
|
||||||
|
icon: area.icon ?? undefined,
|
||||||
|
iconPath: mdiTextureBox,
|
||||||
|
});
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, DisplayItem[]>
|
||||||
|
);
|
||||||
|
return groupedItems;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
private _sortedFloors = memoizeOne(
|
||||||
|
(
|
||||||
|
hassFloors: HomeAssistant["floors"],
|
||||||
|
order: string[] | undefined
|
||||||
|
): FloorRegistryEntry[] => {
|
||||||
|
const floors = getFloors(hassFloors, order);
|
||||||
|
const noFloors = floors.length === 0;
|
||||||
|
floors.push({
|
||||||
|
floor_id: UNASSIGNED_FLOOR,
|
||||||
|
name: noFloors
|
||||||
|
? this.hass.localize("ui.panel.lovelace.strategy.areas.areas")
|
||||||
|
: this.hass.localize("ui.panel.lovelace.strategy.areas.other_areas"),
|
||||||
|
icon: null,
|
||||||
|
level: null,
|
||||||
|
aliases: [],
|
||||||
|
created_at: 0,
|
||||||
|
modified_at: 0,
|
||||||
|
});
|
||||||
|
return floors;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
private _floorMoved(ev: CustomEvent<HASSDomEvents["item-moved"]>) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const newIndex = ev.detail.newIndex;
|
||||||
|
const oldIndex = ev.detail.oldIndex;
|
||||||
|
const floorIds = this._sortedFloors(
|
||||||
|
this.hass.floors,
|
||||||
|
this.value?.floors_display?.order
|
||||||
|
).map((floor) => floor.floor_id);
|
||||||
|
const newOrder = [...floorIds];
|
||||||
|
const movedFloorId = newOrder.splice(oldIndex, 1)[0];
|
||||||
|
newOrder.splice(newIndex, 0, movedFloorId);
|
||||||
|
const newValue: AreasFloorsDisplayValue = {
|
||||||
|
areas_display: this.value?.areas_display,
|
||||||
|
floors_display: {
|
||||||
|
order: newOrder,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (newValue.floors_display?.order?.length === 0) {
|
||||||
|
delete newValue.floors_display.order;
|
||||||
|
}
|
||||||
|
fireEvent(this, "value-changed", { value: newValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _areaDisplayChanged(ev: CustomEvent<{ value: DisplayValue }>) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const value = ev.detail.value;
|
||||||
|
const currentFloorId = (ev.currentTarget as any).floorId;
|
||||||
|
|
||||||
|
const floorIds = this._sortedFloors(
|
||||||
|
this.hass.floors,
|
||||||
|
this.value?.floors_display?.order
|
||||||
|
).map((floor) => floor.floor_id);
|
||||||
|
|
||||||
|
const oldAreaDisplay = this.value?.areas_display ?? {};
|
||||||
|
|
||||||
|
const oldHidden = oldAreaDisplay?.hidden ?? [];
|
||||||
|
const oldOrder = oldAreaDisplay?.order ?? [];
|
||||||
|
|
||||||
|
const newHidden: string[] = [];
|
||||||
|
const newOrder: string[] = [];
|
||||||
|
|
||||||
|
for (const floorId of floorIds) {
|
||||||
|
if ((currentFloorId ?? UNASSIGNED_FLOOR) === floorId) {
|
||||||
|
newHidden.push(...(value.hidden ?? []));
|
||||||
|
newOrder.push(...(value.order ?? []));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hidden = oldHidden.filter((areaId) => {
|
||||||
|
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
|
||||||
|
return id === floorId;
|
||||||
|
});
|
||||||
|
if (hidden?.length) {
|
||||||
|
newHidden.push(...hidden);
|
||||||
|
}
|
||||||
|
const order = oldOrder.filter((areaId) => {
|
||||||
|
const id = this.hass.areas[areaId]?.floor_id ?? UNASSIGNED_FLOOR;
|
||||||
|
return id === floorId;
|
||||||
|
});
|
||||||
|
if (order?.length) {
|
||||||
|
newOrder.push(...order);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue: AreasFloorsDisplayValue = {
|
||||||
|
areas_display: {
|
||||||
|
hidden: newHidden,
|
||||||
|
order: newOrder,
|
||||||
|
},
|
||||||
|
floors_display: this.value?.floors_display,
|
||||||
|
};
|
||||||
|
if (newValue.areas_display?.hidden?.length === 0) {
|
||||||
|
delete newValue.areas_display.hidden;
|
||||||
|
}
|
||||||
|
if (newValue.areas_display?.order?.length === 0) {
|
||||||
|
delete newValue.areas_display.order;
|
||||||
|
}
|
||||||
|
if (newValue.floors_display?.order?.length === 0) {
|
||||||
|
delete newValue.floors_display.order;
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent(this, "value-changed", { value: newValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
ha-expansion-panel {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
--expansion-panel-summary-padding: 0 16px;
|
||||||
|
}
|
||||||
|
ha-expansion-panel [slot="leading-icon"] {
|
||||||
|
margin-inline-end: 16px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: var(--ha-font-weight-bold);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ha-areas-floors-display-editor": HaAreasFloorsDisplayEditor;
|
||||||
|
}
|
||||||
|
}
|
61
src/components/ha-aspect-ratio.ts
Normal file
61
src/components/ha-aspect-ratio.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { css, html, LitElement, type PropertyValues } from "lit";
|
||||||
|
import { customElement, property } from "lit/decorators";
|
||||||
|
import { styleMap } from "lit/directives/style-map";
|
||||||
|
import parseAspectRatio from "../common/util/parse-aspect-ratio";
|
||||||
|
|
||||||
|
const DEFAULT_ASPECT_RATIO = "16:9";
|
||||||
|
|
||||||
|
@customElement("ha-aspect-ratio")
|
||||||
|
export class HaAspectRatio extends LitElement {
|
||||||
|
@property({ type: String, attribute: "aspect-ratio" })
|
||||||
|
public aspectRatio?: string;
|
||||||
|
|
||||||
|
private _ratio: {
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
} | null = null;
|
||||||
|
|
||||||
|
public willUpdate(changedProps: PropertyValues) {
|
||||||
|
if (changedProps.has("aspect_ratio") || this._ratio === null) {
|
||||||
|
this._ratio = this.aspectRatio
|
||||||
|
? parseAspectRatio(this.aspectRatio)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (this._ratio === null || this._ratio.w <= 0 || this._ratio.h <= 0) {
|
||||||
|
this._ratio = parseAspectRatio(DEFAULT_ASPECT_RATIO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render(): unknown {
|
||||||
|
if (!this.aspectRatio) {
|
||||||
|
return html`<slot></slot>`;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<div
|
||||||
|
class="ratio"
|
||||||
|
style=${styleMap({
|
||||||
|
paddingBottom: `${((100 * this._ratio!.h) / this._ratio!.w).toFixed(2)}%`,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
.ratio ::slotted(*) {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ha-aspect-ratio": HaAspectRatio;
|
||||||
|
}
|
||||||
|
}
|
@ -271,7 +271,9 @@ export class HaBaseTimeInput extends LitElement {
|
|||||||
</ha-select>`}
|
</ha-select>`}
|
||||||
</div>
|
</div>
|
||||||
${this.helper
|
${this.helper
|
||||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||||
|
>${this.helper}</ha-input-helper-text
|
||||||
|
>`
|
||||||
: nothing}
|
: nothing}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import type {
|
|||||||
} from "@codemirror/autocomplete";
|
} from "@codemirror/autocomplete";
|
||||||
import type { Extension, TransactionSpec } from "@codemirror/state";
|
import type { Extension, TransactionSpec } from "@codemirror/state";
|
||||||
import type { EditorView, KeyBinding, ViewUpdate } from "@codemirror/view";
|
import type { EditorView, KeyBinding, ViewUpdate } from "@codemirror/view";
|
||||||
|
import { mdiArrowExpand, mdiArrowCollapse } from "@mdi/js";
|
||||||
import type { HassEntities } from "home-assistant-js-websocket";
|
import type { HassEntities } from "home-assistant-js-websocket";
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
import { css, ReactiveElement } from "lit";
|
import { css, ReactiveElement } from "lit";
|
||||||
@ -15,6 +16,7 @@ import { fireEvent } from "../common/dom/fire_event";
|
|||||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||||
import type { HomeAssistant } from "../types";
|
import type { HomeAssistant } from "../types";
|
||||||
import "./ha-icon";
|
import "./ha-icon";
|
||||||
|
import "./ha-icon-button";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface HASSDomEvents {
|
interface HASSDomEvents {
|
||||||
@ -59,8 +61,13 @@ export class HaCodeEditor extends ReactiveElement {
|
|||||||
|
|
||||||
@property({ type: Boolean }) public error = false;
|
@property({ type: Boolean }) public error = false;
|
||||||
|
|
||||||
|
@property({ type: Boolean, attribute: "disable-fullscreen" })
|
||||||
|
public disableFullscreen = false;
|
||||||
|
|
||||||
@state() private _value = "";
|
@state() private _value = "";
|
||||||
|
|
||||||
|
@state() private _isFullscreen = false;
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
private _loadedCodeMirror?: typeof import("../resources/codemirror");
|
private _loadedCodeMirror?: typeof import("../resources/codemirror");
|
||||||
|
|
||||||
@ -92,6 +99,7 @@ export class HaCodeEditor extends ReactiveElement {
|
|||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
this.addEventListener("keydown", stopPropagation);
|
this.addEventListener("keydown", stopPropagation);
|
||||||
|
this.addEventListener("keydown", this._handleKeyDown);
|
||||||
// This is unreachable as editor will not exist yet,
|
// This is unreachable as editor will not exist yet,
|
||||||
// but focus should not behave like this for good a11y.
|
// but focus should not behave like this for good a11y.
|
||||||
// (@steverep to fix in autofocus PR)
|
// (@steverep to fix in autofocus PR)
|
||||||
@ -106,6 +114,10 @@ export class HaCodeEditor extends ReactiveElement {
|
|||||||
public disconnectedCallback() {
|
public disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
this.removeEventListener("keydown", stopPropagation);
|
this.removeEventListener("keydown", stopPropagation);
|
||||||
|
this.removeEventListener("keydown", this._handleKeyDown);
|
||||||
|
if (this._isFullscreen) {
|
||||||
|
this._toggleFullscreen();
|
||||||
|
}
|
||||||
this.updateComplete.then(() => {
|
this.updateComplete.then(() => {
|
||||||
this.codemirror!.destroy();
|
this.codemirror!.destroy();
|
||||||
delete this.codemirror;
|
delete this.codemirror;
|
||||||
@ -164,6 +176,12 @@ export class HaCodeEditor extends ReactiveElement {
|
|||||||
if (changedProps.has("error")) {
|
if (changedProps.has("error")) {
|
||||||
this.classList.toggle("error-state", this.error);
|
this.classList.toggle("error-state", this.error);
|
||||||
}
|
}
|
||||||
|
if (changedProps.has("_isFullscreen")) {
|
||||||
|
this.classList.toggle("fullscreen", this._isFullscreen);
|
||||||
|
}
|
||||||
|
if (changedProps.has("disableFullscreen")) {
|
||||||
|
this._updateFullscreenButton();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private get _mode() {
|
private get _mode() {
|
||||||
@ -238,8 +256,74 @@ export class HaCodeEditor extends ReactiveElement {
|
|||||||
}),
|
}),
|
||||||
parent: this.renderRoot,
|
parent: this.renderRoot,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this._updateFullscreenButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _updateFullscreenButton() {
|
||||||
|
const existingButton = this.renderRoot.querySelector(".fullscreen-button");
|
||||||
|
|
||||||
|
if (this.disableFullscreen) {
|
||||||
|
// Remove button if it exists and fullscreen is disabled
|
||||||
|
if (existingButton) {
|
||||||
|
existingButton.remove();
|
||||||
|
}
|
||||||
|
// Exit fullscreen if currently in fullscreen mode
|
||||||
|
if (this._isFullscreen) {
|
||||||
|
this._isFullscreen = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create button if it doesn't exist
|
||||||
|
if (!existingButton) {
|
||||||
|
const button = document.createElement("ha-icon-button");
|
||||||
|
(button as any).path = this._isFullscreen
|
||||||
|
? mdiArrowCollapse
|
||||||
|
: mdiArrowExpand;
|
||||||
|
button.setAttribute(
|
||||||
|
"label",
|
||||||
|
this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
|
||||||
|
);
|
||||||
|
button.classList.add("fullscreen-button");
|
||||||
|
// Use bound method to ensure proper this context
|
||||||
|
button.addEventListener("click", this._handleFullscreenClick);
|
||||||
|
this.renderRoot.appendChild(button);
|
||||||
|
} else {
|
||||||
|
// Update existing button
|
||||||
|
(existingButton as any).path = this._isFullscreen
|
||||||
|
? mdiArrowCollapse
|
||||||
|
: mdiArrowExpand;
|
||||||
|
existingButton.setAttribute(
|
||||||
|
"label",
|
||||||
|
this._isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleFullscreenClick = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this._toggleFullscreen();
|
||||||
|
};
|
||||||
|
|
||||||
|
private _toggleFullscreen() {
|
||||||
|
this._isFullscreen = !this._isFullscreen;
|
||||||
|
this._updateFullscreenButton();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (this._isFullscreen && e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this._toggleFullscreen();
|
||||||
|
} else if (e.key === "F11" && !this.disableFullscreen) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
this._toggleFullscreen();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
|
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
|
||||||
if (!states) {
|
if (!states) {
|
||||||
return [];
|
return [];
|
||||||
@ -257,6 +341,126 @@ export class HaCodeEditor extends ReactiveElement {
|
|||||||
private _entityCompletions(
|
private _entityCompletions(
|
||||||
context: CompletionContext
|
context: CompletionContext
|
||||||
): CompletionResult | null | Promise<CompletionResult | null> {
|
): CompletionResult | null | Promise<CompletionResult | null> {
|
||||||
|
// Check for YAML mode and entity-related fields
|
||||||
|
if (this.mode === "yaml") {
|
||||||
|
const currentLine = context.state.doc.lineAt(context.pos);
|
||||||
|
const lineText = currentLine.text;
|
||||||
|
|
||||||
|
// Properties that commonly contain entity IDs
|
||||||
|
const entityProperties = [
|
||||||
|
"entity_id",
|
||||||
|
"entity",
|
||||||
|
"entities",
|
||||||
|
"badges",
|
||||||
|
"devices",
|
||||||
|
"lights",
|
||||||
|
"light",
|
||||||
|
"group_members",
|
||||||
|
"scene",
|
||||||
|
"zone",
|
||||||
|
"zones",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create regex pattern for all entity properties
|
||||||
|
const propertyPattern = entityProperties.join("|");
|
||||||
|
const entityFieldRegex = new RegExp(
|
||||||
|
`^\\s*(-\\s+)?(${propertyPattern}):\\s*`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if we're in an entity field (single entity or list item)
|
||||||
|
const entityFieldMatch = lineText.match(entityFieldRegex);
|
||||||
|
const listItemMatch = lineText.match(/^\s*-\s+/);
|
||||||
|
|
||||||
|
if (entityFieldMatch) {
|
||||||
|
// Calculate the position after the entity field
|
||||||
|
const afterField = currentLine.from + entityFieldMatch[0].length;
|
||||||
|
|
||||||
|
// If cursor is after the entity field, show all entities
|
||||||
|
if (context.pos >= afterField) {
|
||||||
|
const states = this._getStates(this.hass!.states);
|
||||||
|
|
||||||
|
if (!states || !states.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find what's already typed after the field
|
||||||
|
const typedText = context.state.sliceDoc(afterField, context.pos);
|
||||||
|
|
||||||
|
// Filter states based on what's typed
|
||||||
|
const filteredStates = typedText
|
||||||
|
? states.filter((entityState) =>
|
||||||
|
entityState.label
|
||||||
|
.toLowerCase()
|
||||||
|
.startsWith(typedText.toLowerCase())
|
||||||
|
)
|
||||||
|
: states;
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: afterField,
|
||||||
|
options: filteredStates,
|
||||||
|
validFor: /^[a-z_]*\.?\w*$/,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (listItemMatch) {
|
||||||
|
// Check if this is a list item under an entity_id field
|
||||||
|
const lineNumber = currentLine.number;
|
||||||
|
|
||||||
|
// Look at previous lines to check if we're under an entity_id field
|
||||||
|
for (let i = lineNumber - 1; i > 0 && i >= lineNumber - 10; i--) {
|
||||||
|
const prevLine = context.state.doc.line(i);
|
||||||
|
const prevText = prevLine.text;
|
||||||
|
|
||||||
|
// Stop if we hit a non-indented line (new field)
|
||||||
|
if (
|
||||||
|
prevText.trim() &&
|
||||||
|
!prevText.startsWith(" ") &&
|
||||||
|
!prevText.startsWith("\t")
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we found an entity property field
|
||||||
|
const entityListFieldRegex = new RegExp(
|
||||||
|
`^\\s*(${propertyPattern}):\\s*$`
|
||||||
|
);
|
||||||
|
if (prevText.match(entityListFieldRegex)) {
|
||||||
|
// We're in a list under an entity field
|
||||||
|
const afterListMarker = currentLine.from + listItemMatch[0].length;
|
||||||
|
|
||||||
|
if (context.pos >= afterListMarker) {
|
||||||
|
const states = this._getStates(this.hass!.states);
|
||||||
|
|
||||||
|
if (!states || !states.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find what's already typed after the list marker
|
||||||
|
const typedText = context.state.sliceDoc(
|
||||||
|
afterListMarker,
|
||||||
|
context.pos
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter states based on what's typed
|
||||||
|
const filteredStates = typedText
|
||||||
|
? states.filter((entityState) =>
|
||||||
|
entityState.label
|
||||||
|
.toLowerCase()
|
||||||
|
.startsWith(typedText.toLowerCase())
|
||||||
|
)
|
||||||
|
: states;
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: afterListMarker,
|
||||||
|
options: filteredStates,
|
||||||
|
validFor: /^[a-z_]*\.?\w*$/,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original entity completion logic for non-YAML or when not in entity_id field
|
||||||
const entityWord = context.matchBefore(/[a-z_]{3,}\.\w*/);
|
const entityWord = context.matchBefore(/[a-z_]{3,}\.\w*/);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -340,9 +544,77 @@ export class HaCodeEditor extends ReactiveElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
:host(.error-state) .cm-gutters {
|
:host(.error-state) .cm-gutters {
|
||||||
border-color: var(--error-state-color, red);
|
border-color: var(--error-state-color, red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fullscreen-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
background-color: var(--secondary-background-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
--mdc-icon-button-size: 32px;
|
||||||
|
--mdc-icon-size: 18px;
|
||||||
|
/* Ensure button is clickable on iOS */
|
||||||
|
cursor: pointer;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
touch-action: manipulation;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullscreen-button:hover,
|
||||||
|
.fullscreen-button:active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: none) {
|
||||||
|
.fullscreen-button {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(.fullscreen) {
|
||||||
|
position: fixed !important;
|
||||||
|
top: calc(var(--header-height, 56px) + 8px) !important;
|
||||||
|
left: 8px !important;
|
||||||
|
right: 8px !important;
|
||||||
|
bottom: 8px !important;
|
||||||
|
z-index: 9999 !important;
|
||||||
|
border-radius: 12px !important;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
background-color: var(
|
||||||
|
--code-editor-background-color,
|
||||||
|
var(--card-background-color)
|
||||||
|
) !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding-top: var(--safe-area-inset-top) !important;
|
||||||
|
padding-left: var(--safe-area-inset-left) !important;
|
||||||
|
padding-right: var(--safe-area-inset-right) !important;
|
||||||
|
padding-bottom: var(--safe-area-inset-bottom) !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(.fullscreen) .cm-editor {
|
||||||
|
height: 100% !important;
|
||||||
|
max-height: 100% !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(.fullscreen) .fullscreen-button {
|
||||||
|
top: calc(var(--safe-area-inset-top, 0px) + 8px);
|
||||||
|
right: calc(var(--safe-area-inset-right, 0px) + 8px);
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ import type { HomeAssistant } from "../types";
|
|||||||
import "./ha-combo-box-item";
|
import "./ha-combo-box-item";
|
||||||
import "./ha-combo-box-textfield";
|
import "./ha-combo-box-textfield";
|
||||||
import "./ha-icon-button";
|
import "./ha-icon-button";
|
||||||
|
import "./ha-input-helper-text";
|
||||||
import "./ha-textfield";
|
import "./ha-textfield";
|
||||||
import type { HaTextField } from "./ha-textfield";
|
import type { HaTextField } from "./ha-textfield";
|
||||||
|
|
||||||
@ -195,8 +196,6 @@ export class HaComboBox extends LitElement {
|
|||||||
></div>`}
|
></div>`}
|
||||||
.icon=${this.icon}
|
.icon=${this.icon}
|
||||||
.invalid=${this.invalid}
|
.invalid=${this.invalid}
|
||||||
.helper=${this.helper}
|
|
||||||
helperPersistent
|
|
||||||
.disableSetValue=${this._disableSetValue}
|
.disableSetValue=${this._disableSetValue}
|
||||||
>
|
>
|
||||||
<slot name="icon" slot="leadingIcon"></slot>
|
<slot name="icon" slot="leadingIcon"></slot>
|
||||||
@ -222,9 +221,18 @@ export class HaComboBox extends LitElement {
|
|||||||
@click=${this._toggleOpen}
|
@click=${this._toggleOpen}
|
||||||
></ha-svg-icon>
|
></ha-svg-icon>
|
||||||
</vaadin-combo-box-light>
|
</vaadin-combo-box-light>
|
||||||
|
${this._renderHelper()}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _renderHelper() {
|
||||||
|
return this.helper
|
||||||
|
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||||
|
>${this.helper}</ha-input-helper-text
|
||||||
|
>`
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
private _defaultRowRenderer: ComboBoxLitRenderer<
|
private _defaultRowRenderer: ComboBoxLitRenderer<
|
||||||
string | Record<string, any>
|
string | Record<string, any>
|
||||||
> = (item) => html`
|
> = (item) => html`
|
||||||
@ -361,7 +369,6 @@ export class HaComboBox extends LitElement {
|
|||||||
}
|
}
|
||||||
vaadin-combo-box-light {
|
vaadin-combo-box-light {
|
||||||
position: relative;
|
position: relative;
|
||||||
--vaadin-combo-box-overlay-max-height: calc(45vh - 56px);
|
|
||||||
}
|
}
|
||||||
ha-combo-box-textfield {
|
ha-combo-box-textfield {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@ -398,6 +405,9 @@ export class HaComboBox extends LitElement {
|
|||||||
inset-inline-end: 36px;
|
inset-inline-end: 36px;
|
||||||
direction: var(--direction);
|
direction: var(--direction);
|
||||||
}
|
}
|
||||||
|
ha-input-helper-text {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -18,6 +18,8 @@ export class HaDomainIcon extends LitElement {
|
|||||||
|
|
||||||
@property({ attribute: false }) public deviceClass?: string;
|
@property({ attribute: false }) public deviceClass?: string;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public state?: string;
|
||||||
|
|
||||||
@property() public icon?: string;
|
@property() public icon?: string;
|
||||||
|
|
||||||
@property({ attribute: "brand-fallback", type: Boolean })
|
@property({ attribute: "brand-fallback", type: Boolean })
|
||||||
@ -36,14 +38,17 @@ export class HaDomainIcon extends LitElement {
|
|||||||
return this._renderFallback();
|
return this._renderFallback();
|
||||||
}
|
}
|
||||||
|
|
||||||
const icon = domainIcon(this.hass, this.domain, this.deviceClass).then(
|
const icon = domainIcon(
|
||||||
(icn) => {
|
this.hass,
|
||||||
if (icn) {
|
this.domain,
|
||||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
this.deviceClass,
|
||||||
}
|
this.state
|
||||||
return this._renderFallback();
|
).then((icn) => {
|
||||||
|
if (icn) {
|
||||||
|
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||||
}
|
}
|
||||||
);
|
return this._renderFallback();
|
||||||
|
});
|
||||||
|
|
||||||
return html`${until(icon)}`;
|
return html`${until(icon)}`;
|
||||||
}
|
}
|
||||||
|
@ -14,6 +14,7 @@ import "./ha-check-list-item";
|
|||||||
import "./ha-expansion-panel";
|
import "./ha-expansion-panel";
|
||||||
import "./ha-icon-button";
|
import "./ha-icon-button";
|
||||||
import "./ha-list";
|
import "./ha-list";
|
||||||
|
import { deepEqual } from "../common/util/deep-equal";
|
||||||
|
|
||||||
@customElement("ha-filter-blueprints")
|
@customElement("ha-filter-blueprints")
|
||||||
export class HaFilterBlueprints extends LitElement {
|
export class HaFilterBlueprints extends LitElement {
|
||||||
@ -34,10 +35,11 @@ export class HaFilterBlueprints extends LitElement {
|
|||||||
public willUpdate(properties: PropertyValues) {
|
public willUpdate(properties: PropertyValues) {
|
||||||
super.willUpdate(properties);
|
super.willUpdate(properties);
|
||||||
|
|
||||||
if (!this.hasUpdated) {
|
if (
|
||||||
if (this.value?.length) {
|
properties.has("value") &&
|
||||||
this._findRelated();
|
!deepEqual(this.value, properties.get("value"))
|
||||||
}
|
) {
|
||||||
|
this._findRelated();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,17 +132,15 @@ export class HaFilterBlueprints extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.value = value;
|
this.value = value;
|
||||||
|
|
||||||
this._findRelated();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _findRelated() {
|
private async _findRelated() {
|
||||||
if (!this.value?.length) {
|
if (!this.value?.length) {
|
||||||
|
this.value = [];
|
||||||
fireEvent(this, "data-table-filter-changed", {
|
fireEvent(this, "data-table-filter-changed", {
|
||||||
value: [],
|
value: [],
|
||||||
items: undefined,
|
items: undefined,
|
||||||
});
|
});
|
||||||
this.value = [];
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,6 +6,7 @@ import memoizeOne from "memoize-one";
|
|||||||
import { fireEvent } from "../common/dom/fire_event";
|
import { fireEvent } from "../common/dom/fire_event";
|
||||||
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
|
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
|
||||||
import { stringCompare } from "../common/string/compare";
|
import { stringCompare } from "../common/string/compare";
|
||||||
|
import { deepEqual } from "../common/util/deep-equal";
|
||||||
import type { RelatedResult } from "../data/search";
|
import type { RelatedResult } from "../data/search";
|
||||||
import { findRelated } from "../data/search";
|
import { findRelated } from "../data/search";
|
||||||
import { haStyleScrollbar } from "../resources/styles";
|
import { haStyleScrollbar } from "../resources/styles";
|
||||||
@ -37,9 +38,13 @@ export class HaFilterDevices extends LitElement {
|
|||||||
|
|
||||||
if (!this.hasUpdated) {
|
if (!this.hasUpdated) {
|
||||||
loadVirtualizer();
|
loadVirtualizer();
|
||||||
if (this.value?.length) {
|
}
|
||||||
this._findRelated();
|
|
||||||
}
|
if (
|
||||||
|
properties.has("value") &&
|
||||||
|
!deepEqual(this.value, properties.get("value"))
|
||||||
|
) {
|
||||||
|
this._findRelated();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +115,6 @@ export class HaFilterDevices extends LitElement {
|
|||||||
this.value = [...(this.value || []), value];
|
this.value = [...(this.value || []), value];
|
||||||
}
|
}
|
||||||
listItem.selected = this.value?.includes(value);
|
listItem.selected = this.value?.includes(value);
|
||||||
this._findRelated();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected updated(changed) {
|
protected updated(changed) {
|
||||||
@ -160,11 +164,11 @@ export class HaFilterDevices extends LitElement {
|
|||||||
const relatedPromises: Promise<RelatedResult>[] = [];
|
const relatedPromises: Promise<RelatedResult>[] = [];
|
||||||
|
|
||||||
if (!this.value?.length) {
|
if (!this.value?.length) {
|
||||||
|
this.value = [];
|
||||||
fireEvent(this, "data-table-filter-changed", {
|
fireEvent(this, "data-table-filter-changed", {
|
||||||
value: [],
|
value: [],
|
||||||
items: undefined,
|
items: undefined,
|
||||||
});
|
});
|
||||||
this.value = [];
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,7 +180,6 @@ export class HaFilterDevices extends LitElement {
|
|||||||
relatedPromises.push(findRelated(this.hass, "device", deviceId));
|
relatedPromises.push(findRelated(this.hass, "device", deviceId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.value = value;
|
|
||||||
const results = await Promise.all(relatedPromises);
|
const results = await Promise.all(relatedPromises);
|
||||||
const items = new Set<string>();
|
const items = new Set<string>();
|
||||||
for (const result of results) {
|
for (const result of results) {
|
||||||
|
@ -17,6 +17,7 @@ import "./ha-expansion-panel";
|
|||||||
import "./ha-list";
|
import "./ha-list";
|
||||||
import "./ha-state-icon";
|
import "./ha-state-icon";
|
||||||
import "./search-input-outlined";
|
import "./search-input-outlined";
|
||||||
|
import { deepEqual } from "../common/util/deep-equal";
|
||||||
|
|
||||||
@customElement("ha-filter-entities")
|
@customElement("ha-filter-entities")
|
||||||
export class HaFilterEntities extends LitElement {
|
export class HaFilterEntities extends LitElement {
|
||||||
@ -39,9 +40,13 @@ export class HaFilterEntities extends LitElement {
|
|||||||
|
|
||||||
if (!this.hasUpdated) {
|
if (!this.hasUpdated) {
|
||||||
loadVirtualizer();
|
loadVirtualizer();
|
||||||
if (this.value?.length) {
|
}
|
||||||
this._findRelated();
|
|
||||||
}
|
if (
|
||||||
|
properties.has("value") &&
|
||||||
|
!deepEqual(this.value, properties.get("value"))
|
||||||
|
) {
|
||||||
|
this._findRelated();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -131,7 +136,6 @@ export class HaFilterEntities extends LitElement {
|
|||||||
this.value = [...(this.value || []), value];
|
this.value = [...(this.value || []), value];
|
||||||
}
|
}
|
||||||
listItem.selected = this.value?.includes(value);
|
listItem.selected = this.value?.includes(value);
|
||||||
this._findRelated();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _expandedWillChange(ev) {
|
private _expandedWillChange(ev) {
|
||||||
@ -178,11 +182,11 @@ export class HaFilterEntities extends LitElement {
|
|||||||
const relatedPromises: Promise<RelatedResult>[] = [];
|
const relatedPromises: Promise<RelatedResult>[] = [];
|
||||||
|
|
||||||
if (!this.value?.length) {
|
if (!this.value?.length) {
|
||||||
|
this.value = [];
|
||||||
fireEvent(this, "data-table-filter-changed", {
|
fireEvent(this, "data-table-filter-changed", {
|
||||||
value: [],
|
value: [],
|
||||||
items: undefined,
|
items: undefined,
|
||||||
});
|
});
|
||||||
this.value = [];
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,6 +20,7 @@ import "./ha-icon-button";
|
|||||||
import "./ha-list";
|
import "./ha-list";
|
||||||
import "./ha-svg-icon";
|
import "./ha-svg-icon";
|
||||||
import "./ha-tree-indicator";
|
import "./ha-tree-indicator";
|
||||||
|
import { deepEqual } from "../common/util/deep-equal";
|
||||||
|
|
||||||
@customElement("ha-filter-floor-areas")
|
@customElement("ha-filter-floor-areas")
|
||||||
export class HaFilterFloorAreas extends LitElement {
|
export class HaFilterFloorAreas extends LitElement {
|
||||||
@ -41,10 +42,11 @@ export class HaFilterFloorAreas extends LitElement {
|
|||||||
public willUpdate(properties: PropertyValues) {
|
public willUpdate(properties: PropertyValues) {
|
||||||
super.willUpdate(properties);
|
super.willUpdate(properties);
|
||||||
|
|
||||||
if (!this.hasUpdated) {
|
if (
|
||||||
if (this.value?.floors?.length || this.value?.areas?.length) {
|
properties.has("value") &&
|
||||||
this._findRelated();
|
!deepEqual(this.value, properties.get("value"))
|
||||||
}
|
) {
|
||||||
|
this._findRelated();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -174,8 +176,6 @@ export class HaFilterFloorAreas extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listItem.selected = this.value[type]?.includes(value);
|
listItem.selected = this.value[type]?.includes(value);
|
||||||
|
|
||||||
this._findRelated();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected updated(changed) {
|
protected updated(changed) {
|
||||||
@ -188,10 +188,6 @@ export class HaFilterFloorAreas extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected firstUpdated() {
|
|
||||||
this._findRelated();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _expandedWillChange(ev) {
|
private _expandedWillChange(ev) {
|
||||||
this._shouldRender = ev.detail.expanded;
|
this._shouldRender = ev.detail.expanded;
|
||||||
}
|
}
|
||||||
@ -226,6 +222,7 @@ export class HaFilterFloorAreas extends LitElement {
|
|||||||
!this.value ||
|
!this.value ||
|
||||||
(!this.value.areas?.length && !this.value.floors?.length)
|
(!this.value.areas?.length && !this.value.floors?.length)
|
||||||
) {
|
) {
|
||||||
|
this.value = {};
|
||||||
fireEvent(this, "data-table-filter-changed", {
|
fireEvent(this, "data-table-filter-changed", {
|
||||||
value: {},
|
value: {},
|
||||||
items: undefined,
|
items: undefined,
|
||||||
|
@ -30,6 +30,22 @@ export const floorDefaultIconPath = (
|
|||||||
return mdiHome;
|
return mdiHome;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const floorDefaultIcon = (floor: Pick<FloorRegistryEntry, "level">) => {
|
||||||
|
switch (floor.level) {
|
||||||
|
case 0:
|
||||||
|
return "mdi:home-floor-0";
|
||||||
|
case 1:
|
||||||
|
return "mdi:home-floor-1";
|
||||||
|
case 2:
|
||||||
|
return "mdi:home-floor-2";
|
||||||
|
case 3:
|
||||||
|
return "mdi:home-floor-3";
|
||||||
|
case -1:
|
||||||
|
return "mdi:home-floor-negative-1";
|
||||||
|
}
|
||||||
|
return "mdi:home";
|
||||||
|
};
|
||||||
|
|
||||||
@customElement("ha-floor-icon")
|
@customElement("ha-floor-icon")
|
||||||
export class HaFloorIcon extends LitElement {
|
export class HaFloorIcon extends LitElement {
|
||||||
@property({ attribute: false }) public floor!: Pick<
|
@property({ attribute: false }) public floor!: Pick<
|
||||||
|
@ -71,7 +71,9 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
|||||||
></ha-slider>
|
></ha-slider>
|
||||||
</div>
|
</div>
|
||||||
${this.helper
|
${this.helper
|
||||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||||
|
>${this.helper}</ha-input-helper-text
|
||||||
|
>`
|
||||||
: ""}
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
@ -24,12 +24,16 @@ export class HaFormSelect extends LitElement implements HaFormElement {
|
|||||||
|
|
||||||
@property() public helper?: string;
|
@property() public helper?: string;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public localizeValue?: (key: string) => string;
|
||||||
|
|
||||||
@property({ type: Boolean }) public disabled = false;
|
@property({ type: Boolean }) public disabled = false;
|
||||||
|
|
||||||
private _selectSchema = memoizeOne(
|
private _selectSchema = memoizeOne(
|
||||||
(options): SelectSelector => ({
|
(schema: HaFormSelectSchema): SelectSelector => ({
|
||||||
select: {
|
select: {
|
||||||
options: options.map((option) => ({
|
translation_key: schema.name,
|
||||||
|
options: schema.options.map((option) => ({
|
||||||
value: option[0],
|
value: option[0],
|
||||||
label: option[1],
|
label: option[1],
|
||||||
})),
|
})),
|
||||||
@ -41,13 +45,13 @@ export class HaFormSelect extends LitElement implements HaFormElement {
|
|||||||
return html`
|
return html`
|
||||||
<ha-selector-select
|
<ha-selector-select
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.schema=${this.schema}
|
|
||||||
.value=${this.data}
|
.value=${this.data}
|
||||||
.label=${this.label}
|
.label=${this.label}
|
||||||
.helper=${this.helper}
|
.helper=${this.helper}
|
||||||
.disabled=${this.disabled}
|
.disabled=${this.disabled}
|
||||||
.required=${this.schema.required}
|
.required=${this.schema.required || false}
|
||||||
.selector=${this._selectSchema(this.schema.options)}
|
.selector=${this._selectSchema(this.schema)}
|
||||||
|
.localizeValue=${this.localizeValue}
|
||||||
@value-changed=${this._valueChanged}
|
@value-changed=${this._valueChanged}
|
||||||
></ha-selector-select>
|
></ha-selector-select>
|
||||||
`;
|
`;
|
||||||
|
@ -19,6 +19,11 @@ class InputHelperText extends LitElement {
|
|||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
padding-inline-start: 16px;
|
padding-inline-start: 16px;
|
||||||
padding-inline-end: 16px;
|
padding-inline-end: 16px;
|
||||||
|
letter-spacing: var(
|
||||||
|
--mdc-typography-caption-letter-spacing,
|
||||||
|
0.0333333333em
|
||||||
|
);
|
||||||
|
line-height: normal;
|
||||||
}
|
}
|
||||||
:host([disabled]) {
|
:host([disabled]) {
|
||||||
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
|
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
|
||||||
|
@ -122,22 +122,6 @@ export class HaItemDisplayEditor extends LitElement {
|
|||||||
${description
|
${description
|
||||||
? html`<span slot="supporting-text">${description}</span>`
|
? html`<span slot="supporting-text">${description}</span>`
|
||||||
: nothing}
|
: nothing}
|
||||||
${isVisible && !disableSorting
|
|
||||||
? html`
|
|
||||||
<ha-svg-icon
|
|
||||||
tabindex=${ifDefined(
|
|
||||||
this.showNavigationButton ? "0" : undefined
|
|
||||||
)}
|
|
||||||
.idx=${idx}
|
|
||||||
@keydown=${this.showNavigationButton
|
|
||||||
? this._dragHandleKeydown
|
|
||||||
: undefined}
|
|
||||||
class="handle"
|
|
||||||
.path=${mdiDrag}
|
|
||||||
slot="start"
|
|
||||||
></ha-svg-icon>
|
|
||||||
`
|
|
||||||
: html`<ha-svg-icon slot="start"></ha-svg-icon>`}
|
|
||||||
${!showIcon
|
${!showIcon
|
||||||
? nothing
|
? nothing
|
||||||
: icon
|
: icon
|
||||||
@ -162,6 +146,9 @@ export class HaItemDisplayEditor extends LitElement {
|
|||||||
<span slot="end"> ${this.actionsRenderer(item)} </span>
|
<span slot="end"> ${this.actionsRenderer(item)} </span>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
${this.showNavigationButton
|
||||||
|
? html`<ha-icon-next slot="end"></ha-icon-next>`
|
||||||
|
: nothing}
|
||||||
<ha-icon-button
|
<ha-icon-button
|
||||||
.path=${isVisible ? mdiEye : mdiEyeOff}
|
.path=${isVisible ? mdiEye : mdiEyeOff}
|
||||||
slot="end"
|
slot="end"
|
||||||
@ -174,9 +161,22 @@ export class HaItemDisplayEditor extends LitElement {
|
|||||||
.value=${value}
|
.value=${value}
|
||||||
@click=${this._toggle}
|
@click=${this._toggle}
|
||||||
></ha-icon-button>
|
></ha-icon-button>
|
||||||
${this.showNavigationButton
|
${isVisible && !disableSorting
|
||||||
? html` <ha-icon-next slot="end"></ha-icon-next> `
|
? html`
|
||||||
: nothing}
|
<ha-svg-icon
|
||||||
|
tabindex=${ifDefined(
|
||||||
|
this.showNavigationButton ? "0" : undefined
|
||||||
|
)}
|
||||||
|
.idx=${idx}
|
||||||
|
@keydown=${this.showNavigationButton
|
||||||
|
? this._dragHandleKeydown
|
||||||
|
: undefined}
|
||||||
|
class="handle"
|
||||||
|
.path=${mdiDrag}
|
||||||
|
slot="end"
|
||||||
|
></ha-svg-icon>
|
||||||
|
`
|
||||||
|
: html`<ha-svg-icon slot="end"></ha-svg-icon>`}
|
||||||
</ha-md-list-item>
|
</ha-md-list-item>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -47,7 +47,9 @@ class HaLabeledSlider extends LitElement {
|
|||||||
></ha-slider>
|
></ha-slider>
|
||||||
</div>
|
</div>
|
||||||
${this.helper
|
${this.helper
|
||||||
? html`<ha-input-helper-text> ${this.helper} </ha-input-helper-text>`
|
? html`<ha-input-helper-text .disabled=${this.disabled}>
|
||||||
|
${this.helper}
|
||||||
|
</ha-input-helper-text>`
|
||||||
: nothing}
|
: nothing}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -44,7 +44,7 @@ class HaNavigationList extends LitElement {
|
|||||||
>
|
>
|
||||||
<ha-svg-icon .path=${page.iconPath}></ha-svg-icon>
|
<ha-svg-icon .path=${page.iconPath}></ha-svg-icon>
|
||||||
</div>
|
</div>
|
||||||
<span>${page.name}</span>
|
<span slot="headline">${page.name}</span>
|
||||||
${this.hasSecondary
|
${this.hasSecondary
|
||||||
? html`<span slot="supporting-text">${page.description}</span>`
|
? html`<span slot="supporting-text">${page.description}</span>`
|
||||||
: ""}
|
: ""}
|
||||||
|
@ -54,7 +54,7 @@ export class HaAreaSelector extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProperties: PropertyValues): void {
|
protected willUpdate(changedProperties: PropertyValues): void {
|
||||||
if (changedProperties.has("selector") && this.value !== undefined) {
|
if (changedProperties.get("selector") && this.value !== undefined) {
|
||||||
if (this.selector.area?.multiple && !Array.isArray(this.value)) {
|
if (this.selector.area?.multiple && !Array.isArray(this.value)) {
|
||||||
this.value = [this.value];
|
this.value = [this.value];
|
||||||
fireEvent(this, "value-changed", { value: this.value });
|
fireEvent(this, "value-changed", { value: this.value });
|
||||||
|
@ -54,7 +54,9 @@ export class HaDateTimeSelector extends LitElement {
|
|||||||
></ha-time-input>
|
></ha-time-input>
|
||||||
</div>
|
</div>
|
||||||
${this.helper
|
${this.helper
|
||||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||||
|
>${this.helper}</ha-input-helper-text
|
||||||
|
>`
|
||||||
: ""}
|
: ""}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -56,7 +56,7 @@ export class HaDeviceSelector extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProperties: PropertyValues): void {
|
protected willUpdate(changedProperties: PropertyValues): void {
|
||||||
if (changedProperties.has("selector") && this.value !== undefined) {
|
if (changedProperties.get("selector") && this.value !== undefined) {
|
||||||
if (this.selector.device?.multiple && !Array.isArray(this.value)) {
|
if (this.selector.device?.multiple && !Array.isArray(this.value)) {
|
||||||
this.value = [this.value];
|
this.value = [this.value];
|
||||||
fireEvent(this, "value-changed", { value: this.value });
|
fireEvent(this, "value-changed", { value: this.value });
|
||||||
|
@ -43,7 +43,7 @@ export class HaEntitySelector extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProperties: PropertyValues): void {
|
protected willUpdate(changedProperties: PropertyValues): void {
|
||||||
if (changedProperties.has("selector") && this.value !== undefined) {
|
if (changedProperties.get("selector") && this.value !== undefined) {
|
||||||
if (this.selector.entity?.multiple && !Array.isArray(this.value)) {
|
if (this.selector.entity?.multiple && !Array.isArray(this.value)) {
|
||||||
this.value = [this.value];
|
this.value = [this.value];
|
||||||
fireEvent(this, "value-changed", { value: this.value });
|
fireEvent(this, "value-changed", { value: this.value });
|
||||||
|
@ -54,7 +54,7 @@ export class HaFloorSelector extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected willUpdate(changedProperties: PropertyValues): void {
|
protected willUpdate(changedProperties: PropertyValues): void {
|
||||||
if (changedProperties.has("selector") && this.value !== undefined) {
|
if (changedProperties.get("selector") && this.value !== undefined) {
|
||||||
if (this.selector.floor?.multiple && !Array.isArray(this.value)) {
|
if (this.selector.floor?.multiple && !Array.isArray(this.value)) {
|
||||||
this.value = [this.value];
|
this.value = [this.value];
|
||||||
fireEvent(this, "value-changed", { value: this.value });
|
fireEvent(this, "value-changed", { value: this.value });
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { mdiPlayBox, mdiPlus } from "@mdi/js";
|
import { mdiPlayBox, mdiPlus } from "@mdi/js";
|
||||||
import type { PropertyValues } from "lit";
|
import type { PropertyValues } from "lit";
|
||||||
import { css, html, LitElement } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { classMap } from "lit/directives/class-map";
|
import { classMap } from "lit/directives/class-map";
|
||||||
import { fireEvent } from "../../common/dom/fire_event";
|
import { fireEvent } from "../../common/dom/fire_event";
|
||||||
@ -24,6 +24,10 @@ const MANUAL_SCHEMA = [
|
|||||||
{ name: "media_content_type", required: false, selector: { text: {} } },
|
{ name: "media_content_type", required: false, selector: { text: {} } },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const INCLUDE_DOMAINS = ["media_player"];
|
||||||
|
|
||||||
|
const EMPTY_FORM = {};
|
||||||
|
|
||||||
@customElement("ha-selector-media")
|
@customElement("ha-selector-media")
|
||||||
export class HaMediaSelector extends LitElement {
|
export class HaMediaSelector extends LitElement {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
@ -84,85 +88,104 @@ export class HaMediaSelector extends LitElement {
|
|||||||
(stateObj &&
|
(stateObj &&
|
||||||
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
|
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
|
||||||
|
|
||||||
return html`<ha-entity-picker
|
const hasAccept = this.selector?.media?.accept?.length;
|
||||||
.hass=${this.hass}
|
|
||||||
.value=${this.value?.entity_id}
|
return html`
|
||||||
.label=${this.label ||
|
${hasAccept
|
||||||
this.hass.localize("ui.components.selectors.media.pick_media_player")}
|
? nothing
|
||||||
.disabled=${this.disabled}
|
: html`
|
||||||
.helper=${this.helper}
|
<ha-entity-picker
|
||||||
.required=${this.required}
|
.hass=${this.hass}
|
||||||
include-domains='["media_player"]'
|
.value=${this.value?.entity_id}
|
||||||
allow-custom-entity
|
.label=${this.label ||
|
||||||
@value-changed=${this._entityChanged}
|
this.hass.localize(
|
||||||
></ha-entity-picker>
|
"ui.components.selectors.media.pick_media_player"
|
||||||
|
)}
|
||||||
|
.disabled=${this.disabled}
|
||||||
|
.helper=${this.helper}
|
||||||
|
.required=${this.required}
|
||||||
|
.includeDomains=${INCLUDE_DOMAINS}
|
||||||
|
allow-custom-entity
|
||||||
|
@value-changed=${this._entityChanged}
|
||||||
|
></ha-entity-picker>
|
||||||
|
`}
|
||||||
${!supportsBrowse
|
${!supportsBrowse
|
||||||
? html`<ha-alert>
|
? html`
|
||||||
|
<ha-alert>
|
||||||
${this.hass.localize(
|
${this.hass.localize(
|
||||||
"ui.components.selectors.media.browse_not_supported"
|
"ui.components.selectors.media.browse_not_supported"
|
||||||
)}
|
)}
|
||||||
</ha-alert>
|
</ha-alert>
|
||||||
<ha-form
|
<ha-form
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.data=${this.value}
|
.data=${this.value || EMPTY_FORM}
|
||||||
.schema=${MANUAL_SCHEMA}
|
.schema=${MANUAL_SCHEMA}
|
||||||
.computeLabel=${this._computeLabelCallback}
|
.computeLabel=${this._computeLabelCallback}
|
||||||
></ha-form>`
|
></ha-form>
|
||||||
: html`<ha-card
|
`
|
||||||
outlined
|
: html`
|
||||||
@click=${this._pickMedia}
|
<ha-card
|
||||||
class=${this.disabled || !this.value?.entity_id ? "disabled" : ""}
|
outlined
|
||||||
>
|
tabindex="0"
|
||||||
<div
|
role="button"
|
||||||
class="thumbnail ${classMap({
|
aria-label=${!this.value?.media_content_id
|
||||||
portrait:
|
|
||||||
!!this.value?.metadata?.media_class &&
|
|
||||||
MediaClassBrowserSettings[
|
|
||||||
this.value.metadata.children_media_class ||
|
|
||||||
this.value.metadata.media_class
|
|
||||||
].thumbnail_ratio === "portrait",
|
|
||||||
})}"
|
|
||||||
>
|
|
||||||
${this.value?.metadata?.thumbnail
|
|
||||||
? html`
|
|
||||||
<div
|
|
||||||
class="${classMap({
|
|
||||||
"centered-image":
|
|
||||||
!!this.value.metadata.media_class &&
|
|
||||||
["app", "directory"].includes(
|
|
||||||
this.value.metadata.media_class
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
image"
|
|
||||||
style=${this._thumbnailUrl
|
|
||||||
? `background-image: url(${this._thumbnailUrl});`
|
|
||||||
: ""}
|
|
||||||
></div>
|
|
||||||
`
|
|
||||||
: html`
|
|
||||||
<div class="icon-holder image">
|
|
||||||
<ha-svg-icon
|
|
||||||
class="folder"
|
|
||||||
.path=${!this.value?.media_content_id
|
|
||||||
? mdiPlus
|
|
||||||
: this.value?.metadata?.media_class
|
|
||||||
? MediaClassBrowserSettings[
|
|
||||||
this.value.metadata.media_class === "directory"
|
|
||||||
? this.value.metadata.children_media_class ||
|
|
||||||
this.value.metadata.media_class
|
|
||||||
: this.value.metadata.media_class
|
|
||||||
].icon
|
|
||||||
: mdiPlayBox}
|
|
||||||
></ha-svg-icon>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
<div class="title">
|
|
||||||
${!this.value?.media_content_id
|
|
||||||
? this.hass.localize("ui.components.selectors.media.pick_media")
|
? this.hass.localize("ui.components.selectors.media.pick_media")
|
||||||
: this.value.metadata?.title || this.value.media_content_id}
|
: this.value.metadata?.title || this.value.media_content_id}
|
||||||
</div>
|
@click=${this._pickMedia}
|
||||||
</ha-card>`}`;
|
@keydown=${this._handleKeyDown}
|
||||||
|
class=${this.disabled || (!this.value?.entity_id && !hasAccept)
|
||||||
|
? "disabled"
|
||||||
|
: ""}
|
||||||
|
>
|
||||||
|
<div class="content-container">
|
||||||
|
<div class="thumbnail">
|
||||||
|
${this.value?.metadata?.thumbnail
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
class="${classMap({
|
||||||
|
"centered-image":
|
||||||
|
!!this.value.metadata.media_class &&
|
||||||
|
["app", "directory"].includes(
|
||||||
|
this.value.metadata.media_class
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
image"
|
||||||
|
style=${this._thumbnailUrl
|
||||||
|
? `background-image: url(${this._thumbnailUrl});`
|
||||||
|
: ""}
|
||||||
|
></div>
|
||||||
|
`
|
||||||
|
: html`
|
||||||
|
<div class="icon-holder image">
|
||||||
|
<ha-svg-icon
|
||||||
|
class="folder"
|
||||||
|
.path=${!this.value?.media_content_id
|
||||||
|
? mdiPlus
|
||||||
|
: this.value?.metadata?.media_class
|
||||||
|
? MediaClassBrowserSettings[
|
||||||
|
this.value.metadata.media_class ===
|
||||||
|
"directory"
|
||||||
|
? this.value.metadata
|
||||||
|
.children_media_class ||
|
||||||
|
this.value.metadata.media_class
|
||||||
|
: this.value.metadata.media_class
|
||||||
|
].icon
|
||||||
|
: mdiPlayBox}
|
||||||
|
></ha-svg-icon>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
<div class="title">
|
||||||
|
${!this.value?.media_content_id
|
||||||
|
? this.hass.localize(
|
||||||
|
"ui.components.selectors.media.pick_media"
|
||||||
|
)
|
||||||
|
: this.value.metadata?.title || this.value.media_content_id}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ha-card>
|
||||||
|
`}
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _computeLabelCallback = (
|
private _computeLabelCallback = (
|
||||||
@ -184,8 +207,9 @@ export class HaMediaSelector extends LitElement {
|
|||||||
private _pickMedia() {
|
private _pickMedia() {
|
||||||
showMediaBrowserDialog(this, {
|
showMediaBrowserDialog(this, {
|
||||||
action: "pick",
|
action: "pick",
|
||||||
entityId: this.value!.entity_id!,
|
entityId: this.value?.entity_id,
|
||||||
navigateIds: this.value!.metadata?.navigateIds,
|
navigateIds: this.value?.metadata?.navigateIds,
|
||||||
|
accept: this.selector.media?.accept,
|
||||||
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
|
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => {
|
||||||
fireEvent(this, "value-changed", {
|
fireEvent(this, "value-changed", {
|
||||||
value: {
|
value: {
|
||||||
@ -208,6 +232,13 @@ export class HaMediaSelector extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _handleKeyDown(ev: KeyboardEvent) {
|
||||||
|
if (ev.key === "Enter" || ev.key === " ") {
|
||||||
|
ev.preventDefault();
|
||||||
|
this._pickMedia();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
ha-entity-picker {
|
ha-entity-picker {
|
||||||
display: block;
|
display: block;
|
||||||
@ -222,41 +253,52 @@ export class HaMediaSelector extends LitElement {
|
|||||||
}
|
}
|
||||||
ha-card {
|
ha-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 200px;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background-color 180ms ease-in-out;
|
||||||
|
min-height: 56px;
|
||||||
|
}
|
||||||
|
ha-card:hover:not(.disabled),
|
||||||
|
ha-card:focus:not(.disabled) {
|
||||||
|
background-color: var(--state-icon-hover-color, rgba(0, 0, 0, 0.04));
|
||||||
|
}
|
||||||
|
ha-card:focus {
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
ha-card.disabled {
|
ha-card.disabled {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
color: var(--disabled-text-color);
|
color: var(--disabled-text-color);
|
||||||
}
|
}
|
||||||
|
.content-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
ha-card .thumbnail {
|
ha-card .thumbnail {
|
||||||
width: 100%;
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
transition: padding-bottom 0.1s ease-out;
|
border-radius: 8px;
|
||||||
padding-bottom: 100%;
|
overflow: hidden;
|
||||||
}
|
|
||||||
ha-card .thumbnail.portrait {
|
|
||||||
padding-bottom: 150%;
|
|
||||||
}
|
}
|
||||||
ha-card .image {
|
ha-card .image {
|
||||||
border-radius: 3px 3px 0 0;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
.folder {
|
.folder {
|
||||||
--mdc-icon-size: calc(var(--media-browse-item-size, 175px) * 0.4);
|
--mdc-icon-size: 24px;
|
||||||
}
|
}
|
||||||
.title {
|
.title {
|
||||||
font-size: var(--ha-font-size-l);
|
font-size: var(--ha-font-size-m);
|
||||||
padding-top: 16px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
margin-bottom: 16px;
|
|
||||||
padding-left: 16px;
|
|
||||||
padding-right: 4px;
|
|
||||||
padding-inline-start: 16px;
|
|
||||||
padding-inline-end: 4px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
line-height: 1.4;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.image {
|
.image {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@ -269,13 +311,15 @@ export class HaMediaSelector extends LitElement {
|
|||||||
background-position: center;
|
background-position: center;
|
||||||
}
|
}
|
||||||
.centered-image {
|
.centered-image {
|
||||||
margin: 0 8px;
|
margin: 4px;
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
}
|
}
|
||||||
.icon-holder {
|
.icon-holder {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,9 @@ export class HaNumberSelector extends LitElement {
|
|||||||
|
|
||||||
@property() public helper?: string;
|
@property() public helper?: string;
|
||||||
|
|
||||||
|
@property({ attribute: false })
|
||||||
|
public localizeValue?: (key: string) => string;
|
||||||
|
|
||||||
@property({ type: Boolean }) public required = true;
|
@property({ type: Boolean }) public required = true;
|
||||||
|
|
||||||
@property({ type: Boolean }) public disabled = false;
|
@property({ type: Boolean }) public disabled = false;
|
||||||
@ -60,6 +63,14 @@ export class HaNumberSelector extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const translationKey = this.selector.number?.translation_key;
|
||||||
|
let unit = this.selector.number?.unit_of_measurement;
|
||||||
|
if (isBox && unit && this.localizeValue && translationKey) {
|
||||||
|
unit =
|
||||||
|
this.localizeValue(`${translationKey}.unit_of_measurement.${unit}`) ||
|
||||||
|
unit;
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
${this.label && !isBox
|
${this.label && !isBox
|
||||||
? html`${this.label}${this.required ? "*" : ""}`
|
? html`${this.label}${this.required ? "*" : ""}`
|
||||||
@ -97,7 +108,7 @@ export class HaNumberSelector extends LitElement {
|
|||||||
.helper=${isBox ? this.helper : undefined}
|
.helper=${isBox ? this.helper : undefined}
|
||||||
.disabled=${this.disabled}
|
.disabled=${this.disabled}
|
||||||
.required=${this.required}
|
.required=${this.required}
|
||||||
.suffix=${this.selector.number?.unit_of_measurement}
|
.suffix=${unit}
|
||||||
type="number"
|
type="number"
|
||||||
autoValidate
|
autoValidate
|
||||||
?no-spinner=${!isBox}
|
?no-spinner=${!isBox}
|
||||||
@ -106,7 +117,9 @@ export class HaNumberSelector extends LitElement {
|
|||||||
</ha-textfield>
|
</ha-textfield>
|
||||||
</div>
|
</div>
|
||||||
${!isBox && this.helper
|
${!isBox && this.helper
|
||||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||||
|
>${this.helper}</ha-input-helper-text
|
||||||
|
>`
|
||||||
: nothing}
|
: nothing}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,27 @@
|
|||||||
import type { PropertyValues } from "lit";
|
import { mdiClose, mdiDelete, mdiDrag, mdiPencil } from "@mdi/js";
|
||||||
import { html, LitElement } from "lit";
|
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||||
import { customElement, property, query } from "lit/decorators";
|
import { customElement, property, query } from "lit/decorators";
|
||||||
|
import memoizeOne from "memoize-one";
|
||||||
|
import { ensureArray } from "../../common/array/ensure-array";
|
||||||
import { fireEvent } from "../../common/dom/fire_event";
|
import { fireEvent } from "../../common/dom/fire_event";
|
||||||
|
import type { ObjectSelector } from "../../data/selector";
|
||||||
|
import { formatSelectorValue } from "../../data/selector/format_selector_value";
|
||||||
|
import { showFormDialog } from "../../dialogs/form/show-form-dialog";
|
||||||
import type { HomeAssistant } from "../../types";
|
import type { HomeAssistant } from "../../types";
|
||||||
import "../ha-yaml-editor";
|
import type { HaFormSchema } from "../ha-form/types";
|
||||||
import "../ha-input-helper-text";
|
import "../ha-input-helper-text";
|
||||||
|
import "../ha-md-list";
|
||||||
|
import "../ha-md-list-item";
|
||||||
|
import "../ha-sortable";
|
||||||
|
import "../ha-yaml-editor";
|
||||||
import type { HaYamlEditor } from "../ha-yaml-editor";
|
import type { HaYamlEditor } from "../ha-yaml-editor";
|
||||||
|
|
||||||
@customElement("ha-selector-object")
|
@customElement("ha-selector-object")
|
||||||
export class HaObjectSelector extends LitElement {
|
export class HaObjectSelector extends LitElement {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public selector!: ObjectSelector;
|
||||||
|
|
||||||
@property() public value?: any;
|
@property() public value?: any;
|
||||||
|
|
||||||
@property() public label?: string;
|
@property() public label?: string;
|
||||||
@ -23,11 +34,132 @@ export class HaObjectSelector extends LitElement {
|
|||||||
|
|
||||||
@property({ type: Boolean }) public required = true;
|
@property({ type: Boolean }) public required = true;
|
||||||
|
|
||||||
@query("ha-yaml-editor", true) private _yamlEditor!: HaYamlEditor;
|
@property({ attribute: false }) public localizeValue?: (
|
||||||
|
key: string
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
|
||||||
|
|
||||||
private _valueChangedFromChild = false;
|
private _valueChangedFromChild = false;
|
||||||
|
|
||||||
|
private _computeLabel = (schema: HaFormSchema): string => {
|
||||||
|
const translationKey = this.selector.object?.translation_key;
|
||||||
|
|
||||||
|
if (this.localizeValue && translationKey) {
|
||||||
|
const label = this.localizeValue(
|
||||||
|
`${translationKey}.fields.${schema.name}`
|
||||||
|
);
|
||||||
|
if (label) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.selector.object?.fields?.[schema.name]?.label || schema.name;
|
||||||
|
};
|
||||||
|
|
||||||
|
private _renderItem(item: any, index: number) {
|
||||||
|
const labelField =
|
||||||
|
this.selector.object!.label_field ||
|
||||||
|
Object.keys(this.selector.object!.fields!)[0];
|
||||||
|
|
||||||
|
const labelSelector = this.selector.object!.fields![labelField].selector;
|
||||||
|
|
||||||
|
const label = labelSelector
|
||||||
|
? formatSelectorValue(this.hass, item[labelField], labelSelector)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
let description = "";
|
||||||
|
|
||||||
|
const descriptionField = this.selector.object!.description_field;
|
||||||
|
if (descriptionField) {
|
||||||
|
const descriptionSelector =
|
||||||
|
this.selector.object!.fields![descriptionField].selector;
|
||||||
|
|
||||||
|
description = descriptionSelector
|
||||||
|
? formatSelectorValue(
|
||||||
|
this.hass,
|
||||||
|
item[descriptionField],
|
||||||
|
descriptionSelector
|
||||||
|
)
|
||||||
|
: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderable = this.selector.object!.multiple || false;
|
||||||
|
const multiple = this.selector.object!.multiple || false;
|
||||||
|
return html`
|
||||||
|
<ha-md-list-item class="item">
|
||||||
|
${reorderable
|
||||||
|
? html`
|
||||||
|
<ha-svg-icon
|
||||||
|
class="handle"
|
||||||
|
.path=${mdiDrag}
|
||||||
|
slot="start"
|
||||||
|
></ha-svg-icon>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
<div slot="headline" class="label">${label}</div>
|
||||||
|
${description
|
||||||
|
? html`<div slot="supporting-text" class="description">
|
||||||
|
${description}
|
||||||
|
</div>`
|
||||||
|
: nothing}
|
||||||
|
<ha-icon-button
|
||||||
|
slot="end"
|
||||||
|
.item=${item}
|
||||||
|
.index=${index}
|
||||||
|
.label=${this.hass.localize("ui.common.edit")}
|
||||||
|
.path=${mdiPencil}
|
||||||
|
@click=${this._editItem}
|
||||||
|
></ha-icon-button>
|
||||||
|
<ha-icon-button
|
||||||
|
slot="end"
|
||||||
|
.index=${index}
|
||||||
|
.label=${this.hass.localize("ui.common.delete")}
|
||||||
|
.path=${multiple ? mdiDelete : mdiClose}
|
||||||
|
@click=${this._deleteItem}
|
||||||
|
></ha-icon-button>
|
||||||
|
</ha-md-list-item>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
|
if (this.selector.object?.fields) {
|
||||||
|
if (this.selector.object.multiple) {
|
||||||
|
const items = ensureArray(this.value ?? []);
|
||||||
|
return html`
|
||||||
|
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||||
|
<div class="items-container">
|
||||||
|
<ha-sortable
|
||||||
|
handle-selector=".handle"
|
||||||
|
draggable-selector=".item"
|
||||||
|
@item-moved=${this._itemMoved}
|
||||||
|
>
|
||||||
|
<ha-md-list>
|
||||||
|
${items.map((item, index) => this._renderItem(item, index))}
|
||||||
|
</ha-md-list>
|
||||||
|
</ha-sortable>
|
||||||
|
<ha-button outlined @click=${this._addItem}>
|
||||||
|
${this.hass.localize("ui.common.add")}
|
||||||
|
</ha-button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||||
|
<div class="items-container">
|
||||||
|
${this.value
|
||||||
|
? html`<ha-md-list>
|
||||||
|
${this._renderItem(this.value, 0)}
|
||||||
|
</ha-md-list>`
|
||||||
|
: html`
|
||||||
|
<ha-button outlined @click=${this._addItem}>
|
||||||
|
${this.hass.localize("ui.common.add")}
|
||||||
|
</ha-button>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
return html`<ha-yaml-editor
|
return html`<ha-yaml-editor
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.readonly=${this.disabled}
|
.readonly=${this.disabled}
|
||||||
@ -38,13 +170,109 @@ export class HaObjectSelector extends LitElement {
|
|||||||
@value-changed=${this._handleChange}
|
@value-changed=${this._handleChange}
|
||||||
></ha-yaml-editor>
|
></ha-yaml-editor>
|
||||||
${this.helper
|
${this.helper
|
||||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||||
|
>${this.helper}</ha-input-helper-text
|
||||||
|
>`
|
||||||
: ""} `;
|
: ""} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _schema = memoizeOne((selector: ObjectSelector) => {
|
||||||
|
if (!selector.object || !selector.object.fields) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return Object.entries(selector.object.fields).map(([key, field]) => ({
|
||||||
|
name: key,
|
||||||
|
selector: field.selector,
|
||||||
|
required: field.required ?? false,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
private _itemMoved(ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const newIndex = ev.detail.newIndex;
|
||||||
|
const oldIndex = ev.detail.oldIndex;
|
||||||
|
if (!this.selector.object!.multiple) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const newValue = ensureArray(this.value ?? []).concat();
|
||||||
|
const item = newValue.splice(oldIndex, 1)[0];
|
||||||
|
newValue.splice(newIndex, 0, item);
|
||||||
|
fireEvent(this, "value-changed", { value: newValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _addItem(ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
const newItem = await showFormDialog(this, {
|
||||||
|
title: this.hass.localize("ui.common.add"),
|
||||||
|
schema: this._schema(this.selector),
|
||||||
|
data: {},
|
||||||
|
computeLabel: this._computeLabel,
|
||||||
|
submitText: this.hass.localize("ui.common.add"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (newItem === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.selector.object!.multiple) {
|
||||||
|
fireEvent(this, "value-changed", { value: newItem });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = ensureArray(this.value ?? []).concat();
|
||||||
|
newValue.push(newItem);
|
||||||
|
fireEvent(this, "value-changed", { value: newValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _editItem(ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const item = ev.currentTarget.item;
|
||||||
|
const index = ev.currentTarget.index;
|
||||||
|
|
||||||
|
const updatedItem = await showFormDialog(this, {
|
||||||
|
title: this.hass.localize("ui.common.edit"),
|
||||||
|
schema: this._schema(this.selector),
|
||||||
|
data: item,
|
||||||
|
computeLabel: this._computeLabel,
|
||||||
|
submitText: this.hass.localize("ui.common.save"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updatedItem === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.selector.object!.multiple) {
|
||||||
|
fireEvent(this, "value-changed", { value: updatedItem });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = ensureArray(this.value ?? []).concat();
|
||||||
|
newValue[index] = updatedItem;
|
||||||
|
fireEvent(this, "value-changed", { value: newValue });
|
||||||
|
}
|
||||||
|
|
||||||
|
private _deleteItem(ev) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
const index = ev.currentTarget.index;
|
||||||
|
|
||||||
|
if (!this.selector.object!.multiple) {
|
||||||
|
fireEvent(this, "value-changed", { value: undefined });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = ensureArray(this.value ?? []).concat();
|
||||||
|
newValue.splice(index, 1);
|
||||||
|
fireEvent(this, "value-changed", { value: newValue });
|
||||||
|
}
|
||||||
|
|
||||||
protected updated(changedProps: PropertyValues) {
|
protected updated(changedProps: PropertyValues) {
|
||||||
super.updated(changedProps);
|
super.updated(changedProps);
|
||||||
if (changedProps.has("value") && !this._valueChangedFromChild) {
|
if (
|
||||||
|
changedProps.has("value") &&
|
||||||
|
!this._valueChangedFromChild &&
|
||||||
|
this._yamlEditor
|
||||||
|
) {
|
||||||
this._yamlEditor.setValue(this.value);
|
this._yamlEditor.setValue(this.value);
|
||||||
}
|
}
|
||||||
this._valueChangedFromChild = false;
|
this._valueChangedFromChild = false;
|
||||||
@ -61,6 +289,42 @@ export class HaObjectSelector extends LitElement {
|
|||||||
}
|
}
|
||||||
fireEvent(this, "value-changed", { value });
|
fireEvent(this, "value-changed", { value });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get styles() {
|
||||||
|
return [
|
||||||
|
css`
|
||||||
|
ha-md-list {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
ha-md-list-item {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
--ha-md-list-item-gap: 0;
|
||||||
|
--md-list-item-top-space: 0;
|
||||||
|
--md-list-item-bottom-space: 0;
|
||||||
|
--md-list-item-leading-space: 12px;
|
||||||
|
--md-list-item-trailing-space: 4px;
|
||||||
|
--md-list-item-two-line-container-height: 48px;
|
||||||
|
--md-list-item-one-line-container-height: 48px;
|
||||||
|
}
|
||||||
|
.handle {
|
||||||
|
cursor: move;
|
||||||
|
padding: 8px;
|
||||||
|
margin-inline-start: -8px;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
ha-md-list-item .label,
|
||||||
|
ha-md-list-item .description {
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
@ -285,7 +285,9 @@ export class HaSelectSelector extends LitElement {
|
|||||||
|
|
||||||
private _renderHelper() {
|
private _renderHelper() {
|
||||||
return this.helper
|
return this.helper
|
||||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||||
|
>${this.helper}</ha-input-helper-text
|
||||||
|
>`
|
||||||
: "";
|
: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -80,7 +80,16 @@ const SELECTOR_SCHEMAS = {
|
|||||||
] as const,
|
] as const,
|
||||||
icon: [] as const,
|
icon: [] as const,
|
||||||
location: [] as const,
|
location: [] as const,
|
||||||
media: [] as const,
|
media: [
|
||||||
|
{
|
||||||
|
name: "accept",
|
||||||
|
selector: {
|
||||||
|
text: {
|
||||||
|
multiple: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as const,
|
||||||
number: [
|
number: [
|
||||||
{
|
{
|
||||||
name: "min",
|
name: "min",
|
||||||
|
@ -63,7 +63,9 @@ export class HaTemplateSelector extends LitElement {
|
|||||||
linewrap
|
linewrap
|
||||||
></ha-code-editor>
|
></ha-code-editor>
|
||||||
${this.helper
|
${this.helper
|
||||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||||
|
>${this.helper}</ha-input-helper-text
|
||||||
|
>`
|
||||||
: nothing}
|
: nothing}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -276,6 +276,16 @@ export class HaServiceControl extends LitElement {
|
|||||||
|
|
||||||
private _getTargetedEntities = memoizeOne((target, value) => {
|
private _getTargetedEntities = memoizeOne((target, value) => {
|
||||||
const targetSelector = target ? { target } : { target: {} };
|
const targetSelector = target ? { target } : { target: {} };
|
||||||
|
if (
|
||||||
|
hasTemplate(value?.target) ||
|
||||||
|
hasTemplate(value?.data?.entity_id) ||
|
||||||
|
hasTemplate(value?.data?.device_id) ||
|
||||||
|
hasTemplate(value?.data?.area_id) ||
|
||||||
|
hasTemplate(value?.data?.floor_id) ||
|
||||||
|
hasTemplate(value?.data?.label_id)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const targetEntities =
|
const targetEntities =
|
||||||
ensureArray(
|
ensureArray(
|
||||||
value?.target?.entity_id || value?.data?.entity_id
|
value?.target?.entity_id || value?.data?.entity_id
|
||||||
@ -349,8 +359,11 @@ export class HaServiceControl extends LitElement {
|
|||||||
|
|
||||||
private _filterField(
|
private _filterField(
|
||||||
filter: ExtHassService["fields"][number]["filter"],
|
filter: ExtHassService["fields"][number]["filter"],
|
||||||
targetEntities: string[]
|
targetEntities: string[] | null
|
||||||
) {
|
) {
|
||||||
|
if (targetEntities === null) {
|
||||||
|
return true; // Target is a template, show all fields
|
||||||
|
}
|
||||||
if (!targetEntities.length) {
|
if (!targetEntities.length) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -386,8 +399,21 @@ export class HaServiceControl extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _targetSelector = memoizeOne(
|
private _targetSelector = memoizeOne(
|
||||||
(targetSelector: TargetSelector | null | undefined) =>
|
(targetSelector: TargetSelector | null | undefined, value) => {
|
||||||
targetSelector ? { target: { ...targetSelector } } : { target: {} }
|
if (!value || (typeof value === "object" && !Object.keys(value).length)) {
|
||||||
|
delete this._stickySelector.target;
|
||||||
|
} else if (hasTemplate(value)) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
this._stickySelector.target = { template: null };
|
||||||
|
} else {
|
||||||
|
this._stickySelector.target = { object: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this._stickySelector.target ??
|
||||||
|
(targetSelector ? { target: { ...targetSelector } } : { target: {} })
|
||||||
|
);
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
@ -482,7 +508,8 @@ export class HaServiceControl extends LitElement {
|
|||||||
><ha-selector
|
><ha-selector
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.selector=${this._targetSelector(
|
.selector=${this._targetSelector(
|
||||||
serviceData.target as TargetSelector
|
serviceData.target as TargetSelector,
|
||||||
|
this._value?.target
|
||||||
)}
|
)}
|
||||||
.disabled=${this.disabled}
|
.disabled=${this.disabled}
|
||||||
@value-changed=${this._targetChanged}
|
@value-changed=${this._targetChanged}
|
||||||
@ -575,7 +602,7 @@ export class HaServiceControl extends LitElement {
|
|||||||
|
|
||||||
private _hasFilteredFields(
|
private _hasFilteredFields(
|
||||||
dataFields: ExtHassService["fields"],
|
dataFields: ExtHassService["fields"],
|
||||||
targetEntities: string[]
|
targetEntities: string[] | null
|
||||||
) {
|
) {
|
||||||
return dataFields.some(
|
return dataFields.some(
|
||||||
(dataField) =>
|
(dataField) =>
|
||||||
@ -588,7 +615,7 @@ export class HaServiceControl extends LitElement {
|
|||||||
hasOptional: boolean,
|
hasOptional: boolean,
|
||||||
domain: string | undefined,
|
domain: string | undefined,
|
||||||
serviceName: string | undefined,
|
serviceName: string | undefined,
|
||||||
targetEntities: string[]
|
targetEntities: string[] | null
|
||||||
) => {
|
) => {
|
||||||
if (
|
if (
|
||||||
dataField.filter &&
|
dataField.filter &&
|
||||||
@ -822,6 +849,10 @@ export class HaServiceControl extends LitElement {
|
|||||||
|
|
||||||
private _targetChanged(ev: CustomEvent) {
|
private _targetChanged(ev: CustomEvent) {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
|
if (ev.detail.isValid === false) {
|
||||||
|
// Don't clear an object selector that returns invalid YAML
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newValue = ev.detail.value;
|
const newValue = ev.detail.value;
|
||||||
if (this._value?.target === newValue) {
|
if (this._value?.target === newValue) {
|
||||||
return;
|
return;
|
||||||
|
@ -89,6 +89,7 @@ export class HaSettingsRow extends LitElement {
|
|||||||
display: var(--settings-row-content-display, flex);
|
display: var(--settings-row-content-display, flex);
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
}
|
}
|
||||||
.content ::slotted(*) {
|
.content ::slotted(*) {
|
||||||
|
@ -289,7 +289,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
|||||||
${this._renderPicker()}
|
${this._renderPicker()}
|
||||||
</div>
|
</div>
|
||||||
${this.helper
|
${this.helper
|
||||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||||
|
>${this.helper}</ha-input-helper-text
|
||||||
|
>`
|
||||||
: ""}
|
: ""}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ import { showToast } from "../util/toast";
|
|||||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||||
import type { HaCodeEditor } from "./ha-code-editor";
|
import type { HaCodeEditor } from "./ha-code-editor";
|
||||||
import "./ha-button";
|
import "./ha-button";
|
||||||
|
import "./ha-alert";
|
||||||
|
|
||||||
const isEmpty = (obj: Record<string, unknown>): boolean => {
|
const isEmpty = (obj: Record<string, unknown>): boolean => {
|
||||||
if (typeof obj !== "object" || obj === null) {
|
if (typeof obj !== "object" || obj === null) {
|
||||||
@ -43,6 +44,9 @@ export class HaYamlEditor extends LitElement {
|
|||||||
|
|
||||||
@property({ attribute: "read-only", type: Boolean }) public readOnly = false;
|
@property({ attribute: "read-only", type: Boolean }) public readOnly = false;
|
||||||
|
|
||||||
|
@property({ type: Boolean, attribute: "disable-fullscreen" })
|
||||||
|
public disableFullscreen = false;
|
||||||
|
|
||||||
@property({ type: Boolean }) public required = false;
|
@property({ type: Boolean }) public required = false;
|
||||||
|
|
||||||
@property({ attribute: "copy-clipboard", type: Boolean })
|
@property({ attribute: "copy-clipboard", type: Boolean })
|
||||||
@ -51,8 +55,15 @@ export class HaYamlEditor extends LitElement {
|
|||||||
@property({ attribute: "has-extra-actions", type: Boolean })
|
@property({ attribute: "has-extra-actions", type: Boolean })
|
||||||
public hasExtraActions = false;
|
public hasExtraActions = false;
|
||||||
|
|
||||||
|
@property({ attribute: "show-errors", type: Boolean })
|
||||||
|
public showErrors = true;
|
||||||
|
|
||||||
@state() private _yaml = "";
|
@state() private _yaml = "";
|
||||||
|
|
||||||
|
@state() private _error = "";
|
||||||
|
|
||||||
|
@state() private _showingError = false;
|
||||||
|
|
||||||
@query("ha-code-editor") _codeEditor?: HaCodeEditor;
|
@query("ha-code-editor") _codeEditor?: HaCodeEditor;
|
||||||
|
|
||||||
public setValue(value): void {
|
public setValue(value): void {
|
||||||
@ -102,13 +113,18 @@ export class HaYamlEditor extends LitElement {
|
|||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.value=${this._yaml}
|
.value=${this._yaml}
|
||||||
.readOnly=${this.readOnly}
|
.readOnly=${this.readOnly}
|
||||||
|
.disableFullscreen=${this.disableFullscreen}
|
||||||
mode="yaml"
|
mode="yaml"
|
||||||
autocomplete-entities
|
autocomplete-entities
|
||||||
autocomplete-icons
|
autocomplete-icons
|
||||||
.error=${this.isValid === false}
|
.error=${this.isValid === false}
|
||||||
@value-changed=${this._onChange}
|
@value-changed=${this._onChange}
|
||||||
|
@blur=${this._onBlur}
|
||||||
dir="ltr"
|
dir="ltr"
|
||||||
></ha-code-editor>
|
></ha-code-editor>
|
||||||
|
${this._showingError
|
||||||
|
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||||
|
: nothing}
|
||||||
${this.copyClipboard || this.hasExtraActions
|
${this.copyClipboard || this.hasExtraActions
|
||||||
? html`
|
? html`
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
@ -146,6 +162,10 @@ export class HaYamlEditor extends LitElement {
|
|||||||
} else {
|
} else {
|
||||||
parsed = {};
|
parsed = {};
|
||||||
}
|
}
|
||||||
|
this._error = errorMsg ?? "";
|
||||||
|
if (isValid) {
|
||||||
|
this._showingError = false;
|
||||||
|
}
|
||||||
|
|
||||||
this.value = parsed;
|
this.value = parsed;
|
||||||
this.isValid = isValid;
|
this.isValid = isValid;
|
||||||
@ -157,6 +177,12 @@ export class HaYamlEditor extends LitElement {
|
|||||||
} as any);
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _onBlur(): void {
|
||||||
|
if (this.showErrors && this._error) {
|
||||||
|
this._showingError = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get yaml() {
|
get yaml() {
|
||||||
return this._yaml;
|
return this._yaml;
|
||||||
}
|
}
|
||||||
|
@ -164,6 +164,7 @@ class DialogMediaPlayerBrowse extends LitElement {
|
|||||||
.navigateIds=${this._navigateIds}
|
.navigateIds=${this._navigateIds}
|
||||||
.action=${this._action}
|
.action=${this._action}
|
||||||
.preferredLayout=${this._preferredLayout}
|
.preferredLayout=${this._preferredLayout}
|
||||||
|
.accept=${this._params.accept}
|
||||||
@close-dialog=${this.closeDialog}
|
@close-dialog=${this.closeDialog}
|
||||||
@media-picked=${this._mediaPicked}
|
@media-picked=${this._mediaPicked}
|
||||||
@media-browsed=${this._mediaBrowsed}
|
@media-browsed=${this._mediaBrowsed}
|
||||||
|
@ -78,7 +78,7 @@ export interface MediaPlayerItemId {
|
|||||||
export class HaMediaPlayerBrowse extends LitElement {
|
export class HaMediaPlayerBrowse extends LitElement {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false }) public entityId!: string;
|
@property({ attribute: false }) public entityId?: string;
|
||||||
|
|
||||||
@property() public action: MediaPlayerBrowseAction = "play";
|
@property() public action: MediaPlayerBrowseAction = "play";
|
||||||
|
|
||||||
@ -89,6 +89,8 @@ export class HaMediaPlayerBrowse extends LitElement {
|
|||||||
|
|
||||||
@property({ attribute: false }) public navigateIds: MediaPlayerItemId[] = [];
|
@property({ attribute: false }) public navigateIds: MediaPlayerItemId[] = [];
|
||||||
|
|
||||||
|
@property({ attribute: false }) public accept?: string[];
|
||||||
|
|
||||||
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
|
// @todo Consider reworking to eliminate need for attribute since it is manipulated internally
|
||||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||||
|
|
||||||
@ -250,6 +252,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
|||||||
});
|
});
|
||||||
} else if (
|
} else if (
|
||||||
err.code === "entity_not_found" &&
|
err.code === "entity_not_found" &&
|
||||||
|
this.entityId &&
|
||||||
isUnavailableState(this.hass.states[this.entityId]?.state)
|
isUnavailableState(this.hass.states[this.entityId]?.state)
|
||||||
) {
|
) {
|
||||||
this._setError({
|
this._setError({
|
||||||
@ -334,7 +337,37 @@ export class HaMediaPlayerBrowse extends LitElement {
|
|||||||
const subtitle = this.hass.localize(
|
const subtitle = this.hass.localize(
|
||||||
`ui.components.media-browser.class.${currentItem.media_class}`
|
`ui.components.media-browser.class.${currentItem.media_class}`
|
||||||
);
|
);
|
||||||
const children = currentItem.children || [];
|
let children = currentItem.children || [];
|
||||||
|
const canPlayChildren = new Set<string>();
|
||||||
|
|
||||||
|
// Filter children based on accept property if provided
|
||||||
|
if (this.accept && children.length > 0) {
|
||||||
|
let checks: ((t: string) => boolean)[] = [];
|
||||||
|
|
||||||
|
for (const type of this.accept) {
|
||||||
|
if (type.endsWith("/*")) {
|
||||||
|
const baseType = type.slice(0, -1);
|
||||||
|
checks.push((t) => t.startsWith(baseType));
|
||||||
|
} else if (type === "*") {
|
||||||
|
checks = [() => true];
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
checks.push((t) => t === type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
children = children.filter((child) => {
|
||||||
|
const contentType = child.media_content_type.toLowerCase();
|
||||||
|
const canPlay =
|
||||||
|
child.media_content_type &&
|
||||||
|
checks.some((check) => check(contentType));
|
||||||
|
if (canPlay) {
|
||||||
|
canPlayChildren.add(child.media_content_id);
|
||||||
|
}
|
||||||
|
return !child.media_content_type || child.can_expand || canPlay;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
|
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
|
||||||
const childrenMediaClass = currentItem.children_media_class
|
const childrenMediaClass = currentItem.children_media_class
|
||||||
? MediaClassBrowserSettings[currentItem.children_media_class]
|
? MediaClassBrowserSettings[currentItem.children_media_class]
|
||||||
@ -367,7 +400,12 @@ export class HaMediaPlayerBrowse extends LitElement {
|
|||||||
""
|
""
|
||||||
)}"
|
)}"
|
||||||
>
|
>
|
||||||
${this.narrow && currentItem?.can_play
|
${this.narrow &&
|
||||||
|
currentItem?.can_play &&
|
||||||
|
(!this.accept ||
|
||||||
|
canPlayChildren.has(
|
||||||
|
currentItem.media_content_id
|
||||||
|
))
|
||||||
? html`
|
? html`
|
||||||
<ha-fab
|
<ha-fab
|
||||||
mini
|
mini
|
||||||
@ -748,11 +786,11 @@ export class HaMediaPlayerBrowse extends LitElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private async _fetchData(
|
private async _fetchData(
|
||||||
entityId: string,
|
entityId: string | undefined,
|
||||||
mediaContentId?: string,
|
mediaContentId?: string,
|
||||||
mediaContentType?: string
|
mediaContentType?: string
|
||||||
): Promise<MediaPlayerItem> {
|
): Promise<MediaPlayerItem> {
|
||||||
return entityId !== BROWSER_PLAYER
|
return entityId && entityId !== BROWSER_PLAYER
|
||||||
? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType)
|
? browseMediaPlayer(this.hass, entityId, mediaContentId, mediaContentType)
|
||||||
: browseLocalMediaPlayer(this.hass, mediaContentId);
|
: browseLocalMediaPlayer(this.hass, mediaContentId);
|
||||||
}
|
}
|
||||||
|
@ -7,10 +7,11 @@ import type { MediaPlayerItemId } from "./ha-media-player-browse";
|
|||||||
|
|
||||||
export interface MediaPlayerBrowseDialogParams {
|
export interface MediaPlayerBrowseDialogParams {
|
||||||
action: MediaPlayerBrowseAction;
|
action: MediaPlayerBrowseAction;
|
||||||
entityId: string;
|
entityId?: string;
|
||||||
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => void;
|
mediaPickedCallback: (pickedMedia: MediaPickedEvent) => void;
|
||||||
navigateIds?: MediaPlayerItemId[];
|
navigateIds?: MediaPlayerItemId[];
|
||||||
minimumNavigateLevel?: number;
|
minimumNavigateLevel?: number;
|
||||||
|
accept?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const showMediaBrowserDialog = (
|
export const showMediaBrowserDialog = (
|
||||||
|
53
src/data/ai_task.ts
Normal file
53
src/data/ai_task.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import type { HomeAssistant } from "../types";
|
||||||
|
import type { Selector } from "./selector";
|
||||||
|
|
||||||
|
export interface AITaskPreferences {
|
||||||
|
gen_data_entity_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GenDataTaskResult<T = string> {
|
||||||
|
conversation_id: string;
|
||||||
|
data: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AITaskStructureField {
|
||||||
|
description?: string;
|
||||||
|
required?: boolean;
|
||||||
|
selector: Selector;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AITaskStructure = Record<string, AITaskStructureField>;
|
||||||
|
|
||||||
|
export const fetchAITaskPreferences = (hass: HomeAssistant) =>
|
||||||
|
hass.callWS<AITaskPreferences>({
|
||||||
|
type: "ai_task/preferences/get",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const saveAITaskPreferences = (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
preferences: Partial<AITaskPreferences>
|
||||||
|
) =>
|
||||||
|
hass.callWS<AITaskPreferences>({
|
||||||
|
type: "ai_task/preferences/set",
|
||||||
|
...preferences,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const generateDataAITask = async <T = string>(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
task: {
|
||||||
|
task_name: string;
|
||||||
|
entity_id?: string;
|
||||||
|
instructions: string;
|
||||||
|
structure?: AITaskStructure;
|
||||||
|
}
|
||||||
|
): Promise<GenDataTaskResult<T>> => {
|
||||||
|
const result = await hass.callService<GenDataTaskResult<T>>(
|
||||||
|
"ai_task",
|
||||||
|
"generate_data",
|
||||||
|
task,
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
return result.response!;
|
||||||
|
};
|
@ -1114,12 +1114,16 @@ export const formatConsumptionShort = (
|
|||||||
if (!consumption) {
|
if (!consumption) {
|
||||||
return `0 ${unit}`;
|
return `0 ${unit}`;
|
||||||
}
|
}
|
||||||
const units = ["kWh", "MWh", "GWh", "TWh"];
|
const units = ["Wh", "kWh", "MWh", "GWh", "TWh"];
|
||||||
let pickedUnit = unit;
|
let pickedUnit = unit;
|
||||||
let val = consumption;
|
let val = consumption;
|
||||||
let unitIndex = units.findIndex((u) => u === unit);
|
let unitIndex = units.findIndex((u) => u === unit);
|
||||||
if (unitIndex >= 0) {
|
if (unitIndex >= 0) {
|
||||||
while (val >= 1000 && unitIndex < units.length - 1) {
|
while (Math.abs(val) < 1 && unitIndex > 0) {
|
||||||
|
val *= 1000;
|
||||||
|
unitIndex--;
|
||||||
|
}
|
||||||
|
while (Math.abs(val) >= 1000 && unitIndex < units.length - 1) {
|
||||||
val /= 1000;
|
val /= 1000;
|
||||||
unitIndex++;
|
unitIndex++;
|
||||||
}
|
}
|
||||||
@ -1127,7 +1131,8 @@ export const formatConsumptionShort = (
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
formatNumber(val, hass.locale, {
|
formatNumber(val, hass.locale, {
|
||||||
maximumFractionDigits: val < 10 ? 2 : val < 100 ? 1 : 0,
|
maximumFractionDigits:
|
||||||
|
Math.abs(val) < 10 ? 2 : Math.abs(val) < 100 ? 1 : 0,
|
||||||
}) +
|
}) +
|
||||||
" " +
|
" " +
|
||||||
pickedUnit
|
pickedUnit
|
||||||
|
@ -37,6 +37,7 @@ import {
|
|||||||
mdiRoomService,
|
mdiRoomService,
|
||||||
mdiScriptText,
|
mdiScriptText,
|
||||||
mdiSpeakerMessage,
|
mdiSpeakerMessage,
|
||||||
|
mdiStarFourPoints,
|
||||||
mdiThermostat,
|
mdiThermostat,
|
||||||
mdiTimerOutline,
|
mdiTimerOutline,
|
||||||
mdiToggleSwitch,
|
mdiToggleSwitch,
|
||||||
@ -66,6 +67,7 @@ export const DEFAULT_DOMAIN_ICON = mdiBookmark;
|
|||||||
|
|
||||||
/** Fallback icons for each domain */
|
/** Fallback icons for each domain */
|
||||||
export const FALLBACK_DOMAIN_ICONS = {
|
export const FALLBACK_DOMAIN_ICONS = {
|
||||||
|
ai_task: mdiStarFourPoints,
|
||||||
air_quality: mdiAirFilter,
|
air_quality: mdiAirFilter,
|
||||||
alert: mdiAlert,
|
alert: mdiAlert,
|
||||||
automation: mdiRobot,
|
automation: mdiRobot,
|
||||||
@ -352,7 +354,10 @@ const getIconFromTranslations = (
|
|||||||
}
|
}
|
||||||
// Then check for range-based icons if we have a numeric state
|
// Then check for range-based icons if we have a numeric state
|
||||||
if (state !== undefined && translations.range && !isNaN(Number(state))) {
|
if (state !== undefined && translations.range && !isNaN(Number(state))) {
|
||||||
return getIconFromRange(Number(state), translations.range);
|
return (
|
||||||
|
getIconFromRange(Number(state), translations.range) ??
|
||||||
|
translations.default
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Fallback to default icon
|
// Fallback to default icon
|
||||||
return translations.default;
|
return translations.default;
|
||||||
@ -502,14 +507,28 @@ export const serviceSectionIcon = async (
|
|||||||
export const domainIcon = async (
|
export const domainIcon = async (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
domain: string,
|
domain: string,
|
||||||
deviceClass?: string
|
deviceClass?: string,
|
||||||
|
state?: string
|
||||||
): Promise<string | undefined> => {
|
): Promise<string | undefined> => {
|
||||||
const entityComponentIcons = await getComponentIcons(hass, domain);
|
const entityComponentIcons = await getComponentIcons(hass, domain);
|
||||||
if (entityComponentIcons) {
|
if (entityComponentIcons) {
|
||||||
const translations =
|
const translations =
|
||||||
(deviceClass && entityComponentIcons[deviceClass]) ||
|
(deviceClass && entityComponentIcons[deviceClass]) ||
|
||||||
entityComponentIcons._;
|
entityComponentIcons._;
|
||||||
return translations?.default;
|
// First check for exact state match
|
||||||
|
if (state && translations.state?.[state]) {
|
||||||
|
return translations.state[state];
|
||||||
|
}
|
||||||
|
// Then check for range-based icons if we have a numeric state
|
||||||
|
if (state !== undefined && translations.range && !isNaN(Number(state))) {
|
||||||
|
return (
|
||||||
|
getIconFromRange(Number(state), translations.range) ??
|
||||||
|
translations.default
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Fallback to default icon
|
||||||
|
return translations.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { mdiContentSave, mdiMedal, mdiTrophy } from "@mdi/js";
|
import { mdiContentSave, mdiMedal, mdiTrophy } from "@mdi/js";
|
||||||
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
|
|
||||||
import type { LocalizeKeys } from "../common/translations/localize";
|
import type { LocalizeKeys } from "../common/translations/localize";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -26,11 +25,6 @@ export const QUALITY_SCALE_MAP: Record<
|
|||||||
translationKey:
|
translationKey:
|
||||||
"ui.panel.config.integrations.config_entry.platinum_quality",
|
"ui.panel.config.integrations.config_entry.platinum_quality",
|
||||||
},
|
},
|
||||||
internal: {
|
|
||||||
icon: mdiHomeAssistant,
|
|
||||||
translationKey:
|
|
||||||
"ui.panel.config.integrations.config_entry.internal_integration",
|
|
||||||
},
|
|
||||||
legacy: {
|
legacy: {
|
||||||
icon: mdiContentSave,
|
icon: mdiContentSave,
|
||||||
translationKey:
|
translationKey:
|
||||||
|
@ -114,9 +114,13 @@ const getLogbookDataFromServer = (
|
|||||||
|
|
||||||
export const subscribeLogbook = (
|
export const subscribeLogbook = (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
callbackFunction: (message: LogbookStreamMessage) => void,
|
callbackFunction: (
|
||||||
|
message: LogbookStreamMessage,
|
||||||
|
subscriptionId: number
|
||||||
|
) => void,
|
||||||
startDate: string,
|
startDate: string,
|
||||||
endDate: string,
|
endDate: string,
|
||||||
|
subscriptionId: number,
|
||||||
entityIds?: string[],
|
entityIds?: string[],
|
||||||
deviceIds?: string[]
|
deviceIds?: string[]
|
||||||
): Promise<UnsubscribeFunc> => {
|
): Promise<UnsubscribeFunc> => {
|
||||||
@ -140,7 +144,7 @@ export const subscribeLogbook = (
|
|||||||
params.device_ids = deviceIds;
|
params.device_ids = deviceIds;
|
||||||
}
|
}
|
||||||
return hass.connection.subscribeMessage<LogbookStreamMessage>(
|
return hass.connection.subscribeMessage<LogbookStreamMessage>(
|
||||||
(message) => callbackFunction(message),
|
(message) => callbackFunction(message, subscriptionId),
|
||||||
params
|
params
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -13,7 +13,7 @@ export const subscribePreviewGeneric = (
|
|||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
domain: string,
|
domain: string,
|
||||||
flow_id: string,
|
flow_id: string,
|
||||||
flow_type: "config_flow" | "options_flow",
|
flow_type: "config_flow" | "options_flow" | "config_subentries_flow",
|
||||||
user_input: Record<string, any>,
|
user_input: Record<string, any>,
|
||||||
callback: (preview: GenericPreview) => void
|
callback: (preview: GenericPreview) => void
|
||||||
): Promise<UnsubscribeFunc> =>
|
): Promise<UnsubscribeFunc> =>
|
||||||
|
@ -14,6 +14,7 @@ import {
|
|||||||
literal,
|
literal,
|
||||||
is,
|
is,
|
||||||
boolean,
|
boolean,
|
||||||
|
refine,
|
||||||
} from "superstruct";
|
} from "superstruct";
|
||||||
import { arrayLiteralIncludes } from "../common/array/literal-includes";
|
import { arrayLiteralIncludes } from "../common/array/literal-includes";
|
||||||
import { navigate } from "../common/navigate";
|
import { navigate } from "../common/navigate";
|
||||||
@ -49,13 +50,18 @@ export const targetStruct = object({
|
|||||||
label_id: optional(union([string(), array(string())])),
|
label_id: optional(union([string(), array(string())])),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const serviceActionStruct: Describe<ServiceAction> = assign(
|
export const serviceActionStruct: Describe<ServiceActionWithTemplate> = assign(
|
||||||
baseActionStruct,
|
baseActionStruct,
|
||||||
object({
|
object({
|
||||||
action: optional(string()),
|
action: optional(string()),
|
||||||
service_template: optional(string()),
|
service_template: optional(string()),
|
||||||
entity_id: optional(string()),
|
entity_id: optional(string()),
|
||||||
target: optional(targetStruct),
|
target: optional(
|
||||||
|
union([
|
||||||
|
targetStruct,
|
||||||
|
refine(string(), "has_template", (val) => hasTemplate(val)),
|
||||||
|
])
|
||||||
|
),
|
||||||
data: optional(object()),
|
data: optional(object()),
|
||||||
response_variable: optional(string()),
|
response_variable: optional(string()),
|
||||||
metadata: optional(object()),
|
metadata: optional(object()),
|
||||||
@ -132,6 +138,12 @@ export interface ServiceAction extends BaseAction {
|
|||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServiceActionWithTemplate = ServiceAction & {
|
||||||
|
target?: HassServiceTarget | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { ServiceActionWithTemplate };
|
||||||
|
|
||||||
export interface DeviceAction extends BaseAction {
|
export interface DeviceAction extends BaseAction {
|
||||||
type: string;
|
type: string;
|
||||||
device_id: string;
|
device_id: string;
|
||||||
@ -415,7 +427,7 @@ export const migrateAutomationAction = (
|
|||||||
return action.map(migrateAutomationAction) as Action[];
|
return action.map(migrateAutomationAction) as Action[];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("service" in action) {
|
if (typeof action === "object" && action !== null && "service" in action) {
|
||||||
if (!("action" in action)) {
|
if (!("action" in action)) {
|
||||||
action.action = action.service;
|
action.action = action.service;
|
||||||
}
|
}
|
||||||
@ -423,7 +435,7 @@ export const migrateAutomationAction = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// legacy scene (scene: scene_name)
|
// legacy scene (scene: scene_name)
|
||||||
if ("scene" in action) {
|
if (typeof action === "object" && action !== null && "scene" in action) {
|
||||||
action.action = "scene.turn_on";
|
action.action = "scene.turn_on";
|
||||||
action.target = {
|
action.target = {
|
||||||
entity_id: action.scene,
|
entity_id: action.scene,
|
||||||
@ -431,7 +443,7 @@ export const migrateAutomationAction = (
|
|||||||
delete action.scene;
|
delete action.scene;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("sequence" in action) {
|
if (typeof action === "object" && action !== null && "sequence" in action) {
|
||||||
for (const sequenceAction of (action as SequenceAction).sequence) {
|
for (const sequenceAction of (action as SequenceAction).sequence) {
|
||||||
migrateAutomationAction(sequenceAction);
|
migrateAutomationAction(sequenceAction);
|
||||||
}
|
}
|
||||||
|
@ -303,7 +303,9 @@ export interface LocationSelectorValue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaSelector {
|
export interface MediaSelector {
|
||||||
media: {} | null;
|
media: {
|
||||||
|
accept?: string[];
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MediaSelectorValue {
|
export interface MediaSelectorValue {
|
||||||
@ -331,11 +333,24 @@ export interface NumberSelector {
|
|||||||
mode?: "box" | "slider";
|
mode?: "box" | "slider";
|
||||||
unit_of_measurement?: string;
|
unit_of_measurement?: string;
|
||||||
slider_ticks?: boolean;
|
slider_ticks?: boolean;
|
||||||
|
translation_key?: string;
|
||||||
} | null;
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ObjectSelectorField {
|
||||||
|
selector: Selector;
|
||||||
|
label?: string;
|
||||||
|
required?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ObjectSelector {
|
export interface ObjectSelector {
|
||||||
object: {} | null;
|
object?: {
|
||||||
|
label_field?: string;
|
||||||
|
description_field?: string;
|
||||||
|
translation_key?: string;
|
||||||
|
fields?: Record<string, ObjectSelectorField>;
|
||||||
|
multiple?: boolean;
|
||||||
|
} | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssistPipelineSelector {
|
export interface AssistPipelineSelector {
|
||||||
|
104
src/data/selector/format_selector_value.ts
Normal file
104
src/data/selector/format_selector_value.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { ensureArray } from "../../common/array/ensure-array";
|
||||||
|
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||||
|
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||||
|
import { computeEntityName } from "../../common/entity/compute_entity_name";
|
||||||
|
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||||
|
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||||
|
import type { HomeAssistant } from "../../types";
|
||||||
|
import type { Selector } from "../selector";
|
||||||
|
|
||||||
|
export const formatSelectorValue = (
|
||||||
|
hass: HomeAssistant,
|
||||||
|
value: any,
|
||||||
|
selector?: Selector
|
||||||
|
) => {
|
||||||
|
if (value == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selector) {
|
||||||
|
return ensureArray(value).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("text" in selector) {
|
||||||
|
const { prefix, suffix } = selector.text || {};
|
||||||
|
|
||||||
|
const texts = ensureArray(value);
|
||||||
|
return texts
|
||||||
|
.map((text) => `${prefix || ""}${text}${suffix || ""}`)
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("number" in selector) {
|
||||||
|
const { unit_of_measurement } = selector.number || {};
|
||||||
|
const numbers = ensureArray(value);
|
||||||
|
return numbers
|
||||||
|
.map((number) => {
|
||||||
|
const num = Number(number);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
return unit_of_measurement
|
||||||
|
? `${num}${blankBeforeUnit(unit_of_measurement, hass.locale)}${unit_of_measurement}`
|
||||||
|
: num.toString();
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("floor" in selector) {
|
||||||
|
const floors = ensureArray(value);
|
||||||
|
return floors
|
||||||
|
.map((floorId) => {
|
||||||
|
const floor = hass.floors[floorId];
|
||||||
|
if (!floor) {
|
||||||
|
return floorId;
|
||||||
|
}
|
||||||
|
return floor.name || floorId;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("area" in selector) {
|
||||||
|
const areas = ensureArray(value);
|
||||||
|
return areas
|
||||||
|
.map((areaId) => {
|
||||||
|
const area = hass.areas[areaId];
|
||||||
|
if (!area) {
|
||||||
|
return areaId;
|
||||||
|
}
|
||||||
|
return computeAreaName(area);
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("entity" in selector) {
|
||||||
|
const entities = ensureArray(value);
|
||||||
|
return entities
|
||||||
|
.map((entityId) => {
|
||||||
|
const stateObj = hass.states[entityId];
|
||||||
|
if (!stateObj) {
|
||||||
|
return entityId;
|
||||||
|
}
|
||||||
|
const { device } = getEntityContext(stateObj, hass);
|
||||||
|
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||||
|
const entityName = computeEntityName(stateObj, hass);
|
||||||
|
return [deviceName, entityName].filter(Boolean).join(" ") || entityId;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("device" in selector) {
|
||||||
|
const devices = ensureArray(value);
|
||||||
|
return devices
|
||||||
|
.map((deviceId) => {
|
||||||
|
const device = hass.devices[deviceId];
|
||||||
|
if (!device) {
|
||||||
|
return deviceId;
|
||||||
|
}
|
||||||
|
return device.name || deviceId;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return ensureArray(value).join(", ");
|
||||||
|
};
|
@ -34,6 +34,7 @@ export type SystemHealthInfo = Partial<{
|
|||||||
dev: boolean;
|
dev: boolean;
|
||||||
hassio: boolean;
|
hassio: boolean;
|
||||||
docker: boolean;
|
docker: boolean;
|
||||||
|
container_arch: string;
|
||||||
user: string;
|
user: string;
|
||||||
virtualenv: boolean;
|
virtualenv: boolean;
|
||||||
python_version: string;
|
python_version: string;
|
||||||
|
@ -286,7 +286,7 @@ class DataEntryFlowDialog extends LitElement {
|
|||||||
scrimClickAction
|
scrimClickAction
|
||||||
escapeKeyAction
|
escapeKeyAction
|
||||||
hideActions
|
hideActions
|
||||||
.heading=${dialogTitle}
|
.heading=${dialogTitle || true}
|
||||||
>
|
>
|
||||||
<ha-dialog-header slot="heading">
|
<ha-dialog-header slot="heading">
|
||||||
<ha-icon-button
|
<ha-icon-button
|
||||||
|
@ -82,7 +82,11 @@ export class FlowPreviewGeneric extends LitElement {
|
|||||||
(await this._unsub)();
|
(await this._unsub)();
|
||||||
this._unsub = undefined;
|
this._unsub = undefined;
|
||||||
}
|
}
|
||||||
if (this.flowType !== "config_flow" && this.flowType !== "options_flow") {
|
if (
|
||||||
|
this.flowType !== "config_flow" &&
|
||||||
|
this.flowType !== "options_flow" &&
|
||||||
|
this.flowType !== "config_subentries_flow"
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._error = undefined;
|
this._error = undefined;
|
||||||
|
@ -35,10 +35,16 @@ export const showConfigFlowDialog = (
|
|||||||
return step;
|
return step;
|
||||||
},
|
},
|
||||||
fetchFlow: async (hass, flowId) => {
|
fetchFlow: async (hass, flowId) => {
|
||||||
const step = await fetchConfigFlow(hass, flowId);
|
const [step] = await Promise.all([
|
||||||
await hass.loadFragmentTranslation("config");
|
fetchConfigFlow(hass, flowId),
|
||||||
await hass.loadBackendTranslation("config", step.handler);
|
hass.loadFragmentTranslation("config"),
|
||||||
await hass.loadBackendTranslation("selector", step.handler);
|
]);
|
||||||
|
await Promise.all([
|
||||||
|
hass.loadBackendTranslation("config", step.handler),
|
||||||
|
hass.loadBackendTranslation("selector", step.handler),
|
||||||
|
// Used as fallback if no header defined for step
|
||||||
|
hass.loadBackendTranslation("title", step.handler),
|
||||||
|
]);
|
||||||
return step;
|
return step;
|
||||||
},
|
},
|
||||||
handleFlowStep: handleConfigFlowStep,
|
handleFlowStep: handleConfigFlowStep,
|
||||||
|
89
src/dialogs/form/dialog-form.ts
Normal file
89
src/dialogs/form/dialog-form.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators";
|
||||||
|
import { fireEvent } from "../../common/dom/fire_event";
|
||||||
|
import "../../components/ha-button";
|
||||||
|
import { createCloseHeading } from "../../components/ha-dialog";
|
||||||
|
import "../../components/ha-form/ha-form";
|
||||||
|
import type { HomeAssistant } from "../../types";
|
||||||
|
import type { HassDialog } from "../make-dialog-manager";
|
||||||
|
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
|
||||||
|
import { haStyleDialog } from "../../resources/styles";
|
||||||
|
|
||||||
|
@customElement("dialog-form")
|
||||||
|
export class DialogForm
|
||||||
|
extends LitElement
|
||||||
|
implements HassDialog<FormDialogData>
|
||||||
|
{
|
||||||
|
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||||
|
|
||||||
|
@state() private _params?: FormDialogParams;
|
||||||
|
|
||||||
|
@state() private _data: FormDialogData = {};
|
||||||
|
|
||||||
|
public async showDialog(params: FormDialogParams): Promise<void> {
|
||||||
|
this._params = params;
|
||||||
|
this._data = params.data || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public closeDialog() {
|
||||||
|
this._params = undefined;
|
||||||
|
this._data = {};
|
||||||
|
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _submit(): void {
|
||||||
|
this._params?.submit?.(this._data);
|
||||||
|
this.closeDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _cancel(): void {
|
||||||
|
this._params?.cancel?.();
|
||||||
|
this.closeDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _valueChanged(ev: CustomEvent): void {
|
||||||
|
this._data = ev.detail.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
if (!this._params || !this.hass) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ha-dialog
|
||||||
|
open
|
||||||
|
scrimClickAction
|
||||||
|
escapeKeyAction
|
||||||
|
.heading=${createCloseHeading(this.hass, this._params.title)}
|
||||||
|
@closed=${this._cancel}
|
||||||
|
>
|
||||||
|
<ha-form
|
||||||
|
dialogInitialFocus
|
||||||
|
.hass=${this.hass}
|
||||||
|
.computeLabel=${this._params.computeLabel}
|
||||||
|
.computeHelper=${this._params.computeHelper}
|
||||||
|
.data=${this._data}
|
||||||
|
.schema=${this._params.schema}
|
||||||
|
@value-changed=${this._valueChanged}
|
||||||
|
>
|
||||||
|
</ha-form>
|
||||||
|
<ha-button @click=${this._cancel} slot="secondaryAction">
|
||||||
|
${this._params.cancelText || this.hass.localize("ui.common.cancel")}
|
||||||
|
</ha-button>
|
||||||
|
<ha-button @click=${this._submit} slot="primaryAction">
|
||||||
|
${this._params.submitText || this.hass.localize("ui.common.save")}
|
||||||
|
</ha-button>
|
||||||
|
</ha-dialog>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = [haStyleDialog, css``];
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"dialog-form": DialogForm;
|
||||||
|
}
|
||||||
|
}
|
45
src/dialogs/form/show-form-dialog.ts
Normal file
45
src/dialogs/form/show-form-dialog.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { fireEvent } from "../../common/dom/fire_event";
|
||||||
|
import type { HaFormSchema } from "../../components/ha-form/types";
|
||||||
|
|
||||||
|
export type FormDialogData = Record<string, any>;
|
||||||
|
|
||||||
|
export interface FormDialogParams {
|
||||||
|
title: string;
|
||||||
|
schema: HaFormSchema[];
|
||||||
|
data?: FormDialogData;
|
||||||
|
submit?: (data?: FormDialogData) => void;
|
||||||
|
cancel?: () => void;
|
||||||
|
computeLabel?: (schema, data) => string | undefined;
|
||||||
|
computeHelper?: (schema) => string | undefined;
|
||||||
|
submitText?: string;
|
||||||
|
cancelText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const showFormDialog = (
|
||||||
|
element: HTMLElement,
|
||||||
|
dialogParams: FormDialogParams
|
||||||
|
) =>
|
||||||
|
new Promise<FormDialogData | null>((resolve) => {
|
||||||
|
const origCancel = dialogParams.cancel;
|
||||||
|
const origSubmit = dialogParams.submit;
|
||||||
|
|
||||||
|
fireEvent(element, "show-dialog", {
|
||||||
|
dialogTag: "dialog-form",
|
||||||
|
dialogImport: () => import("./dialog-form"),
|
||||||
|
dialogParams: {
|
||||||
|
...dialogParams,
|
||||||
|
cancel: () => {
|
||||||
|
resolve(null);
|
||||||
|
if (origCancel) {
|
||||||
|
origCancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
submit: (data: FormDialogData) => {
|
||||||
|
resolve(data);
|
||||||
|
if (origSubmit) {
|
||||||
|
origSubmit(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
@ -208,6 +208,7 @@ class DialogEditSidebar extends LitElement {
|
|||||||
ha-md-dialog {
|
ha-md-dialog {
|
||||||
min-width: 600px;
|
min-width: 600px;
|
||||||
max-height: 90%;
|
max-height: 90%;
|
||||||
|
--dialog-content-padding: 8px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media all and (max-width: 600px), all and (max-height: 500px) {
|
@media all and (max-width: 600px), all and (max-height: 500px) {
|
||||||
|
@ -2,12 +2,19 @@ import { html, LitElement } from "lit";
|
|||||||
import { customElement, property } from "lit/decorators";
|
import { customElement, property } from "lit/decorators";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||||
import "../../../../../components/ha-selector/ha-selector-media";
|
import "../../../../../components/ha-selector/ha-selector";
|
||||||
import type { PlayMediaAction } from "../../../../../data/script";
|
import type { PlayMediaAction } from "../../../../../data/script";
|
||||||
import type { MediaSelectorValue } from "../../../../../data/selector";
|
import type {
|
||||||
|
MediaSelectorValue,
|
||||||
|
Selector,
|
||||||
|
} from "../../../../../data/selector";
|
||||||
import type { HomeAssistant } from "../../../../../types";
|
import type { HomeAssistant } from "../../../../../types";
|
||||||
import type { ActionElement } from "../ha-automation-action-row";
|
import type { ActionElement } from "../ha-automation-action-row";
|
||||||
|
|
||||||
|
const MEDIA_SELECTOR_SCHEMA: Selector = {
|
||||||
|
media: {},
|
||||||
|
};
|
||||||
|
|
||||||
@customElement("ha-automation-action-play_media")
|
@customElement("ha-automation-action-play_media")
|
||||||
export class HaPlayMediaAction extends LitElement implements ActionElement {
|
export class HaPlayMediaAction extends LitElement implements ActionElement {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
@ -38,12 +45,13 @@ export class HaPlayMediaAction extends LitElement implements ActionElement {
|
|||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
return html`
|
return html`
|
||||||
<ha-selector-media
|
<ha-selector
|
||||||
|
.selector=${MEDIA_SELECTOR_SCHEMA}
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.disabled=${this.disabled}
|
.disabled=${this.disabled}
|
||||||
.value=${this._getSelectorValue(this.action)}
|
.value=${this._getSelectorValue(this.action)}
|
||||||
@value-changed=${this._valueChanged}
|
@value-changed=${this._valueChanged}
|
||||||
></ha-selector-media>
|
></ha-selector>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ export class HaServiceAction extends LitElement implements ActionElement {
|
|||||||
if (
|
if (
|
||||||
this.action &&
|
this.action &&
|
||||||
Object.entries(this.action).some(
|
Object.entries(this.action).some(
|
||||||
([key, val]) => key !== "data" && hasTemplate(val)
|
([key, val]) => !["data", "target"].includes(key) && hasTemplate(val)
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
fireEvent(
|
fireEvent(
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import "@material/mwc-button";
|
import "@material/mwc-button";
|
||||||
import type { CSSResultGroup } from "lit";
|
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { mdiClose, mdiPlus } from "@mdi/js";
|
import { mdiClose, mdiPlus, mdiStarFourPoints } from "@mdi/js";
|
||||||
|
import { dump } from "js-yaml";
|
||||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||||
import "../../../../components/ha-alert";
|
import "../../../../components/ha-alert";
|
||||||
import "../../../../components/ha-domain-icon";
|
import "../../../../components/ha-domain-icon";
|
||||||
@ -24,6 +25,14 @@ import type {
|
|||||||
SaveDialogParams,
|
SaveDialogParams,
|
||||||
} from "./show-dialog-automation-save";
|
} from "./show-dialog-automation-save";
|
||||||
import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support";
|
import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support";
|
||||||
|
import {
|
||||||
|
fetchAITaskPreferences,
|
||||||
|
generateDataAITask,
|
||||||
|
} from "../../../../data/ai_task";
|
||||||
|
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
|
||||||
|
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
|
||||||
|
import { subscribeOne } from "../../../../common/util/subscribe-one";
|
||||||
|
import { subscribeLabelRegistry } from "../../../../data/label_registry";
|
||||||
|
|
||||||
@customElement("ha-dialog-automation-save")
|
@customElement("ha-dialog-automation-save")
|
||||||
class DialogAutomationSave extends LitElement implements HassDialog {
|
class DialogAutomationSave extends LitElement implements HassDialog {
|
||||||
@ -37,9 +46,11 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
|||||||
|
|
||||||
@state() private _entryUpdates!: EntityRegistryUpdate;
|
@state() private _entryUpdates!: EntityRegistryUpdate;
|
||||||
|
|
||||||
|
@state() private _canSuggest = false;
|
||||||
|
|
||||||
private _params!: SaveDialogParams;
|
private _params!: SaveDialogParams;
|
||||||
|
|
||||||
private _newName?: string;
|
@state() private _newName?: string;
|
||||||
|
|
||||||
private _newIcon?: string;
|
private _newIcon?: string;
|
||||||
|
|
||||||
@ -67,7 +78,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
|||||||
this._entryUpdates.category ? "category" : "",
|
this._entryUpdates.category ? "category" : "",
|
||||||
this._entryUpdates.labels.length > 0 ? "labels" : "",
|
this._entryUpdates.labels.length > 0 ? "labels" : "",
|
||||||
this._entryUpdates.area ? "area" : "",
|
this._entryUpdates.area ? "area" : "",
|
||||||
];
|
].filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
public closeDialog() {
|
public closeDialog() {
|
||||||
@ -81,6 +92,15 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected firstUpdated(changedProperties: PropertyValues): void {
|
||||||
|
super.firstUpdated(changedProperties);
|
||||||
|
if (isComponentLoaded(this.hass, "ai_task")) {
|
||||||
|
fetchAITaskPreferences(this.hass).then((prefs) => {
|
||||||
|
this._canSuggest = prefs.gen_data_entity_id !== null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
protected _renderOptionalChip(id: string, label: string) {
|
protected _renderOptionalChip(id: string, label: string) {
|
||||||
if (this._visibleOptionals.includes(id)) {
|
if (this._visibleOptionals.includes(id)) {
|
||||||
return nothing;
|
return nothing;
|
||||||
@ -250,6 +270,21 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
|||||||
.path=${mdiClose}
|
.path=${mdiClose}
|
||||||
></ha-icon-button>
|
></ha-icon-button>
|
||||||
<span slot="title">${this._params.title || title}</span>
|
<span slot="title">${this._params.title || title}</span>
|
||||||
|
${this._canSuggest
|
||||||
|
? html`
|
||||||
|
<ha-assist-chip
|
||||||
|
id="suggest"
|
||||||
|
slot="actionItems"
|
||||||
|
@click=${this._suggest}
|
||||||
|
label=${this.hass.localize("ui.common.suggest_ai")}
|
||||||
|
>
|
||||||
|
<ha-svg-icon
|
||||||
|
slot="icon"
|
||||||
|
.path=${mdiStarFourPoints}
|
||||||
|
></ha-svg-icon>
|
||||||
|
</ha-assist-chip>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
</ha-dialog-header>
|
</ha-dialog-header>
|
||||||
${this._error
|
${this._error
|
||||||
? html`<ha-alert alert-type="error"
|
? html`<ha-alert alert-type="error"
|
||||||
@ -313,6 +348,124 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
|||||||
this.closeDialog();
|
this.closeDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _suggest() {
|
||||||
|
const labels = await subscribeOne(
|
||||||
|
this.hass.connection,
|
||||||
|
subscribeLabelRegistry
|
||||||
|
).then((labs) =>
|
||||||
|
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
|
||||||
|
);
|
||||||
|
const automationInspiration: string[] = [];
|
||||||
|
|
||||||
|
for (const automation of Object.values(this.hass.states)) {
|
||||||
|
const entityEntry = this.hass.entities[automation.entity_id];
|
||||||
|
if (
|
||||||
|
computeStateDomain(automation) !== "automation" ||
|
||||||
|
automation.attributes.restored ||
|
||||||
|
!automation.attributes.friendly_name ||
|
||||||
|
!entityEntry
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let inspiration = `- ${automation.attributes.friendly_name}`;
|
||||||
|
|
||||||
|
if (entityEntry.labels.length) {
|
||||||
|
inspiration += ` (labels: ${entityEntry.labels
|
||||||
|
.map((label) => labels[label])
|
||||||
|
.join(", ")})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
automationInspiration.push(inspiration);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await generateDataAITask<{
|
||||||
|
name: string;
|
||||||
|
description: string | undefined;
|
||||||
|
labels: string[] | undefined;
|
||||||
|
}>(this.hass, {
|
||||||
|
task_name: "frontend:automation:save",
|
||||||
|
instructions: `Suggest in language "${this.hass.language}" a name, description, and labels for the following Home Assistant automation.
|
||||||
|
|
||||||
|
The name should be relevant to the automation's purpose.
|
||||||
|
${
|
||||||
|
automationInspiration.length
|
||||||
|
? `The name should be in same style as existing automations.
|
||||||
|
Suggest labels if relevant to the automation's purpose.
|
||||||
|
Only suggest labels that are already used by existing automations.`
|
||||||
|
: `The name should be short, descriptive, sentence case, and written in the language ${this.hass.language}.`
|
||||||
|
}
|
||||||
|
If the automation contains 5+ steps, include a short description.
|
||||||
|
|
||||||
|
For inspiration, here are existing automations:
|
||||||
|
${automationInspiration.join("\n")}
|
||||||
|
|
||||||
|
The automation configuration is as follows:
|
||||||
|
${dump(this._params.config)}
|
||||||
|
`,
|
||||||
|
structure: {
|
||||||
|
name: {
|
||||||
|
description: "The name of the automation",
|
||||||
|
required: true,
|
||||||
|
selector: {
|
||||||
|
text: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
description: "A short description of the automation",
|
||||||
|
required: false,
|
||||||
|
selector: {
|
||||||
|
text: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
labels: {
|
||||||
|
description: "Labels for the automation",
|
||||||
|
required: false,
|
||||||
|
selector: {
|
||||||
|
text: {
|
||||||
|
multiple: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this._newName = result.data.name;
|
||||||
|
if (result.data.description) {
|
||||||
|
this._newDescription = result.data.description;
|
||||||
|
if (!this._visibleOptionals.includes("description")) {
|
||||||
|
this._visibleOptionals = [...this._visibleOptionals, "description"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.data.labels?.length) {
|
||||||
|
// We get back label names, convert them to IDs
|
||||||
|
const newLabels: Record<string, undefined | string> = Object.fromEntries(
|
||||||
|
result.data.labels.map((name) => [name, undefined])
|
||||||
|
);
|
||||||
|
let toFind = result.data.labels.length;
|
||||||
|
for (const [labelId, labelName] of Object.entries(labels)) {
|
||||||
|
if (labelName in newLabels && newLabels[labelName] === undefined) {
|
||||||
|
newLabels[labelName] = labelId;
|
||||||
|
toFind--;
|
||||||
|
if (toFind === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const foundLabels = Object.values(newLabels).filter(
|
||||||
|
(labelId) => labelId !== undefined
|
||||||
|
);
|
||||||
|
if (foundLabels.length) {
|
||||||
|
this._entryUpdates = {
|
||||||
|
...this._entryUpdates,
|
||||||
|
labels: foundLabels,
|
||||||
|
};
|
||||||
|
if (!this._visibleOptionals.includes("labels")) {
|
||||||
|
this._visibleOptionals = [...this._visibleOptionals, "labels"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async _save(): Promise<void> {
|
private async _save(): Promise<void> {
|
||||||
if (!this._newName) {
|
if (!this._newName) {
|
||||||
this._error = "Name is required";
|
this._error = "Name is required";
|
||||||
@ -381,6 +534,10 @@ class DialogAutomationSave extends LitElement implements HassDialog {
|
|||||||
.destructive {
|
.destructive {
|
||||||
--mdc-theme-primary: var(--error-color);
|
--mdc-theme-primary: var(--error-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#suggest {
|
||||||
|
margin: 8px 16px;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -501,6 +501,8 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
|
|||||||
.defaultValue=${this._preprocessYaml()}
|
.defaultValue=${this._preprocessYaml()}
|
||||||
.readOnly=${this._readOnly}
|
.readOnly=${this._readOnly}
|
||||||
@value-changed=${this._yamlChanged}
|
@value-changed=${this._yamlChanged}
|
||||||
|
.showErrors=${false}
|
||||||
|
disable-fullscreen
|
||||||
></ha-yaml-editor>`
|
></ha-yaml-editor>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
|
@ -566,6 +566,7 @@ export default class HaAutomationTriggerRow extends LitElement {
|
|||||||
text: html`
|
text: html`
|
||||||
<ha-yaml-editor
|
<ha-yaml-editor
|
||||||
read-only
|
read-only
|
||||||
|
disable-fullscreen
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.defaultValue=${this._triggered}
|
.defaultValue=${this._triggered}
|
||||||
></ha-yaml-editor>
|
></ha-yaml-editor>
|
||||||
|
@ -173,6 +173,7 @@ export abstract class HaBlueprintGenericEditor extends LitElement {
|
|||||||
.content=${value?.description}
|
.content=${value?.description}
|
||||||
></ha-markdown>
|
></ha-markdown>
|
||||||
${html`<ha-selector
|
${html`<ha-selector
|
||||||
|
narrow
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.selector=${selector}
|
.selector=${selector}
|
||||||
.key=${key}
|
.key=${key}
|
||||||
|
@ -204,6 +204,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
|
|||||||
|
|
||||||
<cloud-tts-pref
|
<cloud-tts-pref
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
|
.narrow=${this.narrow}
|
||||||
.cloudStatus=${this.cloudStatus}
|
.cloudStatus=${this.cloudStatus}
|
||||||
></cloud-tts-pref>
|
></cloud-tts-pref>
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import "@material/mwc-button";
|
import "@material/mwc-button";
|
||||||
|
|
||||||
import { css, html, LitElement, nothing } from "lit";
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
|
import { mdiContentCopy } from "@mdi/js";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||||
@ -20,6 +21,8 @@ import {
|
|||||||
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
import { showAlertDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
import { showTryTtsDialog } from "./show-dialog-cloud-tts-try";
|
import { showTryTtsDialog } from "./show-dialog-cloud-tts-try";
|
||||||
|
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
|
||||||
|
import { showToast } from "../../../../util/toast";
|
||||||
|
|
||||||
export const getCloudTtsSupportedVoices = (
|
export const getCloudTtsSupportedVoices = (
|
||||||
language: string,
|
language: string,
|
||||||
@ -46,6 +49,8 @@ export class CloudTTSPref extends LitElement {
|
|||||||
|
|
||||||
@property({ attribute: false }) public cloudStatus?: CloudStatusLoggedIn;
|
@property({ attribute: false }) public cloudStatus?: CloudStatusLoggedIn;
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||||
|
|
||||||
@state() private savingPreferences = false;
|
@state() private savingPreferences = false;
|
||||||
|
|
||||||
@state() private ttsInfo?: CloudTTSInfo;
|
@state() private ttsInfo?: CloudTTSInfo;
|
||||||
@ -103,6 +108,25 @@ export class CloudTTSPref extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
|
<div class="voice-id" @click=${this._copyVoiceId}>
|
||||||
|
<div class="label">
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.components.media-browser.tts.selected_voice_id"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<code>${defaultVoice[1]}</code>
|
||||||
|
${this.narrow
|
||||||
|
? nothing
|
||||||
|
: html`
|
||||||
|
<ha-icon-button
|
||||||
|
.path=${mdiContentCopy}
|
||||||
|
title=${this.hass.localize(
|
||||||
|
"ui.components.media-browser.tts.copy_voice_id"
|
||||||
|
)}
|
||||||
|
></ha-icon-button>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
<div class="flex"></div>
|
||||||
<mwc-button @click=${this._openTryDialog}>
|
<mwc-button @click=${this._openTryDialog}>
|
||||||
${this.hass.localize("ui.panel.config.cloud.account.tts.try")}
|
${this.hass.localize("ui.panel.config.cloud.account.tts.try")}
|
||||||
</mwc-button>
|
</mwc-button>
|
||||||
@ -196,6 +220,14 @@ export class CloudTTSPref extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async _copyVoiceId(ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
await copyToClipboard(this.cloudStatus!.prefs.tts_default_voice[1]);
|
||||||
|
showToast(this, {
|
||||||
|
message: this.hass.localize("ui.common.copied_clipboard"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static styles = css`
|
static styles = css`
|
||||||
a {
|
a {
|
||||||
color: var(--primary-color);
|
color: var(--primary-color);
|
||||||
@ -226,7 +258,34 @@ export class CloudTTSPref extends LitElement {
|
|||||||
}
|
}
|
||||||
.card-actions {
|
.card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row-reverse;
|
align-items: center;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-weight: var(--ha-font-weight-bold);
|
||||||
|
}
|
||||||
|
.voice-id {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--ha-font-size-s);
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
--mdc-icon-size: 14px;
|
||||||
|
--mdc-icon-button-size: 24px;
|
||||||
|
}
|
||||||
|
:host([narrow]) .voice-id {
|
||||||
|
flex-direction: column;
|
||||||
|
font-size: var(--ha-font-size-xs);
|
||||||
|
align-items: start;
|
||||||
|
align-items: left;
|
||||||
|
}
|
||||||
|
:host([narrow]) .label {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
:host([narrow]) code {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
.flex {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
160
src/panels/config/core/ai-task-pref.ts
Normal file
160
src/panels/config/core/ai-task-pref.ts
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
import "@material/mwc-button";
|
||||||
|
import { mdiHelpCircle, mdiStarFourPoints } from "@mdi/js";
|
||||||
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators";
|
||||||
|
import "../../../components/ha-card";
|
||||||
|
import "../../../components/ha-settings-row";
|
||||||
|
import "../../../components/entity/ha-entity-picker";
|
||||||
|
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||||
|
import type { HomeAssistant } from "../../../types";
|
||||||
|
import { brandsUrl } from "../../../util/brands-url";
|
||||||
|
import {
|
||||||
|
fetchAITaskPreferences,
|
||||||
|
saveAITaskPreferences,
|
||||||
|
type AITaskPreferences,
|
||||||
|
} from "../../../data/ai_task";
|
||||||
|
import { documentationUrl } from "../../../util/documentation-url";
|
||||||
|
|
||||||
|
@customElement("ai-task-pref")
|
||||||
|
export class AITaskPref extends LitElement {
|
||||||
|
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@state() private _prefs?: AITaskPreferences;
|
||||||
|
|
||||||
|
protected firstUpdated(changedProps) {
|
||||||
|
super.firstUpdated(changedProps);
|
||||||
|
fetchAITaskPreferences(this.hass).then((prefs) => {
|
||||||
|
this._prefs = prefs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
if (!this._prefs) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<ha-card outlined>
|
||||||
|
<h1 class="card-header">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
src=${brandsUrl({
|
||||||
|
domain: "ai_task",
|
||||||
|
type: "icon",
|
||||||
|
darkOptimized: this.hass.themes?.darkMode,
|
||||||
|
})}
|
||||||
|
crossorigin="anonymous"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
/>${this.hass.localize("ui.panel.config.ai_task.header")}
|
||||||
|
</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<a
|
||||||
|
href=${documentationUrl(this.hass, "/integrations/ai_task/")}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
class="icon-link"
|
||||||
|
>
|
||||||
|
<ha-icon-button
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.panel.config.cloud.account.alexa.link_learn_how_it_works"
|
||||||
|
)}
|
||||||
|
.path=${mdiHelpCircle}
|
||||||
|
></ha-icon-button>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="card-content">
|
||||||
|
<p>
|
||||||
|
${this.hass!.localize("ui.panel.config.ai_task.description", {
|
||||||
|
button: html`<ha-svg-icon
|
||||||
|
.path=${mdiStarFourPoints}
|
||||||
|
></ha-svg-icon>`,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<ha-settings-row .narrow=${this.narrow}>
|
||||||
|
<span slot="heading">
|
||||||
|
${this.hass!.localize("ui.panel.config.ai_task.gen_data_header")}
|
||||||
|
</span>
|
||||||
|
<span slot="description">
|
||||||
|
${this.hass!.localize(
|
||||||
|
"ui.panel.config.ai_task.gen_data_description"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<ha-entity-picker
|
||||||
|
data-name="gen_data_entity_id"
|
||||||
|
.hass=${this.hass}
|
||||||
|
.value=${this._prefs.gen_data_entity_id}
|
||||||
|
.includeDomains=${["ai_task"]}
|
||||||
|
@value-changed=${this._handlePrefChange}
|
||||||
|
></ha-entity-picker>
|
||||||
|
</ha-settings-row>
|
||||||
|
</div>
|
||||||
|
</ha-card>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handlePrefChange(
|
||||||
|
ev: CustomEvent<{ value: string | undefined }>
|
||||||
|
) {
|
||||||
|
const input = ev.target as HaEntityPicker;
|
||||||
|
const key = input.getAttribute("data-name") as keyof AITaskPreferences;
|
||||||
|
const entityId = ev.detail.value || null;
|
||||||
|
const oldPrefs = this._prefs;
|
||||||
|
this._prefs = { ...this._prefs!, [key]: entityId };
|
||||||
|
try {
|
||||||
|
this._prefs = await saveAITaskPreferences(this.hass, {
|
||||||
|
[key]: entityId,
|
||||||
|
});
|
||||||
|
} catch (_err: any) {
|
||||||
|
this._prefs = oldPrefs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.card-header img {
|
||||||
|
max-width: 28px;
|
||||||
|
margin-right: 16px;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
ha-settings-row {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.header-actions {
|
||||||
|
position: absolute;
|
||||||
|
right: 0px;
|
||||||
|
inset-inline-end: 0px;
|
||||||
|
inset-inline-start: initial;
|
||||||
|
top: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.header-actions .icon-link {
|
||||||
|
margin-top: -16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-inline-end: 8px;
|
||||||
|
margin-inline-start: initial;
|
||||||
|
direction: var(--direction);
|
||||||
|
color: var(--secondary-text-color);
|
||||||
|
}
|
||||||
|
ha-entity-picker {
|
||||||
|
flex: 1;
|
||||||
|
margin-left: 16px;
|
||||||
|
}
|
||||||
|
:host([narrow]) ha-entity-picker {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ai-task-pref": AITaskPref;
|
||||||
|
}
|
||||||
|
}
|
@ -25,8 +25,10 @@ import type { ConfigUpdateValues } from "../../../data/core";
|
|||||||
import { saveCoreConfig } from "../../../data/core";
|
import { saveCoreConfig } from "../../../data/core";
|
||||||
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||||
import "../../../layouts/hass-subpage";
|
import "../../../layouts/hass-subpage";
|
||||||
|
import "./ai-task-pref";
|
||||||
import { haStyle } from "../../../resources/styles";
|
import { haStyle } from "../../../resources/styles";
|
||||||
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
|
||||||
|
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||||
|
|
||||||
@customElement("ha-config-section-general")
|
@customElement("ha-config-section-general")
|
||||||
class HaConfigSectionGeneral extends LitElement {
|
class HaConfigSectionGeneral extends LitElement {
|
||||||
@ -265,6 +267,12 @@ class HaConfigSectionGeneral extends LitElement {
|
|||||||
</ha-progress-button>
|
</ha-progress-button>
|
||||||
</div>
|
</div>
|
||||||
</ha-card>
|
</ha-card>
|
||||||
|
${isComponentLoaded(this.hass, "ai_task")
|
||||||
|
? html`<ai-task-pref
|
||||||
|
.hass=${this.hass}
|
||||||
|
.narrow=${this.narrow}
|
||||||
|
></ai-task-pref>`
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
</hass-subpage>
|
</hass-subpage>
|
||||||
`;
|
`;
|
||||||
@ -377,7 +385,8 @@ class HaConfigSectionGeneral extends LitElement {
|
|||||||
max-width: 1040px;
|
max-width: 1040px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
ha-card {
|
ha-card,
|
||||||
|
ai-task-pref {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -385,6 +394,10 @@ class HaConfigSectionGeneral extends LitElement {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
ha-card,
|
||||||
|
ai-task-pref {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
.card-content {
|
.card-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
@ -58,7 +58,7 @@ export class DashboardCard extends LitElement {
|
|||||||
.card-header {
|
.card-header {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: block;
|
display: block;
|
||||||
text-align: left;
|
text-align: var(--float-start);
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.preview {
|
.preview {
|
||||||
|
@ -54,6 +54,9 @@ class HaConfigNavigation extends LitElement {
|
|||||||
`,
|
`,
|
||||||
}));
|
}));
|
||||||
return html`
|
return html`
|
||||||
|
<div class="visually-hidden" role="heading" aria-level="2">
|
||||||
|
${this.hass.localize("panel.config")}
|
||||||
|
</div>
|
||||||
<ha-navigation-list
|
<ha-navigation-list
|
||||||
has-secondary
|
has-secondary
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
@ -68,6 +71,17 @@ class HaConfigNavigation extends LitElement {
|
|||||||
ha-navigation-list {
|
ha-navigation-list {
|
||||||
--navigation-list-item-title-font-size: var(--ha-font-size-l);
|
--navigation-list-item-title-font-size: var(--ha-font-size-l);
|
||||||
}
|
}
|
||||||
|
/* Accessibility */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
height: 1px;
|
||||||
|
width: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,7 +64,7 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
|
|||||||
const updates = this.updateEntities;
|
const updates = this.updateEntities;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="title">
|
<div class="title" role="heading" aria-level="2">
|
||||||
${this.hass.localize("ui.panel.config.updates.title", {
|
${this.hass.localize("ui.panel.config.updates.title", {
|
||||||
count: this.total || this.updateEntities.length,
|
count: this.total || this.updateEntities.length,
|
||||||
})}
|
})}
|
||||||
@ -115,7 +115,7 @@ class HaConfigUpdates extends SubscribeMixin(LitElement) {
|
|||||||
></ha-spinner>`
|
></ha-spinner>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span slot="headline"
|
||||||
>${deviceEntry
|
>${deviceEntry
|
||||||
? computeDeviceNameDisplay(deviceEntry, this.hass)
|
? computeDeviceNameDisplay(deviceEntry, this.hass)
|
||||||
: entity.attributes.friendly_name}</span
|
: entity.attributes.friendly_name}</span
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
mdiChatQuestion,
|
mdiChatQuestion,
|
||||||
mdiCog,
|
mdiCog,
|
||||||
|
mdiDelete,
|
||||||
mdiDeleteForever,
|
mdiDeleteForever,
|
||||||
mdiHospitalBox,
|
mdiHospitalBox,
|
||||||
mdiInformation,
|
mdiInformation,
|
||||||
@ -16,17 +17,19 @@ import {
|
|||||||
fetchZwaveIsNodeFirmwareUpdateInProgress,
|
fetchZwaveIsNodeFirmwareUpdateInProgress,
|
||||||
fetchZwaveNetworkStatus,
|
fetchZwaveNetworkStatus,
|
||||||
fetchZwaveNodeStatus,
|
fetchZwaveNodeStatus,
|
||||||
|
fetchZwaveProvisioningEntries,
|
||||||
|
unprovisionZwaveSmartStartNode,
|
||||||
} from "../../../../../../data/zwave_js";
|
} from "../../../../../../data/zwave_js";
|
||||||
import { showConfirmationDialog } from "../../../../../../dialogs/generic/show-dialog-box";
|
import { showConfirmationDialog } from "../../../../../../dialogs/generic/show-dialog-box";
|
||||||
import type { HomeAssistant } from "../../../../../../types";
|
import type { HomeAssistant } from "../../../../../../types";
|
||||||
import { showZWaveJSRebuildNodeRoutesDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-rebuild-node-routes";
|
import { showZWaveJSRebuildNodeRoutesDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-rebuild-node-routes";
|
||||||
import { showZWaveJSNodeStatisticsDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics";
|
import { showZWaveJSNodeStatisticsDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-node-statistics";
|
||||||
import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node";
|
import { showZWaveJSReinterviewNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-reinterview-node";
|
||||||
import { showZWaveJSRemoveFailedNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-failed-node";
|
|
||||||
import { showZWaveJSUpdateFirmwareNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node";
|
import { showZWaveJSUpdateFirmwareNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-update-firmware-node";
|
||||||
import type { DeviceAction } from "../../../ha-config-device-page";
|
import type { DeviceAction } from "../../../ha-config-device-page";
|
||||||
import { showZWaveJSHardResetControllerDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-hard-reset-controller";
|
import { showZWaveJSHardResetControllerDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-hard-reset-controller";
|
||||||
import { showZWaveJSAddNodeDialog } from "../../../../integrations/integration-panels/zwave_js/add-node/show-dialog-zwave_js-add-node";
|
import { showZWaveJSAddNodeDialog } from "../../../../integrations/integration-panels/zwave_js/add-node/show-dialog-zwave_js-add-node";
|
||||||
|
import { showZWaveJSRemoveNodeDialog } from "../../../../integrations/integration-panels/zwave_js/show-dialog-zwave_js-remove-node";
|
||||||
|
|
||||||
export const getZwaveDeviceActions = async (
|
export const getZwaveDeviceActions = async (
|
||||||
el: HTMLElement,
|
el: HTMLElement,
|
||||||
@ -47,6 +50,43 @@ export const getZwaveDeviceActions = async (
|
|||||||
|
|
||||||
const entryId = configEntry.entry_id;
|
const entryId = configEntry.entry_id;
|
||||||
|
|
||||||
|
const provisioningEntries = await fetchZwaveProvisioningEntries(
|
||||||
|
hass,
|
||||||
|
entryId
|
||||||
|
);
|
||||||
|
const provisioningEntry = provisioningEntries.find(
|
||||||
|
(entry) => entry.device_id === device.id
|
||||||
|
);
|
||||||
|
if (provisioningEntry && !provisioningEntry.nodeId) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: hass.localize("ui.panel.config.devices.delete_device"),
|
||||||
|
classes: "warning",
|
||||||
|
icon: mdiDelete,
|
||||||
|
action: async () => {
|
||||||
|
const confirm = await showConfirmationDialog(el, {
|
||||||
|
title: hass.localize(
|
||||||
|
"ui.panel.config.zwave_js.provisioned.confirm_unprovision_title"
|
||||||
|
),
|
||||||
|
text: hass.localize(
|
||||||
|
"ui.panel.config.zwave_js.provisioned.confirm_unprovision_text",
|
||||||
|
{ name: device.name_by_user || device.name }
|
||||||
|
),
|
||||||
|
confirmText: hass.localize("ui.common.remove"),
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirm) {
|
||||||
|
await unprovisionZwaveSmartStartNode(
|
||||||
|
hass,
|
||||||
|
entryId,
|
||||||
|
provisioningEntry.dsk
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
const nodeStatus = await fetchZwaveNodeStatus(hass, device.id);
|
const nodeStatus = await fetchZwaveNodeStatus(hass, device.id);
|
||||||
|
|
||||||
if (!nodeStatus) {
|
if (!nodeStatus) {
|
||||||
@ -84,16 +124,6 @@ export const getZwaveDeviceActions = async (
|
|||||||
device,
|
device,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: hass.localize(
|
|
||||||
"ui.panel.config.zwave_js.device_info.remove_failed"
|
|
||||||
),
|
|
||||||
icon: mdiDeleteForever,
|
|
||||||
action: () =>
|
|
||||||
showZWaveJSRemoveFailedNodeDialog(el, {
|
|
||||||
device_id: device.id,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: hass.localize(
|
label: hass.localize(
|
||||||
"ui.panel.config.zwave_js.device_info.node_statistics"
|
"ui.panel.config.zwave_js.device_info.node_statistics"
|
||||||
@ -103,6 +133,16 @@ export const getZwaveDeviceActions = async (
|
|||||||
showZWaveJSNodeStatisticsDialog(el, {
|
showZWaveJSNodeStatisticsDialog(el, {
|
||||||
device,
|
device,
|
||||||
}),
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: hass.localize("ui.panel.config.devices.delete_device"),
|
||||||
|
classes: "warning",
|
||||||
|
icon: mdiDelete,
|
||||||
|
action: () =>
|
||||||
|
showZWaveJSRemoveNodeDialog(el, {
|
||||||
|
deviceId: device.id,
|
||||||
|
entryId,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,11 +10,12 @@ import {
|
|||||||
mdiPlusCircle,
|
mdiPlusCircle,
|
||||||
mdiRestore,
|
mdiRestore,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||||
import { LitElement, css, html, nothing } from "lit";
|
import { LitElement, css, html, nothing } from "lit";
|
||||||
import { customElement, property, state } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { ifDefined } from "lit/directives/if-defined";
|
import { ifDefined } from "lit/directives/if-defined";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
|
import type { HassEntity } from "home-assistant-js-websocket";
|
||||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||||
import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../../common/const";
|
import { ASSIST_ENTITIES, SENSOR_ENTITIES } from "../../../common/const";
|
||||||
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||||
@ -185,6 +186,27 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private _getEntitiesSorted = (entities: HassEntity[]) =>
|
||||||
|
entities.sort((ent1, ent2) =>
|
||||||
|
stringCompare(
|
||||||
|
ent1.attributes.friendly_name || `zzz${ent1.entity_id}`,
|
||||||
|
ent2.attributes.friendly_name || `zzz${ent2.entity_id}`,
|
||||||
|
this.hass.locale.language
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
private _getRelated = memoizeOne((related?: RelatedResult) => ({
|
||||||
|
automation: this._getEntitiesSorted(
|
||||||
|
(related?.automation ?? []).map((entityId) => this.hass.states[entityId])
|
||||||
|
),
|
||||||
|
scene: this._getEntitiesSorted(
|
||||||
|
(related?.scene ?? []).map((entityId) => this.hass.states[entityId])
|
||||||
|
),
|
||||||
|
script: this._getEntitiesSorted(
|
||||||
|
(related?.script ?? []).map((entityId) => this.hass.states[entityId])
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
private _deviceIdInList = memoizeOne((deviceId: string) => [deviceId]);
|
private _deviceIdInList = memoizeOne((deviceId: string) => [deviceId]);
|
||||||
|
|
||||||
private _entityIds = memoizeOne(
|
private _entityIds = memoizeOne(
|
||||||
@ -251,22 +273,24 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
findBatteryChargingEntity(this.hass, entities)
|
findBatteryChargingEntity(this.hass, entities)
|
||||||
);
|
);
|
||||||
|
|
||||||
public willUpdate(changedProps) {
|
public willUpdate(changedProps: PropertyValues<this>) {
|
||||||
super.willUpdate(changedProps);
|
super.willUpdate(changedProps);
|
||||||
|
|
||||||
if (changedProps.has("deviceId") || changedProps.has("entries")) {
|
if (changedProps.has("deviceId")) {
|
||||||
this._deviceActions = [];
|
this._deviceActions = [];
|
||||||
this._deviceAlerts = [];
|
this._deviceAlerts = [];
|
||||||
this._deleteButtons = [];
|
this._deleteButtons = [];
|
||||||
this._diagnosticDownloadLinks = [];
|
this._diagnosticDownloadLinks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedProps.has("deviceId") || changedProps.has("entries")) {
|
||||||
this._fetchData();
|
this._fetchData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected firstUpdated(changedProps) {
|
protected firstUpdated(changedProps: PropertyValues) {
|
||||||
super.firstUpdated(changedProps);
|
super.firstUpdated(changedProps);
|
||||||
loadDeviceRegistryDetailDialog();
|
loadDeviceRegistryDetailDialog();
|
||||||
this._fetchData();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected updated(changedProps) {
|
protected updated(changedProps) {
|
||||||
@ -433,23 +457,25 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
${this._related?.automation?.length
|
${this._related?.automation?.length
|
||||||
? html`
|
? html`
|
||||||
<div class="items">
|
<div class="items">
|
||||||
${this._related.automation.map((automation) => {
|
${this._getRelated(this._related).automation.map(
|
||||||
const entityState = this.hass.states[automation];
|
(automation) => {
|
||||||
return entityState
|
const entityState = automation;
|
||||||
? html`<a
|
return entityState
|
||||||
href=${ifDefined(
|
? html`<a
|
||||||
entityState.attributes.id
|
href=${ifDefined(
|
||||||
? `/config/automation/edit/${encodeURIComponent(entityState.attributes.id)}`
|
entityState.attributes.id
|
||||||
: `/config/automation/show/${entityState.entity_id}`
|
? `/config/automation/edit/${encodeURIComponent(entityState.attributes.id)}`
|
||||||
)}
|
: `/config/automation/show/${entityState.entity_id}`
|
||||||
>
|
)}
|
||||||
<ha-list-item hasMeta .automation=${entityState}>
|
>
|
||||||
${computeStateName(entityState)}
|
<ha-list-item hasMeta .automation=${entityState}>
|
||||||
<ha-icon-next slot="meta"></ha-icon-next>
|
${computeStateName(entityState)}
|
||||||
</ha-list-item>
|
<ha-icon-next slot="meta"></ha-icon-next>
|
||||||
</a>`
|
</ha-list-item>
|
||||||
: nothing;
|
</a>`
|
||||||
})}
|
: nothing;
|
||||||
|
}
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
: html`
|
: html`
|
||||||
@ -510,8 +536,8 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
${this._related?.scene?.length
|
${this._related?.scene?.length
|
||||||
? html`
|
? html`
|
||||||
<div class="items">
|
<div class="items">
|
||||||
${this._related.scene.map((scene) => {
|
${this._getRelated(this._related).scene.map((scene) => {
|
||||||
const entityState = this.hass.states[scene];
|
const entityState = scene;
|
||||||
return entityState && entityState.attributes.id
|
return entityState && entityState.attributes.id
|
||||||
? html`
|
? html`
|
||||||
<a
|
<a
|
||||||
@ -598,10 +624,10 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
${this._related?.script?.length
|
${this._related?.script?.length
|
||||||
? html`
|
? html`
|
||||||
<div class="items">
|
<div class="items">
|
||||||
${this._related.script.map((script) => {
|
${this._getRelated(this._related).script.map((script) => {
|
||||||
const entityState = this.hass.states[script];
|
const entityState = script;
|
||||||
const entry = this._entityReg.find(
|
const entry = this._entityReg.find(
|
||||||
(e) => e.entity_id === script
|
(e) => e.entity_id === script.entity_id
|
||||||
);
|
);
|
||||||
let url = `/config/script/show/${entityState.entity_id}`;
|
let url = `/config/script/show/${entityState.entity_id}`;
|
||||||
if (entry) {
|
if (entry) {
|
||||||
@ -965,6 +991,7 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private _getDeleteActions() {
|
private _getDeleteActions() {
|
||||||
|
const deviceId = this.deviceId;
|
||||||
const device = this.hass.devices[this.deviceId];
|
const device = this.hass.devices[this.deviceId];
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -1034,12 +1061,18 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (this.deviceId !== deviceId) {
|
||||||
|
// abort if the device has changed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (buttons.length > 0) {
|
if (buttons.length > 0) {
|
||||||
this._deleteButtons = buttons;
|
this._deleteButtons = buttons;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _getDeviceActions() {
|
private async _getDeviceActions() {
|
||||||
|
const deviceId = this.deviceId;
|
||||||
const device = this.hass.devices[this.deviceId];
|
const device = this.hass.devices[this.deviceId];
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -1133,14 +1166,25 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
|
|
||||||
// load matter device actions async to avoid an UI with 0 actions when the matter integration needs very long to get node diagnostics
|
// load matter device actions async to avoid an UI with 0 actions when the matter integration needs very long to get node diagnostics
|
||||||
matter.getMatterDeviceActions(this, this.hass, device).then((actions) => {
|
matter.getMatterDeviceActions(this, this.hass, device).then((actions) => {
|
||||||
|
if (this.deviceId !== deviceId) {
|
||||||
|
// abort if the device has changed
|
||||||
|
return;
|
||||||
|
}
|
||||||
this._deviceActions = [...actions, ...(this._deviceActions || [])];
|
this._deviceActions = [...actions, ...(this._deviceActions || [])];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.deviceId !== deviceId) {
|
||||||
|
// abort if the device has changed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this._deviceActions = deviceActions;
|
this._deviceActions = deviceActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _getDeviceAlerts() {
|
private async _getDeviceAlerts() {
|
||||||
|
const deviceId = this.deviceId;
|
||||||
|
|
||||||
const device = this.hass.devices[this.deviceId];
|
const device = this.hass.devices[this.deviceId];
|
||||||
|
|
||||||
if (!device) {
|
if (!device) {
|
||||||
@ -1164,6 +1208,11 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
deviceAlerts.push(...alerts);
|
deviceAlerts.push(...alerts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.deviceId !== deviceId) {
|
||||||
|
// abort if the device has changed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this._deviceAlerts = deviceAlerts;
|
this._deviceAlerts = deviceAlerts;
|
||||||
if (deviceAlerts.length) {
|
if (deviceAlerts.length) {
|
||||||
this._deviceAlertsActionsTimeout = window.setTimeout(() => {
|
this._deviceAlertsActionsTimeout = window.setTimeout(() => {
|
||||||
@ -1293,9 +1342,13 @@ export class HaConfigDevicePage extends LitElement {
|
|||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
(await showConfirmationDialog(this, {
|
(await showConfirmationDialog(this, {
|
||||||
title: this.hass.localize(
|
title: this.hass.localize(
|
||||||
"ui.panel.config.devices.confirm_disable_config_entry",
|
"ui.panel.config.devices.confirm_disable_config_entry_title"
|
||||||
{ entry_name: config_entry.title }
|
|
||||||
),
|
),
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.config.devices.confirm_disable_config_entry_message",
|
||||||
|
{ name: config_entry.title }
|
||||||
|
),
|
||||||
|
destructive: true,
|
||||||
confirmText: this.hass.localize("ui.common.yes"),
|
confirmText: this.hass.localize("ui.common.yes"),
|
||||||
dismissText: this.hass.localize("ui.common.no"),
|
dismissText: this.hass.localize("ui.common.no"),
|
||||||
}))
|
}))
|
||||||
|
@ -1099,10 +1099,10 @@ ${
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected firstUpdated() {
|
protected firstUpdated() {
|
||||||
|
this._setFiltersFromUrl();
|
||||||
fetchEntitySourcesWithCache(this.hass).then((sources) => {
|
fetchEntitySourcesWithCache(this.hass).then((sources) => {
|
||||||
this._entitySources = sources;
|
this._entitySources = sources;
|
||||||
});
|
});
|
||||||
this._setFiltersFromUrl();
|
|
||||||
if (Object.keys(this._filters).length) {
|
if (Object.keys(this._filters).length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1115,9 +1115,10 @@ ${
|
|||||||
const domain = this._searchParms.get("domain");
|
const domain = this._searchParms.get("domain");
|
||||||
const configEntry = this._searchParms.get("config_entry");
|
const configEntry = this._searchParms.get("config_entry");
|
||||||
const subEntry = this._searchParms.get("sub_entry");
|
const subEntry = this._searchParms.get("sub_entry");
|
||||||
const label = this._searchParms.has("label");
|
const device = this._searchParms.get("device");
|
||||||
|
const label = this._searchParms.get("label");
|
||||||
|
|
||||||
if (!domain && !configEntry && !label) {
|
if (!domain && !configEntry && !label && !device) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1126,21 +1127,11 @@ ${
|
|||||||
this._filters = {
|
this._filters = {
|
||||||
"ha-filter-states": [],
|
"ha-filter-states": [],
|
||||||
"ha-filter-integrations": domain ? [domain] : [],
|
"ha-filter-integrations": domain ? [domain] : [],
|
||||||
|
"ha-filter-devices": device ? [device] : [],
|
||||||
|
"ha-filter-labels": label ? [label] : [],
|
||||||
config_entry: configEntry ? [configEntry] : [],
|
config_entry: configEntry ? [configEntry] : [],
|
||||||
sub_entry: subEntry ? [subEntry] : [],
|
sub_entry: subEntry ? [subEntry] : [],
|
||||||
};
|
};
|
||||||
this._filterLabel();
|
|
||||||
}
|
|
||||||
|
|
||||||
private _filterLabel() {
|
|
||||||
const label = this._searchParms.get("label");
|
|
||||||
if (!label) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._filters = {
|
|
||||||
...this._filters,
|
|
||||||
"ha-filter-labels": [label],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _clearFilter() {
|
private _clearFilter() {
|
||||||
@ -1150,6 +1141,11 @@ ${
|
|||||||
|
|
||||||
public willUpdate(changedProps: PropertyValues): void {
|
public willUpdate(changedProps: PropertyValues): void {
|
||||||
super.willUpdate(changedProps);
|
super.willUpdate(changedProps);
|
||||||
|
|
||||||
|
if (!this.hasUpdated) {
|
||||||
|
this._setFiltersFromUrl();
|
||||||
|
}
|
||||||
|
|
||||||
const oldHass = changedProps.get("hass");
|
const oldHass = changedProps.get("hass");
|
||||||
let changed = false;
|
let changed = false;
|
||||||
if (!this.hass || !this._entities) {
|
if (!this.hass || !this._entities) {
|
||||||
|
@ -5,10 +5,14 @@ import { fireEvent } from "../../../../common/dom/fire_event";
|
|||||||
import "../../../../components/ha-checkbox";
|
import "../../../../components/ha-checkbox";
|
||||||
import "../../../../components/ha-formfield";
|
import "../../../../components/ha-formfield";
|
||||||
import "../../../../components/ha-icon-picker";
|
import "../../../../components/ha-icon-picker";
|
||||||
|
import "../../../../components/ha-duration-input";
|
||||||
import "../../../../components/ha-textfield";
|
import "../../../../components/ha-textfield";
|
||||||
import type { DurationDict, Timer } from "../../../../data/timer";
|
import type { DurationDict, Timer } from "../../../../data/timer";
|
||||||
import { haStyle } from "../../../../resources/styles";
|
import { haStyle } from "../../../../resources/styles";
|
||||||
import type { HomeAssistant } from "../../../../types";
|
import type { HomeAssistant } from "../../../../types";
|
||||||
|
import { createDurationData } from "../../../../common/datetime/create_duration_data";
|
||||||
|
import type { HaDurationData } from "../../../../components/ha-duration-input";
|
||||||
|
import type { ForDict } from "../../../../data/automation";
|
||||||
|
|
||||||
@customElement("ha-timer-form")
|
@customElement("ha-timer-form")
|
||||||
class HaTimerForm extends LitElement {
|
class HaTimerForm extends LitElement {
|
||||||
@ -24,6 +28,8 @@ class HaTimerForm extends LitElement {
|
|||||||
|
|
||||||
@state() private _duration!: string | number | DurationDict;
|
@state() private _duration!: string | number | DurationDict;
|
||||||
|
|
||||||
|
@state() private _duration_data!: HaDurationData | undefined;
|
||||||
|
|
||||||
@state() private _restore!: boolean;
|
@state() private _restore!: boolean;
|
||||||
|
|
||||||
set item(item: Timer) {
|
set item(item: Timer) {
|
||||||
@ -39,6 +45,8 @@ class HaTimerForm extends LitElement {
|
|||||||
this._duration = "00:00:00";
|
this._duration = "00:00:00";
|
||||||
this._restore = false;
|
this._restore = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this._setDurationData();
|
||||||
}
|
}
|
||||||
|
|
||||||
public focus() {
|
public focus() {
|
||||||
@ -79,14 +87,11 @@ class HaTimerForm extends LitElement {
|
|||||||
"ui.dialogs.helper_settings.generic.icon"
|
"ui.dialogs.helper_settings.generic.icon"
|
||||||
)}
|
)}
|
||||||
></ha-icon-picker>
|
></ha-icon-picker>
|
||||||
<ha-textfield
|
<ha-duration-input
|
||||||
.configValue=${"duration"}
|
.configValue=${"duration"}
|
||||||
.value=${this._duration}
|
.data=${this._duration_data}
|
||||||
@input=${this._valueChanged}
|
@value-changed=${this._valueChanged}
|
||||||
.label=${this.hass.localize(
|
></ha-duration-input>
|
||||||
"ui.dialogs.helper_settings.timer.duration"
|
|
||||||
)}
|
|
||||||
></ha-textfield>
|
|
||||||
<ha-formfield
|
<ha-formfield
|
||||||
.label=${this.hass.localize(
|
.label=${this.hass.localize(
|
||||||
"ui.dialogs.helper_settings.timer.restore"
|
"ui.dialogs.helper_settings.timer.restore"
|
||||||
@ -131,6 +136,25 @@ class HaTimerForm extends LitElement {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _setDurationData() {
|
||||||
|
let durationInput: string | number | ForDict;
|
||||||
|
|
||||||
|
if (typeof this._duration === "object" && this._duration !== null) {
|
||||||
|
const d = this._duration as DurationDict;
|
||||||
|
durationInput = {
|
||||||
|
hours: typeof d.hours === "string" ? parseFloat(d.hours) : d.hours,
|
||||||
|
minutes:
|
||||||
|
typeof d.minutes === "string" ? parseFloat(d.minutes) : d.minutes,
|
||||||
|
seconds:
|
||||||
|
typeof d.seconds === "string" ? parseFloat(d.seconds) : d.seconds,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
durationInput = this._duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._duration_data = createDurationData(durationInput);
|
||||||
|
}
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
static get styles(): CSSResultGroup {
|
||||||
return [
|
return [
|
||||||
haStyle,
|
haStyle,
|
||||||
@ -138,7 +162,8 @@ class HaTimerForm extends LitElement {
|
|||||||
.form {
|
.form {
|
||||||
color: var(--primary-text-color);
|
color: var(--primary-text-color);
|
||||||
}
|
}
|
||||||
ha-textfield {
|
ha-textfield,
|
||||||
|
ha-duration-input {
|
||||||
display: block;
|
display: block;
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
98
src/panels/config/integrations/dialog-pick-config-entry.ts
Normal file
98
src/panels/config/integrations/dialog-pick-config-entry.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { mdiClose } from "@mdi/js";
|
||||||
|
import { LitElement, css, html, nothing } from "lit";
|
||||||
|
import { customElement, property, query, state } from "lit/decorators";
|
||||||
|
import { fireEvent } from "../../../common/dom/fire_event";
|
||||||
|
import "../../../components/ha-dialog-header";
|
||||||
|
import "../../../components/ha-icon-button";
|
||||||
|
import "../../../components/ha-md-dialog";
|
||||||
|
import type { HaMdDialog } from "../../../components/ha-md-dialog";
|
||||||
|
import "../../../components/ha-md-list";
|
||||||
|
import "../../../components/ha-md-list-item";
|
||||||
|
import { ERROR_STATES, RECOVERABLE_STATES } from "../../../data/config_entries";
|
||||||
|
import type { HomeAssistant } from "../../../types";
|
||||||
|
import type { PickConfigEntryDialogParams } from "./show-pick-config-entry-dialog";
|
||||||
|
|
||||||
|
@customElement("dialog-pick-config-entry")
|
||||||
|
export class DialogPickConfigEntry extends LitElement {
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@state() private _params?: PickConfigEntryDialogParams;
|
||||||
|
|
||||||
|
@query("ha-md-dialog") private _dialog?: HaMdDialog;
|
||||||
|
|
||||||
|
public showDialog(params: PickConfigEntryDialogParams): void {
|
||||||
|
this._params = params;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _dialogClosed(): void {
|
||||||
|
this._params = undefined;
|
||||||
|
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||||
|
}
|
||||||
|
|
||||||
|
public closeDialog() {
|
||||||
|
this._dialog?.close();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
if (!this._params) {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<ha-md-dialog open @closed=${this._dialogClosed}>
|
||||||
|
<ha-dialog-header slot="headline">
|
||||||
|
<ha-icon-button
|
||||||
|
slot="navigationIcon"
|
||||||
|
.label=${this.hass.localize("ui.common.close")}
|
||||||
|
.path=${mdiClose}
|
||||||
|
@click=${this.closeDialog}
|
||||||
|
></ha-icon-button>
|
||||||
|
<span
|
||||||
|
slot="title"
|
||||||
|
.title=${this.hass.localize(
|
||||||
|
`component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user`
|
||||||
|
)}
|
||||||
|
>${this.hass.localize(
|
||||||
|
`component.${this._params.domain}.config_subentries.${this._params.subFlowType}.initiate_flow.user`
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
</ha-dialog-header>
|
||||||
|
<ha-md-list slot="content">
|
||||||
|
${this._params.configEntries.map(
|
||||||
|
(entry) =>
|
||||||
|
html`<ha-md-list-item
|
||||||
|
type="button"
|
||||||
|
@click=${this._itemPicked}
|
||||||
|
.entry=${entry}
|
||||||
|
.disabled=${!ERROR_STATES.includes(entry.state) &&
|
||||||
|
!RECOVERABLE_STATES.includes(entry.state)}
|
||||||
|
>${entry.title}</ha-md-list-item
|
||||||
|
>`
|
||||||
|
)}
|
||||||
|
</ha-md-list>
|
||||||
|
</ha-md-dialog>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _itemPicked(ev: Event) {
|
||||||
|
this._params?.configEntryPicked((ev.currentTarget as any).entry);
|
||||||
|
this.closeDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
:host {
|
||||||
|
--dialog-content-padding: 0;
|
||||||
|
}
|
||||||
|
@media all and (min-width: 600px) {
|
||||||
|
ha-dialog {
|
||||||
|
--mdc-dialog-min-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"dialog-pick-config-entry": DialogPickConfigEntry;
|
||||||
|
}
|
||||||
|
}
|
330
src/panels/config/integrations/ha-config-entry-device-row.ts
Normal file
330
src/panels/config/integrations/ha-config-entry-device-row.ts
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
import {
|
||||||
|
mdiDelete,
|
||||||
|
mdiDevices,
|
||||||
|
mdiDotsVertical,
|
||||||
|
mdiPencil,
|
||||||
|
mdiShapeOutline,
|
||||||
|
mdiStopCircleOutline,
|
||||||
|
mdiTransitConnectionVariant,
|
||||||
|
} from "@mdi/js";
|
||||||
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
|
import { customElement, property } from "lit/decorators";
|
||||||
|
import { classMap } from "lit/directives/class-map";
|
||||||
|
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||||
|
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
|
||||||
|
import { getDeviceContext } from "../../../common/entity/context/get_device_context";
|
||||||
|
import { navigate } from "../../../common/navigate";
|
||||||
|
import {
|
||||||
|
disableConfigEntry,
|
||||||
|
type ConfigEntry,
|
||||||
|
type DisableConfigEntryResult,
|
||||||
|
} from "../../../data/config_entries";
|
||||||
|
import {
|
||||||
|
removeConfigEntryFromDevice,
|
||||||
|
updateDeviceRegistryEntry,
|
||||||
|
type DeviceRegistryEntry,
|
||||||
|
} from "../../../data/device_registry";
|
||||||
|
import type { EntityRegistryEntry } from "../../../data/entity_registry";
|
||||||
|
import { haStyle } from "../../../resources/styles";
|
||||||
|
import type { HomeAssistant } from "../../../types";
|
||||||
|
import {
|
||||||
|
showAlertDialog,
|
||||||
|
showConfirmationDialog,
|
||||||
|
} from "../../lovelace/custom-card-helpers";
|
||||||
|
import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail";
|
||||||
|
import "./ha-config-sub-entry-row";
|
||||||
|
|
||||||
|
@customElement("ha-config-entry-device-row")
|
||||||
|
class HaConfigEntryDeviceRow extends LitElement {
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public entry!: ConfigEntry;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public device!: DeviceRegistryEntry;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
const device = this.device;
|
||||||
|
|
||||||
|
const entities = this._getEntities();
|
||||||
|
|
||||||
|
const { area } = getDeviceContext(device, this.hass);
|
||||||
|
|
||||||
|
const supportingText = [
|
||||||
|
device.model || device.sw_version || device.manufacturer,
|
||||||
|
area ? area.name : undefined,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
return html`<ha-md-list-item
|
||||||
|
type="button"
|
||||||
|
@click=${this._handleNavigateToDevice}
|
||||||
|
class=${classMap({ disabled: Boolean(device.disabled_by) })}
|
||||||
|
>
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${device.entry_type === "service"
|
||||||
|
? mdiTransitConnectionVariant
|
||||||
|
: mdiDevices}
|
||||||
|
slot="start"
|
||||||
|
></ha-svg-icon>
|
||||||
|
<div slot="headline">${computeDeviceNameDisplay(device, this.hass)}</div>
|
||||||
|
<span slot="supporting-text"
|
||||||
|
>${supportingText.join(" • ")}
|
||||||
|
${supportingText.length && entities.length ? " • " : nothing}
|
||||||
|
${entities.length
|
||||||
|
? this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.entities",
|
||||||
|
{ count: entities.length }
|
||||||
|
)
|
||||||
|
: nothing}</span
|
||||||
|
>
|
||||||
|
${!this.narrow
|
||||||
|
? html`<ha-icon-next slot="end"> </ha-icon-next>`
|
||||||
|
: nothing}
|
||||||
|
<div class="vertical-divider" slot="end" @click=${stopPropagation}></div>
|
||||||
|
${!this.narrow
|
||||||
|
? html`<ha-icon-button
|
||||||
|
slot="end"
|
||||||
|
@click=${this._handleEditDevice}
|
||||||
|
.path=${mdiPencil}
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.device.edit"
|
||||||
|
)}
|
||||||
|
></ha-icon-button>`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
<ha-md-button-menu
|
||||||
|
positioning="popover"
|
||||||
|
slot="end"
|
||||||
|
@click=${stopPropagation}
|
||||||
|
>
|
||||||
|
<ha-icon-button
|
||||||
|
slot="trigger"
|
||||||
|
.label=${this.hass.localize("ui.common.menu")}
|
||||||
|
.path=${mdiDotsVertical}
|
||||||
|
></ha-icon-button>
|
||||||
|
${this.narrow
|
||||||
|
? html`<ha-md-menu-item @click=${this._handleEditDevice}>
|
||||||
|
<ha-svg-icon .path=${mdiPencil} slot="start"></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.device.edit"
|
||||||
|
)}
|
||||||
|
</ha-md-menu-item>`
|
||||||
|
: nothing}
|
||||||
|
${entities.length
|
||||||
|
? html`
|
||||||
|
<ha-md-menu-item
|
||||||
|
href=${`/config/entities/?historyBack=1&device=${device.id}`}
|
||||||
|
>
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${mdiShapeOutline}
|
||||||
|
slot="start"
|
||||||
|
></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
`ui.panel.config.integrations.config_entry.entities`,
|
||||||
|
{ count: entities.length }
|
||||||
|
)}
|
||||||
|
<ha-icon-next slot="end"></ha-icon-next>
|
||||||
|
</ha-md-menu-item>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
<ha-md-menu-item
|
||||||
|
class=${device.disabled_by !== "user" ? "warning" : ""}
|
||||||
|
@click=${this._handleDisableDevice}
|
||||||
|
.disabled=${device.disabled_by !== "user" && device.disabled_by}
|
||||||
|
>
|
||||||
|
<ha-svg-icon .path=${mdiStopCircleOutline} slot="start"></ha-svg-icon>
|
||||||
|
|
||||||
|
${device.disabled_by && device.disabled_by !== "user"
|
||||||
|
? this.hass.localize(
|
||||||
|
"ui.dialogs.device-registry-detail.enabled_cause",
|
||||||
|
{
|
||||||
|
type: this.hass.localize(
|
||||||
|
`ui.dialogs.device-registry-detail.type.${
|
||||||
|
device.entry_type || "device"
|
||||||
|
}`
|
||||||
|
),
|
||||||
|
cause: this.hass.localize(
|
||||||
|
`config_entry.disabled_by.${device.disabled_by}`
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: device.disabled_by
|
||||||
|
? this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.device.enable"
|
||||||
|
)
|
||||||
|
: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.device.disable"
|
||||||
|
)}
|
||||||
|
</ha-md-menu-item>
|
||||||
|
${this.entry.supports_remove_device
|
||||||
|
? html`<ha-md-menu-item
|
||||||
|
class="warning"
|
||||||
|
@click=${this._handleDeleteDevice}
|
||||||
|
>
|
||||||
|
<ha-svg-icon .path=${mdiDelete} slot="start"></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.device.delete"
|
||||||
|
)}
|
||||||
|
</ha-md-menu-item>`
|
||||||
|
: nothing}
|
||||||
|
</ha-md-button-menu>
|
||||||
|
</ha-md-list-item> `;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getEntities = (): EntityRegistryEntry[] =>
|
||||||
|
this.entities?.filter((entity) => entity.device_id === this.device.id);
|
||||||
|
|
||||||
|
private _handleEditDevice(ev: MouseEvent) {
|
||||||
|
ev.stopPropagation(); // Prevent triggering the click handler on the list item
|
||||||
|
showDeviceRegistryDetailDialog(this, {
|
||||||
|
device: this.device,
|
||||||
|
updateEntry: async (updates) => {
|
||||||
|
await updateDeviceRegistryEntry(this.hass, this.device.id, updates);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleDisableDevice() {
|
||||||
|
const disable = this.device.disabled_by === null;
|
||||||
|
|
||||||
|
if (disable) {
|
||||||
|
if (
|
||||||
|
!Object.values(this.hass.devices).some(
|
||||||
|
(dvc) =>
|
||||||
|
dvc.id !== this.device.id &&
|
||||||
|
dvc.config_entries.includes(this.entry.entry_id)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const config_entry = this.entry;
|
||||||
|
if (
|
||||||
|
config_entry &&
|
||||||
|
!config_entry.disabled_by &&
|
||||||
|
(await showConfirmationDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
"ui.panel.config.devices.confirm_disable_config_entry_title"
|
||||||
|
),
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.config.devices.confirm_disable_config_entry_message",
|
||||||
|
{ name: config_entry.title }
|
||||||
|
),
|
||||||
|
destructive: true,
|
||||||
|
confirmText: this.hass.localize("ui.common.yes"),
|
||||||
|
dismissText: this.hass.localize("ui.common.no"),
|
||||||
|
}))
|
||||||
|
) {
|
||||||
|
let result: DisableConfigEntryResult;
|
||||||
|
try {
|
||||||
|
result = await disableConfigEntry(this.hass, this.entry.entry_id);
|
||||||
|
} catch (err: any) {
|
||||||
|
showAlertDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.disable_error"
|
||||||
|
),
|
||||||
|
text: err.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.require_restart) {
|
||||||
|
showAlertDialog(this, {
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disable) {
|
||||||
|
const confirm = await showConfirmationDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.device.confirm_disable_title"
|
||||||
|
),
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.device.confirm_disable_message",
|
||||||
|
{ name: computeDeviceNameDisplay(this.device, this.hass) }
|
||||||
|
),
|
||||||
|
destructive: true,
|
||||||
|
confirmText: this.hass.localize("ui.common.yes"),
|
||||||
|
dismissText: this.hass.localize("ui.common.no"),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirm) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateDeviceRegistryEntry(this.hass, this.device.id, {
|
||||||
|
disabled_by: disable ? "user" : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleDeleteDevice() {
|
||||||
|
const entry = this.entry;
|
||||||
|
const confirmed = await showConfirmationDialog(this, {
|
||||||
|
text: this.hass.localize("ui.panel.config.devices.confirm_delete"),
|
||||||
|
confirmText: this.hass.localize("ui.common.delete"),
|
||||||
|
dismissText: this.hass.localize("ui.common.cancel"),
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeConfigEntryFromDevice(
|
||||||
|
this.hass!,
|
||||||
|
this.device.id,
|
||||||
|
entry.entry_id
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
showAlertDialog(this, {
|
||||||
|
title: this.hass.localize("ui.panel.config.devices.error_delete"),
|
||||||
|
text: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleNavigateToDevice() {
|
||||||
|
navigate(`/config/devices/device/${this.device.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
haStyle,
|
||||||
|
css`
|
||||||
|
:host {
|
||||||
|
border-top: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
ha-md-list-item {
|
||||||
|
--md-list-item-leading-space: 56px;
|
||||||
|
--md-ripple-hover-color: transparent;
|
||||||
|
--md-ripple-pressed-color: transparent;
|
||||||
|
}
|
||||||
|
.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
:host([narrow]) ha-md-list-item {
|
||||||
|
--md-list-item-leading-space: 16px;
|
||||||
|
}
|
||||||
|
.vertical-divider {
|
||||||
|
height: 100%;
|
||||||
|
width: 1px;
|
||||||
|
background: var(--divider-color);
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ha-config-entry-device-row": HaConfigEntryDeviceRow;
|
||||||
|
}
|
||||||
|
}
|
787
src/panels/config/integrations/ha-config-entry-row.ts
Normal file
787
src/panels/config/integrations/ha-config-entry-row.ts
Normal file
@ -0,0 +1,787 @@
|
|||||||
|
import {
|
||||||
|
mdiAlertCircle,
|
||||||
|
mdiChevronDown,
|
||||||
|
mdiCogOutline,
|
||||||
|
mdiDelete,
|
||||||
|
mdiDevices,
|
||||||
|
mdiDotsVertical,
|
||||||
|
mdiDownload,
|
||||||
|
mdiHandExtendedOutline,
|
||||||
|
mdiPlayCircleOutline,
|
||||||
|
mdiPlus,
|
||||||
|
mdiProgressHelper,
|
||||||
|
mdiReload,
|
||||||
|
mdiReloadAlert,
|
||||||
|
mdiRenameBox,
|
||||||
|
mdiShapeOutline,
|
||||||
|
mdiStopCircleOutline,
|
||||||
|
mdiWrench,
|
||||||
|
} from "@mdi/js";
|
||||||
|
import type { PropertyValues, TemplateResult } from "lit";
|
||||||
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators";
|
||||||
|
import { classMap } from "lit/directives/class-map";
|
||||||
|
import memoizeOne from "memoize-one";
|
||||||
|
import { isDevVersion } from "../../../common/config/version";
|
||||||
|
import {
|
||||||
|
deleteApplicationCredential,
|
||||||
|
fetchApplicationCredentialsConfigEntry,
|
||||||
|
} from "../../../data/application_credential";
|
||||||
|
import { getSignedPath } from "../../../data/auth";
|
||||||
|
import type {
|
||||||
|
ConfigEntry,
|
||||||
|
DisableConfigEntryResult,
|
||||||
|
SubEntry,
|
||||||
|
} from "../../../data/config_entries";
|
||||||
|
import {
|
||||||
|
deleteConfigEntry,
|
||||||
|
disableConfigEntry,
|
||||||
|
enableConfigEntry,
|
||||||
|
ERROR_STATES,
|
||||||
|
getSubEntries,
|
||||||
|
RECOVERABLE_STATES,
|
||||||
|
reloadConfigEntry,
|
||||||
|
updateConfigEntry,
|
||||||
|
} from "../../../data/config_entries";
|
||||||
|
import type { DeviceRegistryEntry } from "../../../data/device_registry";
|
||||||
|
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||||
|
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
|
||||||
|
import type { EntityRegistryEntry } from "../../../data/entity_registry";
|
||||||
|
import type { IntegrationManifest } from "../../../data/integration";
|
||||||
|
import {
|
||||||
|
domainToName,
|
||||||
|
fetchIntegrationManifest,
|
||||||
|
integrationsWithPanel,
|
||||||
|
} from "../../../data/integration";
|
||||||
|
import { showConfigEntrySystemOptionsDialog } from "../../../dialogs/config-entry-system-options/show-dialog-config-entry-system-options";
|
||||||
|
import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow";
|
||||||
|
import { showOptionsFlowDialog } from "../../../dialogs/config-flow/show-dialog-options-flow";
|
||||||
|
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
|
||||||
|
import { haStyle } from "../../../resources/styles";
|
||||||
|
import type { HomeAssistant } from "../../../types";
|
||||||
|
import { documentationUrl } from "../../../util/documentation-url";
|
||||||
|
import { fileDownload } from "../../../util/file_download";
|
||||||
|
import {
|
||||||
|
showAlertDialog,
|
||||||
|
showConfirmationDialog,
|
||||||
|
showPromptDialog,
|
||||||
|
} from "../../lovelace/custom-card-helpers";
|
||||||
|
import "./ha-config-entry-device-row";
|
||||||
|
import { renderConfigEntryError } from "./ha-config-integration-page";
|
||||||
|
import "./ha-config-sub-entry-row";
|
||||||
|
|
||||||
|
@customElement("ha-config-entry-row")
|
||||||
|
class HaConfigEntryRow extends LitElement {
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public manifest?: IntegrationManifest;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public diagnosticHandler?: DiagnosticInfo;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
|
||||||
|
|
||||||
|
@property({ attribute: false }) public entry!: ConfigEntry;
|
||||||
|
|
||||||
|
@state() private _expanded = true;
|
||||||
|
|
||||||
|
@state() private _devicesExpanded = true;
|
||||||
|
|
||||||
|
@state() private _subEntries?: SubEntry[];
|
||||||
|
|
||||||
|
protected willUpdate(changedProperties: PropertyValues): void {
|
||||||
|
if (changedProperties.has("entry")) {
|
||||||
|
this._fetchSubEntries();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
const item = this.entry;
|
||||||
|
|
||||||
|
let stateText: Parameters<typeof this.hass.localize> | undefined;
|
||||||
|
let stateTextExtra: TemplateResult | string | undefined;
|
||||||
|
let icon: string = mdiAlertCircle;
|
||||||
|
|
||||||
|
if (!item.disabled_by && item.state === "not_loaded") {
|
||||||
|
stateText = ["ui.panel.config.integrations.config_entry.not_loaded"];
|
||||||
|
} else if (item.state === "setup_in_progress") {
|
||||||
|
icon = mdiProgressHelper;
|
||||||
|
stateText = [
|
||||||
|
"ui.panel.config.integrations.config_entry.setup_in_progress",
|
||||||
|
];
|
||||||
|
} else if (ERROR_STATES.includes(item.state)) {
|
||||||
|
if (item.state === "setup_retry") {
|
||||||
|
icon = mdiReloadAlert;
|
||||||
|
}
|
||||||
|
stateText = [
|
||||||
|
`ui.panel.config.integrations.config_entry.state.${item.state}`,
|
||||||
|
];
|
||||||
|
stateTextExtra = renderConfigEntryError(this.hass, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = this._getDevices();
|
||||||
|
const services = this._getServices();
|
||||||
|
const entities = this._getEntities();
|
||||||
|
|
||||||
|
const ownDevices = [...devices, ...services].filter(
|
||||||
|
(device) =>
|
||||||
|
!device.config_entries_subentries[item.entry_id].length ||
|
||||||
|
device.config_entries_subentries[item.entry_id][0] === null
|
||||||
|
);
|
||||||
|
|
||||||
|
const statusLine: (TemplateResult | string)[] = [];
|
||||||
|
|
||||||
|
if (item.disabled_by) {
|
||||||
|
statusLine.push(
|
||||||
|
this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.disable.disabled_cause",
|
||||||
|
{
|
||||||
|
cause:
|
||||||
|
this.hass.localize(
|
||||||
|
`ui.panel.config.integrations.config_entry.disable.disabled_by.${item.disabled_by}`
|
||||||
|
) || item.disabled_by,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (item.state === "failed_unload") {
|
||||||
|
statusLine.push(`.
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
|
||||||
|
)}.`);
|
||||||
|
}
|
||||||
|
} else if (!devices.length && !services.length && entities.length) {
|
||||||
|
statusLine.push(
|
||||||
|
html`<a
|
||||||
|
href=${`/config/entities/?historyBack=1&config_entry=${item.entry_id}`}
|
||||||
|
>${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.entities",
|
||||||
|
{ count: entities.length }
|
||||||
|
)}</a
|
||||||
|
>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPanel = this._configPanel(item.domain, this.hass.panels);
|
||||||
|
|
||||||
|
const subEntries = this._subEntries || [];
|
||||||
|
|
||||||
|
return html`<ha-md-list>
|
||||||
|
<ha-md-list-item
|
||||||
|
class=${classMap({
|
||||||
|
config_entry: true,
|
||||||
|
"state-not-loaded": item!.state === "not_loaded",
|
||||||
|
"state-failed-unload": item!.state === "failed_unload",
|
||||||
|
"state-setup": item!.state === "setup_in_progress",
|
||||||
|
"state-error": ERROR_STATES.includes(item!.state),
|
||||||
|
"state-disabled": item.disabled_by !== null,
|
||||||
|
"has-subentries": this._expanded && subEntries.length > 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
${subEntries.length || ownDevices.length
|
||||||
|
? html`<ha-icon-button
|
||||||
|
class="expand-button ${classMap({ expanded: this._expanded })}"
|
||||||
|
.path=${mdiChevronDown}
|
||||||
|
slot="start"
|
||||||
|
@click=${this._toggleExpand}
|
||||||
|
></ha-icon-button>`
|
||||||
|
: nothing}
|
||||||
|
<div slot="headline">
|
||||||
|
${item.title || domainToName(this.hass.localize, item.domain)}
|
||||||
|
</div>
|
||||||
|
<div slot="supporting-text">
|
||||||
|
<div>${statusLine}</div>
|
||||||
|
${stateText
|
||||||
|
? html`
|
||||||
|
<div class="message">
|
||||||
|
<ha-svg-icon .path=${icon}></ha-svg-icon>
|
||||||
|
<div>
|
||||||
|
${this.hass.localize(...stateText)}${stateTextExtra
|
||||||
|
? html`: ${stateTextExtra}`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
${item.disabled_by === "user"
|
||||||
|
? html`<ha-button unelevated slot="end" @click=${this._handleEnable}>
|
||||||
|
${this.hass.localize("ui.common.enable")}
|
||||||
|
</ha-button>`
|
||||||
|
: configPanel &&
|
||||||
|
(item.domain !== "matter" ||
|
||||||
|
isDevVersion(this.hass.config.version)) &&
|
||||||
|
!stateText
|
||||||
|
? html`<a
|
||||||
|
slot="end"
|
||||||
|
href=${`/${configPanel}?config_entry=${item.entry_id}`}
|
||||||
|
><ha-icon-button
|
||||||
|
.path=${mdiCogOutline}
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.configure"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</ha-icon-button
|
||||||
|
></a>`
|
||||||
|
: item.supports_options
|
||||||
|
? html`
|
||||||
|
<ha-icon-button
|
||||||
|
slot="end"
|
||||||
|
@click=${this._showOptions}
|
||||||
|
.path=${mdiCogOutline}
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.configure"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</ha-icon-button>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
<ha-md-button-menu positioning="popover" slot="end">
|
||||||
|
<ha-icon-button
|
||||||
|
slot="trigger"
|
||||||
|
.label=${this.hass.localize("ui.common.menu")}
|
||||||
|
.path=${mdiDotsVertical}
|
||||||
|
></ha-icon-button>
|
||||||
|
${devices.length
|
||||||
|
? html`
|
||||||
|
<ha-md-menu-item
|
||||||
|
href=${devices.length === 1
|
||||||
|
? `/config/devices/device/${devices[0].id}`
|
||||||
|
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
|
||||||
|
>
|
||||||
|
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
`ui.panel.config.integrations.config_entry.devices`,
|
||||||
|
{ count: devices.length }
|
||||||
|
)}
|
||||||
|
<ha-icon-next slot="end"></ha-icon-next>
|
||||||
|
</ha-md-menu-item>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
${services.length
|
||||||
|
? html`<ha-md-menu-item
|
||||||
|
href=${services.length === 1
|
||||||
|
? `/config/devices/device/${services[0].id}`
|
||||||
|
: `/config/devices/dashboard?historyBack=1&config_entry=${item.entry_id}`}
|
||||||
|
>
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${mdiHandExtendedOutline}
|
||||||
|
slot="start"
|
||||||
|
></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
`ui.panel.config.integrations.config_entry.services`,
|
||||||
|
{ count: services.length }
|
||||||
|
)}
|
||||||
|
<ha-icon-next slot="end"></ha-icon-next>
|
||||||
|
</ha-md-menu-item> `
|
||||||
|
: nothing}
|
||||||
|
${entities.length
|
||||||
|
? html`
|
||||||
|
<ha-md-menu-item
|
||||||
|
href=${`/config/entities?historyBack=1&config_entry=${item.entry_id}`}
|
||||||
|
>
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${mdiShapeOutline}
|
||||||
|
slot="start"
|
||||||
|
></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
`ui.panel.config.integrations.config_entry.entities`,
|
||||||
|
{ count: entities.length }
|
||||||
|
)}
|
||||||
|
<ha-icon-next slot="end"></ha-icon-next>
|
||||||
|
</ha-md-menu-item>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
${!item.disabled_by &&
|
||||||
|
RECOVERABLE_STATES.includes(item.state) &&
|
||||||
|
item.supports_unload &&
|
||||||
|
item.source !== "system"
|
||||||
|
? html`
|
||||||
|
<ha-md-menu-item @click=${this._handleReload}>
|
||||||
|
<ha-svg-icon slot="start" .path=${mdiReload}></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.reload"
|
||||||
|
)}
|
||||||
|
</ha-md-menu-item>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
<ha-md-menu-item @click=${this._handleRename} graphic="icon">
|
||||||
|
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.rename"
|
||||||
|
)}
|
||||||
|
</ha-md-menu-item>
|
||||||
|
|
||||||
|
${Object.keys(item.supported_subentry_types).map(
|
||||||
|
(flowType) =>
|
||||||
|
html`<ha-md-menu-item
|
||||||
|
@click=${this._addSubEntry}
|
||||||
|
.entry=${item}
|
||||||
|
.flowType=${flowType}
|
||||||
|
graphic="icon"
|
||||||
|
>
|
||||||
|
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
`component.${item.domain}.config_subentries.${flowType}.initiate_flow.user`
|
||||||
|
)}</ha-md-menu-item
|
||||||
|
>`
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
|
||||||
|
|
||||||
|
${this.diagnosticHandler && item.state === "loaded"
|
||||||
|
? html`
|
||||||
|
<ha-md-menu-item
|
||||||
|
href=${getConfigEntryDiagnosticsDownloadUrl(item.entry_id)}
|
||||||
|
target="_blank"
|
||||||
|
@click=${this._signUrl}
|
||||||
|
>
|
||||||
|
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.download_diagnostics"
|
||||||
|
)}
|
||||||
|
</ha-md-menu-item>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
${!item.disabled_by &&
|
||||||
|
item.supports_reconfigure &&
|
||||||
|
item.source !== "system"
|
||||||
|
? html`
|
||||||
|
<ha-md-menu-item @click=${this._handleReconfigure}>
|
||||||
|
<ha-svg-icon slot="start" .path=${mdiWrench}></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.reconfigure"
|
||||||
|
)}
|
||||||
|
</ha-md-menu-item>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
|
||||||
|
<ha-md-menu-item @click=${this._handleSystemOptions} graphic="icon">
|
||||||
|
<ha-svg-icon slot="start" .path=${mdiCogOutline}></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.system_options"
|
||||||
|
)}
|
||||||
|
</ha-md-menu-item>
|
||||||
|
${item.disabled_by === "user"
|
||||||
|
? html`
|
||||||
|
<ha-md-menu-item @click=${this._handleEnable}>
|
||||||
|
<ha-svg-icon
|
||||||
|
slot="start"
|
||||||
|
.path=${mdiPlayCircleOutline}
|
||||||
|
></ha-svg-icon>
|
||||||
|
${this.hass.localize("ui.common.enable")}
|
||||||
|
</ha-md-menu-item>
|
||||||
|
`
|
||||||
|
: item.source !== "system"
|
||||||
|
? html`
|
||||||
|
<ha-md-menu-item
|
||||||
|
class="warning"
|
||||||
|
@click=${this._handleDisable}
|
||||||
|
graphic="icon"
|
||||||
|
>
|
||||||
|
<ha-svg-icon
|
||||||
|
slot="start"
|
||||||
|
class="warning"
|
||||||
|
.path=${mdiStopCircleOutline}
|
||||||
|
></ha-svg-icon>
|
||||||
|
${this.hass.localize("ui.common.disable")}
|
||||||
|
</ha-md-menu-item>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
${item.source !== "system"
|
||||||
|
? html`
|
||||||
|
<ha-md-menu-item class="warning" @click=${this._handleDelete}>
|
||||||
|
<ha-svg-icon
|
||||||
|
slot="start"
|
||||||
|
class="warning"
|
||||||
|
.path=${mdiDelete}
|
||||||
|
></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.delete"
|
||||||
|
)}
|
||||||
|
</ha-md-menu-item>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</ha-md-button-menu>
|
||||||
|
</ha-md-list-item>
|
||||||
|
${this._expanded
|
||||||
|
? subEntries.length
|
||||||
|
? html`${ownDevices.length
|
||||||
|
? html`<ha-md-list class="devices">
|
||||||
|
<ha-md-list-item
|
||||||
|
@click=${this._toggleOwnDevices}
|
||||||
|
type="button"
|
||||||
|
class="toggle-devices-row ${classMap({
|
||||||
|
expanded: this._devicesExpanded,
|
||||||
|
})}"
|
||||||
|
>
|
||||||
|
<ha-icon-button
|
||||||
|
class="expand-button ${classMap({
|
||||||
|
expanded: this._devicesExpanded,
|
||||||
|
})}"
|
||||||
|
.path=${mdiChevronDown}
|
||||||
|
slot="start"
|
||||||
|
>
|
||||||
|
</ha-icon-button>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.devices_without_subentry"
|
||||||
|
)}
|
||||||
|
</ha-md-list-item>
|
||||||
|
${this._devicesExpanded
|
||||||
|
? ownDevices.map(
|
||||||
|
(device) =>
|
||||||
|
html`<ha-config-entry-device-row
|
||||||
|
.hass=${this.hass}
|
||||||
|
.narrow=${this.narrow}
|
||||||
|
.entry=${item}
|
||||||
|
.device=${device}
|
||||||
|
.entities=${entities}
|
||||||
|
></ha-config-entry-device-row>`
|
||||||
|
)
|
||||||
|
: nothing}
|
||||||
|
</ha-md-list>`
|
||||||
|
: nothing}
|
||||||
|
${subEntries.map(
|
||||||
|
(subEntry) => html`
|
||||||
|
<ha-config-sub-entry-row
|
||||||
|
.hass=${this.hass}
|
||||||
|
.narrow=${this.narrow}
|
||||||
|
.manifest=${this.manifest}
|
||||||
|
.diagnosticHandler=${this.diagnosticHandler}
|
||||||
|
.entities=${this.entities}
|
||||||
|
.entry=${item}
|
||||||
|
.subEntry=${subEntry}
|
||||||
|
data-entry-id=${item.entry_id}
|
||||||
|
></ha-config-sub-entry-row>
|
||||||
|
`
|
||||||
|
)}`
|
||||||
|
: html`
|
||||||
|
${ownDevices.map(
|
||||||
|
(device) =>
|
||||||
|
html`<ha-config-entry-device-row
|
||||||
|
.hass=${this.hass}
|
||||||
|
.narrow=${this.narrow}
|
||||||
|
.entry=${item}
|
||||||
|
.device=${device}
|
||||||
|
.entities=${entities}
|
||||||
|
></ha-config-entry-device-row>`
|
||||||
|
)}
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</ha-md-list>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _fetchSubEntries() {
|
||||||
|
this._subEntries = this.entry.num_subentries
|
||||||
|
? await getSubEntries(this.hass, this.entry.entry_id)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _configPanel = memoizeOne(
|
||||||
|
(domain: string, panels: HomeAssistant["panels"]): string | undefined =>
|
||||||
|
Object.values(panels).find(
|
||||||
|
(panel) => panel.config_panel_domain === domain
|
||||||
|
)?.url_path || integrationsWithPanel[domain]
|
||||||
|
);
|
||||||
|
|
||||||
|
private _getEntities = (): EntityRegistryEntry[] =>
|
||||||
|
this.entities.filter(
|
||||||
|
(entity) => entity.config_entry_id === this.entry.entry_id
|
||||||
|
);
|
||||||
|
|
||||||
|
private _getDevices = (): DeviceRegistryEntry[] =>
|
||||||
|
Object.values(this.hass.devices).filter(
|
||||||
|
(device) =>
|
||||||
|
device.config_entries.includes(this.entry.entry_id) &&
|
||||||
|
device.entry_type !== "service"
|
||||||
|
);
|
||||||
|
|
||||||
|
private _getServices = (): DeviceRegistryEntry[] =>
|
||||||
|
Object.values(this.hass.devices).filter(
|
||||||
|
(device) =>
|
||||||
|
device.config_entries.includes(this.entry.entry_id) &&
|
||||||
|
device.entry_type === "service"
|
||||||
|
);
|
||||||
|
|
||||||
|
private _toggleExpand() {
|
||||||
|
this._expanded = !this._expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _toggleOwnDevices() {
|
||||||
|
this._devicesExpanded = !this._devicesExpanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _showOptions() {
|
||||||
|
showOptionsFlowDialog(this, this.entry, { manifest: this.manifest });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return an application credentials id for this config entry to prompt the
|
||||||
|
// user for removal. This is best effort so we don't stop overall removal
|
||||||
|
// if the integration isn't loaded or there is some other error.
|
||||||
|
private async _applicationCredentialForRemove(entryId: string) {
|
||||||
|
try {
|
||||||
|
return (await fetchApplicationCredentialsConfigEntry(this.hass, entryId))
|
||||||
|
.application_credentials_id;
|
||||||
|
} catch (_err: any) {
|
||||||
|
// We won't prompt the user to remove credentials
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _removeApplicationCredential(applicationCredentialsId: string) {
|
||||||
|
const confirmed = await showConfirmationDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.application_credentials.delete_title"
|
||||||
|
),
|
||||||
|
text: html`${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.application_credentials.delete_prompt"
|
||||||
|
)},
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.application_credentials.delete_detail"
|
||||||
|
)}
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<a
|
||||||
|
href=${documentationUrl(
|
||||||
|
this.hass,
|
||||||
|
"/integrations/application_credentials/"
|
||||||
|
)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.application_credentials.learn_more"
|
||||||
|
)}
|
||||||
|
</a>`,
|
||||||
|
destructive: true,
|
||||||
|
confirmText: this.hass.localize("ui.common.remove"),
|
||||||
|
dismissText: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.application_credentials.dismiss"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await deleteApplicationCredential(this.hass, applicationCredentialsId);
|
||||||
|
} catch (err: any) {
|
||||||
|
showAlertDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.application_credentials.delete_error_title"
|
||||||
|
),
|
||||||
|
text: err.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleReload() {
|
||||||
|
const result = await reloadConfigEntry(this.hass, this.entry.entry_id);
|
||||||
|
const locale_key = result.require_restart
|
||||||
|
? "reload_restart_confirm"
|
||||||
|
: "reload_confirm";
|
||||||
|
showAlertDialog(this, {
|
||||||
|
text: this.hass.localize(
|
||||||
|
`ui.panel.config.integrations.config_entry.${locale_key}`
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleReconfigure() {
|
||||||
|
showConfigFlowDialog(this, {
|
||||||
|
startFlowHandler: this.entry.domain,
|
||||||
|
showAdvanced: this.hass.userData?.showAdvanced,
|
||||||
|
manifest: await fetchIntegrationManifest(this.hass, this.entry.domain),
|
||||||
|
entryId: this.entry.entry_id,
|
||||||
|
navigateToResult: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleRename() {
|
||||||
|
const newName = await showPromptDialog(this, {
|
||||||
|
title: this.hass.localize("ui.panel.config.integrations.rename_dialog"),
|
||||||
|
defaultValue: this.entry.title,
|
||||||
|
inputLabel: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.rename_input_label"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (newName === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await updateConfigEntry(this.hass, this.entry.entry_id, {
|
||||||
|
title: newName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _signUrl(ev) {
|
||||||
|
const anchor = ev.currentTarget;
|
||||||
|
ev.preventDefault();
|
||||||
|
const signedUrl = await getSignedPath(
|
||||||
|
this.hass,
|
||||||
|
anchor.getAttribute("href")
|
||||||
|
);
|
||||||
|
fileDownload(signedUrl.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleDisable() {
|
||||||
|
const entryId = this.entry.entry_id;
|
||||||
|
|
||||||
|
const confirmed = await showConfirmationDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.disable_confirm_title",
|
||||||
|
{ title: this.entry.title }
|
||||||
|
),
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.disable_confirm_text"
|
||||||
|
),
|
||||||
|
confirmText: this.hass!.localize("ui.common.disable"),
|
||||||
|
dismissText: this.hass!.localize("ui.common.cancel"),
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let result: DisableConfigEntryResult;
|
||||||
|
try {
|
||||||
|
result = await disableConfigEntry(this.hass, entryId);
|
||||||
|
} catch (err: any) {
|
||||||
|
showAlertDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.disable_error"
|
||||||
|
),
|
||||||
|
text: err.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.require_restart) {
|
||||||
|
showAlertDialog(this, {
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.disable_restart_confirm"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleEnable() {
|
||||||
|
const entryId = this.entry.entry_id;
|
||||||
|
|
||||||
|
let result: DisableConfigEntryResult;
|
||||||
|
try {
|
||||||
|
result = await enableConfigEntry(this.hass, entryId);
|
||||||
|
} catch (err: any) {
|
||||||
|
showAlertDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.disable_error"
|
||||||
|
),
|
||||||
|
text: err.message,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.require_restart) {
|
||||||
|
showAlertDialog(this, {
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.enable_restart_confirm"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleDelete() {
|
||||||
|
const entryId = this.entry.entry_id;
|
||||||
|
|
||||||
|
const applicationCredentialsId =
|
||||||
|
await this._applicationCredentialForRemove(entryId);
|
||||||
|
|
||||||
|
const confirmed = await showConfirmationDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.delete_confirm_title",
|
||||||
|
{ title: this.entry.title }
|
||||||
|
),
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.delete_confirm_text"
|
||||||
|
),
|
||||||
|
confirmText: this.hass!.localize("ui.common.delete"),
|
||||||
|
dismissText: this.hass!.localize("ui.common.cancel"),
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await deleteConfigEntry(this.hass, entryId);
|
||||||
|
|
||||||
|
if (result.require_restart) {
|
||||||
|
showAlertDialog(this, {
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.restart_confirm"
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (applicationCredentialsId) {
|
||||||
|
this._removeApplicationCredential(applicationCredentialsId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleSystemOptions() {
|
||||||
|
showConfigEntrySystemOptionsDialog(this, {
|
||||||
|
entry: this.entry,
|
||||||
|
manifest: this.manifest,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private _addSubEntry(ev) {
|
||||||
|
showSubConfigFlowDialog(this, this.entry, ev.target.flowType, {
|
||||||
|
startFlowHandler: this.entry.entry_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = [
|
||||||
|
haStyle,
|
||||||
|
css`
|
||||||
|
.expand-button {
|
||||||
|
margin: 0 -12px;
|
||||||
|
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.expand-button.expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
ha-md-list {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: var(--ha-card-border-radius, 12px);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
:host([narrow]) {
|
||||||
|
margin-left: -12px;
|
||||||
|
margin-right: -12px;
|
||||||
|
}
|
||||||
|
ha-md-list.devices {
|
||||||
|
margin: 16px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
a ha-icon-button {
|
||||||
|
color: var(
|
||||||
|
--md-list-item-trailing-icon-color,
|
||||||
|
var(--md-sys-color-on-surface-variant, #49454f)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.toggle-devices-row {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--ha-card-border-radius, 12px);
|
||||||
|
}
|
||||||
|
.toggle-devices-row.expanded {
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ha-config-entry-row": HaConfigEntryRow;
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -406,11 +406,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
|
|||||||
${!this._showDisabled && this.narrow && disabledConfigEntries.length
|
${!this._showDisabled && this.narrow && disabledConfigEntries.length
|
||||||
? html`<span class="badge">${disabledConfigEntries.length}</span>`
|
? html`<span class="badge">${disabledConfigEntries.length}</span>`
|
||||||
: ""}
|
: ""}
|
||||||
<ha-button-menu
|
<ha-button-menu multi @action=${this._handleMenuAction}>
|
||||||
multi
|
|
||||||
@action=${this._handleMenuAction}
|
|
||||||
@click=${this._preventDefault}
|
|
||||||
>
|
|
||||||
<ha-icon-button
|
<ha-icon-button
|
||||||
slot="trigger"
|
slot="trigger"
|
||||||
.label=${this.hass.localize("ui.common.menu")}
|
.label=${this.hass.localize("ui.common.menu")}
|
||||||
|
264
src/panels/config/integrations/ha-config-sub-entry-row.ts
Normal file
264
src/panels/config/integrations/ha-config-sub-entry-row.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import {
|
||||||
|
mdiChevronDown,
|
||||||
|
mdiCogOutline,
|
||||||
|
mdiDelete,
|
||||||
|
mdiDevices,
|
||||||
|
mdiDotsVertical,
|
||||||
|
mdiHandExtendedOutline,
|
||||||
|
mdiShapeOutline,
|
||||||
|
} from "@mdi/js";
|
||||||
|
import { css, html, LitElement, nothing } from "lit";
|
||||||
|
import { customElement, property, state } from "lit/decorators";
|
||||||
|
import { classMap } from "lit/directives/class-map";
|
||||||
|
import type { ConfigEntry, SubEntry } from "../../../data/config_entries";
|
||||||
|
import { deleteSubEntry } from "../../../data/config_entries";
|
||||||
|
import type { DeviceRegistryEntry } from "../../../data/device_registry";
|
||||||
|
import type { DiagnosticInfo } from "../../../data/diagnostics";
|
||||||
|
import type { EntityRegistryEntry } from "../../../data/entity_registry";
|
||||||
|
import type { IntegrationManifest } from "../../../data/integration";
|
||||||
|
import { showSubConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-sub-config-flow";
|
||||||
|
import type { HomeAssistant } from "../../../types";
|
||||||
|
import { showConfirmationDialog } from "../../lovelace/custom-card-helpers";
|
||||||
|
import "./ha-config-entry-device-row";
|
||||||
|
|
||||||
|
@customElement("ha-config-sub-entry-row")
|
||||||
|
class HaConfigSubEntryRow extends LitElement {
|
||||||
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
|
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public manifest?: IntegrationManifest;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public diagnosticHandler?: DiagnosticInfo;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
|
||||||
|
|
||||||
|
@property({ attribute: false }) public entry!: ConfigEntry;
|
||||||
|
|
||||||
|
@property({ attribute: false }) public subEntry!: SubEntry;
|
||||||
|
|
||||||
|
@state() private _expanded = true;
|
||||||
|
|
||||||
|
protected render() {
|
||||||
|
const subEntry = this.subEntry;
|
||||||
|
const configEntry = this.entry;
|
||||||
|
|
||||||
|
const devices = this._getDevices();
|
||||||
|
const services = this._getServices();
|
||||||
|
const entities = this._getEntities();
|
||||||
|
|
||||||
|
return html`<ha-md-list>
|
||||||
|
<ha-md-list-item
|
||||||
|
class="sub-entry"
|
||||||
|
data-entry-id=${configEntry.entry_id}
|
||||||
|
.configEntry=${configEntry}
|
||||||
|
.subEntry=${subEntry}
|
||||||
|
>
|
||||||
|
${devices.length || services.length
|
||||||
|
? html`<ha-icon-button
|
||||||
|
class="expand-button ${classMap({ expanded: this._expanded })}"
|
||||||
|
.path=${mdiChevronDown}
|
||||||
|
slot="start"
|
||||||
|
@click=${this._toggleExpand}
|
||||||
|
></ha-icon-button>`
|
||||||
|
: nothing}
|
||||||
|
<span slot="headline">${subEntry.title}</span>
|
||||||
|
<span slot="supporting-text"
|
||||||
|
>${this.hass.localize(
|
||||||
|
`component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.entry_type`
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
${configEntry.supported_subentry_types[subEntry.subentry_type]
|
||||||
|
?.supports_reconfigure
|
||||||
|
? html`
|
||||||
|
<ha-icon-button
|
||||||
|
slot="end"
|
||||||
|
@click=${this._handleReconfigureSub}
|
||||||
|
.path=${mdiCogOutline}
|
||||||
|
.label=${this.hass.localize(
|
||||||
|
`component.${configEntry.domain}.config_subentries.${subEntry.subentry_type}.initiate_flow.reconfigure`
|
||||||
|
) ||
|
||||||
|
this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.configure"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
</ha-icon-button>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
<ha-md-button-menu positioning="popover" slot="end">
|
||||||
|
<ha-icon-button
|
||||||
|
slot="trigger"
|
||||||
|
.label=${this.hass.localize("ui.common.menu")}
|
||||||
|
.path=${mdiDotsVertical}
|
||||||
|
></ha-icon-button>
|
||||||
|
${devices.length || services.length
|
||||||
|
? html`
|
||||||
|
<ha-md-menu-item
|
||||||
|
href=${devices.length === 1
|
||||||
|
? `/config/devices/device/${devices[0].id}`
|
||||||
|
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
|
||||||
|
>
|
||||||
|
<ha-svg-icon .path=${mdiDevices} slot="start"></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
`ui.panel.config.integrations.config_entry.devices`,
|
||||||
|
{ count: devices.length }
|
||||||
|
)}
|
||||||
|
<ha-icon-next slot="end"></ha-icon-next>
|
||||||
|
</ha-md-menu-item>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
${services.length
|
||||||
|
? html`<ha-md-menu-item
|
||||||
|
href=${services.length === 1
|
||||||
|
? `/config/devices/device/${services[0].id}`
|
||||||
|
: `/config/devices/dashboard?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
|
||||||
|
>
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${mdiHandExtendedOutline}
|
||||||
|
slot="start"
|
||||||
|
></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
`ui.panel.config.integrations.config_entry.services`,
|
||||||
|
{ count: services.length }
|
||||||
|
)}
|
||||||
|
<ha-icon-next slot="end"></ha-icon-next>
|
||||||
|
</ha-md-menu-item> `
|
||||||
|
: nothing}
|
||||||
|
${entities.length
|
||||||
|
? html`
|
||||||
|
<ha-md-menu-item
|
||||||
|
href=${`/config/entities?historyBack=1&config_entry=${configEntry.entry_id}&sub_entry=${subEntry.subentry_id}`}
|
||||||
|
>
|
||||||
|
<ha-svg-icon
|
||||||
|
.path=${mdiShapeOutline}
|
||||||
|
slot="start"
|
||||||
|
></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
`ui.panel.config.integrations.config_entry.entities`,
|
||||||
|
{ count: entities.length }
|
||||||
|
)}
|
||||||
|
<ha-icon-next slot="end"></ha-icon-next>
|
||||||
|
</ha-md-menu-item>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
<ha-md-menu-item class="warning" @click=${this._handleDeleteSub}>
|
||||||
|
<ha-svg-icon
|
||||||
|
slot="start"
|
||||||
|
class="warning"
|
||||||
|
.path=${mdiDelete}
|
||||||
|
></ha-svg-icon>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.delete"
|
||||||
|
)}
|
||||||
|
</ha-md-menu-item>
|
||||||
|
</ha-md-button-menu>
|
||||||
|
</ha-md-list-item>
|
||||||
|
${this._expanded
|
||||||
|
? html`
|
||||||
|
${devices.map(
|
||||||
|
(device) =>
|
||||||
|
html`<ha-config-entry-device-row
|
||||||
|
.hass=${this.hass}
|
||||||
|
.narrow=${this.narrow}
|
||||||
|
.entry=${this.entry}
|
||||||
|
.device=${device}
|
||||||
|
.entities=${this.entities}
|
||||||
|
></ha-config-entry-device-row>`
|
||||||
|
)}
|
||||||
|
${services.map(
|
||||||
|
(service) =>
|
||||||
|
html`<ha-config-entry-device-row
|
||||||
|
.hass=${this.hass}
|
||||||
|
.narrow=${this.narrow}
|
||||||
|
.entry=${this.entry}
|
||||||
|
.device=${service}
|
||||||
|
.entities=${this.entities}
|
||||||
|
></ha-config-entry-device-row>`
|
||||||
|
)}
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</ha-md-list>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _toggleExpand() {
|
||||||
|
this._expanded = !this._expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _getEntities = (): EntityRegistryEntry[] =>
|
||||||
|
this.entities.filter(
|
||||||
|
(entity) => entity.config_subentry_id === this.subEntry.subentry_id
|
||||||
|
);
|
||||||
|
|
||||||
|
private _getDevices = (): DeviceRegistryEntry[] =>
|
||||||
|
Object.values(this.hass.devices).filter(
|
||||||
|
(device) =>
|
||||||
|
device.config_entries_subentries[this.entry.entry_id]?.includes(
|
||||||
|
this.subEntry.subentry_id
|
||||||
|
) && device.entry_type !== "service"
|
||||||
|
);
|
||||||
|
|
||||||
|
private _getServices = (): DeviceRegistryEntry[] =>
|
||||||
|
Object.values(this.hass.devices).filter(
|
||||||
|
(device) =>
|
||||||
|
device.config_entries_subentries[this.entry.entry_id]?.includes(
|
||||||
|
this.subEntry.subentry_id
|
||||||
|
) && device.entry_type === "service"
|
||||||
|
);
|
||||||
|
|
||||||
|
private async _handleReconfigureSub(): Promise<void> {
|
||||||
|
showSubConfigFlowDialog(this, this.entry, this.subEntry.subentry_type, {
|
||||||
|
startFlowHandler: this.entry.entry_id,
|
||||||
|
subEntryId: this.subEntry.subentry_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleDeleteSub(): Promise<void> {
|
||||||
|
const confirmed = await showConfirmationDialog(this, {
|
||||||
|
title: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.delete_confirm_title",
|
||||||
|
{ title: this.subEntry.title }
|
||||||
|
),
|
||||||
|
text: this.hass.localize(
|
||||||
|
"ui.panel.config.integrations.config_entry.delete_confirm_text"
|
||||||
|
),
|
||||||
|
confirmText: this.hass!.localize("ui.common.delete"),
|
||||||
|
dismissText: this.hass!.localize("ui.common.cancel"),
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await deleteSubEntry(
|
||||||
|
this.hass,
|
||||||
|
this.entry.entry_id,
|
||||||
|
this.subEntry.subentry_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static styles = css`
|
||||||
|
.expand-button {
|
||||||
|
margin: 0 -12px;
|
||||||
|
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
.expand-button.expanded {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
ha-md-list {
|
||||||
|
border: 1px solid var(--divider-color);
|
||||||
|
border-radius: var(--ha-card-border-radius, 12px);
|
||||||
|
padding: 0;
|
||||||
|
margin: 16px;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
ha-md-list-item.has-subentries {
|
||||||
|
border-bottom: 1px solid var(--divider-color);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
"ha-config-sub-entry-row": HaConfigSubEntryRow;
|
||||||
|
}
|
||||||
|
}
|
@ -60,6 +60,7 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
|
|||||||
title: localize("ui.panel.config.dhcp.ip_address"),
|
title: localize("ui.panel.config.dhcp.ip_address"),
|
||||||
filterable: true,
|
filterable: true,
|
||||||
sortable: true,
|
sortable: true,
|
||||||
|
type: "ip",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -80,9 +80,7 @@ export class ZWaveJsAddNodeConfigureDevice extends LitElement {
|
|||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
value: Protocols.ZWaveLongRange.toString(),
|
value: Protocols.ZWaveLongRange.toString(),
|
||||||
label: localize(
|
label: "Long Range", // brand name and we should not translate that
|
||||||
"ui.panel.config.zwave_js.add_node.configure_device.long_range_label"
|
|
||||||
),
|
|
||||||
description: localize(
|
description: localize(
|
||||||
"ui.panel.config.zwave_js.add_node.configure_device.long_range_description"
|
"ui.panel.config.zwave_js.add_node.configure_device.long_range_description"
|
||||||
),
|
),
|
||||||
|
@ -1,234 +0,0 @@
|
|||||||
import "@material/mwc-button/mwc-button";
|
|
||||||
import { mdiCheckCircle, mdiCloseCircle, mdiRobotDead } from "@mdi/js";
|
|
||||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
|
||||||
import type { CSSResultGroup } from "lit";
|
|
||||||
import { css, html, LitElement, nothing } from "lit";
|
|
||||||
import { customElement, property, state } from "lit/decorators";
|
|
||||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
|
||||||
import "../../../../../components/ha-spinner";
|
|
||||||
import { createCloseHeading } from "../../../../../components/ha-dialog";
|
|
||||||
import type { ZWaveJSRemovedNode } from "../../../../../data/zwave_js";
|
|
||||||
import { removeFailedZwaveNode } from "../../../../../data/zwave_js";
|
|
||||||
import { haStyleDialog } from "../../../../../resources/styles";
|
|
||||||
import type { HomeAssistant } from "../../../../../types";
|
|
||||||
import type { ZWaveJSRemoveFailedNodeDialogParams } from "./show-dialog-zwave_js-remove-failed-node";
|
|
||||||
|
|
||||||
@customElement("dialog-zwave_js-remove-failed-node")
|
|
||||||
class DialogZWaveJSRemoveFailedNode extends LitElement {
|
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
|
||||||
|
|
||||||
@state() private device_id?: string;
|
|
||||||
|
|
||||||
@state() private _status = "";
|
|
||||||
|
|
||||||
@state() private _error?: any;
|
|
||||||
|
|
||||||
@state() private _node?: ZWaveJSRemovedNode;
|
|
||||||
|
|
||||||
private _subscribed?: Promise<UnsubscribeFunc | undefined>;
|
|
||||||
|
|
||||||
public disconnectedCallback(): void {
|
|
||||||
super.disconnectedCallback();
|
|
||||||
this._unsubscribe();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async showDialog(
|
|
||||||
params: ZWaveJSRemoveFailedNodeDialogParams
|
|
||||||
): Promise<void> {
|
|
||||||
this.device_id = params.device_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
public closeDialog(): void {
|
|
||||||
this._unsubscribe();
|
|
||||||
this.device_id = undefined;
|
|
||||||
this._status = "";
|
|
||||||
|
|
||||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
|
||||||
}
|
|
||||||
|
|
||||||
public closeDialogFinished(): void {
|
|
||||||
history.back();
|
|
||||||
this.closeDialog();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected render() {
|
|
||||||
if (!this.device_id) {
|
|
||||||
return nothing;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<ha-dialog
|
|
||||||
open
|
|
||||||
@closed=${this.closeDialog}
|
|
||||||
.heading=${createCloseHeading(
|
|
||||||
this.hass,
|
|
||||||
this.hass.localize(
|
|
||||||
"ui.panel.config.zwave_js.remove_failed_node.title"
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
${this._status === ""
|
|
||||||
? html`
|
|
||||||
<div class="flex-container">
|
|
||||||
<ha-svg-icon
|
|
||||||
.path=${mdiRobotDead}
|
|
||||||
class="introduction"
|
|
||||||
></ha-svg-icon>
|
|
||||||
<div class="status">
|
|
||||||
${this.hass.localize(
|
|
||||||
"ui.panel.config.zwave_js.remove_failed_node.introduction"
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<mwc-button slot="primaryAction" @click=${this._startExclusion}>
|
|
||||||
${this.hass.localize(
|
|
||||||
"ui.panel.config.zwave_js.remove_failed_node.remove_device"
|
|
||||||
)}
|
|
||||||
</mwc-button>
|
|
||||||
`
|
|
||||||
: ``}
|
|
||||||
${this._status === "started"
|
|
||||||
? html`
|
|
||||||
<div class="flex-container">
|
|
||||||
<ha-spinner></ha-spinner>
|
|
||||||
<div class="status">
|
|
||||||
<p>
|
|
||||||
<b>
|
|
||||||
${this.hass.localize(
|
|
||||||
"ui.panel.config.zwave_js.remove_failed_node.in_progress"
|
|
||||||
)}
|
|
||||||
</b>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: ``}
|
|
||||||
${this._status === "failed"
|
|
||||||
? html`
|
|
||||||
<div class="flex-container">
|
|
||||||
<ha-svg-icon
|
|
||||||
.path=${mdiCloseCircle}
|
|
||||||
class="error"
|
|
||||||
></ha-svg-icon>
|
|
||||||
<div class="status">
|
|
||||||
<p>
|
|
||||||
${this.hass.localize(
|
|
||||||
"ui.panel.config.zwave_js.remove_failed_node.removal_failed"
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
${this._error
|
|
||||||
? html` <p><em> ${this._error.message} </em></p> `
|
|
||||||
: ``}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<mwc-button slot="primaryAction" @click=${this.closeDialog}>
|
|
||||||
${this.hass.localize("ui.common.close")}
|
|
||||||
</mwc-button>
|
|
||||||
`
|
|
||||||
: ``}
|
|
||||||
${this._status === "finished"
|
|
||||||
? html`
|
|
||||||
<div class="flex-container">
|
|
||||||
<ha-svg-icon
|
|
||||||
.path=${mdiCheckCircle}
|
|
||||||
class="success"
|
|
||||||
></ha-svg-icon>
|
|
||||||
<div class="status">
|
|
||||||
<p>
|
|
||||||
${this.hass.localize(
|
|
||||||
"ui.panel.config.zwave_js.remove_failed_node.removal_finished",
|
|
||||||
{ id: this._node!.node_id }
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<mwc-button
|
|
||||||
slot="primaryAction"
|
|
||||||
@click=${this.closeDialogFinished}
|
|
||||||
>
|
|
||||||
${this.hass.localize("ui.common.close")}
|
|
||||||
</mwc-button>
|
|
||||||
`
|
|
||||||
: ``}
|
|
||||||
</ha-dialog>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _startExclusion(): void {
|
|
||||||
if (!this.hass) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this._status = "started";
|
|
||||||
this._subscribed = removeFailedZwaveNode(
|
|
||||||
this.hass,
|
|
||||||
this.device_id!,
|
|
||||||
(message: any) => this._handleMessage(message)
|
|
||||||
).catch((error) => {
|
|
||||||
this._status = "failed";
|
|
||||||
this._error = error;
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private _handleMessage(message: any): void {
|
|
||||||
if (message.event === "exclusion started") {
|
|
||||||
this._status = "started";
|
|
||||||
}
|
|
||||||
if (message.event === "node removed") {
|
|
||||||
this._status = "finished";
|
|
||||||
this._node = message.node;
|
|
||||||
this._unsubscribe();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async _unsubscribe(): Promise<void> {
|
|
||||||
if (this._subscribed) {
|
|
||||||
const unsubFunc = await this._subscribed;
|
|
||||||
if (unsubFunc instanceof Function) {
|
|
||||||
unsubFunc();
|
|
||||||
}
|
|
||||||
this._subscribed = undefined;
|
|
||||||
}
|
|
||||||
if (this._status !== "finished") {
|
|
||||||
this._status = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static get styles(): CSSResultGroup {
|
|
||||||
return [
|
|
||||||
haStyleDialog,
|
|
||||||
css`
|
|
||||||
.success {
|
|
||||||
color: var(--success-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.failed {
|
|
||||||
color: var(--warning-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-container {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
ha-svg-icon {
|
|
||||||
width: 68px;
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-container ha-spinner,
|
|
||||||
.flex-container ha-svg-icon {
|
|
||||||
margin-right: 20px;
|
|
||||||
margin-inline-end: 20px;
|
|
||||||
margin-inline-start: initial;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare global {
|
|
||||||
interface HTMLElementTagNameMap {
|
|
||||||
"dialog-zwave_js-remove-failed-node": DialogZWaveJSRemoveFailedNode;
|
|
||||||
}
|
|
||||||
}
|
|
@ -2,6 +2,7 @@ import {
|
|||||||
mdiCheckCircle,
|
mdiCheckCircle,
|
||||||
mdiClose,
|
mdiClose,
|
||||||
mdiCloseCircle,
|
mdiCloseCircle,
|
||||||
|
mdiRobotDead,
|
||||||
mdiVectorSquareRemove,
|
mdiVectorSquareRemove,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||||
@ -17,6 +18,14 @@ import "../../../../../components/ha-spinner";
|
|||||||
import { haStyleDialog } from "../../../../../resources/styles";
|
import { haStyleDialog } from "../../../../../resources/styles";
|
||||||
import type { HomeAssistant } from "../../../../../types";
|
import type { HomeAssistant } from "../../../../../types";
|
||||||
import type { ZWaveJSRemoveNodeDialogParams } from "./show-dialog-zwave_js-remove-node";
|
import type { ZWaveJSRemoveNodeDialogParams } from "./show-dialog-zwave_js-remove-node";
|
||||||
|
import {
|
||||||
|
fetchZwaveNodeStatus,
|
||||||
|
NodeStatus,
|
||||||
|
removeFailedZwaveNode,
|
||||||
|
} from "../../../../../data/zwave_js";
|
||||||
|
import "../../../../../components/ha-list-item";
|
||||||
|
import "../../../../../components/ha-icon-next";
|
||||||
|
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
|
||||||
|
|
||||||
const EXCLUSION_TIMEOUT_SECONDS = 120;
|
const EXCLUSION_TIMEOUT_SECONDS = 120;
|
||||||
|
|
||||||
@ -30,10 +39,16 @@ export interface ZWaveJSRemovedNode {
|
|||||||
class DialogZWaveJSRemoveNode extends LitElement {
|
class DialogZWaveJSRemoveNode extends LitElement {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
@state() private entry_id?: string;
|
@state() private _entryId?: string;
|
||||||
|
|
||||||
|
@state() private _deviceId?: string;
|
||||||
|
|
||||||
|
private _device?: DeviceRegistryEntry;
|
||||||
|
|
||||||
@state() private _step:
|
@state() private _step:
|
||||||
| "start"
|
| "start"
|
||||||
|
| "start_exclusion"
|
||||||
|
| "start_removal"
|
||||||
| "exclusion"
|
| "exclusion"
|
||||||
| "remove"
|
| "remove"
|
||||||
| "finished"
|
| "finished"
|
||||||
@ -42,7 +57,7 @@ class DialogZWaveJSRemoveNode extends LitElement {
|
|||||||
|
|
||||||
@state() private _node?: ZWaveJSRemovedNode;
|
@state() private _node?: ZWaveJSRemovedNode;
|
||||||
|
|
||||||
@state() private _removedCallback?: () => void;
|
@state() private _onClose?: () => void;
|
||||||
|
|
||||||
private _removeNodeTimeoutHandle?: number;
|
private _removeNodeTimeoutHandle?: number;
|
||||||
|
|
||||||
@ -58,15 +73,23 @@ class DialogZWaveJSRemoveNode extends LitElement {
|
|||||||
public async showDialog(
|
public async showDialog(
|
||||||
params: ZWaveJSRemoveNodeDialogParams
|
params: ZWaveJSRemoveNodeDialogParams
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.entry_id = params.entry_id;
|
this._entryId = params.entryId;
|
||||||
this._removedCallback = params.removedCallback;
|
this._deviceId = params.deviceId;
|
||||||
if (params.skipConfirmation) {
|
this._onClose = params.onClose;
|
||||||
|
if (this._deviceId) {
|
||||||
|
const nodeStatus = await fetchZwaveNodeStatus(this.hass, this._deviceId!);
|
||||||
|
this._device = this.hass.devices[this._deviceId];
|
||||||
|
this._step =
|
||||||
|
nodeStatus.status === NodeStatus.Dead ? "start_removal" : "start";
|
||||||
|
} else if (params.skipConfirmation) {
|
||||||
this._startExclusion();
|
this._startExclusion();
|
||||||
|
} else {
|
||||||
|
this._step = "start_exclusion";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
if (!this.entry_id) {
|
if (!this._entryId) {
|
||||||
return nothing;
|
return nothing;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +98,12 @@ class DialogZWaveJSRemoveNode extends LitElement {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-dialog open @closed=${this.closeDialog} .heading=${dialogTitle}>
|
<ha-dialog
|
||||||
|
open
|
||||||
|
@closed=${this.handleDialogClosed}
|
||||||
|
.heading=${dialogTitle}
|
||||||
|
.hideActions=${this._step === "start"}
|
||||||
|
>
|
||||||
<ha-dialog-header slot="heading">
|
<ha-dialog-header slot="heading">
|
||||||
<ha-icon-button
|
<ha-icon-button
|
||||||
slot="navigationIcon"
|
slot="navigationIcon"
|
||||||
@ -100,6 +128,47 @@ class DialogZWaveJSRemoveNode extends LitElement {
|
|||||||
"ui.panel.config.zwave_js.remove_node.introduction"
|
"ui.panel.config.zwave_js.remove_node.introduction"
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="menu-options">
|
||||||
|
<ha-list-item hasMeta @click=${this._startExclusion}>
|
||||||
|
<span
|
||||||
|
>${this.hass.localize(
|
||||||
|
"ui.panel.config.zwave_js.remove_node.menu_exclude_device"
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
<ha-icon-next slot="meta"></ha-icon-next>
|
||||||
|
</ha-list-item>
|
||||||
|
<ha-list-item hasMeta @click=${this._startRemoval}>
|
||||||
|
<span
|
||||||
|
>${this.hass.localize(
|
||||||
|
"ui.panel.config.zwave_js.remove_node.menu_remove_device"
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
<ha-icon-next slot="meta"></ha-icon-next>
|
||||||
|
</ha-list-item>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._step === "start_removal") {
|
||||||
|
return html`
|
||||||
|
<ha-svg-icon .path=${mdiRobotDead}></ha-svg-icon>
|
||||||
|
<p>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.zwave_js.remove_node.failed_node_intro",
|
||||||
|
{ name: this._device!.name_by_user || this._device!.name }
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._step === "start_exclusion") {
|
||||||
|
return html`
|
||||||
|
<ha-svg-icon .path=${mdiVectorSquareRemove}></ha-svg-icon>
|
||||||
|
<p>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.zwave_js.remove_node.exclusion_intro"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -143,30 +212,59 @@ class DialogZWaveJSRemoveNode extends LitElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _renderAction(): TemplateResult {
|
private _renderAction() {
|
||||||
|
if (this._step === "start") {
|
||||||
|
return nothing;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._step === "start_removal") {
|
||||||
|
return html`
|
||||||
|
<ha-button slot="secondaryAction" @click=${this.closeDialog}>
|
||||||
|
${this.hass.localize("ui.common.cancel")}
|
||||||
|
</ha-button>
|
||||||
|
<ha-button
|
||||||
|
slot="primaryAction"
|
||||||
|
@click=${this._startRemoval}
|
||||||
|
destructive
|
||||||
|
>
|
||||||
|
${this.hass.localize("ui.common.remove")}
|
||||||
|
</ha-button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._step === "start_exclusion") {
|
||||||
|
return html`
|
||||||
|
<ha-button slot="secondaryAction" @click=${this.closeDialog}>
|
||||||
|
${this.hass.localize("ui.common.cancel")}
|
||||||
|
</ha-button>
|
||||||
|
<ha-button
|
||||||
|
slot="primaryAction"
|
||||||
|
@click=${this._startExclusion}
|
||||||
|
destructive
|
||||||
|
>
|
||||||
|
${this.hass.localize(
|
||||||
|
"ui.panel.config.zwave_js.remove_node.start_exclusion"
|
||||||
|
)}
|
||||||
|
</ha-button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<ha-button
|
<ha-button slot="primaryAction" @click=${this.closeDialog}>
|
||||||
slot="primaryAction"
|
|
||||||
@click=${this._step === "start"
|
|
||||||
? this._startExclusion
|
|
||||||
: this.closeDialog}
|
|
||||||
>
|
|
||||||
${this.hass.localize(
|
${this.hass.localize(
|
||||||
this._step === "start"
|
this._step === "exclusion"
|
||||||
? "ui.panel.config.zwave_js.remove_node.start_exclusion"
|
? "ui.panel.config.zwave_js.remove_node.cancel_exclusion"
|
||||||
: this._step === "exclusion"
|
: "ui.common.close"
|
||||||
? "ui.panel.config.zwave_js.remove_node.cancel_exclusion"
|
|
||||||
: "ui.common.close"
|
|
||||||
)}
|
)}
|
||||||
</ha-button>
|
</ha-button>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private _startExclusion(): void {
|
private _startExclusion() {
|
||||||
this._subscribed = this.hass.connection
|
this._subscribed = this.hass.connection
|
||||||
.subscribeMessage((message) => this._handleMessage(message), {
|
.subscribeMessage(this._handleMessage, {
|
||||||
type: "zwave_js/remove_node",
|
type: "zwave_js/remove_node",
|
||||||
entry_id: this.entry_id,
|
entry_id: this._entryId,
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
this._step = "failed";
|
this._step = "failed";
|
||||||
@ -180,7 +278,20 @@ class DialogZWaveJSRemoveNode extends LitElement {
|
|||||||
}, EXCLUSION_TIMEOUT_SECONDS * 1000);
|
}, EXCLUSION_TIMEOUT_SECONDS * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleMessage(message: any): void {
|
private _startRemoval() {
|
||||||
|
this._subscribed = removeFailedZwaveNode(
|
||||||
|
this.hass,
|
||||||
|
this._deviceId!,
|
||||||
|
this._handleMessage
|
||||||
|
).catch((err) => {
|
||||||
|
this._step = "failed";
|
||||||
|
this._error = err.message;
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
this._step = "remove";
|
||||||
|
}
|
||||||
|
|
||||||
|
private _handleMessage = (message: any) => {
|
||||||
if (message.event === "exclusion failed") {
|
if (message.event === "exclusion failed") {
|
||||||
this._unsubscribe();
|
this._unsubscribe();
|
||||||
this._step = "failed";
|
this._step = "failed";
|
||||||
@ -192,17 +303,14 @@ class DialogZWaveJSRemoveNode extends LitElement {
|
|||||||
this._step = "finished";
|
this._step = "finished";
|
||||||
this._node = message.node;
|
this._node = message.node;
|
||||||
this._unsubscribe();
|
this._unsubscribe();
|
||||||
if (this._removedCallback) {
|
|
||||||
this._removedCallback();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
private _stopExclusion(): void {
|
private _stopExclusion(): void {
|
||||||
try {
|
try {
|
||||||
this.hass.callWS({
|
this.hass.callWS({
|
||||||
type: "zwave_js/stop_exclusion",
|
type: "zwave_js/stop_exclusion",
|
||||||
entry_id: this.entry_id,
|
entry_id: this._entryId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@ -224,10 +332,16 @@ class DialogZWaveJSRemoveNode extends LitElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
public closeDialog(): void {
|
public closeDialog(): void {
|
||||||
this._unsubscribe();
|
this._entryId = undefined;
|
||||||
this.entry_id = undefined;
|
}
|
||||||
this._step = "start";
|
|
||||||
|
|
||||||
|
public handleDialogClosed(): void {
|
||||||
|
this._unsubscribe();
|
||||||
|
this._entryId = undefined;
|
||||||
|
this._step = "start";
|
||||||
|
if (this._onClose) {
|
||||||
|
this._onClose();
|
||||||
|
}
|
||||||
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
fireEvent(this, "dialog-closed", { dialog: this.localName });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,6 +380,14 @@ class DialogZWaveJSRemoveNode extends LitElement {
|
|||||||
ha-alert {
|
ha-alert {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu-options {
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
ha-list-item {
|
||||||
|
--mdc-list-side-padding: 24px;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user