20240424.0 (#20602)

This commit is contained in:
Bram Kragten 2024-04-24 11:21:24 +02:00 committed by GitHub
commit 8712adbf8d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
139 changed files with 3262 additions and 1874 deletions

View File

@ -21,7 +21,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
with: with:
ref: dev ref: dev
@ -57,7 +57,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
with: with:
ref: master ref: master

View File

@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2
with: with:
@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2
with: with:
@ -76,7 +76,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2
with: with:
@ -89,7 +89,7 @@ jobs:
env: env:
IS_TEST: "true" IS_TEST: "true"
- name: Upload bundle stats - name: Upload bundle stats
uses: actions/upload-artifact@v4.3.1 uses: actions/upload-artifact@v4.3.2
with: with:
name: frontend-bundle-stats name: frontend-bundle-stats
path: build/stats/*.json path: build/stats/*.json
@ -100,7 +100,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2
with: with:
@ -113,7 +113,7 @@ jobs:
env: env:
IS_TEST: "true" IS_TEST: "true"
- name: Upload bundle stats - name: Upload bundle stats
uses: actions/upload-artifact@v4.3.1 uses: actions/upload-artifact@v4.3.2
with: with:
name: supervisor-bundle-stats name: supervisor-bundle-stats
path: build/stats/*.json path: build/stats/*.json

View File

@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
with: with:
# We must fetch at least the immediate parents so that if this is # We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head. # a pull request then we can checkout the head.

View File

@ -22,7 +22,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
with: with:
ref: dev ref: dev
@ -58,7 +58,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
with: with:
ref: master ref: master

View File

@ -16,7 +16,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2

View File

@ -21,7 +21,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview') if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4.0.2 uses: actions/setup-node@v4.0.2

View File

@ -20,7 +20,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
- name: Set up Python ${{ env.PYTHON_VERSION }} - name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5 uses: actions/setup-python@v5
@ -57,14 +57,14 @@ jobs:
run: tar -czvf translations.tar.gz translations run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@v4.3.1 uses: actions/upload-artifact@v4.3.2
with: with:
name: wheels name: wheels
path: dist/home_assistant_frontend*.whl path: dist/home_assistant_frontend*.whl
if-no-files-found: error if-no-files-found: error
- name: Upload translations - name: Upload translations
uses: actions/upload-artifact@v4.3.1 uses: actions/upload-artifact@v4.3.2
with: with:
name: translations name: translations
path: translations.tar.gz path: translations.tar.gz

View File

@ -23,7 +23,7 @@ jobs:
contents: write # Required to upload release assets contents: write # Required to upload release assets
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
- name: Verify version - name: Verify version
uses: home-assistant/actions/helpers/verify-version@master uses: home-assistant/actions/helpers/verify-version@master

View File

@ -13,7 +13,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v4.1.2 uses: actions/checkout@v4.1.3
- name: Upload Translations - name: Upload Translations
run: | run: |

View File

@ -9,7 +9,7 @@ import gulp from "gulp";
import jszip from "jszip"; import jszip from "jszip";
import path from "path"; import path from "path";
import process from "process"; import process from "process";
import tar from "tar"; import { extract } from "tar";
const MAX_AGE = 24; // hours const MAX_AGE = 24; // hours
const OWNER = "home-assistant"; const OWNER = "home-assistant";
@ -156,7 +156,7 @@ gulp.task("fetch-nightly-translations", async function () {
console.log("Unpacking downloaded translations..."); console.log("Unpacking downloaded translations...");
const zip = await jszip.loadAsync(downloadResponse.data); const zip = await jszip.loadAsync(downloadResponse.data);
await deleteCurrent; await deleteCurrent;
const extractStream = zip.file(/.*/)[0].nodeStream().pipe(tar.extract()); const extractStream = zip.file(/.*/)[0].nodeStream().pipe(extract());
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
extractStream.on("close", resolve).on("error", reject); extractStream.on("close", resolve).on("error", reject);
}); });

View File

@ -1,92 +1,76 @@
import { createHash } from "crypto"; import { deleteAsync } from "del";
import { deleteSync } from "del"; import { glob } from "glob";
import { mkdirSync, readdirSync, readFileSync, renameSync } from "fs";
import { writeFile } from "node:fs/promises";
import gulp from "gulp"; import gulp from "gulp";
import flatmap from "gulp-flatmap";
import transform from "gulp-json-transform";
import merge from "gulp-merge-json"; import merge from "gulp-merge-json";
import rename from "gulp-rename"; import rename from "gulp-rename";
import path from "path"; import { createHash } from "node:crypto";
import vinylBuffer from "vinyl-buffer"; import { mkdir, readFile } from "node:fs/promises";
import source from "vinyl-source-stream"; import { basename, join } from "node:path";
import { Transform } from "node:stream";
import { finished } from "node:stream/promises";
import env from "../env.cjs"; import env from "../env.cjs";
import paths from "../paths.cjs"; import paths from "../paths.cjs";
import { mapFiles } from "../util.cjs";
import "./fetch-nightly-translations.js"; import "./fetch-nightly-translations.js";
const inFrontendDir = "translations/frontend"; const inFrontendDir = "translations/frontend";
const inBackendDir = "translations/backend"; const inBackendDir = "translations/backend";
const workDir = "build/translations"; const workDir = "build/translations";
const fullDir = workDir + "/full"; const outDir = join(workDir, "output");
const coreDir = workDir + "/core"; const EN_SRC = join(paths.translations_src, "en.json");
const outDir = workDir + "/output";
let mergeBackend = false; let mergeBackend = false;
gulp.task( gulp.task(
"translations-enable-merge-backend", "translations-enable-merge-backend",
gulp.parallel((done) => { gulp.parallel(async () => {
mergeBackend = true; mergeBackend = true;
done();
}, "allow-setup-fetch-nightly-translations") }, "allow-setup-fetch-nightly-translations")
); );
// Panel translations which should be split from the core translations. // Transform stream to apply a function on Vinyl JSON files (buffer mode only).
const TRANSLATION_FRAGMENTS = Object.keys( // The provided function can either return a new object, or an array of
JSON.parse( // [object, subdirectory] pairs for fragmentizing the JSON.
readFileSync( class CustomJSON extends Transform {
path.resolve(paths.polymer_dir, "src/translations/en.json"), constructor(func, reviver = null) {
"utf-8" super({ objectMode: true });
) this._func = func;
).ui.panel this._reviver = reviver;
); }
function recursiveFlatten(prefix, data) { async _transform(file, _, callback) {
let output = {}; try {
Object.keys(data).forEach((key) => { let obj = JSON.parse(file.contents.toString(), this._reviver);
if (typeof data[key] === "object") { if (this._func) obj = this._func(obj, file.path);
output = { for (const [outObj, dir] of Array.isArray(obj) ? obj : [[obj, ""]]) {
...output, const outFile = file.clone({ contents: false });
...recursiveFlatten(prefix + key + ".", data[key]), outFile.contents = Buffer.from(JSON.stringify(outObj));
}; outFile.dirname += `/${dir}`;
this.push(outFile);
}
callback(null);
} catch (err) {
callback(err);
}
}
}
// Utility to flatten object keys to single level using separator
const flatten = (data, prefix = "", sep = ".") => {
const output = {};
for (const [key, value] of Object.entries(data)) {
if (typeof value === "object") {
Object.assign(output, flatten(value, prefix + key + sep, sep));
} else { } else {
output[prefix + key] = data[key]; output[prefix + key] = value;
} }
}); }
return output; return output;
} };
function flatten(data) { // Filter functions that can be passed directly to JSON.parse()
return recursiveFlatten("", data); const emptyReviver = (_key, value) => value || undefined;
} const testReviver = (_key, value) =>
value && typeof value === "string" ? "TRANSLATED" : value;
function emptyFilter(data) {
const newData = {};
Object.keys(data).forEach((key) => {
if (data[key]) {
if (typeof data[key] === "object") {
newData[key] = emptyFilter(data[key]);
} else {
newData[key] = data[key];
}
}
});
return newData;
}
function recursiveEmpty(data) {
const newData = {};
Object.keys(data).forEach((key) => {
if (data[key]) {
if (typeof data[key] === "object") {
newData[key] = recursiveEmpty(data[key]);
} else {
newData[key] = "TRANSLATED";
}
}
});
return newData;
}
/** /**
* Replace Lokalise key placeholders with their actual values. * Replace Lokalise key placeholders with their actual values.
@ -95,60 +79,44 @@ function recursiveEmpty(data) {
* be included in src/translations/en.json, but still be usable while * be included in src/translations/en.json, but still be usable while
* developing locally. * developing locally.
* *
* @link https://docs.lokalise.co/article/KO5SZWLLsy-key-referencing * @link https://docs.lokalise.com/en/articles/1400528-key-referencing
*/ */
const re_key_reference = /\[%key:([^%]+)%\]/; const KEY_REFERENCE = /\[%key:([^%]+)%\]/;
function lokaliseTransform(data, original, file) { const lokaliseTransform = (data, path, original = data) => {
const output = {}; const output = {};
Object.entries(data).forEach(([key, value]) => { for (const [key, value] of Object.entries(data)) {
if (value instanceof Object) { if (typeof value === "object") {
output[key] = lokaliseTransform(value, original, file); output[key] = lokaliseTransform(value, path, original);
} else { } else {
output[key] = value.replace(re_key_reference, (_match, lokalise_key) => { output[key] = value.replace(KEY_REFERENCE, (_match, lokalise_key) => {
const replace = lokalise_key.split("::").reduce((tr, k) => { const replace = lokalise_key.split("::").reduce((tr, k) => {
if (!tr) { if (!tr) {
throw Error( throw Error(`Invalid key placeholder ${lokalise_key} in ${path}`);
`Invalid key placeholder ${lokalise_key} in ${file.path}`
);
} }
return tr[k]; return tr[k];
}, original); }, original);
if (typeof replace !== "string") { if (typeof replace !== "string") {
throw Error( throw Error(`Invalid key placeholder ${lokalise_key} in ${path}`);
`Invalid key placeholder ${lokalise_key} in ${file.path}`
);
} }
return replace; return replace;
}); });
} }
}); }
return output; return output;
} };
gulp.task("clean-translations", async () => deleteSync([workDir])); gulp.task("clean-translations", () => deleteAsync([workDir]));
gulp.task("ensure-translations-build-dir", async () => { const makeWorkDir = () => mkdir(workDir, { recursive: true });
mkdirSync(workDir, { recursive: true });
});
gulp.task("create-test-metadata", () => const createTestTranslation = () =>
env.isProdBuild()
? Promise.resolve()
: writeFile(
workDir + "/testMetadata.json",
JSON.stringify({ test: { nativeName: "Test" } })
)
);
gulp.task("create-test-translation", () =>
env.isProdBuild() env.isProdBuild()
? Promise.resolve() ? Promise.resolve()
: gulp : gulp
.src(path.join(paths.translations_src, "en.json")) .src(EN_SRC)
.pipe(transform((data, _file) => recursiveEmpty(data))) .pipe(new CustomJSON(null, testReviver))
.pipe(rename("test.json")) .pipe(rename("test.json"))
.pipe(gulp.dest(workDir)) .pipe(gulp.dest(workDir));
);
/** /**
* This task will build a master translation file, to be used as the base for * This task will build a master translation file, to be used as the base for
@ -159,279 +127,171 @@ gulp.task("create-test-translation", () =>
* project is buildable immediately after merging new translation keys, since * project is buildable immediately after merging new translation keys, since
* the Lokalise update to translations/en.json will not happen immediately. * the Lokalise update to translations/en.json will not happen immediately.
*/ */
gulp.task("build-master-translation", () => { const createMasterTranslation = () =>
const src = [path.join(paths.translations_src, "en.json")]; gulp
.src([EN_SRC, ...(mergeBackend ? [`${inBackendDir}/en.json`] : [])])
if (mergeBackend) { .pipe(new CustomJSON(lokaliseTransform))
src.push(path.join(inBackendDir, "en.json"));
}
return gulp
.src(src)
.pipe(transform((data, file) => lokaliseTransform(data, data, file)))
.pipe( .pipe(
merge({ merge({
fileName: "en.json", fileName: "en.json",
jsonSpace: undefined,
}) })
) )
.pipe(gulp.dest(fullDir)); .pipe(gulp.dest(workDir));
});
gulp.task("build-merged-translations", () => const FRAGMENTS = ["base"];
gulp
.src([
inFrontendDir + "/*.json",
"!" + inFrontendDir + "/en.json",
...(env.isProdBuild() ? [] : [workDir + "/test.json"]),
])
.pipe(transform((data, file) => lokaliseTransform(data, data, file)))
.pipe(
flatmap((stream, file) => {
// For each language generate a merged json file. It begins with the master
// translation as a failsafe for untranslated strings, and merges all parent
// tags into one file for each specific subtag
//
// TODO: This is a naive interpretation of BCP47 that should be improved.
// Will be OK for now as long as we don't have anything more complicated
// than a base translation + region.
const tr = path.basename(file.history[0], ".json");
const subtags = tr.split("-");
const src = [fullDir + "/en.json"];
for (let i = 1; i <= subtags.length; i++) {
const lang = subtags.slice(0, i).join("-");
if (lang === "test") {
src.push(workDir + "/test.json");
} else if (lang !== "en") {
src.push(inFrontendDir + "/" + lang + ".json");
if (mergeBackend) {
src.push(inBackendDir + "/" + lang + ".json");
}
}
}
return gulp
.src(src, { allowEmpty: true })
.pipe(transform((data) => emptyFilter(data)))
.pipe(
merge({
fileName: tr + ".json",
})
)
.pipe(gulp.dest(fullDir));
})
)
);
let taskName; const toggleSupervisorFragment = async () => {
FRAGMENTS[0] = "supervisor";
};
const splitTasks = []; const panelFragment = (fragment) =>
TRANSLATION_FRAGMENTS.forEach((fragment) => { fragment !== "base" && fragment !== "supervisor";
taskName = "build-translation-fragment-" + fragment;
gulp.task(taskName, () =>
// Return only the translations for this fragment.
gulp
.src(fullDir + "/*.json")
.pipe(
transform((data) => ({
ui: {
panel: {
[fragment]: data.ui.panel[fragment],
},
},
}))
)
.pipe(gulp.dest(workDir + "/" + fragment))
);
splitTasks.push(taskName);
});
taskName = "build-translation-core"; const HASHES = new Map();
gulp.task(taskName, () =>
// Remove the fragment translations from the core translation.
gulp
.src(fullDir + "/*.json")
.pipe(
transform((data, _file) => {
TRANSLATION_FRAGMENTS.forEach((fragment) => {
delete data.ui.panel[fragment];
});
delete data.supervisor;
return data;
})
)
.pipe(gulp.dest(coreDir))
);
splitTasks.push(taskName); const createTranslations = async () => {
// Parse and store the master to avoid repeating this for each locale, then
gulp.task("build-flattened-translations", () => // add the panel fragments when processing the app.
// Flatten the split versions of our translations, and move them into outDir const enMaster = JSON.parse(await readFile(`${workDir}/en.json`, "utf-8"));
gulp if (FRAGMENTS[0] === "base") {
.src( FRAGMENTS.push(...Object.keys(enMaster.ui.panel));
TRANSLATION_FRAGMENTS.map(
(fragment) => workDir + "/" + fragment + "/*.json"
).concat(coreDir + "/*.json"),
{ base: workDir }
)
.pipe(
transform((data) =>
// Polymer.AppLocalizeBehavior requires flattened json
flatten(data)
)
)
.pipe(
rename((filePath) => {
if (filePath.dirname === "core") {
filePath.dirname = "";
}
// In dev we create the file with the fake hash in the filename
if (!env.isProdBuild()) {
filePath.basename += "-dev";
}
})
)
.pipe(gulp.dest(outDir))
);
const fingerprints = {};
gulp.task("build-translation-fingerprints", () => {
// Fingerprint full file of each language
const files = readdirSync(fullDir);
for (let i = 0; i < files.length; i++) {
fingerprints[files[i].split(".")[0]] = {
// In dev we create fake hashes
hash: env.isProdBuild()
? createHash("md5")
.update(readFileSync(path.join(fullDir, files[i]), "utf-8"))
.digest("hex")
: "dev",
};
} }
// In dev we create the file with the fake hash in the filename // The downstream pipeline is setup first. It hashes the merged data for
if (env.isProdBuild()) { // each locale, then fragmentizes and flattens the data for final output.
mapFiles(outDir, ".json", (filename) => { const translationFiles = await glob([
const parsed = path.parse(filename); `${inFrontendDir}/!(en).json`,
...(env.isProdBuild() ? [] : [`${workDir}/test.json`]),
]);
const hashStream = new Transform({
objectMode: true,
transform: async (file, _, callback) => {
const hash = env.isProdBuild()
? createHash("md5").update(file.contents).digest("hex")
: "dev";
HASHES.set(file.stem, hash);
file.stem += `-${hash}`;
callback(null, file);
},
}).setMaxListeners(translationFiles.length + 1);
const fragmentsStream = hashStream
.pipe(
new CustomJSON((data) =>
FRAGMENTS.map((fragment) => {
switch (fragment) {
case "base":
// Remove the panels and supervisor to create the base translations
return [
flatten({
...data,
ui: { ...data.ui, panel: undefined },
supervisor: undefined,
}),
"",
];
case "supervisor":
// Supervisor key is at the top level
return [flatten(data.supervisor), ""];
default:
// Create a fragment with only the given panel
return [
flatten(data.ui.panel[fragment], `ui.panel.${fragment}.`),
fragment,
];
}
})
)
)
.pipe(gulp.dest(outDir));
// nl.json -> nl-<hash>.json // Send the English master downstream first, then for each other locale
if (!(parsed.name in fingerprints)) { // generate merged JSON data to continue piping. It begins with the master
throw new Error(`Unable to find hash for ${filename}`); // translation as a failsafe for untranslated strings, and merges all parent
// tags into one file for each specific subtag
//
// TODO: This is a naive interpretation of BCP47 that should be improved.
// Will be OK for now as long as we don't have anything more complicated
// than a base translation + region.
gulp.src(`${workDir}/en.json`).pipe(hashStream, { end: false });
const mergesFinished = [];
for (const translationFile of translationFiles) {
const locale = basename(translationFile, ".json");
const subtags = locale.split("-");
const mergeFiles = [];
for (let i = 1; i <= subtags.length; i++) {
const lang = subtags.slice(0, i).join("-");
if (lang === "test") {
mergeFiles.push(`${workDir}/test.json`);
} else if (lang !== "en") {
mergeFiles.push(`${inFrontendDir}/${lang}.json`);
if (mergeBackend) {
mergeFiles.push(`${inBackendDir}/${lang}.json`);
}
} }
}
renameSync( const mergeStream = gulp.src(mergeFiles, { allowEmpty: true }).pipe(
filename, merge({
`${parsed.dir}/${parsed.name}-${fingerprints[parsed.name].hash}${ fileName: `${locale}.json`,
parsed.ext startObj: enMaster,
}` jsonReviver: emptyReviver,
); jsonSpace: undefined,
}); })
);
mergesFinished.push(finished(mergeStream));
mergeStream.pipe(hashStream, { end: false });
} }
const stream = source("translationFingerprints.json"); // Wait for all merges to finish, then it's safe to end writing to the
stream.write(JSON.stringify(fingerprints)); // downstream pipeline and wait for all fragments to finish writing.
process.nextTick(() => stream.end()); await Promise.all(mergesFinished);
return stream.pipe(vinylBuffer()).pipe(gulp.dest(workDir)); hashStream.end();
}); await finished(fragmentsStream);
};
gulp.task("build-translation-fragment-supervisor", () => const writeTranslationMetaData = () =>
gulp gulp
.src(fullDir + "/*.json") .src([`${paths.translations_src}/translationMetadata.json`])
.pipe(transform((data) => data.supervisor))
.pipe( .pipe(
rename((filePath) => { new CustomJSON((meta) => {
// In dev we create the file with the fake hash in the filename // Add the test translation in development.
if (!env.isProdBuild()) { if (!env.isProdBuild()) {
filePath.basename += "-dev"; meta.test = { nativeName: "Test" };
} }
}) // Filter out locales without a native name, and add the hashes.
) for (const locale of Object.keys(meta)) {
.pipe(gulp.dest(workDir + "/supervisor")) if (!meta[locale].nativeName) {
); meta[locale] = undefined;
gulp.task("build-translation-flatten-supervisor", () =>
gulp
.src(workDir + "/supervisor/*.json")
.pipe(
transform((data) =>
// Polymer.AppLocalizeBehavior requires flattened json
flatten(data)
)
)
.pipe(gulp.dest(outDir))
);
gulp.task("build-translation-write-metadata", () =>
gulp
.src([
path.join(paths.translations_src, "translationMetadata.json"),
...(env.isProdBuild() ? [] : [workDir + "/testMetadata.json"]),
workDir + "/translationFingerprints.json",
])
.pipe(merge({}))
.pipe(
transform((data) => {
const newData = {};
Object.entries(data).forEach(([key, value]) => {
// Filter out translations without native name.
if (value.nativeName) {
newData[key] = value;
} else {
console.warn( console.warn(
`Skipping language ${key}. Native name was not translated.` `Skipping locale ${locale} because native name is not translated.`
); );
} else {
meta[locale].hash = HASHES.get(locale);
} }
}); }
return newData; return {
fragments: FRAGMENTS.filter(panelFragment),
translations: meta,
};
}) })
) )
.pipe( .pipe(gulp.dest(workDir));
transform((data) => ({
fragments: TRANSLATION_FRAGMENTS,
translations: data,
}))
)
.pipe(rename("translationMetadata.json"))
.pipe(gulp.dest(workDir))
);
gulp.task(
"create-translations",
gulp.series(
gulp.parallel("create-test-metadata", "create-test-translation"),
"build-master-translation",
"build-merged-translations",
gulp.parallel(...splitTasks),
"build-flattened-translations"
)
);
gulp.task( gulp.task(
"build-translations", "build-translations",
gulp.series( gulp.series(
gulp.parallel( gulp.parallel(
"fetch-nightly-translations", "fetch-nightly-translations",
gulp.series("clean-translations", "ensure-translations-build-dir") gulp.series("clean-translations", makeWorkDir)
), ),
"create-translations", createTestTranslation,
"build-translation-fingerprints", createMasterTranslation,
"build-translation-write-metadata" createTranslations,
writeTranslationMetaData
) )
); );
gulp.task( gulp.task(
"build-supervisor-translations", "build-supervisor-translations",
gulp.series( gulp.series(toggleSupervisorFragment, "build-translations")
gulp.parallel(
"fetch-nightly-translations",
gulp.series("clean-translations", "ensure-translations-build-dir")
),
gulp.parallel("create-test-metadata", "create-test-translation"),
"build-master-translation",
"build-merged-translations",
"build-translation-fragment-supervisor",
"build-translation-flatten-supervisor",
"build-translation-fingerprints",
"build-translation-write-metadata"
)
); );

View File

@ -99,7 +99,7 @@ gulp.task("webpack-watch-app", () => {
).watch({ poll: isWsl }, doneHandler()); ).watch({ poll: isWsl }, doneHandler());
gulp.watch( gulp.watch(
path.join(paths.translations_src, "en.json"), path.join(paths.translations_src, "en.json"),
gulp.series("create-translations", "copy-translations-app") gulp.series("build-translations", "copy-translations-app")
); );
}); });

View File

@ -1,16 +0,0 @@
const path = require("path");
const fs = require("fs");
// Helper function to map recursively over files in a folder and it's subfolders
module.exports.mapFiles = function mapFiles(startPath, filter, mapFunc) {
const files = fs.readdirSync(startPath);
for (let i = 0; i < files.length; i++) {
const filename = path.join(startPath, files[i]);
const stat = fs.lstatSync(filename);
if (stat.isDirectory()) {
mapFiles(filename, filter, mapFunc);
} else if (filename.indexOf(filter) >= 0) {
mapFunc(filename);
}
}
};

View File

@ -10,6 +10,7 @@ const WebpackBar = require("webpackbar");
const { const {
TransformAsyncModulesPlugin, TransformAsyncModulesPlugin,
} = require("transform-async-modules-webpack-plugin"); } = require("transform-async-modules-webpack-plugin");
const { dependencies } = require("../package.json");
const paths = require("./paths.cjs"); const paths = require("./paths.cjs");
const bundle = require("./bundle.cjs"); const bundle = require("./bundle.cjs");
@ -156,7 +157,10 @@ const createWebpackConfig = ({
transform: (stats) => JSON.stringify(filterStats(stats)), transform: (stats) => JSON.stringify(filterStats(stats)),
}), }),
!latestBuild && !latestBuild &&
new TransformAsyncModulesPlugin({ browserslistEnv: "legacy" }), new TransformAsyncModulesPlugin({
browserslistEnv: "legacy",
runtime: { version: dependencies["@babel/runtime"] },
}),
].filter(Boolean), ].filter(Boolean),
resolve: { resolve: {
extensions: [".ts", ".js", ".json"], extensions: [".ts", ".js", ".json"],

View File

@ -1,4 +1,4 @@
import { format, startOfToday, startOfTomorrow } from "date-fns/esm"; import { format, startOfToday, startOfTomorrow } from "date-fns";
import { import {
EnergyInfo, EnergyInfo,
EnergyPreferences, EnergyPreferences,

View File

@ -161,12 +161,14 @@ const LABELS: LabelRegistryEntry[] = [
name: "Energy", name: "Energy",
icon: null, icon: null,
color: "yellow", color: "yellow",
description: null,
}, },
{ {
label_id: "entertainment", label_id: "entertainment",
name: "Entertainment", name: "Entertainment",
icon: "mdi:popcorn", icon: "mdi:popcorn",
color: "blue", color: "blue",
description: null,
}, },
]; ];

View File

@ -2,6 +2,7 @@ import { html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, query } from "lit/decorators"; import { customElement, query } from "lit/decorators";
import { CoverEntityFeature } from "../../../../src/data/cover"; import { CoverEntityFeature } from "../../../../src/data/cover";
import { LightColorMode } from "../../../../src/data/light"; import { LightColorMode } from "../../../../src/data/light";
import { LockEntityFeature } from "../../../../src/data/lock";
import { VacuumEntityFeature } from "../../../../src/data/vacuum"; import { VacuumEntityFeature } from "../../../../src/data/vacuum";
import { getEntity } from "../../../../src/fake_data/entity"; import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass"; import { provideHass } from "../../../../src/fake_data/provide_hass";
@ -20,6 +21,11 @@ const ENTITIES = [
getEntity("light", "unavailable", "unavailable", { getEntity("light", "unavailable", "unavailable", {
friendly_name: "Unavailable entity", friendly_name: "Unavailable entity",
}), }),
getEntity("lock", "front_door", "locked", {
friendly_name: "Front Door Lock",
device_class: "lock",
supported_features: LockEntityFeature.OPEN,
}),
getEntity("climate", "thermostat", "heat", { getEntity("climate", "thermostat", "heat", {
current_temperature: 73, current_temperature: 73,
min_temp: 45, min_temp: 45,
@ -138,6 +144,24 @@ const CONFIGS = [
- type: "color-temp" - type: "color-temp"
`, `,
}, },
{
heading: "Lock commands feature",
config: `
- type: tile
entity: lock.front_door
features:
- type: "lock-commands"
`,
},
{
heading: "Lock open door feature",
config: `
- type: tile
entity: lock.front_door
features:
- type: "lock-open-door"
`,
},
{ {
heading: "Vacuum commands feature", heading: "Vacuum commands feature",
config: ` config: `

View File

@ -36,6 +36,8 @@ const createConfigEntry = (
pref_disable_new_entities: false, pref_disable_new_entities: false,
pref_disable_polling: false, pref_disable_polling: false,
reason: null, reason: null,
error_reason_translation_key: null,
error_reason_translation_placeholders: null,
...override, ...override,
}); });

View File

@ -1,4 +1,7 @@
import { globIterate } from "glob"; import { globIterate } from "glob";
import { availableParallelism } from "node:os";
process.env.UV_THREADPOOL_SIZE = availableParallelism();
const gulpImports = []; const gulpImports = [];

View File

@ -25,15 +25,15 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@babel/runtime": "7.24.1", "@babel/runtime": "7.24.4",
"@braintree/sanitize-url": "7.0.1", "@braintree/sanitize-url": "7.0.1",
"@codemirror/autocomplete": "6.15.0", "@codemirror/autocomplete": "6.16.0",
"@codemirror/commands": "6.3.3", "@codemirror/commands": "6.5.0",
"@codemirror/language": "6.10.1", "@codemirror/language": "6.10.1",
"@codemirror/legacy-modes": "6.3.3", "@codemirror/legacy-modes": "6.4.0",
"@codemirror/search": "6.5.6", "@codemirror/search": "6.5.6",
"@codemirror/state": "6.4.1", "@codemirror/state": "6.4.1",
"@codemirror/view": "6.26.1", "@codemirror/view": "6.26.3",
"@egjs/hammerjs": "2.0.17", "@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.12.3", "@formatjs/intl-datetimeformat": "6.12.3",
"@formatjs/intl-displaynames": "6.6.6", "@formatjs/intl-displaynames": "6.6.6",
@ -81,7 +81,7 @@
"@material/mwc-top-app-bar": "0.27.0", "@material/mwc-top-app-bar": "0.27.0",
"@material/mwc-top-app-bar-fixed": "0.27.0", "@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0", "@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
"@material/web": "=1.3.0", "@material/web": "1.4.1",
"@mdi/js": "7.4.47", "@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47", "@mdi/svg": "7.4.47",
"@polymer/paper-item": "3.0.1", "@polymer/paper-item": "3.0.1",
@ -89,8 +89,8 @@
"@polymer/paper-tabs": "3.1.0", "@polymer/paper-tabs": "3.1.0",
"@polymer/polymer": "3.5.1", "@polymer/polymer": "3.5.1",
"@thomasloven/round-slider": "0.6.0", "@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.3.10", "@vaadin/combo-box": "24.3.11",
"@vaadin/vaadin-themable-mixin": "24.3.10", "@vaadin/vaadin-themable-mixin": "24.3.11",
"@vibrant/color": "3.2.1-alpha.1", "@vibrant/color": "3.2.1-alpha.1",
"@vibrant/core": "3.2.1-alpha.1", "@vibrant/core": "3.2.1-alpha.1",
"@vibrant/quantizer-mmcq": "3.2.1-alpha.1", "@vibrant/quantizer-mmcq": "3.2.1-alpha.1",
@ -101,17 +101,17 @@
"chart.js": "4.4.2", "chart.js": "4.4.2",
"color-name": "2.0.0", "color-name": "2.0.0",
"comlink": "4.4.1", "comlink": "4.4.1",
"core-js": "3.36.1", "core-js": "3.37.0",
"cropperjs": "1.6.1", "cropperjs": "1.6.1",
"date-fns": "2.30.0", "date-fns": "3.6.0",
"date-fns-tz": "2.0.1", "date-fns-tz": "3.1.3",
"deep-clone-simple": "1.1.1", "deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1", "deep-freeze": "0.0.1",
"element-internals-polyfill": "1.3.10", "element-internals-polyfill": "1.3.11",
"fuse.js": "7.0.0", "fuse.js": "7.0.0",
"google-timezones-json": "1.2.0", "google-timezones-json": "1.2.0",
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch", "hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
"home-assistant-js-websocket": "9.2.1", "home-assistant-js-websocket": "9.3.0",
"idb-keyval": "6.2.1", "idb-keyval": "6.2.1",
"intl-messageformat": "10.5.11", "intl-messageformat": "10.5.11",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
@ -119,7 +119,7 @@
"leaflet-draw": "1.0.4", "leaflet-draw": "1.0.4",
"lit": "2.8.0", "lit": "2.8.0",
"luxon": "3.4.4", "luxon": "3.4.4",
"marked": "12.0.1", "marked": "12.0.2",
"memoize-one": "6.0.0", "memoize-one": "6.0.0",
"node-vibrant": "3.2.1-alpha.1", "node-vibrant": "3.2.1-alpha.1",
"proxy-polyfill": "0.3.2", "proxy-polyfill": "0.3.2",
@ -150,18 +150,18 @@
"xss": "1.0.15" "xss": "1.0.15"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.24.3", "@babel/core": "7.24.4",
"@babel/helper-define-polyfill-provider": "0.6.1", "@babel/helper-define-polyfill-provider": "0.6.1",
"@babel/plugin-proposal-decorators": "7.24.1", "@babel/plugin-proposal-decorators": "7.24.1",
"@babel/plugin-transform-runtime": "7.24.3", "@babel/plugin-transform-runtime": "7.24.3",
"@babel/preset-env": "7.24.3", "@babel/preset-env": "7.24.4",
"@babel/preset-typescript": "7.24.1", "@babel/preset-typescript": "7.24.1",
"@bundle-stats/plugin-webpack-filter": "4.12.2", "@bundle-stats/plugin-webpack-filter": "4.12.2",
"@koa/cors": "5.0.0", "@koa/cors": "5.0.0",
"@lokalise/node-api": "12.3.0", "@lokalise/node-api": "12.4.0",
"@octokit/auth-oauth-device": "7.0.1", "@octokit/auth-oauth-device": "7.1.1",
"@octokit/plugin-retry": "7.0.3", "@octokit/plugin-retry": "7.1.0",
"@octokit/rest": "20.0.2", "@octokit/rest": "20.1.0",
"@open-wc/dev-server-hmr": "0.1.4", "@open-wc/dev-server-hmr": "0.1.4",
"@rollup/plugin-babel": "6.0.4", "@rollup/plugin-babel": "6.0.4",
"@rollup/plugin-commonjs": "25.0.7", "@rollup/plugin-commonjs": "25.0.7",
@ -169,24 +169,24 @@
"@rollup/plugin-node-resolve": "15.2.3", "@rollup/plugin-node-resolve": "15.2.3",
"@rollup/plugin-replace": "5.0.5", "@rollup/plugin-replace": "5.0.5",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.13", "@types/chromecast-caf-receiver": "6.0.14",
"@types/chromecast-caf-sender": "1.0.9", "@types/chromecast-caf-sender": "1.0.9",
"@types/color-name": "1.1.3", "@types/color-name": "1.1.4",
"@types/glob": "8.1.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.8", "@types/leaflet": "1.9.11",
"@types/leaflet-draw": "1.0.11", "@types/leaflet-draw": "1.0.11",
"@types/luxon": "3.4.2", "@types/luxon": "3.4.2",
"@types/mocha": "10.0.6", "@types/mocha": "10.0.6",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/serve-handler": "6.1.4", "@types/serve-handler": "6.1.4",
"@types/sortablejs": "1.15.8", "@types/sortablejs": "1.15.8",
"@types/tar": "6.1.11", "@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",
"@typescript-eslint/eslint-plugin": "7.4.0", "@typescript-eslint/eslint-plugin": "7.7.0",
"@typescript-eslint/parser": "7.4.0", "@typescript-eslint/parser": "7.7.0",
"@web/dev-server": "0.1.38", "@web/dev-server": "0.1.38",
"@web/dev-server-rollup": "0.4.1", "@web/dev-server-rollup": "0.4.1",
"babel-loader": "9.1.3", "babel-loader": "9.1.3",
@ -203,12 +203,11 @@
"eslint-plugin-lit": "1.11.0", "eslint-plugin-lit": "1.11.0",
"eslint-plugin-lit-a11y": "4.1.2", "eslint-plugin-lit-a11y": "4.1.2",
"eslint-plugin-unused-imports": "3.1.0", "eslint-plugin-unused-imports": "3.1.0",
"eslint-plugin-wc": "2.0.4", "eslint-plugin-wc": "2.1.0",
"fancy-log": "2.0.0", "fancy-log": "2.0.0",
"fs-extra": "11.2.0", "fs-extra": "11.2.0",
"glob": "10.3.10", "glob": "10.3.12",
"gulp": "4.0.2", "gulp": "4.0.2",
"gulp-flatmap": "1.0.2",
"gulp-json-transform": "0.5.0", "gulp-json-transform": "0.5.0",
"gulp-merge-json": "2.2.1", "gulp-merge-json": "2.2.1",
"gulp-rename": "2.0.0", "gulp-rename": "2.0.0",
@ -220,9 +219,9 @@
"lint-staged": "15.2.2", "lint-staged": "15.2.2",
"lit-analyzer": "2.0.3", "lit-analyzer": "2.0.3",
"lodash.template": "4.5.0", "lodash.template": "4.5.0",
"magic-string": "0.30.8", "magic-string": "0.30.10",
"map-stream": "0.0.7", "map-stream": "0.0.7",
"mocha": "10.3.0", "mocha": "10.4.0",
"object-hash": "3.0.0", "object-hash": "3.0.0",
"open": "10.1.0", "open": "10.1.0",
"pinst": "3.0.0", "pinst": "3.0.0",
@ -235,13 +234,11 @@
"sinon": "17.0.1", "sinon": "17.0.1",
"source-map-url": "0.4.1", "source-map-url": "0.4.1",
"systemjs": "6.14.3", "systemjs": "6.14.3",
"tar": "6.2.1", "tar": "7.0.1",
"terser-webpack-plugin": "5.3.10", "terser-webpack-plugin": "5.3.10",
"transform-async-modules-webpack-plugin": "1.0.4", "transform-async-modules-webpack-plugin": "1.1.0",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.4.3", "typescript": "5.4.5",
"vinyl-buffer": "1.0.1",
"vinyl-source-stream": "2.0.0",
"webpack": "5.91.0", "webpack": "5.91.0",
"webpack-cli": "5.1.4", "webpack-cli": "5.1.4",
"webpack-dev-server": "5.0.4", "webpack-dev-server": "5.0.4",

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20240404.2" version = "20240424.0"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "The Home Assistant frontend" description = "The Home Assistant frontend"
readme = "README.md" readme = "README.md"

View File

@ -40,6 +40,11 @@
"matchPackageNames": ["tsparticles-engine"], "matchPackageNames": ["tsparticles-engine"],
"matchPackagePrefixes": ["tsparticles-preset-"] "matchPackagePrefixes": ["tsparticles-preset-"]
}, },
{
"description": "Group date-fns with dependent timezone package",
"groupName": "date-fns",
"matchPackageNames": ["date-fns", "date-fns-tz"]
},
{ {
"description": "Group and temporarily disable WDS packages", "description": "Group and temporarily disable WDS packages",
"groupName": "Web Dev Server", "groupName": "Web Dev Server",

View File

@ -1,4 +1,4 @@
import { utcToZonedTime, zonedTimeToUtc } from "date-fns-tz"; import { toZonedTime, fromZonedTime } from "date-fns-tz";
import { HassConfig } from "home-assistant-js-websocket"; import { HassConfig } from "home-assistant-js-websocket";
import { FrontendLocaleData, TimeZone } from "../../data/translation"; import { FrontendLocaleData, TimeZone } from "../../data/translation";
@ -8,10 +8,10 @@ const calcZonedDate = (
fn: (date: Date, options?: any) => Date | number | boolean, fn: (date: Date, options?: any) => Date | number | boolean,
options? options?
) => { ) => {
const inputZoned = utcToZonedTime(date, tz); const inputZoned = toZonedTime(date, tz);
const fnZoned = fn(inputZoned, options); const fnZoned = fn(inputZoned, options);
if (fnZoned instanceof Date) { if (fnZoned instanceof Date) {
return zonedTimeToUtc(fnZoned, tz) as Date; return fromZonedTime(fnZoned, tz) as Date;
} }
return fnZoned; return fnZoned;
}; };
@ -51,6 +51,6 @@ export const calcDateDifferenceProperty = (
locale, locale,
config, config,
locale.time_zone === TimeZone.server locale.time_zone === TimeZone.server
? utcToZonedTime(startDate, config.time_zone) ? toZonedTime(startDate, config.time_zone)
: startDate : startDate
); );

View File

@ -187,11 +187,14 @@ export const computeStateDisplayFromEntityAttributes = (
if ( if (
[ [
"button", "button",
"conversation",
"event", "event",
"image", "image",
"input_button", "input_button",
"notify",
"scene", "scene",
"stt", "stt",
"tag",
"tts", "tts",
"wake_word", "wake_word",
].includes(domain) || ].includes(domain) ||

View File

@ -2,6 +2,7 @@ import IntlMessageFormat from "intl-messageformat";
import type { HTMLTemplateResult } from "lit"; import type { HTMLTemplateResult } from "lit";
import { polyfillLocaleData } from "../../resources/locale-data-polyfill"; import { polyfillLocaleData } from "../../resources/locale-data-polyfill";
import { Resources, TranslationDict } from "../../types"; import { Resources, TranslationDict } from "../../types";
import { fireEvent } from "../dom/fire_event";
// Exclude some patterns from key type checking for now // Exclude some patterns from key type checking for now
// These are intended to be removed as errors are fixed // These are intended to be removed as errors are fixed
@ -81,7 +82,9 @@ export interface FormatsType {
*/ */
export const computeLocalize = async <Keys extends string = LocalizeKeys>( export const computeLocalize = async <Keys extends string = LocalizeKeys>(
cache: any, cache: HTMLElement & {
_localizationCache?: Record<string, IntlMessageFormat>;
},
language: string, language: string,
resources: Resources, resources: Resources,
formats?: FormatsType formats?: FormatsType
@ -107,7 +110,7 @@ export const computeLocalize = async <Keys extends string = LocalizeKeys>(
} }
const messageKey = key + translatedValue; const messageKey = key + translatedValue;
let translatedMessage = cache._localizationCache[messageKey] as let translatedMessage = cache._localizationCache![messageKey] as
| IntlMessageFormat | IntlMessageFormat
| undefined; | undefined;
@ -121,7 +124,7 @@ export const computeLocalize = async <Keys extends string = LocalizeKeys>(
} catch (err: any) { } catch (err: any) {
return "Translation error: " + err.message; return "Translation error: " + err.message;
} }
cache._localizationCache[messageKey] = translatedMessage; cache._localizationCache![messageKey] = translatedMessage;
} }
let argObject = {}; let argObject = {};
@ -137,6 +140,12 @@ export const computeLocalize = async <Keys extends string = LocalizeKeys>(
try { try {
return translatedMessage.format<string>(argObject) as string; return translatedMessage.format<string>(argObject) as string;
} catch (err: any) { } catch (err: any) {
// eslint-disable-next-line no-console
console.error("Translation error", key, language, err);
fireEvent(cache, "write_log", {
level: "error",
message: `Failed to format translation for key '${key}' in language '${language}'. ${err}`,
});
return "Translation " + err; return "Translation " + err;
} }
}; };

View File

@ -1,4 +1,4 @@
import { differenceInDays, differenceInWeeks, startOfWeek } from "date-fns/esm"; import { differenceInDays, differenceInWeeks, startOfWeek } from "date-fns";
import { FrontendLocaleData } from "../../data/translation"; import { FrontendLocaleData } from "../../data/translation";
import { firstWeekdayIndex } from "../datetime/first_weekday"; import { firstWeekdayIndex } from "../datetime/first_weekday";

View File

@ -34,7 +34,7 @@ import {
endOfMonth, endOfMonth,
endOfQuarter, endOfQuarter,
endOfYear, endOfYear,
} from "date-fns/esm"; } from "date-fns";
import { import {
formatDate, formatDate,
formatDateMonth, formatDateMonth,

View File

@ -1,13 +1,13 @@
import { mdiArrowDown, mdiArrowUp } from "@mdi/js"; import { mdiArrowDown, mdiArrowUp, mdiChevronDown } from "@mdi/js";
import deepClone from "deep-clone-simple"; import deepClone from "deep-clone-simple";
import { import {
css,
CSSResultGroup, CSSResultGroup,
html,
LitElement, LitElement,
nothing,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
css,
html,
nothing,
} from "lit"; } from "lit";
import { import {
customElement, customElement,
@ -22,7 +22,9 @@ import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { restoreScroll } from "../../common/decorators/restore-scroll"; import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import { debounce } from "../../common/util/debounce"; import { debounce } from "../../common/util/debounce";
import { groupBy } from "../../common/util/group-by";
import { nextRender } from "../../common/util/render-status"; import { nextRender } from "../../common/util/render-status";
import { haStyleScrollbar } from "../../resources/styles"; import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer"; import { loadVirtualizer } from "../../resources/virtualizer";
@ -32,17 +34,6 @@ import type { HaCheckbox } from "../ha-checkbox";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "../search-input"; import "../search-input";
import { filterData, sortData } from "./sort-filter"; import { filterData, sortData } from "./sort-filter";
import { groupBy } from "../../common/util/group-by";
import { stringCompare } from "../../common/string/compare";
declare global {
// for fire event
interface HASSDomEvents {
"selection-changed": SelectionChangedEvent;
"row-click": RowClickedEvent;
"sorting-changed": SortingChangedEvent;
}
}
export interface RowClickedEvent { export interface RowClickedEvent {
id: string; id: string;
@ -52,6 +43,10 @@ export interface SelectionChangedEvent {
value: string[]; value: string[];
} }
export interface CollapsedChangedEvent {
value: string[];
}
export interface SortingChangedEvent { export interface SortingChangedEvent {
column: string; column: string;
direction: SortingDirection; direction: SortingDirection;
@ -142,10 +137,14 @@ export class HaDataTable extends LitElement {
@property() public groupColumn?: string; @property() public groupColumn?: string;
@property({ attribute: false }) public groupOrder?: string[];
@property() public sortColumn?: string; @property() public sortColumn?: string;
@property() public sortDirection: SortingDirection = null; @property() public sortDirection: SortingDirection = null;
@property({ attribute: false }) public initialCollapsedGroups?: string[];
@state() private _filterable = false; @state() private _filterable = false;
@state() private _filter = ""; @state() private _filter = "";
@ -158,6 +157,8 @@ export class HaDataTable extends LitElement {
@state() private _items: DataTableRowData[] = []; @state() private _items: DataTableRowData[] = [];
@state() private _collapsedGroups: string[] = [];
private _checkableRowsCount?: number; private _checkableRowsCount?: number;
private _checkedRows: string[] = []; private _checkedRows: string[] = [];
@ -213,17 +214,19 @@ export class HaDataTable extends LitElement {
(column) => column.filterable (column) => column.filterable
); );
for (const columnId in this.columns) { if (!this.sortColumn) {
if (this.columns[columnId].direction) { for (const columnId in this.columns) {
this.sortDirection = this.columns[columnId].direction!; if (this.columns[columnId].direction) {
this.sortColumn = columnId; this.sortDirection = this.columns[columnId].direction!;
this.sortColumn = columnId;
fireEvent(this, "sorting-changed", { fireEvent(this, "sorting-changed", {
column: columnId, column: columnId,
direction: this.sortDirection, direction: this.sortDirection,
}); });
break; break;
}
} }
} }
@ -248,13 +251,23 @@ export class HaDataTable extends LitElement {
).length; ).length;
} }
if (!this.hasUpdated && this.initialCollapsedGroups) {
this._collapsedGroups = this.initialCollapsedGroups;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
} else if (properties.has("groupColumn")) {
this._collapsedGroups = [];
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
}
if ( if (
properties.has("data") || properties.has("data") ||
properties.has("columns") || properties.has("columns") ||
properties.has("_filter") || properties.has("_filter") ||
properties.has("sortColumn") || properties.has("sortColumn") ||
properties.has("sortDirection") || properties.has("sortDirection") ||
properties.has("groupColumn") properties.has("groupColumn") ||
properties.has("groupOrder") ||
properties.has("_collapsedGroups")
) { ) {
this._sortFilterData(); this._sortFilterData();
} }
@ -447,6 +460,8 @@ export class HaDataTable extends LitElement {
} }
return html` return html`
<div <div
@mouseover=${this._setTitle}
@focus=${this._setTitle}
role=${column.main ? "rowheader" : "cell"} role=${column.main ? "rowheader" : "cell"}
class="mdc-data-table__cell ${classMap({ class="mdc-data-table__cell ${classMap({
"mdc-data-table__cell--flex": column.type === "flex", "mdc-data-table__cell--flex": column.type === "flex",
@ -514,11 +529,7 @@ export class HaDataTable extends LitElement {
} }
if (this.appendRow || this.hasFab || this.groupColumn) { if (this.appendRow || this.hasFab || this.groupColumn) {
const items = [...data]; let items = [...data];
if (this.appendRow) {
items.push({ append: true, content: this.appendRow });
}
if (this.groupColumn) { if (this.groupColumn) {
const grouped = groupBy(items, (item) => item[this.groupColumn!]); const grouped = groupBy(items, (item) => item[this.groupColumn!]);
@ -530,13 +541,24 @@ export class HaDataTable extends LitElement {
const sorted: { const sorted: {
[key: string]: DataTableRowData[]; [key: string]: DataTableRowData[];
} = Object.keys(grouped) } = Object.keys(grouped)
.sort((a, b) => .sort((a, b) => {
stringCompare( const orderA = this.groupOrder?.indexOf(a) ?? -1;
const orderB = this.groupOrder?.indexOf(b) ?? -1;
if (orderA !== orderB) {
if (orderA === -1) {
return 1;
}
if (orderB === -1) {
return -1;
}
return orderA - orderB;
}
return stringCompare(
["", "-", "—"].includes(a) ? "zzz" : a, ["", "-", "—"].includes(a) ? "zzz" : a,
["", "-", "—"].includes(b) ? "zzz" : b, ["", "-", "—"].includes(b) ? "zzz" : b,
this.hass.locale.language this.hass.locale.language
) );
) })
.reduce((obj, key) => { .reduce((obj, key) => {
obj[key] = grouped[key]; obj[key] = grouped[key];
return obj; return obj;
@ -552,23 +574,39 @@ export class HaDataTable extends LitElement {
content: html`<div content: html`<div
class="mdc-data-table__cell group-header" class="mdc-data-table__cell group-header"
role="cell" role="cell"
.group=${groupName}
@click=${this._collapseGroup}
> >
${groupName === UNDEFINED_GROUP_KEY ? "" : groupName || ""} <ha-icon-button
.path=${mdiChevronDown}
class=${this._collapsedGroups.includes(groupName)
? "collapsed"
: ""}
>
</ha-icon-button>
${groupName === UNDEFINED_GROUP_KEY
? this.hass.localize("ui.components.data-table.ungrouped")
: groupName || ""}
</div>`, </div>`,
}); });
} }
if (!this._collapsedGroups.includes(groupName)) {
groupedItems.push(...rows); groupedItems.push(...rows);
}
}); });
this._items = groupedItems; items = groupedItems;
} else { }
this._items = items;
if (this.appendRow) {
items.push({ append: true, content: this.appendRow });
} }
if (this.hasFab) { if (this.hasFab) {
this._items = [...this._items, { empty: true }]; items.push({ empty: true });
} }
this._items = items;
} else { } else {
this._items = data; this._items = data;
} }
@ -649,6 +687,13 @@ export class HaDataTable extends LitElement {
fireEvent(this, "row-click", { id: rowId }, { bubbles: false }); fireEvent(this, "row-click", { id: rowId }, { bubbles: false });
}; };
private _setTitle(ev: Event) {
const target = ev.currentTarget as HTMLElement;
if (target.scrollWidth > target.offsetWidth) {
target.setAttribute("title", target.innerText);
}
}
private _checkedRowsChanged() { private _checkedRowsChanged() {
// force scroller to update, change it's items // force scroller to update, change it's items
if (this._items.length) { if (this._items.length) {
@ -679,6 +724,18 @@ export class HaDataTable extends LitElement {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop; this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
} }
private _collapseGroup = (ev: Event) => {
const groupName = (ev.currentTarget as any).group;
if (this._collapsedGroups.includes(groupName)) {
this._collapsedGroups = this._collapsedGroups.filter(
(grp) => grp !== groupName
);
} else {
this._collapsedGroups = [...this._collapsedGroups, groupName];
}
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
};
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleScrollbar, haStyleScrollbar,
@ -931,8 +988,21 @@ export class HaDataTable extends LitElement {
.group-header { .group-header {
padding-top: 12px; padding-top: 12px;
padding-left: 12px;
padding-inline-start: 12px;
width: 100%; width: 100%;
font-weight: 500; font-weight: 500;
display: flex;
align-items: center;
cursor: pointer;
}
.group-header ha-icon-button {
transition: transform 0.2s ease;
}
.group-header ha-icon-button.collapsed {
transform: rotate(180deg);
} }
:host { :host {
@ -1031,4 +1101,12 @@ declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ha-data-table": HaDataTable; "ha-data-table": HaDataTable;
} }
// for fire event
interface HASSDomEvents {
"selection-changed": SelectionChangedEvent;
"row-click": RowClickedEvent;
"sorting-changed": SortingChangedEvent;
"collapsed-changed": CollapsedChangedEvent;
}
} }

View File

@ -18,6 +18,12 @@ import "../ha-icon-button";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "./state-badge"; import "./state-badge";
import { caseInsensitiveStringCompare } from "../../common/string/compare"; import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
import { domainToName } from "../../data/integration";
import {
isHelperDomain,
HelperDomain,
} from "../../panels/config/helpers/const";
interface HassEntityWithCachedName extends HassEntity, ScorableTextItem { interface HassEntityWithCachedName extends HassEntity, ScorableTextItem {
friendly_name: string; friendly_name: string;
@ -25,6 +31,8 @@ interface HassEntityWithCachedName extends HassEntity, ScorableTextItem {
export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean; export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
const CREATE_ID = "___create-new-entity___";
@customElement("ha-entity-picker") @customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement { export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -44,6 +52,8 @@ export class HaEntityPicker extends LitElement {
@property() public helper?: string; @property() public helper?: string;
@property({ type: Array }) public createDomains?: string[];
/** /**
* Show entities from specific domains. * Show entities from specific domains.
* @type {Array} * @type {Array}
@ -130,7 +140,11 @@ export class HaEntityPicker extends LitElement {
></state-badge>` ></state-badge>`
: ""} : ""}
<span>${item.friendly_name}</span> <span>${item.friendly_name}</span>
<span slot="secondary">${item.entity_id}</span> <span slot="secondary"
>${item.entity_id.startsWith(CREATE_ID)
? this.hass.localize("ui.components.entity.entity-picker.new_entity")
: item.entity_id}</span
>
</ha-list-item>`; </ha-list-item>`;
private _getStates = memoizeOne( private _getStates = memoizeOne(
@ -143,7 +157,8 @@ export class HaEntityPicker extends LitElement {
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
includeUnitOfMeasurement: this["includeUnitOfMeasurement"], includeUnitOfMeasurement: this["includeUnitOfMeasurement"],
includeEntities: this["includeEntities"], includeEntities: this["includeEntities"],
excludeEntities: this["excludeEntities"] excludeEntities: this["excludeEntities"],
createDomains: this["createDomains"]
): HassEntityWithCachedName[] => { ): HassEntityWithCachedName[] => {
let states: HassEntityWithCachedName[] = []; let states: HassEntityWithCachedName[] = [];
@ -152,6 +167,34 @@ export class HaEntityPicker extends LitElement {
} }
let entityIds = Object.keys(hass.states); let entityIds = Object.keys(hass.states);
const createItems = createDomains?.length
? createDomains.map((domain) => {
const newFriendlyName = hass.localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? hass.localize(
`ui.panel.config.helpers.types.${domain as HelperDomain}`
)
: domainToName(hass.localize, domain),
}
);
return {
entity_id: CREATE_ID + domain,
state: "on",
last_changed: "",
last_updated: "",
context: { id: "", user_id: null, parent_id: null },
friendly_name: newFriendlyName,
attributes: {
icon: "mdi:plus",
},
strings: [domain, newFriendlyName],
};
})
: [];
if (!entityIds.length) { if (!entityIds.length) {
return [ return [
{ {
@ -171,6 +214,7 @@ export class HaEntityPicker extends LitElement {
}, },
strings: [], strings: [],
}, },
...createItems,
]; ];
} }
@ -281,9 +325,14 @@ export class HaEntityPicker extends LitElement {
}, },
strings: [], strings: [],
}, },
...createItems,
]; ];
} }
if (createItems?.length) {
states.push(...createItems);
}
return states; return states;
} }
); );
@ -310,13 +359,18 @@ export class HaEntityPicker extends LitElement {
this.includeDeviceClasses, this.includeDeviceClasses,
this.includeUnitOfMeasurement, this.includeUnitOfMeasurement,
this.includeEntities, this.includeEntities,
this.excludeEntities this.excludeEntities,
this.createDomains
); );
if (this._initedStates) { if (this._initedStates) {
this.comboBox.filteredItems = this._states; this.comboBox.filteredItems = this._states;
} }
this._initedStates = true; this._initedStates = true;
} }
if (changedProps.has("createDomains") && this.createDomains?.length) {
this.hass.loadFragmentTranslation("config");
}
} }
protected render(): TemplateResult { protected render(): TemplateResult {
@ -354,6 +408,18 @@ export class HaEntityPicker extends LitElement {
private _valueChanged(ev: ValueChangedEvent<string>) { private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
const newValue = ev.detail.value; const newValue = ev.detail.value;
if (newValue && newValue.startsWith(CREATE_ID)) {
const domain = newValue.substring(CREATE_ID.length);
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) this._setValue(item.entityId);
},
});
return;
}
if (newValue !== this._value) { if (newValue !== this._value) {
this._setValue(newValue); this._setValue(newValue);
} }

View File

@ -14,6 +14,8 @@ export class HaCard extends LitElement {
--ha-card-background, --ha-card-background,
var(--card-background-color, white) var(--card-background-color, white)
); );
-webkit-backdrop-filter: var(--ha-card-backdrop-filter, none);
backdrop-filter: var(--ha-card-backdrop-filter, none);
box-shadow: var(--ha-card-box-shadow, none); box-shadow: var(--ha-card-box-shadow, none);
box-sizing: border-box; box-sizing: border-box;
border-radius: var(--ha-card-border-radius, 12px); border-radius: var(--ha-card-border-radius, 12px);

View File

@ -1,6 +1,6 @@
import "element-internals-polyfill"; import "element-internals-polyfill";
import { MdCircularProgress } from "@material/web/progress/circular-progress"; import { MdCircularProgress } from "@material/web/progress/circular-progress";
import { CSSResult, PropertyValues, css } from "lit"; import { PropertyValues, css } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
@customElement("ha-circular-progress") @customElement("ha-circular-progress")
@ -32,17 +32,15 @@ export class HaCircularProgress extends MdCircularProgress {
} }
} }
static get styles(): CSSResult[] { static override styles = [
return [ ...super.styles,
...super.styles, css`
css` :host {
:host { --md-sys-color-primary: var(--primary-color);
--md-sys-color-primary: var(--primary-color); --md-circular-progress-size: 48px;
--md-circular-progress-size: 48px; }
} `,
`, ];
];
}
} }
declare global { declare global {

View File

@ -67,6 +67,9 @@ export class HaControlSlider extends LitElement {
@property({ attribute: "tooltip-mode" }) @property({ attribute: "tooltip-mode" })
public tooltipMode: TooltipMode = "interaction"; public tooltipMode: TooltipMode = "interaction";
@property({ attribute: "touch-action" })
public touchAction?: string;
@property({ type: Number }) @property({ type: Number })
public value?: number; public value?: number;
@ -152,7 +155,7 @@ export class HaControlSlider extends LitElement {
setupListeners() { setupListeners() {
if (this.slider && !this._mc) { if (this.slider && !this._mc) {
this._mc = new Manager(this.slider, { this._mc = new Manager(this.slider, {
touchAction: this.vertical ? "pan-x" : "pan-y", touchAction: this.touchAction ?? (this.vertical ? "pan-x" : "pan-y"),
}); });
this._mc.add( this._mc.add(
new Pan({ new Pan({

View File

@ -33,6 +33,9 @@ export class HaControlSwitch extends LitElement {
// SVG icon path (if you need a non SVG icon instead, use the provided off icon slot to pass an <ha-icon slot="icon-off"> in) // SVG icon path (if you need a non SVG icon instead, use the provided off icon slot to pass an <ha-icon slot="icon-off"> in)
@property({ type: String }) pathOff?: string; @property({ type: String }) pathOff?: string;
@property({ attribute: "touch-action" })
public touchAction?: string;
private _mc?: HammerManager; private _mc?: HammerManager;
protected firstUpdated(changedProperties: PropertyValues): void { protected firstUpdated(changedProperties: PropertyValues): void {
@ -73,7 +76,7 @@ export class HaControlSwitch extends LitElement {
setupListeners() { setupListeners() {
if (this.switch && !this._mc) { if (this.switch && !this._mc) {
this._mc = new Manager(this.switch, { this._mc = new Manager(this.switch, {
touchAction: this.vertical ? "pan-x" : "pan-y", touchAction: this.touchAction ?? (this.vertical ? "pan-x" : "pan-y"),
}); });
this._mc.add( this._mc.add(
new Swipe({ new Swipe({

View File

@ -75,8 +75,14 @@ export class HaDialog extends DialogBase {
var(--divider-color) var(--divider-color)
); );
z-index: var(--dialog-z-index, 8); z-index: var(--dialog-z-index, 8);
-webkit-backdrop-filter: var(--dialog-backdrop-filter, none); -webkit-backdrop-filter: var(
backdrop-filter: var(--dialog-backdrop-filter, none); --ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
);
backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
);
--mdc-dialog-box-shadow: var(--dialog-box-shadow, none); --mdc-dialog-box-shadow: var(--dialog-box-shadow, none);
--mdc-typography-headline6-font-weight: 400; --mdc-typography-headline6-font-weight: 400;
--mdc-typography-headline6-font-size: 1.574rem; --mdc-typography-headline6-font-size: 1.574rem;
@ -119,6 +125,8 @@ export class HaDialog extends DialogBase {
margin-top: var(--dialog-surface-margin-top); margin-top: var(--dialog-surface-margin-top);
min-height: var(--mdc-dialog-min-height, auto); min-height: var(--mdc-dialog-min-height, auto);
border-radius: var(--ha-dialog-border-radius, 28px); border-radius: var(--ha-dialog-border-radius, 28px);
-webkit-backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
backdrop-filter: var(--ha-dialog-surface-backdrop-filter, none);
} }
:host([flexContent]) .mdc-dialog .mdc-dialog__content { :host([flexContent]) .mdc-dialog .mdc-dialog__content {
display: flex; display: flex;

View File

@ -94,7 +94,7 @@ export class HaFilterDevices extends LitElement {
? nothing ? nothing
: html`<ha-check-list-item : html`<ha-check-list-item
.value=${device.id} .value=${device.id}
.selected=${this.value?.includes(device.id)} .selected=${this.value?.includes(device.id) ?? false}
> >
${computeDeviceName(device, this.hass)} ${computeDeviceName(device, this.hass)}
</ha-check-list-item>`; </ha-check-list-item>`;

View File

@ -108,7 +108,7 @@ export class HaFilterEntities extends LitElement {
? nothing ? nothing
: html`<ha-check-list-item : html`<ha-check-list-item
.value=${entity.entity_id} .value=${entity.entity_id}
.selected=${this.value?.includes(entity.entity_id)} .selected=${this.value?.includes(entity.entity_id) ?? false}
graphic="icon" graphic="icon"
> >
<ha-state-icon <ha-state-icon

View File

@ -62,8 +62,8 @@ export class HaFilterStates extends LitElement {
(item) => (item) =>
html`<ha-check-list-item html`<ha-check-list-item
.value=${item.value} .value=${item.value}
.selected=${this.value?.includes(item.value)} .selected=${this.value?.includes(item.value) ?? false}
.graphic=${hasIcon ? "icon" : undefined} .graphic=${hasIcon ? "icon" : null}
> >
${item.icon ${item.icon
? html`<ha-icon ? html`<ha-icon

View File

@ -302,6 +302,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
name: this.hass.localize("ui.components.label-picker.no_match"), name: this.hass.localize("ui.components.label-picker.no_match"),
icon: null, icon: null,
color: null, color: null,
description: null,
}, },
]; ];
} }
@ -315,6 +316,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
name: this.hass.localize("ui.components.label-picker.add_new"), name: this.hass.localize("ui.components.label-picker.add_new"),
icon: "mdi:plus", icon: "mdi:plus",
color: null, color: null,
description: null,
}, },
]; ];
} }

View File

@ -1,25 +1,23 @@
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import "element-internals-polyfill"; import "element-internals-polyfill";
import { MdListItem } from "@material/web/list/list-item"; import { MdListItem } from "@material/web/list/list-item";
import { CSSResult, css } from "lit"; import { css } from "lit";
@customElement("ha-list-item-new") @customElement("ha-list-item-new")
export class HaListItemNew extends MdListItem { export class HaListItemNew extends MdListItem {
static get styles(): CSSResult[] { static override styles = [
return [ ...super.styles,
...MdListItem.styles, css`
css` :host {
:host { --ha-icon-display: block;
--ha-icon-display: block; --md-sys-color-primary: var(--primary-text-color);
--md-sys-color-primary: var(--primary-text-color); --md-sys-color-secondary: var(--secondary-text-color);
--md-sys-color-secondary: var(--secondary-text-color); --md-sys-color-surface: var(--card-background-color);
--md-sys-color-surface: var(--card-background-color); --md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color); --md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color); }
} `,
`, ];
];
}
} }
declare global { declare global {

View File

@ -1,20 +1,18 @@
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import "element-internals-polyfill"; import "element-internals-polyfill";
import { MdList } from "@material/web/list/list"; import { MdList } from "@material/web/list/list";
import { CSSResult, css } from "lit"; import { css } from "lit";
@customElement("ha-list-new") @customElement("ha-list-new")
export class HaListNew extends MdList { export class HaListNew extends MdList {
static get styles(): CSSResult[] { static override styles = [
return [ ...super.styles,
...MdList.styles, css`
css` :host {
:host { --md-sys-color-surface: var(--card-background-color);
--md-sys-color-surface: var(--card-background-color); }
} `,
`, ];
];
}
} }
declare global { declare global {

View File

@ -1,12 +1,12 @@
import { MdMenuItem } from "@material/web/menu/menu-item"; import { MdMenuItem } from "@material/web/menu/menu-item";
import "element-internals-polyfill"; import "element-internals-polyfill";
import { CSSResult, css } from "lit"; import { css } from "lit";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
@customElement("ha-menu-item") @customElement("ha-menu-item")
export class HaMenuItem extends MdMenuItem { export class HaMenuItem extends MdMenuItem {
static override styles: CSSResult[] = [ static override styles = [
...MdMenuItem.styles, ...super.styles,
css` css`
:host { :host {
--ha-icon-display: block; --ha-icon-display: block;

View File

@ -1,12 +1,12 @@
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import "element-internals-polyfill"; import "element-internals-polyfill";
import { CSSResult, css } from "lit"; import { css } from "lit";
import { MdMenu } from "@material/web/menu/menu"; import { MdMenu } from "@material/web/menu/menu";
@customElement("ha-menu") @customElement("ha-menu")
export class HaMenu extends MdMenu { export class HaMenu extends MdMenu {
static override styles: CSSResult[] = [ static override styles = [
...MdMenu.styles, ...super.styles,
css` css`
:host { :host {
--md-sys-color-surface-container: var(--card-background-color); --md-sys-color-surface-container: var(--card-background-color);

View File

@ -7,8 +7,10 @@ import type {
LocationSelectorValue, LocationSelectorValue,
} from "../../data/selector"; } from "../../data/selector";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import type { SchemaUnion } from "../ha-form/types";
import type { MarkerLocation } from "../map/ha-locations-editor"; import type { MarkerLocation } from "../map/ha-locations-editor";
import "../map/ha-locations-editor"; import "../map/ha-locations-editor";
import "../ha-form/ha-form";
@customElement("ha-selector-location") @customElement("ha-selector-location")
export class HaLocationSelector extends LitElement { export class HaLocationSelector extends LitElement {
@ -24,6 +26,49 @@ export class HaLocationSelector extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false; @property({ type: Boolean, reflect: true }) public disabled = false;
private _schema = memoizeOne(
(radius?: boolean, radius_readonly?: boolean) =>
[
{
name: "",
type: "grid",
schema: [
{
name: "latitude",
required: true,
selector: { number: { step: "any" } },
},
{
name: "longitude",
required: true,
selector: { number: { step: "any" } },
},
],
},
...(radius
? [
{
name: "radius",
required: true,
default: 1000,
disabled: !!radius_readonly,
selector: { number: { min: 0, step: 1, mode: "box" } as const },
} as const,
]
: []),
] as const
);
protected willUpdate() {
if (!this.value) {
this.value = {
latitude: this.hass.config.latitude,
longitude: this.hass.config.longitude,
radius: this.selector.location?.radius ? 1000 : undefined,
};
}
}
protected render() { protected render() {
return html` return html`
<p>${this.label ? this.label : ""}</p> <p>${this.label ? this.label : ""}</p>
@ -35,6 +80,17 @@ export class HaLocationSelector extends LitElement {
@location-updated=${this._locationChanged} @location-updated=${this._locationChanged}
@radius-updated=${this._radiusChanged} @radius-updated=${this._radiusChanged}
></ha-locations-editor> ></ha-locations-editor>
<ha-form
.hass=${this.hass}
.schema=${this._schema(
this.selector.location?.radius,
this.selector.location?.radius_readonly
)}
.data=${this.value}
.computeLabel=${this._computeLabel}
.disabled=${this.disabled}
@value-changed=${this._valueChanged}
></ha-form>
`; `;
} }
@ -66,7 +122,8 @@ export class HaLocationSelector extends LitElement {
? "mdi:map-marker-radius" ? "mdi:map-marker-radius"
: "mdi:map-marker", : "mdi:map-marker",
location_editable: true, location_editable: true,
radius_editable: true, radius_editable:
!!selector.location?.radius && !selector.location?.radius_readonly,
}, },
]; ];
} }
@ -80,14 +137,39 @@ export class HaLocationSelector extends LitElement {
} }
private _radiusChanged(ev: CustomEvent) { private _radiusChanged(ev: CustomEvent) {
const radius = ev.detail.radius; const radius = Math.round(ev.detail.radius);
fireEvent(this, "value-changed", { value: { ...this.value, radius } }); fireEvent(this, "value-changed", { value: { ...this.value, radius } });
} }
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value;
const radius = Math.round(ev.detail.value.radius);
fireEvent(this, "value-changed", {
value: {
latitude: value.latitude,
longitude: value.longitude,
...(this.selector.location?.radius &&
!this.selector.location?.radius_readonly
? {
radius,
}
: {}),
},
});
}
private _computeLabel = (
entry: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(`ui.components.selectors.location.${entry.name}`);
static styles = css` static styles = css`
ha-locations-editor { ha-locations-editor {
display: block; display: block;
height: 400px; height: 400px;
margin-bottom: 16px;
} }
p { p {
margin-top: 0; margin-top: 0;

View File

@ -82,6 +82,7 @@ export class HaTargetSelector extends LitElement {
.deviceFilter=${this._filterDevices} .deviceFilter=${this._filterDevices}
.entityFilter=${this._filterEntities} .entityFilter=${this._filterEntities}
.disabled=${this.disabled} .disabled=${this.disabled}
.createDomains=${this.selector.target?.create_domains}
></ha-target-picker>`; ></ha-target-picker>`;
} }

View File

@ -33,6 +33,7 @@ import {
expandFloorTarget, expandFloorTarget,
expandLabelTarget, expandLabelTarget,
Selector, Selector,
TargetSelector,
} from "../data/selector"; } from "../data/selector";
import { HomeAssistant, ValueChangedEvent } from "../types"; import { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url"; import { documentationUrl } from "../util/documentation-url";
@ -43,6 +44,7 @@ import "./ha-service-picker";
import "./ha-settings-row"; import "./ha-settings-row";
import "./ha-yaml-editor"; import "./ha-yaml-editor";
import type { HaYamlEditor } from "./ha-yaml-editor"; import type { HaYamlEditor } from "./ha-yaml-editor";
import { isHelperDomain } from "../panels/config/helpers/const";
const attributeFilter = (values: any[], attribute: any) => { const attributeFilter = (values: any[], attribute: any) => {
if (typeof attribute === "object") { if (typeof attribute === "object") {
@ -363,6 +365,15 @@ export class HaServiceControl extends LitElement {
return false; return false;
} }
private _targetSelector = memoizeOne(
(targetSelector: TargetSelector | null | undefined, domain?: string) => {
const create_domains = isHelperDomain(domain) ? [domain] : undefined;
return targetSelector
? { target: { ...targetSelector, create_domains } }
: { target: { create_domains } };
}
);
protected render() { protected render() {
const serviceData = this._getServiceInfo( const serviceData = this._getServiceInfo(
this._value?.service, this._value?.service,
@ -401,157 +412,152 @@ export class HaServiceControl extends LitElement {
)) || )) ||
serviceData?.description; serviceData?.description;
return html` return html`${this.hidePicker
${this.hidePicker ? nothing
? nothing : html`<ha-service-picker
: html`<ha-service-picker .hass=${this.hass}
.value=${this._value?.service}
.disabled=${this.disabled}
@value-changed=${this._serviceChanged}
></ha-service-picker>`}
${this.hideDescription
? nothing
: html`
<div class="description">
${description ? html`<p>${description}</p>` : ""}
${this._manifest
? html` <a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
class="help-icon"
></ha-icon-button>
</a>`
: nothing}
</div>
`}
${serviceData && "target" in serviceData
? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""}
<span slot="heading"
>${this.hass.localize("ui.components.service-control.target")}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_description"
)}</span
><ha-selector
.hass=${this.hass} .hass=${this.hass}
.value=${this._value?.service} .selector=${this._targetSelector(
serviceData.target as TargetSelector,
domain
)}
.disabled=${this.disabled} .disabled=${this.disabled}
@value-changed=${this._serviceChanged} @value-changed=${this._targetChanged}
></ha-service-picker>`} .value=${this._value?.target}
${this.hideDescription ></ha-selector
? nothing ></ha-settings-row>`
: html` : entityId
<div class="description"> ? html`<ha-entity-picker
${description ? html`<p>${description}</p>` : ""}
${this._manifest
? html` <a
href=${this._manifest.is_built_in
? documentationUrl(
this.hass,
`/integrations/${this._manifest.domain}`
)
: this._manifest.documentation}
title=${this.hass.localize(
"ui.components.service-control.integration_doc"
)}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
class="help-icon"
></ha-icon-button>
</a>`
: nothing}
</div>
`}
${serviceData && "target" in serviceData
? html`<ha-settings-row .narrow=${this.narrow}>
${hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>`
: ""}
<span slot="heading"
>${this.hass.localize(
"ui.components.service-control.target"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.components.service-control.target_description"
)}</span
><ha-selector
.hass=${this.hass}
.selector=${serviceData.target
? { target: serviceData.target }
: { target: {} }}
.disabled=${this.disabled}
@value-changed=${this._targetChanged}
.value=${this._value?.target}
></ha-selector
></ha-settings-row>`
: entityId
? html`<ha-entity-picker
.hass=${this.hass}
.disabled=${this.disabled}
.value=${this._value?.data?.entity_id}
.label=${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.entity_id.description`
) || entityId.description}
@value-changed=${this._entityPicked}
allow-custom-entity
></ha-entity-picker>`
: ""}
${shouldRenderServiceDataYaml
? html`<ha-yaml-editor
.hass=${this.hass} .hass=${this.hass}
.label=${this.hass.localize("ui.components.service-control.data")} .disabled=${this.disabled}
.name=${"data"} .value=${this._value?.data?.entity_id}
.readOnly=${this.disabled} .label=${this.hass.localize(
.defaultValue=${this._value?.data} `component.${domain}.services.${serviceName}.fields.entity_id.description`
@value-changed=${this._dataChanged} ) || entityId.description}
></ha-yaml-editor>` @value-changed=${this._entityPicked}
: filteredFields?.map((dataField) => { allow-custom-entity
const selector = dataField?.selector ?? { text: undefined }; ></ha-entity-picker>`
const type = Object.keys(selector)[0]; : ""}
const enhancedSelector = [ ${shouldRenderServiceDataYaml
"action", ? html`<ha-yaml-editor
"condition", .hass=${this.hass}
"trigger", .label=${this.hass.localize("ui.components.service-control.data")}
].includes(type) .name=${"data"}
? { .readOnly=${this.disabled}
[type]: { .defaultValue=${this._value?.data}
...selector[type], @value-changed=${this._dataChanged}
path: [dataField.key], ></ha-yaml-editor>`
}, : filteredFields?.map((dataField) => {
} const selector = dataField?.selector ?? { text: undefined };
: selector; const type = Object.keys(selector)[0];
const enhancedSelector = ["action", "condition", "trigger"].includes(
type
)
? {
[type]: {
...selector[type],
path: [dataField.key],
},
}
: selector;
const showOptional = showOptionalToggle(dataField); const showOptional = showOptionalToggle(dataField);
return dataField.selector && return dataField.selector &&
(!dataField.advanced || (!dataField.advanced ||
this.showAdvanced || this.showAdvanced ||
(this._value?.data && (this._value?.data &&
this._value.data[dataField.key] !== undefined)) this._value.data[dataField.key] !== undefined))
? html`<ha-settings-row .narrow=${this.narrow}> ? html`<ha-settings-row .narrow=${this.narrow}>
${!showOptional ${!showOptional
? hasOptional ? hasOptional
? html`<div slot="prefix" class="checkbox-spacer"></div>` ? html`<div slot="prefix" class="checkbox-spacer"></div>`
: "" : ""
: html`<ha-checkbox : html`<ha-checkbox
.key=${dataField.key} .key=${dataField.key}
.checked=${this._checkedKeys.has(dataField.key) || .checked=${this._checkedKeys.has(dataField.key) ||
(this._value?.data && (this._value?.data &&
this._value.data[dataField.key] !== undefined)} this._value.data[dataField.key] !== undefined)}
.disabled=${this.disabled} .disabled=${this.disabled}
@change=${this._checkboxChanged} @change=${this._checkboxChanged}
slot="prefix" slot="prefix"
></ha-checkbox>`} ></ha-checkbox>`}
<span slot="heading" <span slot="heading"
>${this.hass.localize( >${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.name` `component.${domain}.services.${serviceName}.fields.${dataField.key}.name`
) || ) ||
dataField.name || dataField.name ||
dataField.key}</span dataField.key}</span
> >
<span slot="description" <span slot="description"
>${this.hass.localize( >${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description` `component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
) || dataField?.description}</span ) || dataField?.description}</span
> >
<ha-selector <ha-selector
.disabled=${this.disabled || .disabled=${this.disabled ||
(showOptional && (showOptional &&
!this._checkedKeys.has(dataField.key) && !this._checkedKeys.has(dataField.key) &&
(!this._value?.data || (!this._value?.data ||
this._value.data[dataField.key] === undefined))} this._value.data[dataField.key] === undefined))}
.hass=${this.hass} .hass=${this.hass}
.selector=${enhancedSelector} .selector=${enhancedSelector}
.key=${dataField.key} .key=${dataField.key}
@value-changed=${this._serviceDataChanged} @value-changed=${this._serviceDataChanged}
.value=${this._value?.data .value=${this._value?.data
? this._value.data[dataField.key] ? this._value.data[dataField.key]
: undefined} : undefined}
.placeholder=${dataField.default} .placeholder=${dataField.default}
.localizeValue=${this._localizeValueCallback} .localizeValue=${this._localizeValueCallback}
@item-moved=${this._itemMoved} @item-moved=${this._itemMoved}
></ha-selector> ></ha-selector>
</ha-settings-row>` </ha-settings-row>`
: ""; : "";
})} })} `;
`;
} }
private _localizeValueCallback = (key: string) => { private _localizeValueCallback = (key: string) => {

View File

@ -1,7 +1,7 @@
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import "element-internals-polyfill"; import "element-internals-polyfill";
import { MdSlider } from "@material/web/slider/slider"; import { MdSlider } from "@material/web/slider/slider";
import { CSSResult, css } from "lit"; import { css } from "lit";
import { mainWindow } from "../common/dom/get_main_window"; import { mainWindow } from "../common/dom/get_main_window";
@customElement("ha-slider") @customElement("ha-slider")
@ -11,8 +11,8 @@ export class HaSlider extends MdSlider {
this.dir = mainWindow.document.dir; this.dir = mainWindow.document.dir;
} }
static override styles: CSSResult[] = [ static override styles = [
...MdSlider.styles, ...super.styles,
css` css`
:host { :host {
--md-sys-color-primary: var(--primary-color); --md-sys-color-primary: var(--primary-color);

View File

@ -82,7 +82,7 @@ export class HaSortable extends LitElement {
public connectedCallback() { public connectedCallback() {
super.connectedCallback(); super.connectedCallback();
this._shouldBeDestroy = false; this._shouldBeDestroy = false;
if (this.hasUpdated) { if (this.hasUpdated && !this.disabled) {
this._createSortable(); this._createSortable();
} }
} }

View File

@ -1,18 +1,17 @@
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import "element-internals-polyfill"; import "element-internals-polyfill";
import { CSSResult, css } from "lit"; import { css } from "lit";
import { MdSubMenu } from "@material/web/menu/sub-menu"; import { MdSubMenu } from "@material/web/menu/sub-menu";
@customElement("ha-sub-menu") @customElement("ha-sub-menu")
// @ts-expect-error
export class HaSubMenu extends MdSubMenu { export class HaSubMenu extends MdSubMenu {
async show() { async show() {
super.show(); super.show();
this.menu.hasOverflow = false; this.menu.hasOverflow = false;
} }
static override styles: CSSResult[] = [ static override styles = [
MdSubMenu.styles, ...super.styles,
css` css`
:host { :host {
--ha-icon-display: block; --ha-icon-display: block;

View File

@ -65,6 +65,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property() public helper?: string; @property() public helper?: string;
@property({ type: Array }) public createDomains?: string[];
/** /**
* Show only targets with entities from specific domains. * Show only targets with entities from specific domains.
* @type {Array} * @type {Array}
@ -468,6 +470,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDeviceClasses=${this.includeDeviceClasses} .includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains} .includeDomains=${this.includeDomains}
.excludeEntities=${ensureArray(this.value?.entity_id)} .excludeEntities=${ensureArray(this.value?.entity_id)}
.createDomains=${this.createDomains}
@value-changed=${this._targetPicked} @value-changed=${this._targetPicked}
@click=${this._preventDefault} @click=${this._preventDefault}
allow-custom-entity allow-custom-entity

View File

@ -797,6 +797,7 @@ export class HaAutomationTracer extends LitElement {
description: html`${this.hass.localize( description: html`${this.hass.localize(
`ui.panel.config.automation.trace.messages.${message}`, `ui.panel.config.automation.trace.messages.${message}`,
{ {
reason: this.trace.script_execution,
time: renderFinishedAt(), time: renderFinishedAt(),
executiontime: renderRuntime(), executiontime: renderRuntime(),
} }

View File

@ -1,6 +1,8 @@
import { EntityFilter } from "../common/entity/entity_filter"; import { EntityFilter } from "../common/entity/entity_filter";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
type StrictConnectionMode = "disabled" | "guard_page" | "drop_connection";
interface CloudStatusNotLoggedIn { interface CloudStatusNotLoggedIn {
logged_in: false; logged_in: false;
cloud: "disconnected" | "connecting" | "connected"; cloud: "disconnected" | "connecting" | "connected";
@ -19,6 +21,7 @@ export interface CloudPreferences {
alexa_enabled: boolean; alexa_enabled: boolean;
remote_enabled: boolean; remote_enabled: boolean;
remote_allow_remote_enable: boolean; remote_allow_remote_enable: boolean;
strict_connection: StrictConnectionMode;
google_secure_devices_pin: string | undefined; google_secure_devices_pin: string | undefined;
cloudhooks: { [webhookId: string]: CloudWebhook }; cloudhooks: { [webhookId: string]: CloudWebhook };
alexa_report_state: boolean; alexa_report_state: boolean;
@ -141,6 +144,7 @@ export const updateCloudPref = (
google_secure_devices_pin?: CloudPreferences["google_secure_devices_pin"]; google_secure_devices_pin?: CloudPreferences["google_secure_devices_pin"];
tts_default_voice?: CloudPreferences["tts_default_voice"]; tts_default_voice?: CloudPreferences["tts_default_voice"];
remote_allow_remote_enable?: CloudPreferences["remote_allow_remote_enable"]; remote_allow_remote_enable?: CloudPreferences["remote_allow_remote_enable"];
strict_connection?: CloudPreferences["strict_connection"];
} }
) => ) =>
hass.callWS({ hass.callWS({

View File

@ -23,6 +23,8 @@ export interface ConfigEntry {
pref_disable_polling: boolean; pref_disable_polling: boolean;
disabled_by: "user" | null; disabled_by: "user" | null;
reason: string | null; reason: string | null;
error_reason_translation_key: string | null;
error_reason_translation_placeholders: Record<string, string> | null;
} }
export type ConfigEntryMutableParams = Partial< export type ConfigEntryMutableParams = Partial<

View File

@ -9,7 +9,7 @@ import {
startOfDay, startOfDay,
isFirstDayOfMonth, isFirstDayOfMonth,
isLastDayOfMonth, isLastDayOfMonth,
} from "date-fns/esm"; } from "date-fns";
import { Collection, getCollection } from "home-assistant-js-websocket"; import { Collection, getCollection } from "home-assistant-js-websocket";
import { import {
calcDate, calcDate,
@ -95,6 +95,7 @@ export type EnergySolarForecasts = {
export interface DeviceConsumptionEnergyPreference { export interface DeviceConsumptionEnergyPreference {
// This is an ever increasing value // This is an ever increasing value
stat_consumption: string; stat_consumption: string;
name?: string;
} }
export interface FlowFromGridSourceEnergyPreference { export interface FlowFromGridSourceEnergyPreference {

View File

@ -422,7 +422,8 @@ export const computeHistory = (
entityIds: string[], entityIds: string[],
localize: LocalizeFunc, localize: LocalizeFunc,
sensorNumericalDeviceClasses: string[], sensorNumericalDeviceClasses: string[],
splitDeviceClasses = false splitDeviceClasses = false,
forceNumeric = false
): HistoryResult => { ): HistoryResult => {
const lineChartDevices: { [unit: string]: HistoryStates } = {}; const lineChartDevices: { [unit: string]: HistoryStates } = {};
const timelineDevices: TimelineEntity[] = []; const timelineDevices: TimelineEntity[] = [];
@ -468,6 +469,7 @@ export const computeHistory = (
let unit: string | undefined; let unit: string | undefined;
const isNumeric = const isNumeric =
forceNumeric ||
isNumericFromDomain(domain) || isNumericFromDomain(domain) ||
(currentState != null && (currentState != null &&
isNumericFromAttributes(currentState.attributes)) || isNumericFromAttributes(currentState.attributes)) ||

View File

@ -9,12 +9,14 @@ export interface LabelRegistryEntry {
name: string; name: string;
icon: string | null; icon: string | null;
color: string | null; color: string | null;
description: string | null;
} }
export interface LabelRegistryEntryMutableParams { export interface LabelRegistryEntryMutableParams {
name: string; name: string;
icon?: string | null; icon?: string | null;
color?: string | null; color?: string | null;
description?: string | null;
} }
export const fetchLabelRegistry = (conn: Connection) => export const fetchLabelRegistry = (conn: Connection) =>

View File

@ -5,9 +5,7 @@ import {
import { getExtendedEntityRegistryEntry } from "./entity_registry"; import { getExtendedEntityRegistryEntry } from "./entity_registry";
import { showEnterCodeDialog } from "../dialogs/enter-code/show-enter-code-dialog"; import { showEnterCodeDialog } from "../dialogs/enter-code/show-enter-code-dialog";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
import { UNAVAILABLE } from "./entity";
export const FORMAT_TEXT = "text";
export const FORMAT_NUMBER = "number";
export const enum LockEntityFeature { export const enum LockEntityFeature {
OPEN = 1, OPEN = 1,
@ -24,6 +22,33 @@ export interface LockEntity extends HassEntityBase {
type ProtectedLockService = "lock" | "unlock" | "open"; type ProtectedLockService = "lock" | "unlock" | "open";
export function isLocked(stateObj: LockEntity) {
return stateObj.state === "locked";
}
export function isUnlocking(stateObj: LockEntity) {
return stateObj.state === "unlocking";
}
export function isLocking(stateObj: LockEntity) {
return stateObj.state === "locking";
}
export function isJammed(stateObj: LockEntity) {
return stateObj.state === "jammed";
}
export function isAvailable(stateObj: LockEntity) {
if (stateObj.state === UNAVAILABLE) {
return false;
}
const assumedState = stateObj.attributes.assumed_state === true;
return (
assumedState ||
(!isLocking(stateObj) && !isUnlocking(stateObj) && !isJammed(stateObj))
);
}
export const callProtectedLockService = async ( export const callProtectedLockService = async (
element: HTMLElement, element: HTMLElement,
hass: HomeAssistant, hass: HomeAssistant,

View File

@ -270,7 +270,11 @@ export interface LanguageSelector {
} }
export interface LocationSelector { export interface LocationSelector {
location: { radius?: boolean; icon?: string } | null; location: {
radius?: boolean;
radius_readonly?: boolean;
icon?: string;
} | null;
} }
export interface LocationSelectorValue { export interface LocationSelectorValue {
@ -401,6 +405,7 @@ export interface TargetSelector {
target: { target: {
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[]; entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[]; device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[];
create_domains?: string[];
} | null; } | null;
} }

View File

@ -11,6 +11,11 @@ export interface Zone {
radius?: number; radius?: number;
} }
export interface HomeZoneMutableParams {
latitude: number;
longitude: number;
}
export interface ZoneMutableParams { export interface ZoneMutableParams {
name: string; name: string;
icon?: string; icon?: string;

View File

@ -78,6 +78,7 @@ class LightColorTempPicker extends LitElement {
return html` return html`
<ha-control-slider <ha-control-slider
touch-action="none"
inverted inverted
vertical vertical
.value=${this._ctPickerValue} .value=${this._ctPickerValue}

View File

@ -9,11 +9,12 @@ import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group"; import "../../../components/ha-control-button-group";
import "../../../components/ha-outlined-icon-button"; import "../../../components/ha-outlined-icon-button";
import "../../../components/ha-state-icon"; import "../../../components/ha-state-icon";
import { UNAVAILABLE } from "../../../data/entity";
import { import {
LockEntity, LockEntity,
LockEntityFeature, LockEntityFeature,
callProtectedLockService, callProtectedLockService,
isAvailable,
isJammed,
} from "../../../data/lock"; } from "../../../data/lock";
import "../../../state-control/lock/ha-state-control-lock-toggle"; import "../../../state-control/lock/ha-state-control-lock-toggle";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
@ -85,15 +86,13 @@ class MoreInfoLock extends LitElement {
"--state-color": color, "--state-color": color,
}; };
const isJammed = this.stateObj.state === "jammed";
return html` return html`
<ha-more-info-state-header <ha-more-info-state-header
.hass=${this.hass} .hass=${this.hass}
.stateObj=${this.stateObj} .stateObj=${this.stateObj}
></ha-more-info-state-header> ></ha-more-info-state-header>
<div class="controls" style=${styleMap(style)}> <div class="controls" style=${styleMap(style)}>
${this.stateObj.state === "jammed" ${isJammed(this.stateObj)
? html` ? html`
<div class="status"> <div class="status">
<span></span> <span></span>
@ -125,7 +124,7 @@ class MoreInfoLock extends LitElement {
` `
: html` : html`
<ha-control-button <ha-control-button
.disabled=${this.stateObj.state === UNAVAILABLE} .disabled=${!isAvailable(this.stateObj)}
class="open-button ${this._buttonState}" class="open-button ${this._buttonState}"
@click=${this._open} @click=${this._open}
> >
@ -139,7 +138,7 @@ class MoreInfoLock extends LitElement {
: nothing} : nothing}
</div> </div>
<div> <div>
${isJammed ${isJammed(this.stateObj)
? html` ? html`
<ha-control-button-group class="jammed"> <ha-control-button-group class="jammed">
<ha-control-button @click=${this._unlock}> <ha-control-button @click=${this._unlock}>

View File

@ -1,4 +1,4 @@
import { startOfYesterday, subHours } from "date-fns/esm"; import { startOfYesterday, subHours } from "date-fns";
import { LitElement, PropertyValues, css, html, nothing } from "lit"; import { LitElement, PropertyValues, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../common/config/is_component_loaded";

View File

@ -1,4 +1,4 @@
import { startOfYesterday } from "date-fns/esm"; import { startOfYesterday } from "date-fns";
import { css, html, LitElement, PropertyValues, nothing } from "lit"; import { css, html, LitElement, PropertyValues, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";

View File

@ -9,7 +9,19 @@ import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window"; import { mainWindow } from "../common/dom/get_main_window";
import { showAutomationEditor } from "../data/automation"; import { showAutomationEditor } from "../data/automation";
import { HomeAssistantMain } from "../layouts/home-assistant-main"; import { HomeAssistantMain } from "../layouts/home-assistant-main";
import type { EMIncomingMessageCommands } from "./external_messaging"; import type {
EMIncomingMessageBarCodeScanAborted,
EMIncomingMessageBarCodeScanResult,
EMIncomingMessageCommands,
} from "./external_messaging";
const barCodeListeners = new Set<
(
msg:
| EMIncomingMessageBarCodeScanResult
| EMIncomingMessageBarCodeScanAborted
) => boolean
>();
export const attachExternalToApp = (hassMainEl: HomeAssistantMain) => { export const attachExternalToApp = (hassMainEl: HomeAssistantMain) => {
window.addEventListener("haptic", (ev) => window.addEventListener("haptic", (ev) =>
@ -24,6 +36,19 @@ export const attachExternalToApp = (hassMainEl: HomeAssistantMain) => {
); );
}; };
export const addExternalBarCodeListener = (
listener: (
msg:
| EMIncomingMessageBarCodeScanResult
| EMIncomingMessageBarCodeScanAborted
) => boolean
) => {
barCodeListeners.add(listener);
return () => {
barCodeListeners.delete(listener);
};
};
const handleExternalMessage = ( const handleExternalMessage = (
hassMainEl: HomeAssistantMain, hassMainEl: HomeAssistantMain,
msg: EMIncomingMessageCommands msg: EMIncomingMessageCommands
@ -88,6 +113,22 @@ const handleExternalMessage = (
success: true, success: true,
result: null, result: null,
}); });
} else if (msg.command === "bar_code/scan_result") {
barCodeListeners.forEach((listener) => listener(msg));
bus.fireMessage({
id: msg.id,
type: "result",
success: true,
result: null,
});
} else if (msg.command === "bar_code/aborted") {
barCodeListeners.forEach((listener) => listener(msg));
bus.fireMessage({
id: msg.id,
type: "result",
success: true,
result: null,
});
} else { } else {
return false; return false;
} }

View File

@ -37,9 +37,11 @@ interface EMOutgoingMessageConfigGet extends EMMessage {
interface EMOutgoingMessageBarCodeScan extends EMMessage { interface EMOutgoingMessageBarCodeScan extends EMMessage {
type: "bar_code/scan"; type: "bar_code/scan";
title: string; payload: {
description: string; title: string;
alternative_option_label?: string; description: string;
alternative_option_label?: string;
};
} }
interface EMOutgoingMessageBarCodeClose extends EMMessage { interface EMOutgoingMessageBarCodeClose extends EMMessage {
@ -48,7 +50,9 @@ interface EMOutgoingMessageBarCodeClose extends EMMessage {
interface EMOutgoingMessageBarCodeNotify extends EMMessage { interface EMOutgoingMessageBarCodeNotify extends EMMessage {
type: "bar_code/notify"; type: "bar_code/notify";
message: string; payload: {
message: string;
};
} }
interface EMOutgoingMessageMatterCommission extends EMMessage { interface EMOutgoingMessageMatterCommission extends EMMessage {

View File

@ -41,14 +41,6 @@ import type { HomeAssistant, Route } from "../types";
import "./hass-tabs-subpage"; import "./hass-tabs-subpage";
import type { PageNavigation } from "./hass-tabs-subpage"; import type { PageNavigation } from "./hass-tabs-subpage";
declare global {
// for fire event
interface HASSDomEvents {
"search-changed": { value: string };
"clear-filter": undefined;
}
}
@customElement("hass-tabs-subpage-data-table") @customElement("hass-tabs-subpage-data-table")
export class HaTabsSubpageDataTable extends LitElement { export class HaTabsSubpageDataTable extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@ -63,6 +55,8 @@ export class HaTabsSubpageDataTable extends LitElement {
@property({ type: Boolean, attribute: "main-page" }) public mainPage = false; @property({ type: Boolean, attribute: "main-page" }) public mainPage = false;
@property({ attribute: false }) public initialCollapsedGroups: string[] = [];
/** /**
* Object with the columns. * Object with the columns.
* @type {Object} * @type {Object}
@ -166,8 +160,15 @@ export class HaTabsSubpageDataTable extends LitElement {
@property({ type: Boolean }) public showFilters = false; @property({ type: Boolean }) public showFilters = false;
@property({ attribute: false }) public initialSorting?: {
column: string;
direction: SortingDirection;
};
@property() public initialGroupColumn?: string; @property() public initialGroupColumn?: string;
@property({ attribute: false }) public groupOrder?: string[];
@state() private _sortColumn?: string; @state() private _sortColumn?: string;
@state() private _sortDirection: SortingDirection = null; @state() private _sortDirection: SortingDirection = null;
@ -190,9 +191,16 @@ export class HaTabsSubpageDataTable extends LitElement {
this._dataTable.clearSelection(); this._dataTable.clearSelection();
} }
protected firstUpdated() { protected willUpdate() {
if (this.hasUpdated) {
return;
}
if (this.initialGroupColumn) { if (this.initialGroupColumn) {
this._groupColumn = this.initialGroupColumn; this._setGroupColumn(this.initialGroupColumn);
}
if (this.initialSorting) {
this._sortColumn = this.initialSorting.column;
this._sortDirection = this.initialSorting.direction;
} }
} }
@ -418,6 +426,8 @@ export class HaTabsSubpageDataTable extends LitElement {
.sortColumn=${this._sortColumn} .sortColumn=${this._sortColumn}
.sortDirection=${this._sortDirection} .sortDirection=${this._sortDirection}
.groupColumn=${this._groupColumn} .groupColumn=${this._groupColumn}
.groupOrder=${this.groupOrder}
.initialCollapsedGroups=${this.initialCollapsedGroups}
> >
${!this.narrow ${!this.narrow
? html` ? html`
@ -496,9 +506,7 @@ export class HaTabsSubpageDataTable extends LitElement {
${this.showFilters && !showPane ${this.showFilters && !showPane
? html`<ha-dialog ? html`<ha-dialog
open open
.heading=${localize("ui.components.subpage-data-table.filters", { .heading=${localize("ui.components.subpage-data-table.filters")}
number: this.data.length,
})}
> >
<ha-dialog-header slot="heading"> <ha-dialog-header slot="heading">
<ha-icon-button <ha-icon-button
@ -510,9 +518,7 @@ export class HaTabsSubpageDataTable extends LitElement {
)} )}
></ha-icon-button> ></ha-icon-button>
<span slot="title" <span slot="title"
>${localize("ui.components.subpage-data-table.filters", { >${localize("ui.components.subpage-data-table.filters")}</span
number: this.data.length,
})}</span
> >
${this.filters ${this.filters
? html`<ha-icon-button ? html`<ha-icon-button
@ -564,10 +570,20 @@ export class HaTabsSubpageDataTable extends LitElement {
this._sortDirection = null; this._sortDirection = null;
} }
this._sortColumn = this._sortDirection === null ? undefined : columnId; this._sortColumn = this._sortDirection === null ? undefined : columnId;
fireEvent(this, "sorting-changed", {
column: columnId,
direction: this._sortDirection,
});
} }
private _handleGroupBy(ev) { private _handleGroupBy(ev) {
this._groupColumn = ev.currentTarget.value; this._setGroupColumn(ev.currentTarget.value);
}
private _setGroupColumn(columnId: string) {
this._groupColumn = columnId;
fireEvent(this, "grouping-changed", { value: columnId });
} }
private _enableSelectMode() { private _enableSelectMode() {
@ -823,4 +839,11 @@ declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"hass-tabs-subpage-data-table": HaTabsSubpageDataTable; "hass-tabs-subpage-data-table": HaTabsSubpageDataTable;
} }
// for fire event
interface HASSDomEvents {
"search-changed": { value: string };
"grouping-changed": { value: string };
"clear-filter": undefined;
}
} }

View File

@ -169,10 +169,6 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// @ts-ignore // @ts-ignore
this._loadHassTranslations(this.hass!.language, "entity"); this._loadHassTranslations(this.hass!.language, "entity");
// Backwards compatibility for custom integrations
// @ts-ignore
this._loadHassTranslations(this.hass!.language, "state");
document.addEventListener( document.addEventListener(
"visibilitychange", "visibilitychange",
() => this._checkVisibility(), () => this._checkVisibility(),

View File

@ -1,7 +1,7 @@
import "@material/mwc-button"; import "@material/mwc-button";
import { mdiCalendarClock } from "@mdi/js"; import { mdiCalendarClock } from "@mdi/js";
import { toDate } from "date-fns-tz"; import { toDate } from "date-fns-tz";
import { addDays, isSameDay } from "date-fns/esm"; import { addDays, isSameDay } from "date-fns";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { property, state } from "lit/decorators"; import { property, state } from "lit/decorators";
import { formatDate } from "../../common/datetime/format_date"; import { formatDate } from "../../common/datetime/format_date";

View File

@ -6,7 +6,7 @@ import {
addMilliseconds, addMilliseconds,
differenceInMilliseconds, differenceInMilliseconds,
startOfHour, startOfHour,
} from "date-fns/esm"; } from "date-fns";
import { HassEntity } from "home-assistant-js-websocket"; import { HassEntity } from "home-assistant-js-websocket";
import { CSSResultGroup, LitElement, css, html, nothing } from "lit"; import { CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";

View File

@ -511,6 +511,7 @@ export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
overflow-wrap: anywhere;
} }
.warning { .warning {
color: var(--error-color); color: var(--error-color);

View File

@ -19,7 +19,7 @@ import {
mdiToggleSwitchOffOutline, mdiToggleSwitchOffOutline,
mdiTransitConnection, mdiTransitConnection,
} from "@mdi/js"; } from "@mdi/js";
import { differenceInDays } from "date-fns/esm"; import { differenceInDays } from "date-fns";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
CSSResultGroup, CSSResultGroup,
@ -37,15 +37,21 @@ import { computeCssColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatShortDateTime } from "../../../common/datetime/format_date_time"; import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { relativeTime } from "../../../common/datetime/relative_time"; import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage";
import { HASSDomEvent, fireEvent } from "../../../common/dom/fire_event"; import { HASSDomEvent, fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize"; import { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
import "../../../components/chips/ha-assist-chip"; import "../../../components/chips/ha-assist-chip";
import type { import type {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SelectionChangedEvent, SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels"; import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-entity-toggle"; import "../../../components/entity/ha-entity-toggle";
@ -105,10 +111,6 @@ import { showCategoryRegistryDetailDialog } from "../category/show-dialog-catego
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { showNewAutomationDialog } from "./show-dialog-new-automation"; import { showNewAutomationDialog } from "./show-dialog-new-automation";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
type AutomationItem = AutomationEntity & { type AutomationItem = AutomationEntity & {
name: string; name: string;
@ -156,6 +158,19 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
@state() private _overflowAutomation?: AutomationItem; @state() private _overflowAutomation?: AutomationItem;
@storage({ key: "automation-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "automation-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "automation-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
@query("#overflow-menu") private _overflowMenu!: HaMenu; @query("#overflow-menu") private _overflowMenu!: HaMenu;
private _sizeController = new ResizeController(this, { private _sizeController = new ResizeController(this, {
@ -424,9 +439,11 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
${this.hass.localize("ui.panel.config.labels.add_label")} ${this.hass.localize("ui.panel.config.labels.add_label")}
</div></ha-menu-item </div></ha-menu-item
>`; >`;
const labelsInOverflow = const labelsInOverflow =
(this._sizeController.value && this._sizeController.value < 700) || (this._sizeController.value && this._sizeController.value < 700) ||
(!this._sizeController.value && this.hass.dockedSidebar === "docked"); (!this._sizeController.value && this.hass.dockedSidebar === "docked");
const automations = this._automations( const automations = this._automations(
this.automations, this.automations,
this._entityReg, this._entityReg,
@ -468,7 +485,12 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
this.hass.localize, this.hass.localize,
this.hass.locale this.hass.locale
)} )}
initialGroupColumn="category" .initialGroupColumn=${this._activeGrouping || "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
.data=${automations} .data=${automations}
.empty=${!this.automations.length} .empty=${!this.automations.length}
@row-click=${this._handleRowClicked} @row-click=${this._handleRowClicked}
@ -1236,6 +1258,18 @@ ${rejected
}); });
} }
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@ -6,6 +6,7 @@ import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/device/ha-device-picker"; import "../../../../../components/device/ha-device-picker";
import "../../../../../components/device/ha-device-trigger-picker"; import "../../../../../components/device/ha-device-trigger-picker";
import "../../../../../components/ha-form/ha-form"; import "../../../../../components/ha-form/ha-form";
import { computeInitialHaFormData } from "../../../../../components/ha-form/compute-initial-ha-form-data";
import { fullEntitiesContext } from "../../../../../data/context"; import { fullEntitiesContext } from "../../../../../data/context";
import { import {
deviceAutomationsEqual, deviceAutomationsEqual,
@ -44,7 +45,9 @@ export class HaDeviceTrigger extends LitElement {
private _extraFieldsData = memoizeOne( private _extraFieldsData = memoizeOne(
(trigger: DeviceTrigger, capabilities: DeviceCapabilities) => { (trigger: DeviceTrigger, capabilities: DeviceCapabilities) => {
const extraFieldsData: Record<string, any> = {}; const extraFieldsData = computeInitialHaFormData(
capabilities.extra_fields
);
capabilities.extra_fields.forEach((item) => { capabilities.extra_fields.forEach((item) => {
if (trigger[item.name] !== undefined) { if (trigger[item.name] !== undefined) {
extraFieldsData![item.name] = trigger[item.name]; extraFieldsData![item.name] = trigger[item.name];

View File

@ -24,6 +24,7 @@ import { extractSearchParam } from "../../../common/url/search-params";
import { import {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/entity/ha-entity-toggle"; import "../../../components/entity/ha-entity-toggle";
import "../../../components/ha-button"; import "../../../components/ha-button";
@ -54,6 +55,7 @@ import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast"; import { showToast } from "../../../util/toast";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { showAddBlueprintDialog } from "./show-dialog-import-blueprint"; import { showAddBlueprintDialog } from "./show-dialog-import-blueprint";
import { storage } from "../../../common/decorators/storage";
type BlueprintMetaDataPath = BlueprintMetaData & { type BlueprintMetaDataPath = BlueprintMetaData & {
path: string; path: string;
@ -92,8 +94,24 @@ class HaBlueprintOverview extends LitElement {
Blueprints Blueprints
>; >;
@storage({ key: "blueprint-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "blueprint-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "blueprint-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
private _processedBlueprints = memoizeOne( private _processedBlueprints = memoizeOne(
(blueprints: Record<string, Blueprints>): BlueprintMetaDataPath[] => { (
blueprints: Record<string, Blueprints>,
localize: LocalizeFunc
): BlueprintMetaDataPath[] => {
const result: any[] = []; const result: any[] = [];
Object.entries(blueprints).forEach(([type, typeBlueprints]) => Object.entries(blueprints).forEach(([type, typeBlueprints]) =>
Object.entries(typeBlueprints).forEach(([path, blueprint]) => { Object.entries(typeBlueprints).forEach(([path, blueprint]) => {
@ -101,6 +119,9 @@ class HaBlueprintOverview extends LitElement {
result.push({ result.push({
name: blueprint.error, name: blueprint.error,
type, type,
translated_type: localize(
`ui.panel.config.blueprint.overview.types.${type as "automation" | "script"}`
),
error: true, error: true,
path, path,
fullpath: `${type}/${path}`, fullpath: `${type}/${path}`,
@ -109,6 +130,9 @@ class HaBlueprintOverview extends LitElement {
result.push({ result.push({
...blueprint.metadata, ...blueprint.metadata,
type, type,
translated_type: localize(
`ui.panel.config.blueprint.overview.types.${type as "automation" | "script"}`
),
error: false, error: false,
path, path,
fullpath: `${type}/${path}`, fullpath: `${type}/${path}`,
@ -140,14 +164,11 @@ class HaBlueprintOverview extends LitElement {
` `
: undefined, : undefined,
}, },
type: { translated_type: {
title: localize("ui.panel.config.blueprint.overview.headers.type"), title: localize("ui.panel.config.blueprint.overview.headers.type"),
template: (blueprint) =>
html`${this.hass.localize(
`ui.panel.config.blueprint.overview.types.${blueprint.type}`
)}`,
sortable: true, sortable: true,
filterable: true, filterable: true,
groupable: true,
hidden: narrow, hidden: narrow,
direction: "asc", direction: "asc",
width: "10%", width: "10%",
@ -256,7 +277,7 @@ class HaBlueprintOverview extends LitElement {
this.hass.language, this.hass.language,
this.hass.localize this.hass.localize
)} )}
.data=${this._processedBlueprints(this.blueprints)} .data=${this._processedBlueprints(this.blueprints, this.hass.localize)}
id="fullpath" id="fullpath"
.noDataText=${this.hass.localize( .noDataText=${this.hass.localize(
"ui.panel.config.blueprint.overview.no_blueprints" "ui.panel.config.blueprint.overview.no_blueprints"
@ -281,6 +302,12 @@ class HaBlueprintOverview extends LitElement {
> >
</a> </a>
</div>`} </div>`}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
> >
<ha-icon-button <ha-icon-button
slot="toolbar-icon" slot="toolbar-icon"
@ -341,9 +368,10 @@ class HaBlueprintOverview extends LitElement {
} }
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) { private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const blueprint = this._processedBlueprints(this.blueprints).find( const blueprint = this._processedBlueprints(
(b) => b.fullpath === ev.detail.id this.blueprints,
)!; this.hass.localize
).find((b) => b.fullpath === ev.detail.id)!;
if (blueprint.error) { if (blueprint.error) {
showAlertDialog(this, { showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.blueprint.overview.error", { title: this.hass.localize("ui.panel.config.blueprint.overview.error", {
@ -502,6 +530,18 @@ class HaBlueprintOverview extends LitElement {
fireEvent(this, "reload-blueprints"); fireEvent(this, "reload-blueprints");
}; };
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return haStyle; return haStyle;
} }

View File

@ -21,6 +21,7 @@ import {
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast"; import { showToast } from "../../../../util/toast";
import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate"; import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate";
import { showAlertDialog } from "../../../lovelace/custom-card-helpers";
@customElement("cloud-remote-pref") @customElement("cloud-remote-pref")
export class CloudRemotePref extends LitElement { export class CloudRemotePref extends LitElement {
@ -33,7 +34,7 @@ export class CloudRemotePref extends LitElement {
return nothing; return nothing;
} }
const { remote_enabled, remote_allow_remote_enable } = const { remote_enabled, remote_allow_remote_enable, strict_connection } =
this.cloudStatus.prefs; this.cloudStatus.prefs;
const { const {
@ -153,6 +154,61 @@ export class CloudRemotePref extends LitElement {
@change=${this._toggleAllowRemoteEnabledChanged} @change=${this._toggleAllowRemoteEnabledChanged}
></ha-switch> ></ha-switch>
</ha-settings-row> </ha-settings-row>
<ha-settings-row>
<span slot="heading"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_secondary"
)}</span
>
<ha-select
.label=${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_mode"
)}
@selected=${this._setStrictConnectionMode}
naturalMenuWidth
.value=${strict_connection}
>
<ha-list-item value="disabled">
${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_modes.disabled"
)}
</ha-list-item>
<ha-list-item value="guard_page">
${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_modes.guard_page"
)}
</ha-list-item>
<ha-list-item value="drop_connection">
${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_modes.drop_connection"
)}
</ha-list-item>
</ha-select>
</ha-settings-row>
${strict_connection !== "disabled"
? html` <ha-settings-row>
<span slot="heading"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_link"
)}</span
>
<span slot="description"
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_link_secondary"
)}</span
>
<ha-button @click=${this._createLoginUrl}
>${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_create_link"
)}</ha-button
>
</ha-settings-row>`
: nothing}
<ha-settings-row> <ha-settings-row>
<span slot="heading" <span slot="heading"
>${this.hass.localize( >${this.hass.localize(
@ -223,6 +279,18 @@ export class CloudRemotePref extends LitElement {
} }
} }
private async _setStrictConnectionMode(ev) {
const mode = ev.target.value;
try {
await updateCloudPref(this.hass, {
strict_connection: mode,
});
fireEvent(this, "ha-refresh-cloud-status");
} catch (err: any) {
alert(err.message);
}
}
private async _copyURL(ev): Promise<void> { private async _copyURL(ev): Promise<void> {
const url = ev.currentTarget.url; const url = ev.currentTarget.url;
await copyToClipboard(url); await copyToClipboard(url);
@ -231,6 +299,40 @@ export class CloudRemotePref extends LitElement {
}); });
} }
private async _createLoginUrl() {
try {
const result = await this.hass.callService(
"cloud",
"create_temporary_strict_connection_url",
undefined,
undefined,
false,
true
);
showAlertDialog(this, {
title: this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_link"
),
text: html`${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_link_created_message"
)}
<pre>${result.response.url}</pre>
<ha-button
.url=${result.response.url}
@click=${this._copyURL}
unelevated
>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.cloud.account.remote.strict_connection_copy_link"
)}
</ha-button>`,
});
} catch (err: any) {
showAlertDialog(this, { text: err.message });
}
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
.preparing { .preparing {

View File

@ -1,7 +1,6 @@
import "@material/mwc-list/mwc-list-item"; import "@material/mwc-list/mwc-list-item";
import { css, html, LitElement, TemplateResult } from "lit"; import { css, html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { UNIT_C } from "../../../common/const"; import { UNIT_C } from "../../../common/const";
import { stopPropagation } from "../../../common/dom/stop_propagation"; import { stopPropagation } from "../../../common/dom/stop_propagation";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
@ -22,8 +21,6 @@ import "../../../components/ha-settings-row";
import "../../../components/ha-textfield"; import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield"; import type { HaTextField } from "../../../components/ha-textfield";
import "../../../components/ha-timezone-picker"; import "../../../components/ha-timezone-picker";
import "../../../components/map/ha-locations-editor";
import type { MarkerLocation } from "../../../components/map/ha-locations-editor";
import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core"; import { ConfigUpdateValues, 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";
@ -242,35 +239,22 @@ class HaConfigSectionGeneral extends LitElement {
> >
</ha-language-picker> </ha-language-picker>
</div> </div>
${this.narrow
? html` <ha-settings-row>
<ha-locations-editor <div slot="heading">
.hass=${this.hass} ${this.hass.localize(
.locations=${this._markerLocation( "ui.panel.config.core.section.core.core_config.edit_location"
this.hass.config.latitude, )}
this.hass.config.longitude, </div>
this._location <div slot="description" class="secondary">
)} ${this.hass.localize(
@location-updated=${this._locationChanged} "ui.panel.config.core.section.core.core_config.edit_location_description"
></ha-locations-editor> )}
` </div>
: html` <mwc-button @click=${this._editLocation}
<ha-settings-row> >${this.hass.localize("ui.common.edit")}</mwc-button
<div slot="heading"> >
${this.hass.localize( </ha-settings-row>
"ui.panel.config.core.section.core.core_config.edit_location"
)}
</div>
<div slot="description" class="secondary">
${this.hass.localize(
"ui.panel.config.core.section.core.core_config.edit_location_description"
)}
</div>
<mwc-button @click=${this._editLocation}
>${this.hass.localize("ui.common.edit")}</mwc-button
>
</ha-settings-row>
`}
<div class="card-actions"> <div class="card-actions">
<ha-progress-button @click=${this._updateEntry}> <ha-progress-button @click=${this._updateEntry}>
${this.hass!.localize("ui.panel.config.zone.detail.update")} ${this.hass!.localize("ui.panel.config.zone.detail.update")}
@ -319,10 +303,6 @@ class HaConfigSectionGeneral extends LitElement {
this._updateUnits = (ev.target as HaCheckbox).checked; this._updateUnits = (ev.target as HaCheckbox).checked;
} }
private _locationChanged(ev: CustomEvent) {
this._location = ev.detail.location;
}
private async _updateEntry(ev: CustomEvent) { private async _updateEntry(ev: CustomEvent) {
const button = ev.target as HaProgressButton; const button = ev.target as HaProgressButton;
if (button.progress) { if (button.progress) {
@ -381,21 +361,6 @@ class HaConfigSectionGeneral extends LitElement {
} }
} }
private _markerLocation = memoizeOne(
(
lat: number,
lng: number,
location?: [number, number]
): MarkerLocation[] => [
{
id: "location",
latitude: location ? location[0] : lat,
longitude: location ? location[1] : lng,
location_editable: true,
},
]
);
private _editLocation() { private _editLocation() {
navigate("/config/zone/edit/zone.home"); navigate("/config/zone/edit/zone.home");
} }
@ -441,11 +406,6 @@ class HaConfigSectionGeneral extends LitElement {
margin-top: 8px; margin-top: 8px;
display: inline-block; display: inline-block;
} }
ha-locations-editor {
display: block;
height: 400px;
padding: 16px;
}
`, `,
]; ];
} }

View File

@ -82,11 +82,11 @@ class HaConfigSectionUpdates extends LitElement {
> >
${this.hass.localize("ui.panel.config.updates.show_skipped")} ${this.hass.localize("ui.panel.config.updates.show_skipped")}
</ha-check-list-item> </ha-check-list-item>
${this._supervisorInfo?.channel !== "dev" ${this._supervisorInfo && this._supervisorInfo.channel !== "dev"
? html` ? html`
<li divider role="separator"></li> <li divider role="separator"></li>
<mwc-list-item @request-selected=${this._toggleBeta}> <mwc-list-item @request-selected=${this._toggleBeta}>
${this._supervisorInfo?.channel === "stable" ${this._supervisorInfo.channel === "stable"
? this.hass.localize("ui.panel.config.updates.join_beta") ? this.hass.localize("ui.panel.config.updates.join_beta")
: this.hass.localize( : this.hass.localize(
"ui.panel.config.updates.leave_beta" "ui.panel.config.updates.leave_beta"

View File

@ -93,10 +93,19 @@ const randomTip = (hass: HomeAssistant, narrow: boolean) => {
weight: 2, weight: 2,
narrow: true, narrow: true,
}, },
{ content: hass.localize("ui.tips.key_c_hint"), weight: 1, narrow: false },
{ content: hass.localize("ui.tips.key_m_hint"), weight: 1, narrow: false },
]; ];
if (hass?.enableShortcuts) {
tips.push(
{
content: hass.localize("ui.tips.key_c_hint"),
weight: 1,
narrow: false,
},
{ content: hass.localize("ui.tips.key_m_hint"), weight: 1, narrow: false }
);
}
if (narrow) { if (narrow) {
tips = tips.filter((tip) => tip.narrow); tips = tips.filter((tip) => tip.narrow);
} }
@ -310,7 +319,9 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
private _showQuickBar(): void { private _showQuickBar(): void {
showQuickBar(this, { showQuickBar(this, {
commandMode: true, commandMode: true,
hint: this.hass.localize("ui.dialogs.quick-bar.key_c_hint"), hint: this.hass.enableShortcuts
? this.hass.localize("ui.dialogs.quick-bar.key_c_hint")
: undefined,
}); });
} }

View File

@ -1,6 +1,12 @@
import { consume } from "@lit-labs/context"; import { consume } from "@lit-labs/context";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import { mdiChevronRight, mdiMenuDown, mdiPlus } from "@mdi/js"; import {
mdiChevronRight,
mdiDotsVertical,
mdiMenuDown,
mdiPlus,
mdiTextureBox,
} from "@mdi/js";
import { import {
CSSResultGroup, CSSResultGroup,
LitElement, LitElement,
@ -10,10 +16,12 @@ import {
nothing, nothing,
} from "lit"; } from "lit";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
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 { computeCssColor } from "../../../common/color/compute-color"; import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import { HASSDomEvent } from "../../../common/dom/fire_event"; import { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { import {
@ -22,10 +30,15 @@ import {
} from "../../../common/integrations/protocolIntegrationPicked"; } from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize"; import { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
import { import {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SelectionChangedEvent, SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels"; import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-battery-icon"; import "../../../components/entity/ha-battery-icon";
@ -41,6 +54,7 @@ import "../../../components/ha-filter-states";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-menu-item"; import "../../../components/ha-menu-item";
import "../../../components/ha-sub-menu"; import "../../../components/ha-sub-menu";
import { createAreaRegistryEntry } from "../../../data/area_registry";
import { ConfigEntry, sortConfigEntries } from "../../../data/config_entries"; import { ConfigEntry, sortConfigEntries } from "../../../data/config_entries";
import { fullEntitiesContext } from "../../../data/context"; import { fullEntitiesContext } from "../../../data/context";
import { import {
@ -65,15 +79,12 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import { brandsUrl } from "../../../util/brands-url"; import { brandsUrl } from "../../../util/brands-url";
import { showAreaRegistryDetailDialog } from "../areas/show-dialog-area-registry-detail";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import "../integrations/ha-integration-overflow-menu"; import "../integrations/ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog"; import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
import { showAlertDialog } from "../../lovelace/custom-card-helpers";
interface DeviceRowData extends DeviceRegistryEntry { interface DeviceRowData extends DeviceRegistryEntry {
device?: DeviceRowData; device?: DeviceRowData;
@ -117,6 +128,19 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
@state() @state()
_labels!: LabelRegistryEntry[]; _labels!: LabelRegistryEntry[];
@storage({ key: "devices-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "devices-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({ key: "devices-table-collapsed", state: false, subscribe: false })
private _activeCollapsed?: string;
private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width,
});
private _ignoreLocationChange = false; private _ignoreLocationChange = false;
public connectedCallback() { public connectedCallback() {
@ -549,6 +573,41 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
this._labels this._labels
); );
const areasInOverflow =
(this._sizeController.value && this._sizeController.value < 700) ||
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
const areaItems = html`${Object.values(this.hass.areas).map(
(area) =>
html`<ha-menu-item
.value=${area.area_id}
@click=${this._handleBulkArea}
>
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<div slot="headline">${area.name}</div>
</ha-menu-item>`
)}
<ha-menu-item .value=${null} @click=${this._handleBulkArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.no_area"
)}
</div>
</ha-menu-item>
<md-divider role="separator" tabindex="-1"></md-divider>
<ha-menu-item @click=${this._bulkCreateArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.add_area"
)}
</div>
</ha-menu-item>`;
const labelItems = html`${this._labels?.map((label) => { const labelItems = html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined; const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((deviceId) => const selected = this._selected.every((deviceId) =>
@ -614,8 +673,14 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
Array.isArray(val) ? val.length : val Array.isArray(val) ? val.length : val
) )
).length} ).length}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@clear-filter=${this._clearFilter} @clear-filter=${this._clearFilter}
@search-changed=${this._handleSearchChange} @search-changed=${this._handleSearchChange}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@row-click=${this._handleRowClicked} @row-click=${this._handleRowClicked}
clickable clickable
hasFab hasFab
@ -684,36 +749,77 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
${!this.narrow ${!this.narrow
? html`<ha-button-menu-new slot="selection-bar"> ? html`<ha-button-menu-new slot="selection-bar">
<ha-assist-chip <ha-assist-chip
slot="trigger" slot="trigger"
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label" "ui.panel.config.automation.picker.bulk_actions.add_label"
)} )}
> >
<ha-svg-icon <ha-svg-icon
slot="trailing-icon" slot="trailing-icon"
.path=${mdiMenuDown} .path=${mdiMenuDown}
></ha-svg-icon> ></ha-svg-icon>
</ha-assist-chip> </ha-assist-chip>
${labelItems} ${labelItems}
</ha-button-menu-new>` </ha-button-menu-new>
: html` <ha-button-menu-new has-overflow slot="selection-bar"
><ha-assist-chip ${areasInOverflow
.label=${this.hass.localize( ? nothing
"ui.panel.config.automation.picker.bulk_action" : html`<ha-button-menu-new slot="selection-bar">
)} <ha-assist-chip
slot="trigger" slot="trigger"
> .label=${this.hass.localize(
<ha-svg-icon "ui.panel.config.devices.picker.bulk_actions.move_area"
slot="trailing-icon" )}
.path=${mdiMenuDown} >
></ha-svg-icon> <ha-svg-icon
</ha-assist-chip> slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${areaItems}
</ha-button-menu-new>`}`
: nothing}
${this.narrow || areasInOverflow
? html`<ha-button-menu-new has-overflow slot="selection-bar">
${this.narrow
? html`<ha-assist-chip
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_action"
)}
slot="trigger"
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>`
: html`<ha-icon-button
.path=${mdiDotsVertical}
.label=${"ui.panel.config.automation.picker.bulk_action"}
slot="trigger"
></ha-icon-button>`}
${this.narrow
? html` <ha-sub-menu>
<ha-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
</div>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-menu-item>
<ha-menu slot="menu">${labelItems}</ha-menu>
</ha-sub-menu>`
: nothing}
<ha-sub-menu> <ha-sub-menu>
<ha-menu-item slot="item"> <ha-menu-item slot="item">
<div slot="headline"> <div slot="headline">
${this.hass.localize( ${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label" "ui.panel.config.devices.picker.bulk_actions.move_area"
)} )}
</div> </div>
<ha-svg-icon <ha-svg-icon
@ -721,9 +827,10 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
.path=${mdiChevronRight} .path=${mdiChevronRight}
></ha-svg-icon> ></ha-svg-icon>
</ha-menu-item> </ha-menu-item>
<ha-menu slot="menu">${labelItems}</ha-menu> <ha-menu slot="menu">${areaItems}</ha-menu>
</ha-sub-menu> </ha-sub-menu>
</ha-button-menu-new>`} </ha-button-menu-new>`
: nothing}
</hass-tabs-subpage-data-table> </hass-tabs-subpage-data-table>
`; `;
} }
@ -809,6 +916,46 @@ export class HaConfigDeviceDashboard extends SubscribeMixin(LitElement) {
this._selected = ev.detail.value; this._selected = ev.detail.value;
} }
private async _handleBulkArea(ev) {
const area = ev.currentTarget.value;
this._bulkAddArea(area);
}
private async _bulkAddArea(area: string) {
const promises: Promise<DeviceRegistryEntry>[] = [];
this._selected.forEach((deviceId) => {
promises.push(
updateDeviceRegistryEntry(this.hass, deviceId, {
area_id: area,
})
);
});
const result = await Promise.allSettled(promises);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map((r) => r.reason.message || r.reason.code || r.reason)
.join("\r\n")}</pre
>`,
});
}
}
private async _bulkCreateArea() {
showAreaRegistryDetailDialog(this, {
createEntry: async (values) => {
const area = await createAreaRegistryEntry(this.hass, values);
this._bulkAddArea(area.area_id);
return area;
},
});
}
private async _handleBulkLabel(ev) { private async _handleBulkLabel(ev) {
const label = ev.currentTarget.value; const label = ev.currentTarget.value;
const action = ev.currentTarget.action; const action = ev.currentTarget.action;
@ -855,9 +1002,24 @@ ${rejected
}); });
} }
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
css` css`
:host {
display: block;
}
hass-tabs-subpage-data-table { hass-tabs-subpage-data-table {
--data-table-row-height: 60px; --data-table-row-height: 60px;
} }

View File

@ -1,5 +1,5 @@
import "@material/mwc-button/mwc-button"; import "@material/mwc-button/mwc-button";
import { mdiDelete, mdiDevices } from "@mdi/js"; import { mdiDelete, mdiDevices, mdiPencil } from "@mdi/js";
import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
@ -83,18 +83,24 @@ export class EnergyDeviceSettings extends LitElement {
${this.preferences.device_consumption.map((device) => { ${this.preferences.device_consumption.map((device) => {
const entityState = this.hass.states[device.stat_consumption]; const entityState = this.hass.states[device.stat_consumption];
return html` return html`
<div class="row"> <div class="row" .device=${device}>
<ha-state-icon <ha-state-icon
.hass=${this.hass} .hass=${this.hass}
.stateObj=${entityState} .stateObj=${entityState}
></ha-state-icon> ></ha-state-icon>
<span class="content" <span class="content"
>${getStatisticLabel( >${device.name ||
getStatisticLabel(
this.hass, this.hass,
device.stat_consumption, device.stat_consumption,
this.statsMetadata?.[device.stat_consumption] this.statsMetadata?.[device.stat_consumption]
)}</span )}</span
> >
<ha-icon-button
.label=${this.hass.localize("ui.common.edit")}
@click=${this._editDevice}
.path=${mdiPencil}
></ha-icon-button>
<ha-icon-button <ha-icon-button
.label=${this.hass.localize("ui.common.delete")} .label=${this.hass.localize("ui.common.delete")}
@click=${this._deleteDevice} @click=${this._deleteDevice}
@ -117,6 +123,24 @@ export class EnergyDeviceSettings extends LitElement {
`; `;
} }
private _editDevice(ev) {
const origDevice: DeviceConsumptionEnergyPreference =
ev.currentTarget.closest(".row").device;
showEnergySettingsDeviceDialog(this, {
device: { ...origDevice },
device_consumptions: this.preferences
.device_consumption as DeviceConsumptionEnergyPreference[],
saveCallback: async (newDevice) => {
await this._savePreferences({
...this.preferences,
device_consumption: this.preferences.device_consumption.map((d) =>
d === origDevice ? newDevice : d
),
});
},
});
}
private _addDevice() { private _addDevice() {
showEnergySettingsDeviceDialog(this, { showEnergySettingsDeviceDialog(this, {
device_consumptions: this.preferences device_consumptions: this.preferences

View File

@ -44,9 +44,10 @@ export class DialogEnergyDeviceSettings
this._energy_units = ( this._energy_units = (
await getSensorDeviceClassConvertibleUnits(this.hass, "energy") await getSensorDeviceClassConvertibleUnits(this.hass, "energy")
).units; ).units;
this._excludeList = this._params.device_consumptions.map( this._device = this._params.device;
(entry) => entry.stat_consumption this._excludeList = this._params.device_consumptions
); .map((entry) => entry.stat_consumption)
.filter((id) => id !== this._device?.stat_consumption);
} }
public closeDialog(): void { public closeDialog(): void {
@ -88,6 +89,7 @@ export class DialogEnergyDeviceSettings
.hass=${this.hass} .hass=${this.hass}
.helpMissingEntityUrl=${energyStatisticHelpUrl} .helpMissingEntityUrl=${energyStatisticHelpUrl}
.includeUnitClass=${energyUnitClasses} .includeUnitClass=${energyUnitClasses}
.value=${this._device?.stat_consumption}
.label=${this.hass.localize( .label=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.device_consumption_energy" "ui.panel.config.energy.device_consumption.dialog.device_consumption_energy"
)} )}
@ -96,6 +98,17 @@ export class DialogEnergyDeviceSettings
dialogInitialFocus dialogInitialFocus
></ha-statistic-picker> ></ha-statistic-picker>
<ha-textfield
.label=${this.hass.localize(
"ui.panel.config.energy.device_consumption.dialog.display_name"
)}
type="text"
.disabled=${!this._device}
.value=${this._device?.name || ""}
@change=${this._nameChanged}
>
</ha-textfield>
<mwc-button @click=${this.closeDialog} slot="secondaryAction"> <mwc-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass.localize("ui.common.cancel")} ${this.hass.localize("ui.common.cancel")}
</mwc-button> </mwc-button>
@ -118,6 +131,17 @@ export class DialogEnergyDeviceSettings
this._device = { stat_consumption: ev.detail.value }; this._device = { stat_consumption: ev.detail.value };
} }
private _nameChanged(ev) {
const newDevice = {
...this._device!,
name: ev.target!.value,
} as DeviceConsumptionEnergyPreference;
if (!newDevice.name) {
delete newDevice.name;
}
this._device = newDevice;
}
private async _save() { private async _save() {
try { try {
await this._params!.saveCallback(this._device!); await this._params!.saveCallback(this._device!);
@ -134,6 +158,10 @@ export class DialogEnergyDeviceSettings
ha-statistic-picker { ha-statistic-picker {
width: 100%; width: 100%;
} }
ha-textfield {
margin-top: 16px;
width: 100%;
}
`, `,
]; ];
} }

View File

@ -70,6 +70,7 @@ export interface EnergySettingsWaterDialogParams {
} }
export interface EnergySettingsDeviceDialogParams { export interface EnergySettingsDeviceDialogParams {
device?: DeviceConsumptionEnergyPreference;
device_consumptions: DeviceConsumptionEnergyPreference[]; device_consumptions: DeviceConsumptionEnergyPreference[];
saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise<void>; saveCallback: (device: DeviceConsumptionEnergyPreference) => Promise<void>;
} }

View File

@ -29,6 +29,7 @@ import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import memoize from "memoize-one"; import memoize from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color"; import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event"; import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
@ -37,10 +38,15 @@ import {
protocolIntegrationPicked, protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked"; } from "../../../common/integrations/protocolIntegrationPicked";
import { LocalizeFunc } from "../../../common/translations/localize"; import { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
import type { import type {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SelectionChangedEvent, SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels"; import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-alert"; import "../../../components/ha-alert";
@ -66,6 +72,11 @@ import {
removeEntityRegistryEntry, removeEntityRegistryEntry,
updateEntityRegistryEntry, updateEntityRegistryEntry,
} from "../../../data/entity_registry"; } from "../../../data/entity_registry";
import {
EntitySources,
fetchEntitySourcesWithCache,
} from "../../../data/entity_sources";
import { domainToName } from "../../../data/integration";
import { import {
LabelRegistryEntry, LabelRegistryEntry,
createLabelRegistryEntry, createLabelRegistryEntry,
@ -86,14 +97,6 @@ import { configSections } from "../ha-panel-config";
import "../integrations/ha-integration-overflow-menu"; import "../integrations/ha-integration-overflow-menu";
import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog"; import { showAddIntegrationDialog } from "../integrations/show-add-integration-dialog";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
EntitySources,
fetchEntitySourcesWithCache,
} from "../../../data/entity_sources";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
export interface StateEntity export interface StateEntity
extends Omit<EntityRegistryEntry, "id" | "unique_id"> { extends Omit<EntityRegistryEntry, "id" | "unique_id"> {
@ -110,6 +113,7 @@ export interface EntityRow extends StateEntity {
status: string | undefined; status: string | undefined;
area?: string; area?: string;
localized_platform: string; localized_platform: string;
domain: string;
label_entries: LabelRegistryEntry[]; label_entries: LabelRegistryEntry[];
} }
@ -149,6 +153,19 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
@state() private _entitySources?: EntitySources; @state() private _entitySources?: EntitySources;
@storage({ key: "entities-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "entities-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "entities-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
@query("hass-tabs-subpage-data-table", true) @query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable; private _dataTable!: HaTabsSubpageDataTable;
@ -261,6 +278,13 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
filterable: true, filterable: true,
width: "20%", width: "20%",
}, },
domain: {
title: localize("ui.panel.config.entities.picker.headers.domain"),
sortable: false,
hidden: true,
filterable: true,
groupable: true,
},
area: { area: {
title: localize("ui.panel.config.entities.picker.headers.area"), title: localize("ui.panel.config.entities.picker.headers.area"),
sortable: true, sortable: true,
@ -467,9 +491,9 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
), ),
unavailable, unavailable,
restored, restored,
localized_platform: localized_platform: domainToName(localize, entry.platform),
localize(`component.${entry.platform}.title`) || entry.platform,
area: area ? area.name : "—", area: area ? area.name : "—",
domain: domainToName(localize, computeDomain(entry.entity_id)),
status: restored status: restored
? localize("ui.panel.config.entities.picker.status.restored") ? localize("ui.panel.config.entities.picker.status.restored")
: unavailable : unavailable
@ -594,6 +618,12 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) {
.filter=${this._filter} .filter=${this._filter}
selectable selectable
.selected=${this._selected.length} .selected=${this._selected.length}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@selection-changed=${this._handleSelectionChanged} @selection-changed=${this._handleSelectionChanged}
clickable clickable
@clear-filter=${this._clearFilter} @clear-filter=${this._clearFilter}
@ -1196,6 +1226,18 @@ ${rejected
}); });
} }
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@ -32,7 +32,7 @@ import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-c
import { haStyleDialog } from "../../../resources/styles"; import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url"; import { brandsUrl } from "../../../util/brands-url";
import { Helper, HelperDomain } from "./const"; import { Helper, HelperDomain, isHelperDomain } from "./const";
import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail"; import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail";
type HelperCreators = { type HelperCreators = {
@ -96,7 +96,7 @@ export class DialogHelperDetail extends LitElement {
@state() private _opened = false; @state() private _opened = false;
@state() private _domain?: HelperDomain; @state() private _domain?: string;
@state() private _error?: string; @state() private _error?: string;
@ -114,8 +114,12 @@ export class DialogHelperDetail extends LitElement {
this._params = params; this._params = params;
this._domain = params.domain; this._domain = params.domain;
this._item = undefined; this._item = undefined;
if (this._domain && this._domain in HELPERS) {
await HELPERS[this._domain].import();
}
this._opened = true; this._opened = true;
await this.updateComplete; await this.updateComplete;
this.hass.loadFragmentTranslation("config");
Promise.all([ Promise.all([
getConfigFlowHandlers(this.hass, ["helper"]), getConfigFlowHandlers(this.hass, ["helper"]),
// Ensure the titles are loaded before we render the flows. // Ensure the titles are loaded before we render the flows.
@ -141,7 +145,7 @@ export class DialogHelperDetail extends LitElement {
if (this._domain) { if (this._domain) {
content = html` content = html`
<div class="form" @value-changed=${this._valueChanged}> <div class="form" @value-changed=${this._valueChanged}>
${this._error ? html` <div class="error">${this._error}</div> ` : ""} ${this._error ? html`<div class="error">${this._error}</div>` : ""}
${dynamicElement(`ha-${this._domain}-form`, { ${dynamicElement(`ha-${this._domain}-form`, {
hass: this.hass, hass: this.hass,
item: this._item, item: this._item,
@ -155,13 +159,15 @@ export class DialogHelperDetail extends LitElement {
> >
${this.hass!.localize("ui.panel.config.helpers.dialog.create")} ${this.hass!.localize("ui.panel.config.helpers.dialog.create")}
</mwc-button> </mwc-button>
<mwc-button ${this._params?.domain
slot="secondaryAction" ? nothing
@click=${this._goBack} : html`<mwc-button
.disabled=${this._submitting} slot="secondaryAction"
> @click=${this._goBack}
${this.hass!.localize("ui.common.back")} .disabled=${this._submitting}
</mwc-button> >
${this.hass!.localize("ui.common.back")}
</mwc-button>`}
`; `;
} else if (this._loading || this._helperFlows === undefined) { } else if (this._loading || this._helperFlows === undefined) {
content = html`<ha-circular-progress content = html`<ha-circular-progress
@ -253,9 +259,13 @@ export class DialogHelperDetail extends LitElement {
"ui.panel.config.helpers.dialog.create_platform", "ui.panel.config.helpers.dialog.create_platform",
{ {
platform: platform:
this.hass.localize( (isHelperDomain(this._domain) &&
`ui.panel.config.helpers.types.${this._domain}` this.hass.localize(
) || this._domain, `ui.panel.config.helpers.types.${
this._domain as HelperDomain
}`
)) ||
this._domain,
} }
) )
: this.hass.localize("ui.panel.config.helpers.dialog.create_helper") : this.hass.localize("ui.panel.config.helpers.dialog.create_helper")
@ -277,7 +287,16 @@ export class DialogHelperDetail extends LitElement {
this._submitting = true; this._submitting = true;
this._error = ""; this._error = "";
try { try {
await HELPERS[this._domain].create(this.hass, this._item); const createdEntity = await HELPERS[this._domain].create(
this.hass,
this._item
);
if (this._params?.dialogClosedCallback && createdEntity.id) {
this._params.dialogClosedCallback({
flowFinished: true,
entityId: `${this._domain}.${createdEntity.id}`,
});
}
this.closeDialog(); this.closeDialog();
} catch (err: any) { } catch (err: any) {
this._error = err.message || "Unknown error"; this._error = err.message || "Unknown error";

View File

@ -2,7 +2,7 @@ import { Calendar, CalendarOptions } from "@fullcalendar/core";
import allLocales from "@fullcalendar/core/locales-all"; import allLocales from "@fullcalendar/core/locales-all";
import interactionPlugin from "@fullcalendar/interaction"; import interactionPlugin from "@fullcalendar/interaction";
import timeGridPlugin from "@fullcalendar/timegrid"; import timeGridPlugin from "@fullcalendar/timegrid";
import { addDays, isSameDay, isSameWeek, nextDay } from "date-fns"; import { Day, addDays, isSameDay, isSameWeek, nextDay } from "date-fns";
import { import {
CSSResultGroup, CSSResultGroup,
LitElement, LitElement,

View File

@ -24,6 +24,7 @@ import {
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 { computeCssColor } from "../../../common/color/compute-color"; import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import { HASSDomEvent } from "../../../common/dom/fire_event"; import { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
@ -40,6 +41,7 @@ import {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SelectionChangedEvent, SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels"; import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
@ -139,6 +141,19 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public route!: Route; @property({ attribute: false }) public route!: Route;
@storage({ key: "helpers-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "helpers-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "helpers-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
@state() private _stateItems: HassEntity[] = []; @state() private _stateItems: HassEntity[] = [];
@state() private _entityEntries?: Record<string, EntityRegistryEntry>; @state() private _entityEntries?: Record<string, EntityRegistryEntry>;
@ -525,7 +540,12 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
).length} ).length}
.columns=${this._columns(this.narrow, this.hass.localize)} .columns=${this._columns(this.narrow, this.hass.localize)}
.data=${helpers} .data=${helpers}
initialGroupColumn="category" .initialGroupColumn=${this._activeGrouping || "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
.activeFilters=${this._activeFilters} .activeFilters=${this._activeFilters}
@clear-filter=${this._clearFilter} @clear-filter=${this._clearFilter}
@row-click=${this._openEditDialog} @row-click=${this._openEditDialog}
@ -1020,6 +1040,18 @@ ${rejected
}); });
} }
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@ -1,13 +1,14 @@
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { DataEntryFlowDialogParams } from "../../../dialogs/config-flow/show-dialog-data-entry-flow";
import { HelperDomain } from "./const";
export const loadHelperDetailDialog = () => import("./dialog-helper-detail"); export const loadHelperDetailDialog = () => import("./dialog-helper-detail");
export interface ShowDialogHelperDetailParams { export interface ShowDialogHelperDetailParams {
domain?: HelperDomain; domain?: string;
// Only used for config entries dialogClosedCallback?: (params: {
dialogClosedCallback?: DataEntryFlowDialogParams["dialogClosedCallback"]; flowFinished: boolean;
entryId?: string;
entityId?: string;
}) => void;
} }
export const showHelperDetailDialog = ( export const showHelperDetailDialog = (

View File

@ -37,6 +37,7 @@ import {
} from "lit"; } 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 { until } from "lit/directives/until";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { isDevVersion } from "../../../common/config/version"; import { isDevVersion } from "../../../common/config/version";
@ -550,10 +551,24 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
`ui.panel.config.integrations.config_entry.state.${item.state}`, `ui.panel.config.integrations.config_entry.state.${item.state}`,
]; ];
if (item.reason) { if (item.reason) {
this.hass.loadBackendTranslation("config", item.domain); if (item.error_reason_translation_key) {
stateTextExtra = html`${this.hass.localize( const lokalisePromExc = this.hass
`component.${item.domain}.config.error.${item.reason}` .loadBackendTranslation("exceptions", item.domain)
) || item.reason}`; .then((localize) =>
localize(
`component.${item.domain}.exceptions.${item.error_reason_translation_key}.message`,
item.error_reason_translation_placeholders ?? undefined
)
);
stateTextExtra = html`${until(lokalisePromExc)}`;
} else {
const lokalisePromError = this.hass
.loadBackendTranslation("config", item.domain)
.then((localize) =>
localize(`component.${item.domain}.config.error.${item.reason}`)
);
stateTextExtra = html`${until(lokalisePromError, item.reason)}`;
}
} else { } else {
stateTextExtra = html` stateTextExtra = html`
<br /> <br />

View File

@ -238,6 +238,9 @@ export class ZHANetworkVisualizationPage extends LitElement {
label: this._buildLabel(device), label: this._buildLabel(device),
shape: this._getShape(device), shape: this._getShape(device),
mass: this._getMass(device), mass: this._getMass(device),
color: {
background: device.available ? "#66FF99" : "#FF9999",
},
}); });
if (device.neighbors && device.neighbors.length > 0) { if (device.neighbors && device.neighbors.length > 0) {
device.neighbors.forEach((neighbor) => { device.neighbors.forEach((neighbor) => {
@ -249,13 +252,29 @@ export class ZHANetworkVisualizationPage extends LitElement {
from: device.ieee, from: device.ieee,
to: neighbor.ieee, to: neighbor.ieee,
label: neighbor.lqi + "", label: neighbor.lqi + "",
color: this._getLQI(parseInt(neighbor.lqi)), color: this._getLQI(parseInt(neighbor.lqi)).color,
width: this._getLQI(parseInt(neighbor.lqi)).width,
length: 2000 - 4 * parseInt(neighbor.lqi),
arrows: {
from: {
enabled: neighbor.relationship !== "Child",
},
},
dashes: neighbor.relationship !== "Child",
}); });
} else { } else {
edges[idx].color = this._getLQI( edges[idx].color = this._getLQI(
(parseInt(edges[idx].label!) + parseInt(neighbor.lqi)) / 2 (parseInt(edges[idx].label!) + parseInt(neighbor.lqi)) / 2
); ).color;
edges[idx].width = this._getLQI(
(parseInt(edges[idx].label!) + parseInt(neighbor.lqi)) / 2
).width;
edges[idx].length =
2000 -
6 * ((parseInt(edges[idx].label!) + parseInt(neighbor.lqi)) / 2);
edges[idx].label += "/" + neighbor.lqi; edges[idx].label += "/" + neighbor.lqi;
delete edges[idx].arrows;
delete edges[idx].dashes;
} }
}); });
} }
@ -264,20 +283,23 @@ export class ZHANetworkVisualizationPage extends LitElement {
this._network?.setData({ nodes: this._nodes, edges: edges }); this._network?.setData({ nodes: this._nodes, edges: edges });
} }
private _getLQI(lqi: number): EdgeOptions["color"] { private _getLQI(lqi: number): EdgeOptions {
if (lqi > 192) { if (lqi > 192) {
return { color: "#17ab00", highlight: "#17ab00" }; return { color: { color: "#17ab00", highlight: "#17ab00" }, width: 4 };
} }
if (lqi > 128) { if (lqi > 128) {
return { color: "#e6b402", highlight: "#e6b402" }; return { color: { color: "#e6b402", highlight: "#e6b402" }, width: 3 };
} }
if (lqi > 80) { if (lqi > 80) {
return { color: "#fc4c4c", highlight: "#fc4c4c" }; return { color: { color: "#fc4c4c", highlight: "#fc4c4c" }, width: 2 };
} }
return { color: "#bfbfbf", highlight: "#bfbfbf" }; return { color: { color: "#bfbfbf", highlight: "#bfbfbf" }, width: 1 };
} }
private _getMass(device: ZHADevice): number { private _getMass(device: ZHADevice): number {
if (!device.available) {
return 6;
}
if (device.device_type === "Coordinator") { if (device.device_type === "Coordinator") {
return 2; return 2;
} }
@ -312,8 +334,8 @@ export class ZHANetworkVisualizationPage extends LitElement {
} else { } else {
label += "\n<b>Device is not in <i>'zigbee.db'</i></b>"; label += "\n<b>Device is not in <i>'zigbee.db'</i></b>";
} }
if (!device.available) { if (device.area_id) {
label += "\n<b>Device is <i>Offline</i></b>"; label += `\n<b>Area ID: </b>${device.area_id}`;
} }
return label; return label;
} }
@ -402,7 +424,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
? { ? {
physics: { physics: {
barnesHut: { barnesHut: {
springConstant: 0, springConstant: 0.05,
avoidOverlap: 10, avoidOverlap: 10,
damping: 0.09, damping: 0.09,
}, },

View File

@ -235,7 +235,7 @@ class ZWaveJSNodeConfig extends SubscribeMixin(LitElement) {
${item.metadata.label} ${item.metadata.label}
</span> </span>
<span slot="description"> <span slot="description">
${item.metadata.description || item.metadata.label} ${item.metadata.description}
${item.metadata.description !== null && !item.metadata.writeable ${item.metadata.description !== null && !item.metadata.writeable
? html`<br />` ? html`<br />`
: nothing} : nothing}

View File

@ -7,6 +7,7 @@ import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-formfield"; import "../../../components/ha-formfield";
import "../../../components/ha-switch"; import "../../../components/ha-switch";
import "../../../components/ha-textfield"; import "../../../components/ha-textfield";
import "../../../components/ha-textarea";
import "../../../components/ha-icon-picker"; import "../../../components/ha-icon-picker";
import "../../../components/ha-color-picker"; import "../../../components/ha-color-picker";
import { HassDialog } from "../../../dialogs/make-dialog-manager"; import { HassDialog } from "../../../dialogs/make-dialog-manager";
@ -31,6 +32,8 @@ class DialogLabelDetail
@state() private _color!: string; @state() private _color!: string;
@state() private _description!: string;
@state() private _error?: string; @state() private _error?: string;
@state() private _params?: LabelDetailDialogParams; @state() private _params?: LabelDetailDialogParams;
@ -44,10 +47,12 @@ class DialogLabelDetail
this._name = this._params.entry.name || ""; this._name = this._params.entry.name || "";
this._icon = this._params.entry.icon || ""; this._icon = this._params.entry.icon || "";
this._color = this._params.entry.color || ""; this._color = this._params.entry.color || "";
this._description = this._params.entry.description || "";
} else { } else {
this._name = this._params.suggestedName || ""; this._name = this._params.suggestedName || "";
this._icon = ""; this._icon = "";
this._color = ""; this._color = "";
this._description = "";
} }
document.body.addEventListener("keydown", this._handleKeyPress); document.body.addEventListener("keydown", this._handleKeyPress);
} }
@ -118,6 +123,14 @@ class DialogLabelDetail
"ui.panel.config.labels.detail.color" "ui.panel.config.labels.detail.color"
)} )}
></ha-color-picker> ></ha-color-picker>
<ha-textarea
.value=${this._description}
.configValue=${"description"}
@input=${this._input}
.label=${this.hass!.localize(
"ui.panel.config.labels.detail.description"
)}
></ha-textarea>
</div> </div>
</div> </div>
${this._params.entry && this._params.removeEntry ${this._params.entry && this._params.removeEntry
@ -169,6 +182,7 @@ class DialogLabelDetail
name: this._name.trim(), name: this._name.trim(),
icon: this._icon.trim() || null, icon: this._icon.trim() || null,
color: this._color.trim() || null, color: this._color.trim() || null,
description: this._description.trim() || null,
}; };
if (this._params!.entry) { if (this._params!.entry) {
newValue = await this._params!.updateEntry!(values); newValue = await this._params!.updateEntry!(values);
@ -202,12 +216,14 @@ class DialogLabelDetail
a { a {
color: var(--primary-color); color: var(--primary-color);
} }
ha-textarea,
ha-textfield, ha-textfield,
ha-icon-picker, ha-icon-picker,
ha-color-picker { ha-color-picker {
display: block; display: block;
} }
ha-color-picker { ha-color-picker,
ha-textarea {
margin-top: 16px; margin-top: 16px;
} }
`, `,

View File

@ -10,15 +10,17 @@ import { LitElement, PropertyValues, html, nothing } from "lit";
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 { computeCssColor } from "../../../common/color/compute-color"; import { computeCssColor } from "../../../common/color/compute-color";
import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize"; import { LocalizeFunc } from "../../../common/translations/localize";
import { import {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-relative-time";
import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-icon-overflow-menu";
import "../../../components/ha-relative-time";
import { import {
LabelRegistryEntry, LabelRegistryEntry,
LabelRegistryEntryMutableParams, LabelRegistryEntryMutableParams,
@ -35,7 +37,7 @@ import "../../../layouts/hass-tabs-subpage-data-table";
import { HomeAssistant, Route } from "../../../types"; import { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "./show-dialog-label-detail"; import { showLabelDetailDialog } from "./show-dialog-label-detail";
import { navigate } from "../../../common/navigate"; import { storage } from "../../../common/decorators/storage";
@customElement("ha-config-labels") @customElement("ha-config-labels")
export class HaConfigLabels extends LitElement { export class HaConfigLabels extends LitElement {
@ -49,6 +51,13 @@ export class HaConfigLabels extends LitElement {
@state() private _labels: LabelRegistryEntry[] = []; @state() private _labels: LabelRegistryEntry[] = [];
@storage({
key: "labels-table-sort",
state: false,
subscribe: false,
})
private _activeSorting?: SortingChangedEvent;
private _columns = memoizeOne((localize: LocalizeFunc) => { private _columns = memoizeOne((localize: LocalizeFunc) => {
const columns: DataTableColumnContainer<LabelRegistryEntry> = { const columns: DataTableColumnContainer<LabelRegistryEntry> = {
icon: { icon: {
@ -79,6 +88,12 @@ export class HaConfigLabels extends LitElement {
sortable: true, sortable: true,
filterable: true, filterable: true,
grows: true, grows: true,
template: (label) => html`
<div>${label.name}</div>
${label.description
? html`<div class="secondary">${label.description}</div>`
: nothing}
`,
}, },
actions: { actions: {
title: "", title: "",
@ -143,6 +158,8 @@ export class HaConfigLabels extends LitElement {
.data=${this._data(this._labels)} .data=${this._data(this._labels)}
.noDataText=${this.hass.localize("ui.panel.config.labels.no_labels")} .noDataText=${this.hass.localize("ui.panel.config.labels.no_labels")}
hasFab hasFab
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@row-click=${this._editLabel} @row-click=${this._editLabel}
clickable clickable
id="label_id" id="label_id"
@ -262,6 +279,10 @@ export class HaConfigLabels extends LitElement {
`/config/automation/dashboard?historyBack=1&label=${label.label_id}` `/config/automation/dashboard?historyBack=1&label=${label.label_id}`
); );
} }
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
} }
declare global { declare global {

View File

@ -16,6 +16,7 @@ import { stringCompare } from "../../../../common/string/compare";
import { import {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SortingChangedEvent,
} from "../../../../components/data-table/ha-data-table"; } from "../../../../components/data-table/ha-data-table";
import "../../../../components/ha-clickable-list-item"; import "../../../../components/ha-clickable-list-item";
import "../../../../components/ha-fab"; import "../../../../components/ha-fab";
@ -46,6 +47,7 @@ import { showNewDashboardDialog } from "../../dashboard/show-dialog-new-dashboar
import { lovelaceTabs } from "../ha-config-lovelace"; import { lovelaceTabs } from "../ha-config-lovelace";
import { showDashboardConfigureStrategyDialog } from "./show-dialog-lovelace-dashboard-configure-strategy"; import { showDashboardConfigureStrategyDialog } from "./show-dialog-lovelace-dashboard-configure-strategy";
import { showDashboardDetailDialog } from "./show-dialog-lovelace-dashboard-detail"; import { showDashboardDetailDialog } from "./show-dialog-lovelace-dashboard-detail";
import { storage } from "../../../../common/decorators/storage";
type DataTableItem = Pick< type DataTableItem = Pick<
LovelaceDashboard, LovelaceDashboard,
@ -68,6 +70,13 @@ export class HaConfigLovelaceDashboards extends LitElement {
@state() private _dashboards: LovelaceDashboard[] = []; @state() private _dashboards: LovelaceDashboard[] = [];
@storage({
key: "lovelace-dashboards-table-sort",
state: false,
subscribe: false,
})
private _activeSorting?: SortingChangedEvent;
public willUpdate() { public willUpdate() {
if (!this.hasUpdated) { if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace"); this.hass.loadFragmentTranslation("lovelace");
@ -293,6 +302,8 @@ export class HaConfigLovelaceDashboards extends LitElement {
this.hass.localize this.hass.localize
)} )}
.data=${this._getItems(this._dashboards)} .data=${this._getItems(this._dashboards)}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@row-click=${this._editDashboard} @row-click=${this._editDashboard}
id="url_path" id="url_path"
hasFab hasFab
@ -440,6 +451,10 @@ export class HaConfigLovelaceDashboards extends LitElement {
}, },
}); });
} }
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
} }
declare global { declare global {

View File

@ -10,9 +10,11 @@ import {
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoize from "memoize-one"; import memoize from "memoize-one";
import { stringCompare } from "../../../../common/string/compare"; import { stringCompare } from "../../../../common/string/compare";
import { LocalizeFunc } from "../../../../common/translations/localize";
import { import {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SortingChangedEvent,
} from "../../../../components/data-table/ha-data-table"; } from "../../../../components/data-table/ha-data-table";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import "../../../../components/ha-fab"; import "../../../../components/ha-fab";
@ -33,10 +35,10 @@ import "../../../../layouts/hass-subpage";
import "../../../../layouts/hass-tabs-subpage-data-table"; import "../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../resources/styles"; import { haStyle } from "../../../../resources/styles";
import { HomeAssistant, Route } from "../../../../types"; import { HomeAssistant, Route } from "../../../../types";
import { LocalizeFunc } from "../../../../common/translations/localize";
import { loadLovelaceResources } from "../../../lovelace/common/load-resources"; import { loadLovelaceResources } from "../../../lovelace/common/load-resources";
import { lovelaceResourcesTabs } from "../ha-config-lovelace"; import { lovelaceResourcesTabs } from "../ha-config-lovelace";
import { showResourceDetailDialog } from "./show-dialog-lovelace-resource-detail"; import { showResourceDetailDialog } from "./show-dialog-lovelace-resource-detail";
import { storage } from "../../../../common/decorators/storage";
@customElement("ha-config-lovelace-resources") @customElement("ha-config-lovelace-resources")
export class HaConfigLovelaceRescources extends LitElement { export class HaConfigLovelaceRescources extends LitElement {
@ -50,6 +52,13 @@ export class HaConfigLovelaceRescources extends LitElement {
@state() private _resources: LovelaceResource[] = []; @state() private _resources: LovelaceResource[] = [];
@storage({
key: "lovelace-resources-table-sort",
state: false,
subscribe: false,
})
private _activeSorting?: SortingChangedEvent;
private _columns = memoize( private _columns = memoize(
( (
_language, _language,
@ -127,6 +136,8 @@ export class HaConfigLovelaceRescources extends LitElement {
.noDataText=${this.hass.localize( .noDataText=${this.hass.localize(
"ui.panel.config.lovelace.resources.picker.no_resources" "ui.panel.config.lovelace.resources.picker.no_resources"
)} )}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@row-click=${this._editResource} @row-click=${this._editResource}
hasFab hasFab
clickable clickable
@ -237,6 +248,10 @@ export class HaConfigLovelaceRescources extends LitElement {
}); });
} }
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@ -16,7 +16,7 @@ import {
mdiPlus, mdiPlus,
mdiTag, mdiTag,
} from "@mdi/js"; } from "@mdi/js";
import { differenceInDays } from "date-fns/esm"; import { differenceInDays } from "date-fns";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
CSSResultGroup, CSSResultGroup,
@ -32,14 +32,20 @@ import memoizeOne from "memoize-one";
import { computeCssColor } from "../../../common/color/compute-color"; import { computeCssColor } from "../../../common/color/compute-color";
import { formatShortDateTime } from "../../../common/datetime/format_date_time"; import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { relativeTime } from "../../../common/datetime/relative_time"; import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage";
import { HASSDomEvent, fireEvent } from "../../../common/dom/fire_event"; import { HASSDomEvent, fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize"; import { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
import { import {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SelectionChangedEvent, SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels"; import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-button"; import "../../../components/ha-button";
@ -95,10 +101,6 @@ import { showAssignCategoryDialog } from "../category/show-dialog-assign-categor
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail"; import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
type SceneItem = SceneEntity & { type SceneItem = SceneEntity & {
name: string; name: string;
@ -144,6 +146,19 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
@consume({ context: fullEntitiesContext, subscribe: true }) @consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[]; _entityReg!: EntityRegistryEntry[];
@storage({ key: "scene-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "scene-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "scene-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
private _sizeController = new ResizeController(this, { private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width, callback: (entries) => entries[0]?.contentRect.width,
}); });
@ -463,7 +478,12 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
).length} ).length}
.columns=${this._columns(this.narrow, this.hass.localize)} .columns=${this._columns(this.narrow, this.hass.localize)}
id="entity_id" id="entity_id"
initialGroupColumn="category" .initialGroupColumn=${this._activeGrouping || "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
.data=${scenes} .data=${scenes}
.empty=${!this.scenes.length} .empty=${!this.scenes.length}
.activeFilters=${this._activeFilters} .activeFilters=${this._activeFilters}
@ -975,6 +995,18 @@ ${rejected
}); });
} }
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@ -15,7 +15,7 @@ import {
mdiTag, mdiTag,
mdiTransitConnection, mdiTransitConnection,
} from "@mdi/js"; } from "@mdi/js";
import { differenceInDays } from "date-fns/esm"; import { differenceInDays } from "date-fns";
import { UnsubscribeFunc } from "home-assistant-js-websocket"; import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
CSSResultGroup, CSSResultGroup,
@ -33,14 +33,20 @@ import { computeCssColor } from "../../../common/color/compute-color";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatShortDateTime } from "../../../common/datetime/format_date_time"; import { formatShortDateTime } from "../../../common/datetime/format_date_time";
import { relativeTime } from "../../../common/datetime/relative_time"; import { relativeTime } from "../../../common/datetime/relative_time";
import { storage } from "../../../common/decorators/storage";
import { HASSDomEvent, fireEvent } from "../../../common/dom/fire_event"; import { HASSDomEvent, fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import { LocalizeFunc } from "../../../common/translations/localize"; import { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
import { import {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SelectionChangedEvent, SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels"; import "../../../components/data-table/ha-data-table-labels";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
@ -97,10 +103,6 @@ import { showAssignCategoryDialog } from "../category/show-dialog-assign-categor
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail"; import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail"; import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import {
hasRejectedItems,
rejectedItems,
} from "../../../common/util/promise-all-settled-results";
type ScriptItem = ScriptEntity & { type ScriptItem = ScriptEntity & {
name: string; name: string;
@ -148,6 +150,19 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
@consume({ context: fullEntitiesContext, subscribe: true }) @consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[]; _entityReg!: EntityRegistryEntry[];
@storage({ key: "script-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "script-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "script-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
private _sizeController = new ResizeController(this, { private _sizeController = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width, callback: (entries) => entries[0]?.contentRect.width,
}); });
@ -462,7 +477,12 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
{ number: scripts.length } { number: scripts.length }
)} )}
hasFilters hasFilters
initialGroupColumn="category" .initialGroupColumn=${this._activeGrouping || "category"}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
selectable selectable
.selected=${this._selected.length} .selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged} @selection-changed=${this._handleSelectionChanged}
@ -1091,6 +1111,18 @@ ${rejected
}); });
} }
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@ -7,6 +7,7 @@ import { LocalizeFunc } from "../../../common/translations/localize";
import { import {
DataTableColumnContainer, DataTableColumnContainer,
RowClickedEvent, RowClickedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-icon"; import "../../../components/data-table/ha-data-table-icon";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
@ -25,6 +26,7 @@ import { HomeAssistant, Route } from "../../../types";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { showAddUserDialog } from "./show-dialog-add-user"; import { showAddUserDialog } from "./show-dialog-add-user";
import { showUserDetailDialog } from "./show-dialog-user-detail"; import { showUserDetailDialog } from "./show-dialog-user-detail";
import { storage } from "../../../common/decorators/storage";
@customElement("ha-config-users") @customElement("ha-config-users")
export class HaConfigUsers extends LitElement { export class HaConfigUsers extends LitElement {
@ -38,6 +40,19 @@ export class HaConfigUsers extends LitElement {
@state() private _users: User[] = []; @state() private _users: User[] = [];
@storage({ key: "users-table-sort", state: false, subscribe: false })
private _activeSorting?: SortingChangedEvent;
@storage({ key: "users-table-grouping", state: false, subscribe: false })
private _activeGrouping?: string;
@storage({
key: "users-table-collapsed",
state: false,
subscribe: false,
})
private _activeCollapsed?: string;
private _columns = memoizeOne( private _columns = memoizeOne(
(narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => { (narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => {
const columns: DataTableColumnContainer<User> = { const columns: DataTableColumnContainer<User> = {
@ -70,16 +85,14 @@ export class HaConfigUsers extends LitElement {
hidden: narrow, hidden: narrow,
template: (user) => html`${user.username || "—"}`, template: (user) => html`${user.username || "—"}`,
}, },
group_ids: { group: {
title: localize("ui.panel.config.users.picker.headers.group"), title: localize("ui.panel.config.users.picker.headers.group"),
sortable: true, sortable: true,
filterable: true, filterable: true,
groupable: true,
width: "20%", width: "20%",
direction: "asc", direction: "asc",
hidden: narrow, hidden: narrow,
template: (user) => html`
${localize(`groups.${user.group_ids[0]}`)}
`,
}, },
is_active: { is_active: {
title: this.hass.localize( title: this.hass.localize(
@ -164,7 +177,13 @@ export class HaConfigUsers extends LitElement {
backPath="/config" backPath="/config"
.tabs=${configSections.persons} .tabs=${configSections.persons}
.columns=${this._columns(this.narrow, this.hass.localize)} .columns=${this._columns(this.narrow, this.hass.localize)}
.data=${this._users} .data=${this._userData(this._users, this.hass.localize)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
@row-click=${this._editUser} @row-click=${this._editUser}
hasFab hasFab
clickable clickable
@ -181,6 +200,13 @@ export class HaConfigUsers extends LitElement {
`; `;
} }
private _userData = memoizeOne((users: User[], localize: LocalizeFunc) =>
users.map((user) => ({
...user,
group: localize(`groups.${user.group_ids[0]}`),
}))
);
private async _fetchUsers() { private async _fetchUsers() {
this._users = await fetchUsers(this.hass); this._users = await fetchUsers(this.hass);
@ -245,6 +271,18 @@ export class HaConfigUsers extends LitElement {
}, },
}); });
} }
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
private _handleGroupingChanged(ev: CustomEvent) {
this._activeGrouping = ev.detail.value;
}
private _handleCollapseChanged(ev: CustomEvent) {
this._activeCollapsed = ev.detail.value;
}
} }
declare global { declare global {

View File

@ -23,6 +23,7 @@ import {
DataTableRowData, DataTableRowData,
RowClickedEvent, RowClickedEvent,
SelectionChangedEvent, SelectionChangedEvent,
SortingChangedEvent,
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
import { AlexaEntity, fetchCloudAlexaEntities } from "../../../data/alexa"; import { AlexaEntity, fetchCloudAlexaEntities } from "../../../data/alexa";
@ -52,6 +53,7 @@ import "./expose/expose-assistant-icon";
import { voiceAssistantTabs } from "./ha-config-voice-assistants"; import { voiceAssistantTabs } from "./ha-config-voice-assistants";
import { showExposeEntityDialog } from "./show-dialog-expose-entity"; import { showExposeEntityDialog } from "./show-dialog-expose-entity";
import { showVoiceSettingsDialog } from "./show-dialog-voice-settings"; import { showVoiceSettingsDialog } from "./show-dialog-voice-settings";
import { storage } from "../../../common/decorators/storage";
@customElement("ha-config-voice-assistants-expose") @customElement("ha-config-voice-assistants-expose")
export class VoiceAssistantsExpose extends LitElement { export class VoiceAssistantsExpose extends LitElement {
@ -87,6 +89,13 @@ export class VoiceAssistantsExpose extends LitElement {
string[] | undefined string[] | undefined
>; >;
@storage({
key: "voice-expose-table-sort",
state: false,
subscribe: false,
})
private _activeSorting?: SortingChangedEvent;
@query("hass-tabs-subpage-data-table", true) @query("hass-tabs-subpage-data-table", true)
private _dataTable!: HaTabsSubpageDataTable; private _dataTable!: HaTabsSubpageDataTable;
@ -505,6 +514,8 @@ export class VoiceAssistantsExpose extends LitElement {
selectable selectable
.selected=${this._selectedEntities.length} .selected=${this._selectedEntities.length}
clickable clickable
.initialSorting=${this._activeSorting}
@sorting-changed=${this._handleSortingChanged}
@selection-changed=${this._handleSelectionChanged} @selection-changed=${this._handleSelectionChanged}
@clear-filter=${this._clearFilter} @clear-filter=${this._clearFilter}
@search-changed=${this._handleSearchChange} @search-changed=${this._handleSearchChange}
@ -696,6 +707,10 @@ export class VoiceAssistantsExpose extends LitElement {
navigate(window.location.pathname, { replace: true }); navigate(window.location.pathname, { replace: true });
} }
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,

View File

@ -0,0 +1,150 @@
import "@material/mwc-button";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-form/ha-form";
import { HomeZoneMutableParams } from "../../../data/zone";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { HomeZoneDetailDialogParams } from "./show-dialog-home-zone-detail";
const SCHEMA = [
{
name: "location",
required: true,
selector: { location: { radius: true, radius_readonly: true } },
},
];
class DialogHomeZoneDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _error?: Record<string, string>;
@state() private _data?: HomeZoneMutableParams;
@state() private _params?: HomeZoneDetailDialogParams;
@state() private _submitting = false;
public showDialog(params: HomeZoneDetailDialogParams): void {
this._params = params;
this._error = undefined;
this._data = {
latitude: this.hass.config.latitude,
longitude: this.hass.config.longitude,
};
}
public closeDialog(): void {
this._params = undefined;
this._data = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params || !this._data) {
return nothing;
}
const latInvalid = String(this._data.latitude) === "";
const lngInvalid = String(this._data.longitude) === "";
const valid = !latInvalid && !lngInvalid;
return html`
<ha-dialog
open
@closed=${this.closeDialog}
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this.hass!.localize("ui.panel.config.zone.edit_home")
)}
>
<div>
<ha-form
.hass=${this.hass}
.schema=${SCHEMA}
.data=${this._formData(this._data)}
.error=${this._error}
.computeLabel=${this._computeLabel}
@value-changed=${this._valueChanged}
></ha-form>
<p>
${this.hass!.localize(
"ui.panel.config.zone.detail.no_edit_home_zone_radius"
)}
</p>
</div>
<mwc-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${!valid || this._submitting}
>
${this.hass!.localize("ui.panel.config.zone.detail.update")}
</mwc-button>
</ha-dialog>
`;
}
private _formData = memoizeOne((data: HomeZoneMutableParams) => ({
...data,
location: {
latitude: data.latitude,
longitude: data.longitude,
radius: this.hass.states["zone.home"]?.attributes?.radius || 100,
},
}));
private _valueChanged(ev: CustomEvent) {
this._error = undefined;
const value = { ...ev.detail.value };
value.latitude = value.location.latitude;
value.longitude = value.location.longitude;
delete value.location;
this._data = value;
}
private _computeLabel = (): string => "";
private async _updateEntry() {
this._submitting = true;
try {
await this._params!.updateEntry!(this._data!);
this.closeDialog();
} catch (err: any) {
this._error = { base: err ? err.message : "Unknown error" };
} finally {
this._submitting = false;
}
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-min-width: min(600px, 95vw);
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-dialog {
--mdc-dialog-min-width: calc(
100vw - env(safe-area-inset-right) - env(safe-area-inset-left)
);
}
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-home-zone-detail": DialogHomeZoneDetail;
}
}
customElements.define("dialog-home-zone-detail", DialogHomeZoneDetail);

View File

@ -145,30 +145,8 @@ class DialogZoneDetail extends LitElement {
required: true, required: true,
selector: { location: { radius: true, icon } }, selector: { location: { radius: true, icon } },
}, },
{
name: "",
type: "grid",
schema: [
{
name: "latitude",
required: true,
selector: { number: {} },
},
{
name: "longitude",
required: true,
selector: { number: {} },
},
],
},
{ name: "passive_note", type: "constant" }, { name: "passive_note", type: "constant" },
{ name: "passive", selector: { boolean: {} } }, { name: "passive", selector: { boolean: {} } },
{
name: "radius",
required: false,
selector: { number: { min: 0, max: 999999, mode: "box" } },
},
] as const ] as const
); );
@ -184,15 +162,9 @@ class DialogZoneDetail extends LitElement {
private _valueChanged(ev: CustomEvent) { private _valueChanged(ev: CustomEvent) {
this._error = undefined; this._error = undefined;
const value = { ...ev.detail.value }; const value = { ...ev.detail.value };
if ( value.latitude = value.location.latitude;
value.location.latitude !== this._data!.latitude || value.longitude = value.location.longitude;
value.location.longitude !== this._data!.longitude || value.radius = value.location.radius;
value.location.radius !== this._data!.radius
) {
value.latitude = value.location.latitude;
value.longitude = value.location.longitude;
value.radius = Math.round(value.location.radius);
}
delete value.location; delete value.location;
if (!value.icon) { if (!value.icon) {
delete value.icon; delete value.icon;

View File

@ -1,25 +1,26 @@
import { mdiCog, mdiPencil, mdiPencilOff, mdiPlus } from "@mdi/js";
import "@polymer/paper-item/paper-icon-item";
import "@polymer/paper-item/paper-item-body";
import "@polymer/paper-listbox/paper-listbox";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import "@material/mwc-list/mwc-list";
import { mdiPencil, mdiPencilOff, mdiPlus } from "@mdi/js";
import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
css,
CSSResultGroup, CSSResultGroup,
html,
LitElement, LitElement,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
css,
html,
nothing,
} from "lit"; } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import { stringCompare } from "../../../common/string/compare"; import { stringCompare } from "../../../common/string/compare";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon"; import "../../../components/ha-svg-icon";
import "../../../components/map/ha-locations-editor"; import "../../../components/map/ha-locations-editor";
import type { import type {
@ -29,12 +30,13 @@ import type {
import { saveCoreConfig } from "../../../data/core"; import { saveCoreConfig } from "../../../data/core";
import { subscribeEntityRegistry } from "../../../data/entity_registry"; import { subscribeEntityRegistry } from "../../../data/entity_registry";
import { import {
HomeZoneMutableParams,
Zone,
ZoneMutableParams,
createZone, createZone,
deleteZone, deleteZone,
fetchZones, fetchZones,
updateZone, updateZone,
Zone,
ZoneMutableParams,
} from "../../../data/zone"; } from "../../../data/zone";
import { import {
showAlertDialog, showAlertDialog,
@ -46,6 +48,7 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import type { HomeAssistant, Route } from "../../../types"; import type { HomeAssistant, Route } from "../../../types";
import "../ha-config-section"; import "../ha-config-section";
import { configSections } from "../ha-panel-config"; import { configSections } from "../ha-panel-config";
import { showHomeZoneDetailDialog } from "./show-dialog-home-zone-detail";
import { showZoneDetailDialog } from "./show-dialog-zone-detail"; import { showZoneDetailDialog } from "./show-dialog-zone-detail";
@customElement("ha-config-zone") @customElement("ha-config-zone")
@ -143,77 +146,95 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
</div> </div>
` `
: html` : html`
<paper-listbox <mwc-list>
attr-for-selected="data-id"
.selected=${this._activeEntry || ""}
>
${this._storageItems.map( ${this._storageItems.map(
(entry) => html` (entry) => html`
<paper-icon-item <ha-list-item
data-id=${entry.id}
@click=${this._itemClicked}
.entry=${entry} .entry=${entry}
.id=${this.narrow ? entry.id : ""}
graphic="icon"
.hasMeta=${!this.narrow}
@request-selected=${this._itemClicked}
.value=${entry.id}
?selected=${this._activeEntry === entry.id}
> >
<ha-icon .icon=${entry.icon} slot="item-icon"></ha-icon> <ha-icon .icon=${entry.icon} slot="graphic"></ha-icon>
<paper-item-body>${entry.name}</paper-item-body> ${entry.name}
${!this.narrow ${!this.narrow
? html` ? html`
<ha-icon-button <div slot="meta">
.entry=${entry} <ha-icon-button
@click=${this._openEditEntry} .id=${entry.id}
.path=${mdiPencil} .entry=${entry}
.label=${hass.localize( @click=${this._openEditEntry}
"ui.panel.config.zone.edit_zone" .path=${mdiPencil}
)} .label=${hass.localize(
></ha-icon-button> "ui.panel.config.zone.edit_zone"
)}
></ha-icon-button>
</div>
` `
: ""} : ""}
</paper-icon-item> </ha-list-item>
` `
)} )}
${this._stateItems.map( ${this._stateItems.map(
(stateObject) => html` (stateObject) => html`
<paper-icon-item <ha-list-item
data-id=${stateObject.entity_id} graphic="icon"
@click=${this._stateItemClicked} .id=${this.narrow ? stateObject.entity_id : ""}
.hasMeta=${!this.narrow ||
stateObject.entity_id !== "zone.home"}
.value=${stateObject.entity_id}
@request-selected=${this._stateItemClicked}
?selected=${this._activeEntry === stateObject.entity_id}
.noEdit=${stateObject.entity_id !== "zone.home" ||
!this._canEditCore}
> >
<ha-icon <ha-icon
.icon=${stateObject.attributes.icon} .icon=${stateObject.attributes.icon}
slot="item-icon" slot="graphic"
> >
</ha-icon> </ha-icon>
<paper-item-body>
${stateObject.attributes.friendly_name || ${stateObject.attributes.friendly_name ||
stateObject.entity_id} stateObject.entity_id}
</paper-item-body> ${this.narrow &&
<div style="display:inline-block"> stateObject.entity_id === "zone.home" &&
<ha-icon-button !this._canEditCore
.entityId=${stateObject.entity_id} ? nothing
.noEdit=${stateObject.entity_id !== "zone.home" || : html`<div slot="meta">
!this._canEditCore} <ha-icon-button
.path=${stateObject.entity_id === "zone.home" && .id=${!this.narrow ? stateObject.entity_id : ""}
this._canEditCore .entityId=${stateObject.entity_id}
? mdiCog .noEdit=${stateObject.entity_id !== "zone.home" ||
: mdiPencilOff} !this._canEditCore}
.label=${stateObject.entity_id === "zone.home" .path=${stateObject.entity_id === "zone.home" &&
? hass.localize("ui.panel.config.zone.edit_home") this._canEditCore
: hass.localize("ui.panel.config.zone.edit_zone")} ? mdiPencil
@click=${this._openCoreConfig} : mdiPencilOff}
></ha-icon-button> .label=${stateObject.entity_id === "zone.home"
${stateObject.entity_id !== "zone.home" ? hass.localize("ui.panel.config.zone.edit_home")
? html` : hass.localize("ui.panel.config.zone.edit_zone")}
<simple-tooltip animation-delay="0" position="left"> @click=${this._editHomeZone}
${hass.localize( ></ha-icon-button>
"ui.panel.config.zone.configured_in_yaml" ${stateObject.entity_id !== "zone.home"
)} ? html`
</simple-tooltip> <simple-tooltip
` animation-delay="0"
: ""} position="left"
</div> >
</paper-icon-item> ${hass.localize(
"ui.panel.config.zone.configured_in_yaml"
)}
</simple-tooltip>
`
: ""}
</div>`}
</ha-list-item>
` `
)} )}
</paper-listbox> </mwc-list>
`; `;
return html` return html`
@ -286,7 +307,11 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
return; return;
} }
const id = this.route.path.slice(6); const id = this.route.path.slice(6);
this._editZone(id);
navigate("/config/zone", { replace: true }); navigate("/config/zone", { replace: true });
if (this.narrow) {
return;
}
this._zoomZone(id); this._zoomZone(id);
} }
@ -375,32 +400,52 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
this._openDialog(); this._openDialog();
} }
private _itemClicked(ev: Event) { private _itemClicked(ev: CustomEvent) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
if (this.narrow) { if (this.narrow) {
this._openEditEntry(ev); this._openEditEntry(ev);
return; return;
} }
const entry: Zone = (ev.currentTarget! as any).entry; const entryId: string = (ev.currentTarget! as any).value;
this._zoomZone(entry.id); this._zoomZone(entryId);
this._activeEntry = entryId;
} }
private _stateItemClicked(ev: Event) { private _stateItemClicked(ev: CustomEvent) {
const entityId = (ev.currentTarget! as HTMLElement).getAttribute( if (!shouldHandleRequestSelectedEvent(ev)) {
"data-id" return;
)!; }
this._zoomZone(entityId);
const entryId: string = (ev.currentTarget! as any).value;
if (this.narrow && entryId === "zone.home") {
this._editHomeZone(ev);
return;
}
this._zoomZone(entryId);
this._activeEntry = entryId;
} }
private async _zoomZone(id: string) { private async _zoomZone(id: string) {
this._map?.fitMarker(id); this._map?.fitMarker(id);
} }
private async _editZone(id: string) {
await this.updateComplete;
(this.shadowRoot?.querySelector(`[id="${id}"]`) as HTMLElement)?.click();
}
private _openEditEntry(ev: Event) { private _openEditEntry(ev: Event) {
const entry: Zone = (ev.currentTarget! as any).entry; const entry: Zone = (ev.currentTarget! as any).entry;
this._openDialog(entry); this._openDialog(entry);
ev.stopPropagation();
} }
private async _openCoreConfig(ev) { private async _editHomeZone(ev) {
if (ev.currentTarget.noEdit) { if (ev.currentTarget.noEdit) {
showAlertDialog(this, { showAlertDialog(this, {
title: this.hass.localize("ui.panel.config.zone.can_not_edit"), title: this.hass.localize("ui.panel.config.zone.can_not_edit"),
@ -409,7 +454,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
}); });
return; return;
} }
navigate("/config/general"); showHomeZoneDetailDialog(this, {
updateEntry: (values) => this._updateHomeZoneEntry(values),
});
} }
private async _createEntry(values: ZoneMutableParams) { private async _createEntry(values: ZoneMutableParams) {
@ -427,6 +474,14 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
this._map?.fitMarker(created.id); this._map?.fitMarker(created.id);
} }
private async _updateHomeZoneEntry(values: HomeZoneMutableParams) {
await saveCoreConfig(this.hass, {
latitude: values.latitude,
longitude: values.longitude,
});
this._zoomZone("zone.home");
}
private async _updateEntry( private async _updateEntry(
entry: Zone, entry: Zone,
values: Partial<ZoneMutableParams>, values: Partial<ZoneMutableParams>,
@ -485,6 +540,9 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
--app-header-background-color: var(--sidebar-background-color); --app-header-background-color: var(--sidebar-background-color);
--app-header-text-color: var(--sidebar-text-color); --app-header-text-color: var(--sidebar-text-color);
} }
ha-list-item {
--mdc-list-item-meta-size: 48px;
}
a { a {
color: var(--primary-color); color: var(--primary-color);
} }
@ -515,40 +573,16 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
flex-grow: 1; flex-grow: 1;
height: 100%; height: 100%;
} }
.flex paper-listbox, .flex mwc-list,
.flex .empty { .flex .empty {
border-left: 1px solid var(--divider-color); border-left: 1px solid var(--divider-color);
width: 250px; width: 250px;
min-height: 100%; min-height: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
paper-icon-item {
padding-top: 4px;
padding-bottom: 4px;
cursor: pointer;
}
.overflow paper-icon-item:last-child {
margin-bottom: 80px;
}
paper-icon-item.iron-selected:before {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
content: "";
background-color: var(--sidebar-selected-icon-color);
opacity: 0.12;
transition: opacity 15ms linear;
will-change: opacity;
}
ha-card { ha-card {
margin-bottom: 100px; margin-bottom: 100px;
} }
ha-card paper-item {
cursor: pointer;
}
`; `;
} }
} }

View File

@ -0,0 +1,20 @@
import { fireEvent } from "../../../common/dom/fire_event";
import { HomeZoneMutableParams } from "../../../data/zone";
export interface HomeZoneDetailDialogParams {
updateEntry?: (updates: HomeZoneMutableParams) => Promise<unknown>;
}
export const loadHomeZoneDetailDialog = () =>
import("./dialog-home-zone-detail");
export const showHomeZoneDetailDialog = (
element: HTMLElement,
params: HomeZoneDetailDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-home-zone-detail",
dialogImport: loadHomeZoneDetailDialog,
dialogParams: params,
});
};

Some files were not shown because too many files have changed in this diff Show More