diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3529bccbf4..58ef3a040a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,4 +1,4 @@ -name: Report a bug with the UI, Frontend or Lovelace +name: Report a bug with the UI / Dashboards description: Report an issue related to the Home Assistant frontend. labels: bug body: @@ -9,7 +9,7 @@ body: If you have a feature or enhancement request for the frontend, please [start an discussion][fr] instead of creating an issue. - **Please not not report issues for custom Lovelace cards.** + **Please not not report issues for custom cards.** [fr]: https://github.com/home-assistant/frontend/discussions [releases]: https://github.com/home-assistant/home-assistant/releases diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 7468455df2..689b0c011d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,17 +1,17 @@ blank_issues_enabled: false contact_links: - - name: Request a feature for the UI, Frontend or Lovelace + - name: Request a feature for the UI / Dashboards url: https://github.com/home-assistant/frontend/discussions/category_choices about: Request an new feature for the Home Assistant frontend. - - name: Report a bug that is NOT related to the UI, Frontend or Lovelace + - name: Report a bug that is NOT related to the UI / Dashboards url: https://github.com/home-assistant/core/issues - about: This is the issue tracker for our frontend. Please report other issues with the backend repository. + about: This is the issue tracker for our frontend. Please report other issues in the backend ("core") repository. - name: Report incorrect or missing information on our website url: https://github.com/home-assistant/home-assistant.io/issues about: Our documentation has its own issue tracker. Please report issues with the website there. - name: I have a question or need support url: https://www.home-assistant.io/help - about: We use GitHub for tracking bugs, check our website for resources on getting help. + about: We use GitHub for tracking bugs. Check our website for resources on getting help. - name: I'm unsure where to go url: https://www.home-assistant.io/join-chat about: If you are unsure where to go, then joining our chat is recommended; Just ask! diff --git a/package.json b/package.json index 23438c75eb..25cbfbe761 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,6 @@ "deep-clone-simple": "^1.1.1", "deep-freeze": "^0.0.1", "fuse.js": "^6.0.0", - "fuzzysort": "^1.2.1", "google-timezones-json": "^1.0.2", "hls.js": "^1.1.5", "home-assistant-js-websocket": "^7.0.3", diff --git a/src/common/string/filter/char-code.ts b/src/common/string/filter/char-code.ts new file mode 100644 index 0000000000..faa7210898 --- /dev/null +++ b/src/common/string/filter/char-code.ts @@ -0,0 +1,244 @@ +// MIT License + +// Copyright (c) 2015 - present Microsoft Corporation + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/ + +/** + * An inlined enum containing useful character codes (to be used with String.charCodeAt). + * Please leave the const keyword such that it gets inlined when compiled to JavaScript! + */ +export enum CharCode { + Null = 0, + /** + * The `\b` character. + */ + Backspace = 8, + /** + * The `\t` character. + */ + Tab = 9, + /** + * The `\n` character. + */ + LineFeed = 10, + /** + * The `\r` character. + */ + CarriageReturn = 13, + Space = 32, + /** + * The `!` character. + */ + ExclamationMark = 33, + /** + * The `"` character. + */ + DoubleQuote = 34, + /** + * The `#` character. + */ + Hash = 35, + /** + * The `$` character. + */ + DollarSign = 36, + /** + * The `%` character. + */ + PercentSign = 37, + /** + * The `&` character. + */ + Ampersand = 38, + /** + * The `'` character. + */ + SingleQuote = 39, + /** + * The `(` character. + */ + OpenParen = 40, + /** + * The `)` character. + */ + CloseParen = 41, + /** + * The `*` character. + */ + Asterisk = 42, + /** + * The `+` character. + */ + Plus = 43, + /** + * The `,` character. + */ + Comma = 44, + /** + * The `-` character. + */ + Dash = 45, + /** + * The `.` character. + */ + Period = 46, + /** + * The `/` character. + */ + Slash = 47, + + Digit0 = 48, + Digit1 = 49, + Digit2 = 50, + Digit3 = 51, + Digit4 = 52, + Digit5 = 53, + Digit6 = 54, + Digit7 = 55, + Digit8 = 56, + Digit9 = 57, + + /** + * The `:` character. + */ + Colon = 58, + /** + * The `;` character. + */ + Semicolon = 59, + /** + * The `<` character. + */ + LessThan = 60, + /** + * The `=` character. + */ + Equals = 61, + /** + * The `>` character. + */ + GreaterThan = 62, + /** + * The `?` character. + */ + QuestionMark = 63, + /** + * The `@` character. + */ + AtSign = 64, + + A = 65, + B = 66, + C = 67, + D = 68, + E = 69, + F = 70, + G = 71, + H = 72, + I = 73, + J = 74, + K = 75, + L = 76, + M = 77, + N = 78, + O = 79, + P = 80, + Q = 81, + R = 82, + S = 83, + T = 84, + U = 85, + V = 86, + W = 87, + X = 88, + Y = 89, + Z = 90, + + /** + * The `[` character. + */ + OpenSquareBracket = 91, + /** + * The `\` character. + */ + Backslash = 92, + /** + * The `]` character. + */ + CloseSquareBracket = 93, + /** + * The `^` character. + */ + Caret = 94, + /** + * The `_` character. + */ + Underline = 95, + /** + * The ``(`)`` character. + */ + BackTick = 96, + + a = 97, + b = 98, + c = 99, + d = 100, + e = 101, + f = 102, + g = 103, + h = 104, + i = 105, + j = 106, + k = 107, + l = 108, + m = 109, + n = 110, + o = 111, + p = 112, + q = 113, + r = 114, + s = 115, + t = 116, + u = 117, + v = 118, + w = 119, + x = 120, + y = 121, + z = 122, + + /** + * The `{` character. + */ + OpenCurlyBrace = 123, + /** + * The `|` character. + */ + Pipe = 124, + /** + * The `}` character. + */ + CloseCurlyBrace = 125, + /** + * The `~` character. + */ + Tilde = 126, +} diff --git a/src/common/string/filter/filter.ts b/src/common/string/filter/filter.ts new file mode 100644 index 0000000000..e7c0103263 --- /dev/null +++ b/src/common/string/filter/filter.ts @@ -0,0 +1,551 @@ +/* eslint-disable no-console */ +// MIT License + +// Copyright (c) 2015 - present Microsoft Corporation + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { CharCode } from "./char-code"; + +const _debug = false; + +export interface Match { + start: number; + end: number; +} + +const _maxLen = 128; + +function initTable() { + const table: number[][] = []; + const row: number[] = []; + for (let i = 0; i <= _maxLen; i++) { + row[i] = 0; + } + for (let i = 0; i <= _maxLen; i++) { + table.push(row.slice(0)); + } + return table; +} + +function isSeparatorAtPos(value: string, index: number): boolean { + if (index < 0 || index >= value.length) { + return false; + } + const code = value.codePointAt(index); + switch (code) { + case CharCode.Underline: + case CharCode.Dash: + case CharCode.Period: + case CharCode.Space: + case CharCode.Slash: + case CharCode.Backslash: + case CharCode.SingleQuote: + case CharCode.DoubleQuote: + case CharCode.Colon: + case CharCode.DollarSign: + case CharCode.LessThan: + case CharCode.OpenParen: + case CharCode.OpenSquareBracket: + return true; + case undefined: + return false; + default: + if (isEmojiImprecise(code)) { + return true; + } + return false; + } +} + +function isWhitespaceAtPos(value: string, index: number): boolean { + if (index < 0 || index >= value.length) { + return false; + } + const code = value.charCodeAt(index); + switch (code) { + case CharCode.Space: + case CharCode.Tab: + return true; + default: + return false; + } +} + +function isUpperCaseAtPos(pos: number, word: string, wordLow: string): boolean { + return word[pos] !== wordLow[pos]; +} + +export function isPatternInWord( + patternLow: string, + patternPos: number, + patternLen: number, + wordLow: string, + wordPos: number, + wordLen: number, + fillMinWordPosArr = false +): boolean { + while (patternPos < patternLen && wordPos < wordLen) { + if (patternLow[patternPos] === wordLow[wordPos]) { + if (fillMinWordPosArr) { + // Remember the min word position for each pattern position + _minWordMatchPos[patternPos] = wordPos; + } + patternPos += 1; + } + wordPos += 1; + } + return patternPos === patternLen; // pattern must be exhausted +} + +enum Arrow { + Diag = 1, + Left = 2, + LeftLeft = 3, +} + +/** + * An array representing a fuzzy match. + * + * 0. the score + * 1. the offset at which matching started + * 2. `` + * 3. `` + * 4. `` etc + */ +// export type FuzzyScore = [score: number, wordStart: number, ...matches: number[]];// [number, number, number]; +export type FuzzyScore = Array; + +export function fuzzyScore( + pattern: string, + patternLow: string, + patternStart: number, + word: string, + wordLow: string, + wordStart: number, + firstMatchCanBeWeak: boolean +): FuzzyScore | undefined { + const patternLen = pattern.length > _maxLen ? _maxLen : pattern.length; + const wordLen = word.length > _maxLen ? _maxLen : word.length; + + if ( + patternStart >= patternLen || + wordStart >= wordLen || + patternLen - patternStart > wordLen - wordStart + ) { + return undefined; + } + + // Run a simple check if the characters of pattern occur + // (in order) at all in word. If that isn't the case we + // stop because no match will be possible + if ( + !isPatternInWord( + patternLow, + patternStart, + patternLen, + wordLow, + wordStart, + wordLen, + true + ) + ) { + return undefined; + } + + // Find the max matching word position for each pattern position + // NOTE: the min matching word position was filled in above, in the `isPatternInWord` call + _fillInMaxWordMatchPos( + patternLen, + wordLen, + patternStart, + wordStart, + patternLow, + wordLow + ); + + let row: number; + let column = 1; + let patternPos: number; + let wordPos: number; + + const hasStrongFirstMatch = [false]; + + // There will be a match, fill in tables + for ( + row = 1, patternPos = patternStart; + patternPos < patternLen; + row++, patternPos++ + ) { + // Reduce search space to possible matching word positions and to possible access from next row + const minWordMatchPos = _minWordMatchPos[patternPos]; + const maxWordMatchPos = _maxWordMatchPos[patternPos]; + const nextMaxWordMatchPos = + patternPos + 1 < patternLen ? _maxWordMatchPos[patternPos + 1] : wordLen; + + for ( + column = minWordMatchPos - wordStart + 1, wordPos = minWordMatchPos; + wordPos < nextMaxWordMatchPos; + column++, wordPos++ + ) { + let score = Number.MIN_SAFE_INTEGER; + let canComeDiag = false; + + if (wordPos <= maxWordMatchPos) { + score = _doScore( + pattern, + patternLow, + patternPos, + patternStart, + word, + wordLow, + wordPos, + wordLen, + wordStart, + _diag[row - 1][column - 1] === 0, + hasStrongFirstMatch + ); + } + + let diagScore = 0; + if (score !== Number.MAX_SAFE_INTEGER) { + canComeDiag = true; + diagScore = score + _table[row - 1][column - 1]; + } + + const canComeLeft = wordPos > minWordMatchPos; + const leftScore = canComeLeft + ? _table[row][column - 1] + (_diag[row][column - 1] > 0 ? -5 : 0) + : 0; // penalty for a gap start + + const canComeLeftLeft = + wordPos > minWordMatchPos + 1 && _diag[row][column - 1] > 0; + const leftLeftScore = canComeLeftLeft + ? _table[row][column - 2] + (_diag[row][column - 2] > 0 ? -5 : 0) + : 0; // penalty for a gap start + + if ( + canComeLeftLeft && + (!canComeLeft || leftLeftScore >= leftScore) && + (!canComeDiag || leftLeftScore >= diagScore) + ) { + // always prefer choosing left left to jump over a diagonal because that means a match is earlier in the word + _table[row][column] = leftLeftScore; + _arrows[row][column] = Arrow.LeftLeft; + _diag[row][column] = 0; + } else if (canComeLeft && (!canComeDiag || leftScore >= diagScore)) { + // always prefer choosing left since that means a match is earlier in the word + _table[row][column] = leftScore; + _arrows[row][column] = Arrow.Left; + _diag[row][column] = 0; + } else if (canComeDiag) { + _table[row][column] = diagScore; + _arrows[row][column] = Arrow.Diag; + _diag[row][column] = _diag[row - 1][column - 1] + 1; + } else { + throw new Error(`not possible`); + } + } + } + + if (_debug) { + printTables(pattern, patternStart, word, wordStart); + } + + if (!hasStrongFirstMatch[0] && !firstMatchCanBeWeak) { + return undefined; + } + + row--; + column--; + + const result: FuzzyScore = [_table[row][column], wordStart]; + + let backwardsDiagLength = 0; + let maxMatchColumn = 0; + + while (row >= 1) { + // Find the column where we go diagonally up + let diagColumn = column; + do { + const arrow = _arrows[row][diagColumn]; + if (arrow === Arrow.LeftLeft) { + diagColumn -= 2; + } else if (arrow === Arrow.Left) { + diagColumn -= 1; + } else { + // found the diagonal + break; + } + } while (diagColumn >= 1); + + // Overturn the "forwards" decision if keeping the "backwards" diagonal would give a better match + if ( + backwardsDiagLength > 1 && // only if we would have a contiguous match of 3 characters + patternLow[patternStart + row - 1] === wordLow[wordStart + column - 1] && // only if we can do a contiguous match diagonally + !isUpperCaseAtPos(diagColumn + wordStart - 1, word, wordLow) && // only if the forwards chose diagonal is not an uppercase + backwardsDiagLength + 1 > _diag[row][diagColumn] // only if our contiguous match would be longer than the "forwards" contiguous match + ) { + diagColumn = column; + } + + if (diagColumn === column) { + // this is a contiguous match + backwardsDiagLength++; + } else { + backwardsDiagLength = 1; + } + + if (!maxMatchColumn) { + // remember the last matched column + maxMatchColumn = diagColumn; + } + + row--; + column = diagColumn - 1; + result.push(column); + } + + if (wordLen === patternLen) { + // the word matches the pattern with all characters! + // giving the score a total match boost (to come up ahead other words) + result[0] += 2; + } + + // Add 1 penalty for each skipped character in the word + const skippedCharsCount = maxMatchColumn - patternLen; + result[0] -= skippedCharsCount; + + return result; +} + +function _doScore( + pattern: string, + patternLow: string, + patternPos: number, + patternStart: number, + word: string, + wordLow: string, + wordPos: number, + wordLen: number, + wordStart: number, + newMatchStart: boolean, + outFirstMatchStrong: boolean[] +): number { + if (patternLow[patternPos] !== wordLow[wordPos]) { + return Number.MIN_SAFE_INTEGER; + } + + let score = 1; + let isGapLocation = false; + if (wordPos === patternPos - patternStart) { + // common prefix: `foobar <-> foobaz` + // ^^^^^ + score = pattern[patternPos] === word[wordPos] ? 7 : 5; + } else if ( + isUpperCaseAtPos(wordPos, word, wordLow) && + (wordPos === 0 || !isUpperCaseAtPos(wordPos - 1, word, wordLow)) + ) { + // hitting upper-case: `foo <-> forOthers` + // ^^ ^ + score = pattern[patternPos] === word[wordPos] ? 7 : 5; + isGapLocation = true; + } else if ( + isSeparatorAtPos(wordLow, wordPos) && + (wordPos === 0 || !isSeparatorAtPos(wordLow, wordPos - 1)) + ) { + // hitting a separator: `. <-> foo.bar` + // ^ + score = 5; + } else if ( + isSeparatorAtPos(wordLow, wordPos - 1) || + isWhitespaceAtPos(wordLow, wordPos - 1) + ) { + // post separator: `foo <-> bar_foo` + // ^^^ + score = 5; + isGapLocation = true; + } + + if (score > 1 && patternPos === patternStart) { + outFirstMatchStrong[0] = true; + } + + if (!isGapLocation) { + isGapLocation = + isUpperCaseAtPos(wordPos, word, wordLow) || + isSeparatorAtPos(wordLow, wordPos - 1) || + isWhitespaceAtPos(wordLow, wordPos - 1); + } + + // + if (patternPos === patternStart) { + // first character in pattern + if (wordPos > wordStart) { + // the first pattern character would match a word character that is not at the word start + // so introduce a penalty to account for the gap preceding this match + score -= isGapLocation ? 3 : 5; + } + } else if (newMatchStart) { + // this would be the beginning of a new match (i.e. there would be a gap before this location) + score += isGapLocation ? 2 : 0; + } else { + // this is part of a contiguous match, so give it a slight bonus, but do so only if it would not be a prefered gap location + score += isGapLocation ? 0 : 1; + } + + if (wordPos + 1 === wordLen) { + // we always penalize gaps, but this gives unfair advantages to a match that would match the last character in the word + // so pretend there is a gap after the last character in the word to normalize things + score -= isGapLocation ? 3 : 5; + } + + return score; +} + +function printTable( + table: number[][], + pattern: string, + patternLen: number, + word: string, + wordLen: number +): string { + function pad(s: string, n: number, _pad = " ") { + while (s.length < n) { + s = _pad + s; + } + return s; + } + let ret = ` | |${word + .split("") + .map((c) => pad(c, 3)) + .join("|")}\n`; + + for (let i = 0; i <= patternLen; i++) { + if (i === 0) { + ret += " |"; + } else { + ret += `${pattern[i - 1]}|`; + } + ret += + table[i] + .slice(0, wordLen + 1) + .map((n) => pad(n.toString(), 3)) + .join("|") + "\n"; + } + return ret; +} + +function printTables( + pattern: string, + patternStart: number, + word: string, + wordStart: number +): void { + pattern = pattern.substr(patternStart); + word = word.substr(wordStart); + console.log(printTable(_table, pattern, pattern.length, word, word.length)); + console.log(printTable(_arrows, pattern, pattern.length, word, word.length)); + console.log(printTable(_diag, pattern, pattern.length, word, word.length)); +} + +const _minWordMatchPos = initArr(2 * _maxLen); // min word position for a certain pattern position +const _maxWordMatchPos = initArr(2 * _maxLen); // max word position for a certain pattern position +const _diag = initTable(); // the length of a contiguous diagonal match +const _table = initTable(); +const _arrows = initTable(); + +function initArr(maxLen: number) { + const row: number[] = []; + for (let i = 0; i <= maxLen; i++) { + row[i] = 0; + } + return row; +} + +function _fillInMaxWordMatchPos( + patternLen: number, + wordLen: number, + patternStart: number, + wordStart: number, + patternLow: string, + wordLow: string +) { + let patternPos = patternLen - 1; + let wordPos = wordLen - 1; + while (patternPos >= patternStart && wordPos >= wordStart) { + if (patternLow[patternPos] === wordLow[wordPos]) { + _maxWordMatchPos[patternPos] = wordPos; + patternPos--; + } + wordPos--; + } +} + +export interface FuzzyScorer { + ( + pattern: string, + lowPattern: string, + patternPos: number, + word: string, + lowWord: string, + wordPos: number, + firstMatchCanBeWeak: boolean + ): FuzzyScore | undefined; +} + +export function createMatches(score: undefined | FuzzyScore): Match[] { + if (typeof score === "undefined") { + return []; + } + const res: Match[] = []; + const wordPos = score[1]; + for (let i = score.length - 1; i > 1; i--) { + const pos = score[i] + wordPos; + const last = res[res.length - 1]; + if (last && last.end === pos) { + last.end = pos + 1; + } else { + res.push({ start: pos, end: pos + 1 }); + } + } + return res; +} + +/** + * A fast function (therefore imprecise) to check if code points are emojis. + * Generated using https://github.com/alexdima/unicode-utils/blob/master/generate-emoji-test.js + */ +export function isEmojiImprecise(x: number): boolean { + return ( + (x >= 0x1f1e6 && x <= 0x1f1ff) || + x === 8986 || + x === 8987 || + x === 9200 || + x === 9203 || + (x >= 9728 && x <= 10175) || + x === 11088 || + x === 11093 || + (x >= 127744 && x <= 128591) || + (x >= 128640 && x <= 128764) || + (x >= 128992 && x <= 129003) || + (x >= 129280 && x <= 129535) || + (x >= 129648 && x <= 129750) + ); +} diff --git a/src/common/string/filter/sequence-matching.ts b/src/common/string/filter/sequence-matching.ts index d0ec1f1e52..d502cec350 100644 --- a/src/common/string/filter/sequence-matching.ts +++ b/src/common/string/filter/sequence-matching.ts @@ -1,4 +1,52 @@ -import fuzzysort from "fuzzysort"; +import { fuzzyScore } from "./filter"; + +/** + * Determine whether a sequence of letters exists in another string, + * in that order, allowing for skipping. Ex: "chdr" exists in "chandelier") + * + * @param {string} filter - Sequence of letters to check for + * @param {ScorableTextItem} item - Item against whose strings will be checked + * + * @return {number} Score representing how well the word matches the filter. Return of 0 means no match. + */ + +export const fuzzySequentialMatch = ( + filter: string, + item: ScorableTextItem +) => { + let topScore = Number.NEGATIVE_INFINITY; + + for (const word of item.strings) { + const scores = fuzzyScore( + filter, + filter.toLowerCase(), + 0, + word, + word.toLowerCase(), + 0, + true + ); + + if (!scores) { + continue; + } + + // The VS Code implementation of filter returns a 0 for a weak match. + // But if .filter() sees a "0", it considers that a failed match and will remove it. + // So, we set score to 1 in these cases so the match will be included, and mostly respect correct ordering. + const score = scores[0] === 0 ? 1 : scores[0]; + + if (score > topScore) { + topScore = score; + } + } + + if (topScore === Number.NEGATIVE_INFINITY) { + return undefined; + } + + return topScore; +}; /** * An interface that objects must extend in order to use the fuzzy sequence matcher @@ -18,48 +66,18 @@ export interface ScorableTextItem { strings: string[]; } -export type FuzzyFilterSort = ( +type FuzzyFilterSort = ( filter: string, items: T[] ) => T[]; -export function fuzzyMatcher(search: string | null): (string) => boolean { - const scorer = fuzzyScorer(search); - return (value: string) => scorer([value]) !== Number.NEGATIVE_INFINITY; -} - -export function fuzzyScorer( - search: string | null -): (values: string[]) => number { - const searchTerms = (search || "").match(/("[^"]+"|[^"\s]+)/g); - if (!searchTerms) { - return () => 0; - } - return (values) => - searchTerms - .map((term) => { - const resultsForTerm = fuzzysort.go(term, values, { - allowTypo: true, - }); - if (resultsForTerm.length > 0) { - return Math.max(...resultsForTerm.map((result) => result.score)); - } - return Number.NEGATIVE_INFINITY; - }) - .reduce((partial, current) => partial + current, 0); -} - -export const fuzzySortFilterSort: FuzzyFilterSort = (filter, items) => { - const scorer = fuzzyScorer(filter); - return items +export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => + items .map((item) => { - item.score = scorer(item.strings); + item.score = fuzzySequentialMatch(filter, item); return item; }) - .filter((item) => item.score !== undefined && item.score > -100000) + .filter((item) => item.score !== undefined) .sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) => scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0 ); -}; - -export const defaultFuzzyFilterSort = fuzzySortFilterSort; diff --git a/src/components/data-table/ha-data-table.ts b/src/components/data-table/ha-data-table.ts index d5741cafeb..a9fa5ba391 100644 --- a/src/components/data-table/ha-data-table.ts +++ b/src/components/data-table/ha-data-table.ts @@ -269,8 +269,8 @@ export class HaDataTable extends LitElement { @change=${this._handleHeaderRowCheckboxClick} .indeterminate=${this._checkedRows.length && this._checkedRows.length !== this._checkableRowsCount} - .checked=${this._checkedRows.length === - this._checkableRowsCount} + .checked=${this._checkedRows.length && + this._checkedRows.length === this._checkableRowsCount} > diff --git a/src/components/data-table/sort_filter_worker.ts b/src/components/data-table/sort_filter_worker.ts index c2b252f859..286bae3014 100644 --- a/src/components/data-table/sort_filter_worker.ts +++ b/src/components/data-table/sort_filter_worker.ts @@ -7,26 +7,25 @@ import type { SortableColumnContainer, SortingDirection, } from "./ha-data-table"; -import { fuzzyMatcher } from "../../common/string/filter/sequence-matching"; const filterData = ( data: DataTableRowData[], columns: SortableColumnContainer, filter: string ) => { - const matcher = fuzzyMatcher(filter); + filter = filter.toUpperCase(); return data.filter((row) => Object.entries(columns).some((columnEntry) => { const [key, column] = columnEntry; if (column.filterable) { if ( - matcher( - String( - column.filterKey - ? row[column.valueColumn || key][column.filterKey] - : row[column.valueColumn || key] - ) + String( + column.filterKey + ? row[column.valueColumn || key][column.filterKey] + : row[column.valueColumn || key] ) + .toUpperCase() + .includes(filter) ) { return true; } diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index ad921650ce..7f9c8d3d80 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -15,7 +15,6 @@ import type { HaComboBox } from "../ha-combo-box"; import "../ha-icon-button"; import "../ha-svg-icon"; import "./state-badge"; -import { defaultFuzzyFilterSort } from "../../common/string/filter/sequence-matching"; interface HassEntityWithCachedName extends HassEntity { friendly_name: string; @@ -337,18 +336,11 @@ export class HaEntityPicker extends LitElement { } private _filterChanged(ev: CustomEvent): void { - const filterString = ev.detail.value; - - const sortableEntityStates = this._states.map((entityState) => ({ - strings: [entityState.entity_id, computeStateName(entityState)], - entityState: entityState, - })); - const sortedEntityStates = defaultFuzzyFilterSort( - filterString, - sortableEntityStates - ); - (this.comboBox as any).filteredItems = sortedEntityStates.map( - (sortableItem) => sortableItem.entityState + const filterString = ev.detail.value.toLowerCase(); + (this.comboBox as any).filteredItems = this._states.filter( + (entityState) => + entityState.entity_id.toLowerCase().includes(filterString) || + computeStateName(entityState).toLowerCase().includes(filterString) ); } diff --git a/src/components/ha-button-menu.ts b/src/components/ha-button-menu.ts index bf0d1d3487..d3a93cac68 100644 --- a/src/components/ha-button-menu.ts +++ b/src/components/ha-button-menu.ts @@ -50,6 +50,21 @@ export class HaButtonMenu extends LitElement { `; } + protected firstUpdated(changedProps): void { + super.firstUpdated(changedProps); + + if (document.dir === "rtl") { + this.updateComplete.then(() => { + this.querySelectorAll("mwc-list-item").forEach((item) => { + const style = document.createElement("style"); + style.innerHTML = + "span.material-icons:first-of-type { margin-left: var(--mdc-list-item-graphic-margin, 32px) !important; margin-right: 0px !important;}"; + item!.shadowRoot!.appendChild(style); + }); + }); + } + } + private _handleClick(): void { if (this.disabled) { return; diff --git a/src/components/ha-clickable-list-item.ts b/src/components/ha-clickable-list-item.ts index 793b9d1795..032a2be2ed 100644 --- a/src/components/ha-clickable-list-item.ts +++ b/src/components/ha-clickable-list-item.ts @@ -47,10 +47,6 @@ export class HaClickableListItem extends ListItemBase { padding-left: 0px; padding-right: 0px; } - :host([rtl]) span { - margin-left: var(--mdc-list-item-graphic-margin, 20px) !important; - margin-right: 0px !important; - } :host([graphic="avatar"]:not([twoLine])), :host([graphic="icon"]:not([twoLine])) { height: 48px; @@ -64,6 +60,16 @@ export class HaClickableListItem extends ListItemBase { padding-right: var(--mdc-list-side-padding, 20px); overflow: hidden; } + :host-context([style*="direction: rtl;"]) + span.material-icons:first-of-type { + margin-left: var(--mdc-list-item-graphic-margin, 16px) !important; + margin-right: 0px !important; + } + :host-context([style*="direction: rtl;"]) + span.material-icons:last-of-type { + margin-left: 0px !important; + margin-right: auto !important; + } `, ]; } diff --git a/src/data/application_credential.ts b/src/data/application_credential.ts new file mode 100644 index 0000000000..0062301597 --- /dev/null +++ b/src/data/application_credential.ts @@ -0,0 +1,44 @@ +import { HomeAssistant } from "../types"; + +export interface ApplicationCredentialsConfig { + domains: string[]; +} + +export interface ApplicationCredential { + id: string; + domain: string; + client_id: string; + client_secret: string; +} + +export const fetchApplicationCredentialsConfig = async (hass: HomeAssistant) => + hass.callWS({ + type: "application_credentials/config", + }); + +export const fetchApplicationCredentials = async (hass: HomeAssistant) => + hass.callWS({ + type: "application_credentials/list", + }); + +export const createApplicationCredential = async ( + hass: HomeAssistant, + domain: string, + clientId: string, + clientSecret: string +) => + hass.callWS({ + type: "application_credentials/create", + domain, + client_id: clientId, + client_secret: clientSecret, + }); + +export const deleteApplicationCredential = async ( + hass: HomeAssistant, + applicationCredentialsId: string +) => + hass.callWS({ + type: "application_credentials/delete", + application_credentials_id: applicationCredentialsId, + }); diff --git a/src/dialogs/generic/dialog-box.ts b/src/dialogs/generic/dialog-box.ts index 1e8aae7406..4cdb0ef6c9 100644 --- a/src/dialogs/generic/dialog-box.ts +++ b/src/dialogs/generic/dialog-box.ts @@ -1,12 +1,13 @@ import "@material/mwc-button/mwc-button"; import { mdiAlertOutline } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; -import { customElement, property, state } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; import { fireEvent } from "../../common/dom/fire_event"; import "../../components/ha-dialog"; import "../../components/ha-svg-icon"; import "../../components/ha-switch"; -import "../../components/ha-textfield"; +import { HaTextField } from "../../components/ha-textfield"; import { haStyleDialog } from "../../resources/styles"; import { HomeAssistant } from "../../types"; import { DialogBoxParams } from "./show-dialog-box"; @@ -17,13 +18,10 @@ class DialogBox extends LitElement { @state() private _params?: DialogBoxParams; - @state() private _value?: string; + @query("ha-textfield") private _textField?: HaTextField; public async showDialog(params: DialogBoxParams): Promise { this._params = params; - if (params.prompt) { - this._value = params.defaultValue; - } } public closeDialog(): boolean { @@ -75,9 +73,7 @@ class DialogBox extends LitElement { ? html` - defaultFuzzyFilterSort(filter.trimLeft(), items) + fuzzyFilterSort(filter.trimLeft(), items) ); static get styles() { diff --git a/src/panels/config/application_credentials/dialog-add-application-credential.ts b/src/panels/config/application_credentials/dialog-add-application-credential.ts new file mode 100644 index 0000000000..f60ea765b1 --- /dev/null +++ b/src/panels/config/application_credentials/dialog-add-application-credential.ts @@ -0,0 +1,224 @@ +import "@material/mwc-button"; +import "@material/mwc-list/mwc-list-item"; +import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { ComboBoxLitRenderer } from "lit-vaadin-helpers"; +import { fireEvent } from "../../../common/dom/fire_event"; +import "../../../components/ha-circular-progress"; +import "../../../components/ha-combo-box"; +import { createCloseHeading } from "../../../components/ha-dialog"; +import "../../../components/ha-textfield"; +import { + fetchApplicationCredentialsConfig, + createApplicationCredential, + ApplicationCredential, +} from "../../../data/application_credential"; +import { domainToName } from "../../../data/integration"; +import { PolymerChangedEvent } from "../../../polymer-types"; +import { haStyleDialog } from "../../../resources/styles"; +import { HomeAssistant } from "../../../types"; +import { AddApplicationCredentialDialogParams } from "./show-dialog-add-application-credential"; + +interface Domain { + id: string; + name: string; +} + +const rowRenderer: ComboBoxLitRenderer = (item) => html` + ${item.name} +`; + +@customElement("dialog-add-application-credential") +export class DialogAddApplicationCredential extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _loading = false; + + // Error message when can't talk to server etc + @state() private _error?: string; + + @state() private _params?: AddApplicationCredentialDialogParams; + + @state() private _domain?: string; + + @state() private _clientId?: string; + + @state() private _clientSecret?: string; + + @state() private _domains?: Domain[]; + + public showDialog(params: AddApplicationCredentialDialogParams) { + this._params = params; + this._domain = ""; + this._clientId = ""; + this._clientSecret = ""; + this._error = undefined; + this._loading = false; + this._fetchConfig(); + } + + private async _fetchConfig() { + const config = await fetchApplicationCredentialsConfig(this.hass); + this._domains = config.domains.map((domain) => ({ + id: domain, + name: domainToName(this.hass.localize, domain), + })); + } + + protected render(): TemplateResult { + if (!this._params || !this._domains) { + return html``; + } + return html` + +
+ ${this._error ? html`
${this._error}
` : ""} + + + +
+ ${this._loading + ? html` +
+ +
+ ` + : html` + + ${this.hass.localize( + "ui.panel.config.application_credentials.editor.create" + )} + + `} +
+ `; + } + + public closeDialog() { + this._params = undefined; + this._domains = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + private async _handleDomainPicked(ev: PolymerChangedEvent) { + const target = ev.target as any; + if (target.selectedItem) { + this._domain = target.selectedItem.id; + } + } + + private _handleValueChanged(ev: CustomEvent) { + this._error = undefined; + const name = (ev.target as any).name; + const value = (ev.target as any).value; + this[`_${name}`] = value; + } + + private async _createApplicationCredential(ev) { + ev.preventDefault(); + if (!this._domain || !this._clientId || !this._clientSecret) { + return; + } + + this._loading = true; + this._error = ""; + + let applicationCredential: ApplicationCredential; + try { + applicationCredential = await createApplicationCredential( + this.hass, + this._domain, + this._clientId, + this._clientSecret + ); + } catch (err: any) { + this._loading = false; + this._error = err.message; + return; + } + this._params!.applicationCredentialAddedCallback(applicationCredential); + this.closeDialog(); + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-dialog { + --mdc-dialog-max-width: 500px; + --dialog-z-index: 10; + } + .row { + display: flex; + padding: 8px 0; + } + ha-combo-box { + display: block; + margin-bottom: 24px; + } + ha-textfield { + display: block; + margin-bottom: 24px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-add-application-credential": DialogAddApplicationCredential; + } +} diff --git a/src/panels/config/application_credentials/ha-config-application-credentials.ts b/src/panels/config/application_credentials/ha-config-application-credentials.ts new file mode 100644 index 0000000000..fa55f07681 --- /dev/null +++ b/src/panels/config/application_credentials/ha-config-application-credentials.ts @@ -0,0 +1,259 @@ +import { mdiDelete, mdiPlus } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; +import type { HASSDomEvent } from "../../../common/dom/fire_event"; +import { LocalizeFunc } from "../../../common/translations/localize"; +import { + DataTableColumnContainer, + SelectionChangedEvent, +} from "../../../components/data-table/ha-data-table"; +import "../../../components/data-table/ha-data-table-icon"; +import "../../../components/ha-fab"; +import "../../../components/ha-help-tooltip"; +import "../../../components/ha-svg-icon"; +import { + ApplicationCredential, + deleteApplicationCredential, + fetchApplicationCredentials, +} from "../../../data/application_credential"; +import { domainToName } from "../../../data/integration"; +import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; +import "../../../layouts/hass-tabs-subpage-data-table"; +import type { HaTabsSubpageDataTable } from "../../../layouts/hass-tabs-subpage-data-table"; +import { HomeAssistant, Route } from "../../../types"; +import { configSections } from "../ha-panel-config"; +import { showAddApplicationCredentialDialog } from "./show-dialog-add-application-credential"; + +@customElement("ha-config-application-credentials") +export class HaConfigApplicationCredentials extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() public _applicationCredentials: ApplicationCredential[] = []; + + @property() public isWide!: boolean; + + @property() public narrow!: boolean; + + @property() public route!: Route; + + @state() private _selected: string[] = []; + + @query("hass-tabs-subpage-data-table", true) + private _dataTable!: HaTabsSubpageDataTable; + + private _columns = memoizeOne( + (narrow: boolean, localize: LocalizeFunc): DataTableColumnContainer => { + const columns: DataTableColumnContainer = { + clientId: { + title: localize( + "ui.panel.config.application_credentials.picker.headers.client_id" + ), + width: "25%", + direction: "asc", + grows: true, + template: (_, entry: ApplicationCredential) => + html`${entry.client_id}`, + }, + application: { + title: localize( + "ui.panel.config.application_credentials.picker.headers.application" + ), + sortable: true, + width: "20%", + direction: "asc", + hidden: narrow, + template: (_, entry) => html`${domainToName(localize, entry.domain)}`, + }, + }; + + return columns; + } + ); + + protected firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + this._loadTranslations(); + this._fetchApplicationCredentials(); + } + + protected render() { + return html` + + ${this._selected.length + ? html` +
+

+ ${this.hass.localize( + "ui.panel.config.application_credentials.picker.selected", + "number", + this._selected.length + )} +

+
+ ${!this.narrow + ? html` + ${this.hass.localize( + "ui.panel.config.application_credentials.picker.remove_selected.button" + )} + ` + : html` + + + + `} +
+
+ ` + : html``} + + + +
+ `; + } + + private _handleSelectionChanged( + ev: HASSDomEvent + ): void { + this._selected = ev.detail.value; + } + + private _removeSelected() { + showConfirmationDialog(this, { + title: this.hass.localize( + `ui.panel.config.application_credentials.picker.remove_selected.confirm_title`, + "number", + this._selected.length + ), + text: this.hass.localize( + "ui.panel.config.application_credentials.picker.remove_selected.confirm_text" + ), + confirmText: this.hass.localize("ui.common.remove"), + dismissText: this.hass.localize("ui.common.cancel"), + confirm: async () => { + await Promise.all( + this._selected.map(async (applicationCredential) => { + await deleteApplicationCredential(this.hass, applicationCredential); + }) + ); + this._dataTable.clearSelection(); + this._fetchApplicationCredentials(); + }, + }); + } + + private async _loadTranslations() { + await this.hass.loadBackendTranslation("title", undefined, true); + } + + private async _fetchApplicationCredentials() { + this._applicationCredentials = await fetchApplicationCredentials(this.hass); + } + + private _addApplicationCredential() { + showAddApplicationCredentialDialog(this, { + applicationCredentialAddedCallback: async ( + applicationCredential: ApplicationCredential + ) => { + if (applicationCredential) { + this._applicationCredentials = [ + ...this._applicationCredentials, + applicationCredential, + ]; + } + }, + }); + } + + static get styles(): CSSResultGroup { + return css` + .table-header { + display: flex; + justify-content: space-between; + align-items: center; + height: 56px; + background-color: var(--mdc-text-field-fill-color, whitesmoke); + border-bottom: 1px solid + var(--mdc-text-field-idle-line-color, rgba(0, 0, 0, 0.42)); + box-sizing: border-box; + } + .header-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--secondary-text-color); + position: relative; + top: -4px; + } + .selected-txt { + font-weight: bold; + padding-left: 16px; + } + .table-header .selected-txt { + margin-top: 20px; + } + .header-toolbar .selected-txt { + font-size: 16px; + } + .header-toolbar .header-btns { + margin-right: -12px; + } + .header-btns { + display: flex; + } + .header-btns > mwc-button, + .header-btns > ha-icon-button { + margin: 8px; + } + ha-button-menu { + margin-left: 8px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-config-application-credentials": HaConfigApplicationCredentials; + } +} diff --git a/src/panels/config/application_credentials/show-dialog-add-application-credential.ts b/src/panels/config/application_credentials/show-dialog-add-application-credential.ts new file mode 100644 index 0000000000..1b779f60b3 --- /dev/null +++ b/src/panels/config/application_credentials/show-dialog-add-application-credential.ts @@ -0,0 +1,22 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { ApplicationCredential } from "../../../data/application_credential"; + +export interface AddApplicationCredentialDialogParams { + applicationCredentialAddedCallback: ( + applicationCredential: ApplicationCredential + ) => void; +} + +export const loadAddApplicationCredentialDialog = () => + import("./dialog-add-application-credential"); + +export const showAddApplicationCredentialDialog = ( + element: HTMLElement, + dialogParams: AddApplicationCredentialDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-add-application-credential", + dialogImport: loadAddApplicationCredentialDialog, + dialogParams, + }); +}; diff --git a/src/panels/config/cloud/account/cloud-remote-pref.ts b/src/panels/config/cloud/account/cloud-remote-pref.ts index 8db6ea9a39..3703cd85d7 100644 --- a/src/panels/config/cloud/account/cloud-remote-pref.ts +++ b/src/panels/config/cloud/account/cloud-remote-pref.ts @@ -1,9 +1,11 @@ import "@material/mwc-button"; +import { mdiContentCopy } from "@mdi/js"; import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../../../common/dom/fire_event"; -import "../../../../components/ha-card"; +import { copyToClipboard } from "../../../../common/util/copy-clipboard"; import "../../../../components/ha-alert"; +import "../../../../components/ha-card"; import "../../../../components/ha-switch"; // eslint-disable-next-line import type { HaSwitch } from "../../../../components/ha-switch"; @@ -13,6 +15,7 @@ import { disconnectCloudRemote, } from "../../../../data/cloud"; import type { HomeAssistant } from "../../../../types"; +import { showToast } from "../../../../util/toast"; import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate"; @customElement("cloud-remote-pref") @@ -48,6 +51,11 @@ export class CloudRemotePref extends LitElement { `; } + const urlParts = remote_domain!.split("."); + const hiddenURL = `https://${urlParts[0].substring(0, 5)}***.${ + urlParts[1] + }.${urlParts[2]}.${urlParts[3]}`; + return html` - https://${remote_domain}. +
{ + const url = ev.currentTarget.url; + await copyToClipboard(url); + showToast(this, { + message: this.hass.localize("ui.common.copied_clipboard"), + }); + } + static get styles(): CSSResultGroup { return css` .preparing { @@ -154,9 +175,6 @@ export class CloudRemotePref extends LitElement { font-weight: bold; margin-bottom: 1em; } - .warning ha-svg-icon { - color: var(--warning-color); - } .break-word { overflow-wrap: break-word; } @@ -178,6 +196,11 @@ export class CloudRemotePref extends LitElement { .spacer { flex-grow: 1; } + ha-svg-icon { + --mdc-icon-size: 18px; + color: var(--secondary-text-color); + cursor: pointer; + } `; } } diff --git a/src/panels/config/devices/ha-config-devices-dashboard.ts b/src/panels/config/devices/ha-config-devices-dashboard.ts index d7027a9512..460356a2bb 100644 --- a/src/panels/config/devices/ha-config-devices-dashboard.ts +++ b/src/panels/config/devices/ha-config-devices-dashboard.ts @@ -16,9 +16,9 @@ import { } from "../../../components/data-table/ha-data-table"; import "../../../components/entity/ha-battery-icon"; import "../../../components/ha-button-menu"; +import "../../../components/ha-check-list-item"; import "../../../components/ha-fab"; import "../../../components/ha-icon-button"; -import "../../../components/ha-check-list-item"; import { AreaRegistryEntry } from "../../../data/area_registry"; import { ConfigEntry } from "../../../data/config_entries"; import { @@ -36,6 +36,7 @@ import "../../../layouts/hass-tabs-subpage-data-table"; import { haStyle } from "../../../resources/styles"; import { HomeAssistant, Route } from "../../../types"; import { configSections } from "../ha-panel-config"; +import "../integrations/ha-integration-overflow-menu"; import { showZWaveJSAddNodeDialog } from "../integrations/integration-panels/zwave_js/show-dialog-zwave_js-add-node"; interface DeviceRowData extends DeviceRegistryEntry { @@ -408,6 +409,10 @@ export class HaConfigDeviceDashboard extends LitElement { (filteredConfigEntry.domain === "zha" || filteredConfigEntry.domain === "zwave_js")} > + ${!filteredConfigEntry ? "" : filteredConfigEntry.domain === "zwave_js" diff --git a/src/panels/config/entities/ha-config-entities.ts b/src/panels/config/entities/ha-config-entities.ts index 6fd3314830..8385f08758 100644 --- a/src/panels/config/entities/ha-config-entities.ts +++ b/src/panels/config/entities/ha-config-entities.ts @@ -61,6 +61,7 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant, Route } from "../../../types"; import { configSections } from "../ha-panel-config"; +import "../integrations/ha-integration-overflow-menu"; import { DialogEntityEditor } from "./dialog-entity-editor"; import { loadEntityEditorDialog, @@ -526,6 +527,10 @@ export class HaConfigEntities extends SubscribeMixin(LitElement) { id="entity_id" .hasFab=${includeZHAFab} > + ${this._selectedEntities.length ? html`
+ import("./application_credentials/ha-config-application-credentials"), + }, }, }; diff --git a/src/panels/config/helpers/dialog-helper-detail.ts b/src/panels/config/helpers/dialog-helper-detail.ts index a1e303c06b..a53427d4ee 100644 --- a/src/panels/config/helpers/dialog-helper-detail.ts +++ b/src/panels/config/helpers/dialog-helper-detail.ts @@ -6,8 +6,8 @@ import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { dynamicElement } from "../../../common/dom/dynamic-element-directive"; -import "../../../components/ha-dialog"; import "../../../components/ha-circular-progress"; +import "../../../components/ha-dialog"; import { getConfigFlowHandlers } from "../../../data/config_flow"; import { createCounter } from "../../../data/counter"; import { createInputBoolean } from "../../../data/input_boolean"; @@ -16,10 +16,12 @@ import { createInputDateTime } from "../../../data/input_datetime"; import { createInputNumber } from "../../../data/input_number"; import { createInputSelect } from "../../../data/input_select"; import { createInputText } from "../../../data/input_text"; +import { domainToName } from "../../../data/integration"; import { createTimer } from "../../../data/timer"; import { showConfigFlowDialog } from "../../../dialogs/config-flow/show-dialog-config-flow"; import { haStyleDialog } from "../../../resources/styles"; import { HomeAssistant } from "../../../types"; +import { brandsUrl } from "../../../util/brands-url"; import { Helper } from "./const"; import "./forms/ha-counter-form"; import "./forms/ha-input_boolean-form"; @@ -29,9 +31,7 @@ import "./forms/ha-input_number-form"; import "./forms/ha-input_select-form"; import "./forms/ha-input_text-form"; import "./forms/ha-timer-form"; -import { domainToName } from "../../../data/integration"; import type { ShowDialogHelperDetailParams } from "./show-dialog-helper-detail"; -import { brandsUrl } from "../../../util/brands-url"; const HELPERS = { input_boolean: createInputBoolean, @@ -187,13 +187,13 @@ export class DialogHelperDetail extends LitElement { escapeKeyAction .heading=${this._domain ? this.hass.localize( - "ui.panel.config.helpers.dialog.add_platform", + "ui.panel.config.helpers.dialog.create_platform", "platform", this.hass.localize( `ui.panel.config.helpers.types.${this._domain}` ) || this._domain ) - : this.hass.localize("ui.panel.config.helpers.dialog.add_helper")} + : this.hass.localize("ui.panel.config.helpers.dialog.create_helper")} > ${content} diff --git a/src/panels/config/helpers/ha-config-helpers.ts b/src/panels/config/helpers/ha-config-helpers.ts index fbaa0e1c2f..2cd3d23499 100644 --- a/src/panels/config/helpers/ha-config-helpers.ts +++ b/src/panels/config/helpers/ha-config-helpers.ts @@ -35,6 +35,7 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { HomeAssistant, Route } from "../../../types"; import { showEntityEditorDialog } from "../entities/show-dialog-entity-editor"; import { configSections } from "../ha-panel-config"; +import "../integrations/ha-integration-overflow-menu"; import { HELPER_DOMAINS } from "./const"; import { showHelperDetailDialog } from "./show-dialog-helper-detail"; @@ -210,10 +211,14 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) { "ui.panel.config.helpers.picker.no_helpers" )} > + - ${!this._showDisabled && this.narrow && disabledCount - ? html`${disabledCount}` - : ""} - - - - - ${this.hass.localize( - "ui.panel.config.integrations.ignore.show_ignored" - )} - - - ${this.hass.localize( - "ui.panel.config.integrations.disable.show_disabled" - )} - - -
`; + const filterMenu = html` +
+ + ${this.narrow + ? html` + + ` + : ""} +
+ `; return html`