mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-16 13:56:35 +00:00
Improve custom panel support (#1236)
* Add custom panel * Lint * Add reference to docs * Use panel.config
This commit is contained in:
parent
1a3966e55f
commit
c3d67133c2
@ -16,7 +16,7 @@
|
|||||||
"__DEMO__": false,
|
"__DEMO__": false,
|
||||||
"__BUILD__": false,
|
"__BUILD__": false,
|
||||||
"__VERSION__": false,
|
"__VERSION__": false,
|
||||||
"__ROOT__": false,
|
"__PUBLIC_PATH__": false,
|
||||||
"Polymer": true,
|
"Polymer": true,
|
||||||
"webkitSpeechRecognition": false,
|
"webkitSpeechRecognition": false,
|
||||||
"ResizeObserver": false
|
"ResizeObserver": false
|
||||||
@ -52,6 +52,7 @@
|
|||||||
"import/no-unresolved": 0,
|
"import/no-unresolved": 0,
|
||||||
"import/extensions": [2, "ignorePackages"],
|
"import/extensions": [2, "ignorePackages"],
|
||||||
"object-curly-newline": 0,
|
"object-curly-newline": 0,
|
||||||
|
"default-case": 0,
|
||||||
"react/jsx-no-bind": [2, { "ignoreRefs": true }],
|
"react/jsx-no-bind": [2, { "ignoreRefs": true }],
|
||||||
"react/jsx-no-duplicate-props": 2,
|
"react/jsx-no-duplicate-props": 2,
|
||||||
"react/self-closing-comp": 2,
|
"react/self-closing-comp": 2,
|
||||||
|
@ -12,7 +12,7 @@ const buildReplaces = {
|
|||||||
'/frontend_latest/authorize.js': 'authorize.js',
|
'/frontend_latest/authorize.js': 'authorize.js',
|
||||||
};
|
};
|
||||||
|
|
||||||
const es5Extra = "<script src='/frontend_es5/custom-elements-es5-adapter.js'></script>";
|
const es5Extra = "<script src='/static/custom-elements-es5-adapter.js'></script>";
|
||||||
|
|
||||||
async function buildAuth(es6) {
|
async function buildAuth(es6) {
|
||||||
const targetPath = es6 ? config.output : config.output_es5;
|
const targetPath = es6 ? config.output : config.output_es5;
|
||||||
|
@ -30,7 +30,7 @@ function generateIndex(es6) {
|
|||||||
const compatibilityPath = `/frontend_es5/compatibility-${md5(path.resolve(config.output_es5, 'compatibility.js'))}.js`;
|
const compatibilityPath = `/frontend_es5/compatibility-${md5(path.resolve(config.output_es5, 'compatibility.js'))}.js`;
|
||||||
const es5Extra = `
|
const es5Extra = `
|
||||||
<script src='${compatibilityPath}'></script>
|
<script src='${compatibilityPath}'></script>
|
||||||
<script src='/frontend_es5/custom-elements-es5-adapter.js'></script>
|
<script src='/static/custom-elements-es5-adapter.js'></script>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
toReplace.push([
|
toReplace.push([
|
||||||
|
@ -7,7 +7,7 @@ let index = fs.readFileSync('index.html', 'utf-8');
|
|||||||
const toReplace = [
|
const toReplace = [
|
||||||
[
|
[
|
||||||
'<!--EXTRA_SCRIPTS-->',
|
'<!--EXTRA_SCRIPTS-->',
|
||||||
"<script src='/frontend_es5/custom-elements-es5-adapter.js'></script>"
|
"<script src='/static/custom-elements-es5-adapter.js'></script>"
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -18,8 +18,6 @@ cp -r public/__init__.py $OUTPUT_DIR_ES5/
|
|||||||
cp src/authorize.html $OUTPUT_DIR
|
cp src/authorize.html $OUTPUT_DIR
|
||||||
|
|
||||||
# Manually copy over this file as we don't run the ES5 build
|
# Manually copy over this file as we don't run the ES5 build
|
||||||
# The Hass.io panel depends on it.
|
|
||||||
cp node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js $OUTPUT_DIR_ES5
|
|
||||||
cp node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js.map $OUTPUT_DIR
|
cp node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js.map $OUTPUT_DIR
|
||||||
|
|
||||||
./node_modules/.bin/webpack --watch --progress
|
./node_modules/.bin/webpack --watch --progress
|
||||||
|
35
src/common/dom/load_resource.js
Normal file
35
src/common/dom/load_resource.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// Load a resource and get a promise when loading done.
|
||||||
|
// From: https://davidwalsh.name/javascript-loader
|
||||||
|
|
||||||
|
function _load(tag, url) {
|
||||||
|
// This promise will be used by Promise.all to determine success or failure
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
const element = document.createElement(tag);
|
||||||
|
let attr = 'src';
|
||||||
|
let parent = 'body';
|
||||||
|
|
||||||
|
// Important success and error for the promise
|
||||||
|
element.onload = () => resolve(url);
|
||||||
|
element.onerror = () => reject(url);
|
||||||
|
|
||||||
|
// Need to set different attributes depending on tag type
|
||||||
|
switch (tag) {
|
||||||
|
case 'script':
|
||||||
|
element.async = true;
|
||||||
|
break;
|
||||||
|
case 'link':
|
||||||
|
element.type = 'text/css';
|
||||||
|
element.rel = 'stylesheet';
|
||||||
|
attr = 'href';
|
||||||
|
parent = 'head';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject into document to kick off loading
|
||||||
|
element[attr] = url;
|
||||||
|
document[parent].appendChild(element);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadCSS = url => _load('link', url);
|
||||||
|
export const loadJS = url => _load('script', url);
|
||||||
|
export const loadImg = url => _load('img', url);
|
71
src/entrypoints/custom-panel.js
Normal file
71
src/entrypoints/custom-panel.js
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { loadJS } from '../common/dom/load_resource.js';
|
||||||
|
import loadCustomPanel from '../util/custom-panel/load-custom-panel.js';
|
||||||
|
import createCustomPanelElement from '../util/custom-panel/create-custom-panel-element.js';
|
||||||
|
import setCustomPanelProperties from '../util/custom-panel/set-custom-panel-properties.js';
|
||||||
|
|
||||||
|
const webComponentsSupported = (
|
||||||
|
'customElements' in window &&
|
||||||
|
'import' in document.createElement('link') &&
|
||||||
|
'content' in document.createElement('template'));
|
||||||
|
|
||||||
|
let es5Loaded = null;
|
||||||
|
|
||||||
|
window.loadES5Adapter = () => {
|
||||||
|
if (!es5Loaded) {
|
||||||
|
es5Loaded = loadJS(`${__PUBLIC_PATH__}custom-elements-es5-adapter.js`).catch();
|
||||||
|
}
|
||||||
|
return es5Loaded;
|
||||||
|
};
|
||||||
|
|
||||||
|
let root = null;
|
||||||
|
|
||||||
|
function setProperties(properties) {
|
||||||
|
if (root === null) return;
|
||||||
|
setCustomPanelProperties(root, properties);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialize(panel, properties) {
|
||||||
|
const config = panel.config._panel_custom;
|
||||||
|
let start = Promise.resolve();
|
||||||
|
|
||||||
|
if (!webComponentsSupported) {
|
||||||
|
start = start.then(() => loadJS('/static/webcomponents-bundle.js'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (__BUILD__ === 'es5') {
|
||||||
|
// Load ES5 adapter. Swallow errors as it raises errors on old browsers.
|
||||||
|
start = start.then(() => window.loadES5Adapter());
|
||||||
|
}
|
||||||
|
|
||||||
|
start
|
||||||
|
.then(() => loadCustomPanel(config))
|
||||||
|
// If our element is using es5, let it finish loading that and define element
|
||||||
|
// This avoids elements getting upgraded after being added to the DOM
|
||||||
|
.then(() => (es5Loaded || Promise.resolve()))
|
||||||
|
.then(
|
||||||
|
() => {
|
||||||
|
root = createCustomPanelElement(config);
|
||||||
|
|
||||||
|
const forwardEvent = ev => window.parent.customPanel.fire(ev.type, ev.detail);
|
||||||
|
root.addEventListener('hass-open-menu', forwardEvent);
|
||||||
|
root.addEventListener('hass-close-menu', forwardEvent);
|
||||||
|
root.addEventListener(
|
||||||
|
'location-changed',
|
||||||
|
() => window.parent.customPanel.navigate(window.location.pathname)
|
||||||
|
);
|
||||||
|
setProperties(Object.assign({ panel }, properties));
|
||||||
|
document.body.appendChild(root);
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
// eslint-disable-next-line
|
||||||
|
console.error(err, panel);
|
||||||
|
alert(`Unable to load the panel source: ${err}.`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'DOMContentLoaded',
|
||||||
|
() => window.parent.customPanel.registerIframe(initialize, setProperties),
|
||||||
|
{ once: true }
|
||||||
|
);
|
@ -21,6 +21,10 @@ function ensureLoaded(panel) {
|
|||||||
imported = import(/* webpackChunkName: "panel-config" */ '../panels/config/ha-panel-config.js');
|
imported = import(/* webpackChunkName: "panel-config" */ '../panels/config/ha-panel-config.js');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'custom':
|
||||||
|
imported = import(/* webpackChunkName: "panel-custom" */ '../panels/custom/ha-panel-custom.js');
|
||||||
|
break;
|
||||||
|
|
||||||
case 'dev-event':
|
case 'dev-event':
|
||||||
imported = import(/* webpackChunkName: "panel-dev-event" */ '../panels/dev-event/ha-panel-dev-event.js');
|
imported = import(/* webpackChunkName: "panel-dev-event" */ '../panels/dev-event/ha-panel-dev-event.js');
|
||||||
break;
|
break;
|
||||||
|
123
src/panels/custom/ha-panel-custom.js
Normal file
123
src/panels/custom/ha-panel-custom.js
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { PolymerElement } from '@polymer/polymer/polymer-element.js';
|
||||||
|
|
||||||
|
import EventsMixin from '../../mixins/events-mixin.js';
|
||||||
|
import NavigateMixin from '../../mixins/navigate-mixin.js';
|
||||||
|
import loadCustomPanel from '../../util/custom-panel/load-custom-panel.js';
|
||||||
|
import createCustomPanelElement from '../../util/custom-panel/create-custom-panel-element.js';
|
||||||
|
import setCustomPanelProperties from '../../util/custom-panel/set-custom-panel-properties.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Mixins are used by ifram to communicate with main frontend.
|
||||||
|
* @appliesMixin EventsMixin
|
||||||
|
* @appliesMixin NavigateMixin
|
||||||
|
*/
|
||||||
|
class HaPanelCustom extends NavigateMixin(EventsMixin(PolymerElement)) {
|
||||||
|
static get properties() {
|
||||||
|
return {
|
||||||
|
hass: Object,
|
||||||
|
narrow: Boolean,
|
||||||
|
showMenu: Boolean,
|
||||||
|
route: Object,
|
||||||
|
panel: {
|
||||||
|
type: Object,
|
||||||
|
observer: '_panelChanged',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static get observers() {
|
||||||
|
return [
|
||||||
|
'_dataChanged(hass, narrow, showMenu, route)'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._setProperties = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_panelChanged(panel) {
|
||||||
|
// Clean up
|
||||||
|
delete window.customPanel;
|
||||||
|
this._setProperties = null;
|
||||||
|
while (this.lastChild) {
|
||||||
|
this.remove(this.lastChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = panel.config._panel_custom;
|
||||||
|
|
||||||
|
const tempA = document.createElement('a');
|
||||||
|
tempA.href = config.html_url || config.js_url;
|
||||||
|
|
||||||
|
if (!config.trust_external && !['localhost', '127.0.0.1', location.hostname].includes(tempA.hostname)) {
|
||||||
|
if (!confirm(`Do you trust the external panel "${config.name}" at "${tempA.href}"?
|
||||||
|
|
||||||
|
It will have access to all data in Home Assistant.
|
||||||
|
|
||||||
|
(Check docs for the panel_custom component to hide this message)`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.embed_iframe) {
|
||||||
|
loadCustomPanel(config)
|
||||||
|
.then(
|
||||||
|
() => {
|
||||||
|
const element = createCustomPanelElement(config);
|
||||||
|
this._setProperties = props => setCustomPanelProperties(element, props);
|
||||||
|
setCustomPanelProperties(element, {
|
||||||
|
panel,
|
||||||
|
hass: this.hass,
|
||||||
|
narrow: this.narrow,
|
||||||
|
showMenu: this.showMenu,
|
||||||
|
route: this.route,
|
||||||
|
});
|
||||||
|
this.appendChild(element);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
alert(`Unable to load custom panel from ${tempA.href}`);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.customPanel = this;
|
||||||
|
this.innerHTML = `
|
||||||
|
<style>
|
||||||
|
iframe {
|
||||||
|
border: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<iframe></iframe>
|
||||||
|
`;
|
||||||
|
const iframeDoc = this.querySelector('iframe').contentWindow.document;
|
||||||
|
iframeDoc.open();
|
||||||
|
iframeDoc.write(`<script src='${__PUBLIC_PATH__}custom-panel.js'></script>`);
|
||||||
|
iframeDoc.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
super.disconnectedCallback();
|
||||||
|
delete window.customPanel;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dataChanged(hass, narrow, showMenu, route) {
|
||||||
|
if (!this._setProperties) return;
|
||||||
|
this._setProperties({ hass, narrow, showMenu, route });
|
||||||
|
}
|
||||||
|
|
||||||
|
registerIframe(initialize, setProperties) {
|
||||||
|
initialize(this.panel, {
|
||||||
|
hass: this.hass,
|
||||||
|
narrow: this.narrow,
|
||||||
|
showMenu: this.showMenu,
|
||||||
|
route: this.route,
|
||||||
|
});
|
||||||
|
this._setProperties = setProperties;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define('ha-panel-custom', HaPanelCustom);
|
@ -1,4 +1,5 @@
|
|||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
import './polyfill.js';
|
||||||
/**
|
/**
|
||||||
@license
|
@license
|
||||||
Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
|
Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
|
||||||
@ -94,3 +95,6 @@ export const importHref = function (href, onload, onerror, optAsync) {
|
|||||||
}
|
}
|
||||||
return link;
|
return link;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const importHrefPromise = href =>
|
||||||
|
new Promise((resolve, reject) => importHref(href, resolve, reject));
|
||||||
|
5
src/util/custom-panel/create-custom-panel-element.js
Normal file
5
src/util/custom-panel/create-custom-panel-element.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export default function createCustomPanelElement(panelConfig) {
|
||||||
|
// Legacy support. Custom panels used to have to define element ha-panel-{name}
|
||||||
|
const tagName = 'html_url' in panelConfig ? `ha-panel-${panelConfig.name}` : panelConfig.name;
|
||||||
|
return document.createElement(tagName);
|
||||||
|
}
|
20
src/util/custom-panel/load-custom-panel.js
Normal file
20
src/util/custom-panel/load-custom-panel.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { loadJS } from '../../common/dom/load_resource.js';
|
||||||
|
|
||||||
|
// Make sure we only import every JS-based panel once (HTML import has this built-in)
|
||||||
|
const JS_CACHE = {};
|
||||||
|
|
||||||
|
export default function loadCustomPanel(panelConfig) {
|
||||||
|
if ('html_url' in panelConfig) {
|
||||||
|
return Promise.all([
|
||||||
|
import('../legacy-support.js'),
|
||||||
|
import('../../resources/html-import/import-href.js'),
|
||||||
|
// eslint-disable-next-line
|
||||||
|
]).then(([{}, { importHrefPromise }]) => importHrefPromise(panelConfig.html_url));
|
||||||
|
} else if (panelConfig.js_url) {
|
||||||
|
if (!(panelConfig.js_url in JS_CACHE)) {
|
||||||
|
JS_CACHE[panelConfig.js_url] = loadJS(panelConfig.js_url);
|
||||||
|
}
|
||||||
|
return JS_CACHE[panelConfig.js_url];
|
||||||
|
}
|
||||||
|
return Promise.reject('No valid url found in panel config.');
|
||||||
|
}
|
9
src/util/custom-panel/set-custom-panel-properties.js
Normal file
9
src/util/custom-panel/set-custom-panel-properties.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export default function setCustomPanelProperties(root, properties) {
|
||||||
|
if ('setProperties' in root) {
|
||||||
|
root.setProperties(properties);
|
||||||
|
} else {
|
||||||
|
Object.keys(properties).forEach((key) => {
|
||||||
|
root[key] = properties[key];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -18,6 +18,7 @@ function createConfig(isProdBuild, latestBuild) {
|
|||||||
app: './src/entrypoints/app.js',
|
app: './src/entrypoints/app.js',
|
||||||
authorize: './src/entrypoints/authorize.js',
|
authorize: './src/entrypoints/authorize.js',
|
||||||
core: './src/entrypoints/core.js',
|
core: './src/entrypoints/core.js',
|
||||||
|
'custom-panel': './src/entrypoints/custom-panel.js',
|
||||||
};
|
};
|
||||||
|
|
||||||
const babelOptions = {
|
const babelOptions = {
|
||||||
@ -42,6 +43,7 @@ function createConfig(isProdBuild, latestBuild) {
|
|||||||
__DEV__: JSON.stringify(!isProdBuild),
|
__DEV__: JSON.stringify(!isProdBuild),
|
||||||
__BUILD__: JSON.stringify(latestBuild ? 'latest' : 'es5'),
|
__BUILD__: JSON.stringify(latestBuild ? 'latest' : 'es5'),
|
||||||
__VERSION__: JSON.stringify(VERSION),
|
__VERSION__: JSON.stringify(VERSION),
|
||||||
|
__PUBLIC_PATH__: JSON.stringify(publicPath),
|
||||||
}),
|
}),
|
||||||
new CopyWebpackPlugin(copyPluginOpts),
|
new CopyWebpackPlugin(copyPluginOpts),
|
||||||
// Ignore moment.js locales
|
// Ignore moment.js locales
|
||||||
@ -64,9 +66,9 @@ function createConfig(isProdBuild, latestBuild) {
|
|||||||
copyPluginOpts.push('node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js')
|
copyPluginOpts.push('node_modules/@webcomponents/webcomponentsjs/webcomponents-bundle.js')
|
||||||
copyPluginOpts.push({ from: 'node_modules/leaflet/dist/leaflet.css', to: `images/leaflet/` });
|
copyPluginOpts.push({ from: 'node_modules/leaflet/dist/leaflet.css', to: `images/leaflet/` });
|
||||||
copyPluginOpts.push({ from: 'node_modules/leaflet/dist/images', to: `images/leaflet/` });
|
copyPluginOpts.push({ from: 'node_modules/leaflet/dist/images', to: `images/leaflet/` });
|
||||||
|
copyPluginOpts.push('node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js');
|
||||||
entry['hass-icons'] = './src/entrypoints/hass-icons.js';
|
entry['hass-icons'] = './src/entrypoints/hass-icons.js';
|
||||||
} else {
|
} else {
|
||||||
copyPluginOpts.push('node_modules/@webcomponents/webcomponentsjs/custom-elements-es5-adapter.js');
|
|
||||||
babelOptions.presets = [
|
babelOptions.presets = [
|
||||||
['es2015', { modules: false }]
|
['es2015', { modules: false }]
|
||||||
];
|
];
|
||||||
|
Loading…
x
Reference in New Issue
Block a user