mirror of
https://github.com/gethomepage/homepage.git
synced 2025-12-05 21:47:48 +01:00
Feature: fields highlighting (#5868)
This commit is contained in:
@@ -118,6 +118,47 @@ Each widget can optionally provide a list of which fields should be visible via
|
|||||||
key: apikeyapikeyapikeyapikeyapikey
|
key: apikeyapikeyapikeyapikeyapikey
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Block Highlighting
|
||||||
|
|
||||||
|
Widgets can tint their metric block text automatically based on rules defined alongside the service. Attach a `highlight` section to the widget configuration and map each block to one or more numeric or string rules using the field key (for example, `queued`, `lan_users`).
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- Sonarr:
|
||||||
|
icon: sonarr.png
|
||||||
|
href: http://sonarr.host.or.ip
|
||||||
|
widget:
|
||||||
|
type: sonarr
|
||||||
|
url: http://sonarr.host.or.ip
|
||||||
|
key: ${SONARR_API_KEY}
|
||||||
|
highlight:
|
||||||
|
queued:
|
||||||
|
numeric:
|
||||||
|
- level: danger
|
||||||
|
when: gte
|
||||||
|
value: 20
|
||||||
|
- level: warn
|
||||||
|
when: gte
|
||||||
|
value: 5
|
||||||
|
- level: good
|
||||||
|
when: eq
|
||||||
|
value: 0
|
||||||
|
status:
|
||||||
|
string:
|
||||||
|
- level: danger
|
||||||
|
when: regex
|
||||||
|
value: "(failed|import) pending"
|
||||||
|
- level: good
|
||||||
|
when: equals
|
||||||
|
value: "All good"
|
||||||
|
status_code:
|
||||||
|
string:
|
||||||
|
- level: warn
|
||||||
|
when: regex
|
||||||
|
value: "^5\\d{2}$"
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported numeric operators for the `when` property are `gt`, `gte`, `lt`, `lte`, `eq`, `ne`, `between`, and `outside`. String rules support `equals`, `includes`, `startsWith`, `endsWith`, and `regex`. Each rule can be inverted with `negate: true`, and string rules may pass `caseSensitive: true` or custom regex `flags`. The highlight engine does its best to coerce formatted values, but you will get the most reliable results when you pass plain numbers or strings into `<Block>`.
|
||||||
|
|
||||||
## Descriptions
|
## Descriptions
|
||||||
|
|
||||||
Services may have descriptions,
|
Services may have descriptions,
|
||||||
|
|||||||
@@ -109,6 +109,20 @@ color: slate
|
|||||||
|
|
||||||
Supported colors are: `slate`, `gray`, `zinc`, `neutral`, `stone`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`, `red`, `white`
|
Supported colors are: `slate`, `gray`, `zinc`, `neutral`, `stone`, `amber`, `yellow`, `lime`, `green`, `emerald`, `teal`, `cyan`, `sky`, `blue`, `indigo`, `violet`, `purple`, `fuchsia`, `pink`, `rose`, `red`, `white`
|
||||||
|
|
||||||
|
## Block Highlight Levels
|
||||||
|
|
||||||
|
You can override the default Tailwind classes applied when a widget highlight rule resolves to the `good`, `warn`, or `danger` level.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
blockHighlights:
|
||||||
|
levels:
|
||||||
|
good: "bg-emerald-500/40 text-emerald-950 dark:bg-emerald-900/60 dark:text-emerald-400"
|
||||||
|
warn: "bg-amber-300/30 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200"
|
||||||
|
danger: "bg-rose-700/45 text-rose-200 dark:bg-rose-950/70 dark:text-rose-400"
|
||||||
|
```
|
||||||
|
|
||||||
|
Any unspecified level falls back to the built-in defaults.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
You can configure service and bookmarks sections to be either "column" or "row" based layouts, like so:
|
You can configure service and bookmarks sections to be either "column" or "row" based layouts, like so:
|
||||||
|
|||||||
@@ -1,16 +1,47 @@
|
|||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useTranslation } from "next-i18next";
|
import { useTranslation } from "next-i18next";
|
||||||
|
import { useContext, useMemo } from "react";
|
||||||
|
|
||||||
export default function Block({ value, label }) {
|
import { BlockHighlightContext } from "./highlight-context";
|
||||||
|
|
||||||
|
import { evaluateHighlight, getHighlightClass } from "utils/highlights";
|
||||||
|
|
||||||
|
export default function Block({ value, label, field }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const highlightConfig = useContext(BlockHighlightContext);
|
||||||
|
|
||||||
|
const highlight = useMemo(() => {
|
||||||
|
if (!highlightConfig) return null;
|
||||||
|
const labels = Array.isArray(label) ? label : [label];
|
||||||
|
const candidates = [];
|
||||||
|
if (typeof field === "string") candidates.push(field);
|
||||||
|
for (const candidateLabel of labels) {
|
||||||
|
if (typeof candidateLabel === "string") candidates.push(candidateLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const result = evaluateHighlight(candidate, value, highlightConfig);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [field, label, value, highlightConfig]);
|
||||||
|
|
||||||
|
const highlightClass = useMemo(() => {
|
||||||
|
if (!highlight?.level) return undefined;
|
||||||
|
return getHighlightClass(highlight.level, highlightConfig);
|
||||||
|
}, [highlight, highlightConfig]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-col items-center justify-center text-center p-1",
|
"bg-theme-200/50 dark:bg-theme-900/20 rounded-sm m-1 flex-1 flex flex-col items-center justify-center text-center p-1",
|
||||||
value === undefined ? "animate-pulse" : "",
|
value === undefined ? "animate-pulse" : "",
|
||||||
|
highlightClass,
|
||||||
"service-block",
|
"service-block",
|
||||||
)}
|
)}
|
||||||
|
data-highlight-level={highlight?.level}
|
||||||
|
data-highlight-source={highlight?.source}
|
||||||
>
|
>
|
||||||
<div className="font-thin text-sm">{value === undefined || value === null ? "-" : value}</div>
|
<div className="font-thin text-sm">{value === undefined || value === null ? "-" : value}</div>
|
||||||
<div className="font-bold text-xs uppercase">{t(label)}</div>
|
<div className="font-bold text-xs uppercase">{t(label)}</div>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useContext } from "react";
|
import { useContext, useMemo } from "react";
|
||||||
import { SettingsContext } from "utils/contexts/settings";
|
import { SettingsContext } from "utils/contexts/settings";
|
||||||
|
|
||||||
import Error from "./error";
|
import Error from "./error";
|
||||||
|
import { BlockHighlightContext } from "./highlight-context";
|
||||||
|
|
||||||
|
import { buildHighlightConfig } from "utils/highlights";
|
||||||
|
|
||||||
const ALIASED_WIDGETS = {
|
const ALIASED_WIDGETS = {
|
||||||
pialert: "netalertx",
|
pialert: "netalertx",
|
||||||
@@ -11,6 +14,11 @@ const ALIASED_WIDGETS = {
|
|||||||
export default function Container({ error = false, children, service }) {
|
export default function Container({ error = false, children, service }) {
|
||||||
const { settings } = useContext(SettingsContext);
|
const { settings } = useContext(SettingsContext);
|
||||||
|
|
||||||
|
const highlightConfig = useMemo(
|
||||||
|
() => buildHighlightConfig(settings?.blockHighlights, service?.widget?.highlight, service?.widget?.type),
|
||||||
|
[settings?.blockHighlights, service?.widget?.highlight, service?.widget?.type],
|
||||||
|
);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
if (settings.hideErrors || service.widget.hide_errors) {
|
if (settings.hideErrors || service.widget.hide_errors) {
|
||||||
return null;
|
return null;
|
||||||
@@ -51,6 +59,11 @@ export default function Container({ error = false, children, service }) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const content = <div className="relative flex flex-row w-full service-container">{visibleChildren}</div>;
|
||||||
|
|
||||||
return <div className="relative flex flex-row w-full service-container">{visibleChildren}</div>;
|
if (!highlightConfig) {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <BlockHighlightContext.Provider value={highlightConfig}>{content}</BlockHighlightContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
3
src/components/services/widget/highlight-context.jsx
Normal file
3
src/components/services/widget/highlight-context.jsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
export const BlockHighlightContext = createContext(null);
|
||||||
@@ -254,6 +254,7 @@ export function cleanServiceGroups(groups) {
|
|||||||
// all widgets
|
// all widgets
|
||||||
fields,
|
fields,
|
||||||
hideErrors,
|
hideErrors,
|
||||||
|
highlight,
|
||||||
type,
|
type,
|
||||||
|
|
||||||
// azuredevops
|
// azuredevops
|
||||||
@@ -441,6 +442,21 @@ export function cleanServiceGroups(groups) {
|
|||||||
index,
|
index,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (highlight) {
|
||||||
|
let parsedHighlight = highlight;
|
||||||
|
if (typeof highlight === "string") {
|
||||||
|
try {
|
||||||
|
parsedHighlight = JSON.parse(highlight);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Invalid highlight configuration detected in config for service '%s'", service.name);
|
||||||
|
parsedHighlight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsedHighlight && typeof parsedHighlight === "object") {
|
||||||
|
widget.highlight = parsedHighlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (type === "azuredevops") {
|
if (type === "azuredevops") {
|
||||||
if (userEmail) widget.userEmail = userEmail;
|
if (userEmail) widget.userEmail = userEmail;
|
||||||
if (repositoryId) widget.repositoryId = repositoryId;
|
if (repositoryId) widget.repositoryId = repositoryId;
|
||||||
|
|||||||
257
src/utils/highlights.js
Normal file
257
src/utils/highlights.js
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
const DEFAULT_LEVEL_CLASSES = {
|
||||||
|
good: "bg-emerald-500/40 text-emerald-950 dark:bg-emerald-900/60 dark:text-emerald-400",
|
||||||
|
warn: "bg-amber-300/30 text-amber-900 dark:bg-amber-900/30 dark:text-amber-200",
|
||||||
|
danger: "bg-rose-700/45 text-rose-200 dark:bg-rose-950/70 dark:text-rose-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeFieldKeys = (fields, widgetType) => {
|
||||||
|
if (!fields || typeof fields !== "object") return {};
|
||||||
|
|
||||||
|
return Object.entries(fields).reduce((acc, [key, value]) => {
|
||||||
|
if (value === null || value === undefined) return acc;
|
||||||
|
if (typeof key !== "string") return acc;
|
||||||
|
const trimmedKey = key.trim();
|
||||||
|
if (trimmedKey === "") return acc;
|
||||||
|
|
||||||
|
acc[trimmedKey] = value;
|
||||||
|
|
||||||
|
if (widgetType && !trimmedKey.includes(".")) {
|
||||||
|
const namespacedKey = `${widgetType}.${trimmedKey}`;
|
||||||
|
if (!(namespacedKey in acc)) {
|
||||||
|
acc[namespacedKey] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildHighlightConfig = (globalConfig, widgetConfig, widgetType) => {
|
||||||
|
const levels = {
|
||||||
|
...DEFAULT_LEVEL_CLASSES,
|
||||||
|
...(globalConfig?.levels || {}),
|
||||||
|
...(widgetConfig?.levels || {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { levels: _levels, ...fields } = widgetConfig || {};
|
||||||
|
const normalizedFields = normalizeFieldKeys(fields, widgetType);
|
||||||
|
|
||||||
|
const hasLevels = Object.values(levels).some(Boolean);
|
||||||
|
const hasFields = Object.keys(normalizedFields).length > 0;
|
||||||
|
|
||||||
|
if (!hasLevels && !hasFields) return null;
|
||||||
|
|
||||||
|
return { levels, fields: normalizedFields };
|
||||||
|
};
|
||||||
|
|
||||||
|
const NUMERIC_OPERATORS = {
|
||||||
|
gt: (value, target) => value > target,
|
||||||
|
gte: (value, target) => value >= target,
|
||||||
|
lt: (value, target) => value < target,
|
||||||
|
lte: (value, target) => value <= target,
|
||||||
|
eq: (value, target) => value === target,
|
||||||
|
ne: (value, target) => value !== target,
|
||||||
|
};
|
||||||
|
|
||||||
|
const STRING_OPERATORS = {
|
||||||
|
equals: (value, target, caseSensitive) =>
|
||||||
|
caseSensitive ? value === target : value.toLowerCase() === target.toLowerCase(),
|
||||||
|
includes: (value, target, caseSensitive) =>
|
||||||
|
caseSensitive ? value.includes(target) : value.toLowerCase().includes(target.toLowerCase()),
|
||||||
|
startsWith: (value, target, caseSensitive) =>
|
||||||
|
caseSensitive ? value.startsWith(target) : value.toLowerCase().startsWith(target.toLowerCase()),
|
||||||
|
endsWith: (value, target, caseSensitive) =>
|
||||||
|
caseSensitive ? value.endsWith(target) : value.toLowerCase().endsWith(target.toLowerCase()),
|
||||||
|
};
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const candidate = Number(trimmed);
|
||||||
|
if (!Number.isNaN(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseNumericValue = (value) => {
|
||||||
|
if (value === null || value === undefined) return undefined;
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
|
||||||
|
const direct = Number(trimmed);
|
||||||
|
if (!Number.isNaN(direct)) return direct;
|
||||||
|
|
||||||
|
const compact = trimmed.replace(/\s+/g, "");
|
||||||
|
if (!compact || !/^[-+]?[0-9.,]+$/.test(compact)) return undefined;
|
||||||
|
|
||||||
|
const commaCount = (compact.match(/,/g) || []).length;
|
||||||
|
const dotCount = (compact.match(/\./g) || []).length;
|
||||||
|
|
||||||
|
if (commaCount && dotCount) {
|
||||||
|
const lastComma = compact.lastIndexOf(",");
|
||||||
|
const lastDot = compact.lastIndexOf(".");
|
||||||
|
if (lastComma > lastDot) {
|
||||||
|
const asDecimal = compact.replace(/\./g, "").replace(/,/g, ".");
|
||||||
|
const parsed = Number(asDecimal);
|
||||||
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
}
|
||||||
|
const asThousands = compact.replace(/,/g, "");
|
||||||
|
const parsed = Number(asThousands);
|
||||||
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commaCount) {
|
||||||
|
const parts = compact.split(",");
|
||||||
|
if (commaCount === 1 && parts[1]?.length <= 2) {
|
||||||
|
const parsed = Number(compact.replace(",", "."));
|
||||||
|
if (!Number.isNaN(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
const isGrouped = parts.length > 1 && parts.slice(1).every((part) => part.length === 3);
|
||||||
|
if (isGrouped) {
|
||||||
|
const parsed = Number(compact.replace(/,/g, ""));
|
||||||
|
if (!Number.isNaN(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dotCount) {
|
||||||
|
const parts = compact.split(".");
|
||||||
|
if (dotCount === 1 && parts[1]?.length <= 2) {
|
||||||
|
const parsed = Number(compact);
|
||||||
|
if (!Number.isNaN(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
const isGrouped = parts.length > 1 && parts.slice(1).every((part) => part.length === 3);
|
||||||
|
if (isGrouped) {
|
||||||
|
const parsed = Number(compact.replace(/\./g, ""));
|
||||||
|
if (!Number.isNaN(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
const parsed = Number(compact);
|
||||||
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(compact);
|
||||||
|
return Number.isNaN(parsed) ? undefined : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "object" && value !== null && "props" in value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateNumericRule = (value, rule) => {
|
||||||
|
if (!rule || typeof rule !== "object") return false;
|
||||||
|
const operator = rule.when && NUMERIC_OPERATORS[rule.when];
|
||||||
|
const numericValue = toNumber(rule.value);
|
||||||
|
if (operator && numericValue !== undefined) {
|
||||||
|
const passes = operator(value, numericValue);
|
||||||
|
return rule.negate ? !passes : passes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.when === "between") {
|
||||||
|
const min = toNumber(rule.min ?? rule.value?.min);
|
||||||
|
const max = toNumber(rule.max ?? rule.value?.max);
|
||||||
|
if (min === undefined && max === undefined) return false;
|
||||||
|
const lowerBound = min ?? Number.NEGATIVE_INFINITY;
|
||||||
|
const upperBound = max ?? Number.POSITIVE_INFINITY;
|
||||||
|
const passes = value >= lowerBound && value <= upperBound;
|
||||||
|
return rule.negate ? !passes : passes;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.when === "outside") {
|
||||||
|
const min = toNumber(rule.min ?? rule.value?.min);
|
||||||
|
const max = toNumber(rule.max ?? rule.value?.max);
|
||||||
|
if (min === undefined && max === undefined) return false;
|
||||||
|
const passes = value < (min ?? Number.NEGATIVE_INFINITY) || value > (max ?? Number.POSITIVE_INFINITY);
|
||||||
|
return rule.negate ? !passes : passes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluateStringRule = (value, rule) => {
|
||||||
|
if (!rule || typeof rule !== "object") return false;
|
||||||
|
if (rule.when === "regex" && typeof rule.value === "string") {
|
||||||
|
try {
|
||||||
|
const flags = rule.flags || (rule.caseSensitive ? "" : "i");
|
||||||
|
const regex = new RegExp(rule.value, flags);
|
||||||
|
const passes = regex.test(value);
|
||||||
|
return rule.negate ? !passes : passes;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const operator = rule.when && STRING_OPERATORS[rule.when];
|
||||||
|
if (!operator || typeof rule.value !== "string") return false;
|
||||||
|
const passes = operator(value, rule.value, Boolean(rule.caseSensitive));
|
||||||
|
return rule.negate ? !passes : passes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureArray = (value) => {
|
||||||
|
if (Array.isArray(value)) return value;
|
||||||
|
if (value === undefined || value === null) return [];
|
||||||
|
return [value];
|
||||||
|
};
|
||||||
|
|
||||||
|
const findHighlightLevel = (ruleSet, numericValue, stringValue) => {
|
||||||
|
const { numeric, string } = ruleSet;
|
||||||
|
|
||||||
|
if (numeric && numericValue !== undefined) {
|
||||||
|
const numericRules = ensureArray(numeric);
|
||||||
|
const numericCandidates = Array.isArray(numericValue) ? numericValue : [numericValue];
|
||||||
|
for (const candidate of numericCandidates) {
|
||||||
|
for (const rule of numericRules) {
|
||||||
|
if (rule?.level && evaluateNumericRule(candidate, rule)) {
|
||||||
|
return { level: rule.level, source: "numeric", rule };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string && stringValue !== undefined) {
|
||||||
|
const stringRules = ensureArray(string);
|
||||||
|
for (const rule of stringRules) {
|
||||||
|
if (rule?.level && evaluateStringRule(stringValue, rule)) {
|
||||||
|
return { level: rule.level, source: "string", rule };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const evaluateHighlight = (fieldKey, value, highlightConfig) => {
|
||||||
|
if (!highlightConfig || !fieldKey) return null;
|
||||||
|
const { fields } = highlightConfig;
|
||||||
|
if (!fields || typeof fields !== "object") return null;
|
||||||
|
|
||||||
|
const ruleSet = fields[fieldKey];
|
||||||
|
if (!ruleSet) return null;
|
||||||
|
|
||||||
|
const numericValue = parseNumericValue(value);
|
||||||
|
let stringValue;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
stringValue = value;
|
||||||
|
} else if (typeof value === "number" || typeof value === "bigint") {
|
||||||
|
stringValue = String(value);
|
||||||
|
} else if (typeof value === "boolean") {
|
||||||
|
stringValue = value ? "true" : "false";
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedString = typeof stringValue === "string" ? stringValue.trim() : stringValue;
|
||||||
|
|
||||||
|
return findHighlightLevel(ruleSet, numericValue, normalizedString);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getHighlightClass = (level, highlightConfig) => {
|
||||||
|
if (!level || !highlightConfig) return undefined;
|
||||||
|
return highlightConfig.levels?.[level];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDefaultHighlightLevels = () => DEFAULT_LEVEL_CLASSES;
|
||||||
Reference in New Issue
Block a user