mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Add auto cluster icon color picking
- Fix not handling grapheme clusters on cluster names Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
parent
523eff4c97
commit
4c975d8a7e
@ -200,6 +200,7 @@
|
||||
"electron-window-state": "^5.0.3",
|
||||
"filenamify": "^4.1.0",
|
||||
"fs-extra": "^9.0.1",
|
||||
"grapheme-splitter": "^1.0.4",
|
||||
"handlebars": "^4.7.6",
|
||||
"http-proxy": "^1.18.1",
|
||||
"js-yaml": "^3.14.0",
|
||||
|
||||
@ -356,7 +356,8 @@ describe("pre 2.6.0 config with a cluster icon", () => {
|
||||
|
||||
expect(storedClusterData.hasOwnProperty("icon")).toBe(false);
|
||||
expect(storedClusterData.preferences.hasOwnProperty("icon")).toBe(true);
|
||||
expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true);
|
||||
expect(typeof storedClusterData.preferences.icon).toBe("string");
|
||||
expect((storedClusterData.preferences.icon as string).startsWith("data:;base64,")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -443,6 +444,7 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
|
||||
it("migrates to modern format with icon not in file", async () => {
|
||||
const { icon } = clusterStore.clustersList[0].preferences;
|
||||
|
||||
expect(icon.startsWith("data:;base64,")).toBe(true);
|
||||
expect(typeof icon).toBe("string");
|
||||
expect((icon as string).startsWith("data:;base64,")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -70,11 +70,15 @@ export interface ClusterModel {
|
||||
kubeConfig?: string; // yaml
|
||||
}
|
||||
|
||||
export interface IconColourPallet {
|
||||
background: string;
|
||||
}
|
||||
|
||||
export interface ClusterPreferences extends ClusterPrometheusPreferences{
|
||||
terminalCWD?: string;
|
||||
clusterName?: string;
|
||||
iconOrder?: number;
|
||||
icon?: string;
|
||||
icon?: string | IconColourPallet;
|
||||
httpsProxy?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -247,6 +247,19 @@ export class Cluster implements ClusterModel, ClusterState {
|
||||
});
|
||||
}
|
||||
|
||||
@computed get iconPreference(): Required<ClusterPreferences["icon"]> {
|
||||
const { icon } = this.preferences;
|
||||
|
||||
if (typeof icon === "string") {
|
||||
return icon;
|
||||
}
|
||||
|
||||
const defaultBackground = getComputedStyle(document.documentElement).getPropertyValue("--halfGray").trim().slice(0, 7);
|
||||
const { background = defaultBackground } = icon ?? {};
|
||||
|
||||
return { background };
|
||||
}
|
||||
|
||||
/**
|
||||
* Kubernetes version
|
||||
*/
|
||||
|
||||
@ -40,7 +40,7 @@ export default migration({
|
||||
* migrate cluster icon
|
||||
*/
|
||||
try {
|
||||
if (cluster.preferences?.icon) {
|
||||
if (typeof cluster.preferences?.icon === "string") {
|
||||
printLog(`migrating ${cluster.preferences.icon} for ${cluster.preferences.clusterName}`);
|
||||
const iconPath = cluster.preferences.icon.replace("store://", "");
|
||||
const fileData = fse.readFileSync(path.join(userDataPath, iconPath));
|
||||
|
||||
@ -48,4 +48,8 @@
|
||||
.Input, .Select {
|
||||
margin-top: $padding;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-background-color {
|
||||
max-width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { SubTitle } from "../../layout/sub-title";
|
||||
import { ClusterIcon } from "../../cluster-icon";
|
||||
import { Input } from "../../input";
|
||||
import { debounce } from "lodash";
|
||||
|
||||
enum GeneralInputStatus {
|
||||
CLEAN = "clean",
|
||||
@ -48,6 +50,23 @@ export class ClusterIconSetting extends React.Component<Props> {
|
||||
}
|
||||
}
|
||||
|
||||
getIconBackgroundColorValue(): string {
|
||||
const { iconPreference } = this.props.cluster;
|
||||
|
||||
if (typeof iconPreference === "string") {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--halfGray").trim().slice(0, 7);
|
||||
}
|
||||
|
||||
return iconPreference.background;
|
||||
}
|
||||
|
||||
onColorChange = debounce(background => {
|
||||
this.props.cluster.preferences.icon = { background };
|
||||
}, 100, {
|
||||
leading: true,
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
render() {
|
||||
const label = (
|
||||
<>
|
||||
@ -56,7 +75,7 @@ export class ClusterIconSetting extends React.Component<Props> {
|
||||
showErrors={false}
|
||||
showTooltip={false}
|
||||
/>
|
||||
{"Browse for new icon..."}
|
||||
Browse for new icon...
|
||||
</>
|
||||
);
|
||||
|
||||
@ -73,6 +92,19 @@ export class ClusterIconSetting extends React.Component<Props> {
|
||||
/>
|
||||
{this.getClearButton()}
|
||||
</div>
|
||||
<p>Or change the colour of the generated icon.</p>
|
||||
<div>
|
||||
<Input
|
||||
className="icon-background-color"
|
||||
type="color"
|
||||
value={this.getIconBackgroundColorValue()}
|
||||
title="Choose auto generated icon's background color"
|
||||
onChange={this.onColorChange}
|
||||
/>
|
||||
<small className="hint">
|
||||
This action clears any previously set icon.
|
||||
</small>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,18 +7,22 @@
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
&.interactive {
|
||||
img {
|
||||
opacity: .55;
|
||||
}
|
||||
}
|
||||
|
||||
div.MuiAvatar-colorDefault {
|
||||
font-weight:500;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
-webkit-text-stroke: 0.2px black;
|
||||
text-shadow: 1px 1px black;
|
||||
}
|
||||
|
||||
div.active {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
div.default {
|
||||
background-color: var(--halfGray);
|
||||
img {
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
}
|
||||
|
||||
&.active, &.interactive:hover {
|
||||
|
||||
@ -9,6 +9,7 @@ import { Tooltip } from "../tooltip";
|
||||
import { subscribeToBroadcast } from "../../../common/ipc";
|
||||
import { observable } from "mobx";
|
||||
import { Avatar } from "@material-ui/core";
|
||||
import GraphemeSplitter from "grapheme-splitter";
|
||||
|
||||
interface Props extends DOMAttributes<HTMLElement> {
|
||||
cluster: Cluster;
|
||||
@ -25,6 +26,22 @@ const defaultProps: Partial<Props> = {
|
||||
showTooltip: true,
|
||||
};
|
||||
|
||||
function getNameParts(name: string): string[] {
|
||||
const byWhitespace = name.split(/\s+/);
|
||||
|
||||
if (byWhitespace.length > 1) {
|
||||
return byWhitespace;
|
||||
}
|
||||
|
||||
const byDashes = name.split(/[-_]+/);
|
||||
|
||||
if (byDashes.length > 1) {
|
||||
return byDashes;
|
||||
}
|
||||
|
||||
return name.split(/@+/);
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ClusterIcon extends React.Component<Props> {
|
||||
static defaultProps = defaultProps as object;
|
||||
@ -46,46 +63,59 @@ export class ClusterIcon extends React.Component<Props> {
|
||||
}
|
||||
|
||||
get iconString() {
|
||||
let splittedName = this.props.cluster.name.split(" ");
|
||||
const [rawfirst, rawSecond] = getNameParts(this.props.cluster.name);
|
||||
const splitter = new GraphemeSplitter();
|
||||
const first = splitter.iterateGraphemes(rawfirst);
|
||||
const second = rawSecond ? splitter.iterateGraphemes(rawSecond) : first;
|
||||
let res = "";
|
||||
|
||||
if (splittedName.length === 1) {
|
||||
splittedName = splittedName[0].split("-");
|
||||
for (const grapheme of first) {
|
||||
res += grapheme;
|
||||
break;
|
||||
}
|
||||
|
||||
if (splittedName.length === 1) {
|
||||
splittedName = splittedName[0].split("@");
|
||||
for (const grapheme of second) {
|
||||
res += grapheme;
|
||||
break;
|
||||
}
|
||||
|
||||
splittedName = splittedName.map((part) => part.replace(/\W/g, ""));
|
||||
return res;
|
||||
}
|
||||
|
||||
if (splittedName.length === 1) {
|
||||
return splittedName[0].substring(0, 2);
|
||||
} else {
|
||||
return splittedName[0].substring(0, 1) + splittedName[1].substring(0, 1);
|
||||
renderIcon() {
|
||||
const { cluster } = this.props;
|
||||
const { name, iconPreference } = cluster;
|
||||
|
||||
if (typeof iconPreference === "string") {
|
||||
return <img src={iconPreference} alt={name} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Avatar variant="rounded" style={{backgroundColor: iconPreference.background}}>
|
||||
{this.iconString}
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
cluster, showErrors, showTooltip, errorClass, interactive, isActive,
|
||||
children, ...elemProps
|
||||
children, className, ...elemProps
|
||||
} = this.props;
|
||||
const { name, preferences, id: clusterId, online } = cluster;
|
||||
const { name, id: clusterId, online } = cluster;
|
||||
const eventCount = this.eventCount;
|
||||
const { icon } = preferences;
|
||||
const clusterIconId = `cluster-icon-${clusterId}`;
|
||||
const className = cssNames("ClusterIcon flex inline", this.props.className, {
|
||||
interactive: interactive !== undefined ? interactive : !!this.props.onClick,
|
||||
const classNames = cssNames("ClusterIcon flex inline", className, {
|
||||
interactive: interactive ?? Boolean(this.props.onClick),
|
||||
active: isActive,
|
||||
});
|
||||
|
||||
return (
|
||||
<div {...elemProps} className={className} id={showTooltip ? clusterIconId : null}>
|
||||
<div {...elemProps} className={classNames} id={showTooltip ? clusterIconId : null}>
|
||||
{showTooltip && (
|
||||
<Tooltip targetId={clusterIconId}>{name}</Tooltip>
|
||||
)}
|
||||
{icon && <img src={icon} alt={name}/>}
|
||||
{!icon && <Avatar variant="square" className={isActive ? "active" : "default"}>{this.iconString}</Avatar>}
|
||||
{this.renderIcon()}
|
||||
{showErrors && eventCount > 0 && !isActive && online && (
|
||||
<Badge
|
||||
className={cssNames("events-count", errorClass)}
|
||||
|
||||
@ -50,15 +50,15 @@ export interface BaseProps {
|
||||
// the larger number is upper limit, the lower is lower limit
|
||||
// the lower limit is capped at 0 and the upper limit is capped at Infinity
|
||||
limit?: [number, number];
|
||||
|
||||
|
||||
// default is "Reject"
|
||||
onOverLimit?: OverLimitStyle;
|
||||
|
||||
|
||||
// individual files are checked before the total size.
|
||||
maxSize?: number;
|
||||
// default is "Reject"
|
||||
onOverSizeLimit?: OverSizeLimitStyle;
|
||||
|
||||
|
||||
maxTotalSize?: number;
|
||||
// default is "Reject"
|
||||
onOverTotalSizeLimit?: OverTotalSizeLimitStyle;
|
||||
@ -132,7 +132,7 @@ export class FilePicker extends React.Component<Props> {
|
||||
files = _.orderBy(files, ["size"]);
|
||||
case OverTotalSizeLimitStyle.FILTER_LAST:
|
||||
let newTotalSize = totalSize;
|
||||
|
||||
|
||||
for (;files.length > 0;) {
|
||||
newTotalSize -= files.pop().size;
|
||||
|
||||
@ -154,12 +154,12 @@ export class FilePicker extends React.Component<Props> {
|
||||
const numberLimitedFiles = this.handleFileCount(files);
|
||||
const sizeLimitedFiles = this.handleIndiviualFileSizes(numberLimitedFiles);
|
||||
const totalSizeLimitedFiles = this.handleTotalFileSizes(sizeLimitedFiles);
|
||||
|
||||
|
||||
if ("uploadDir" in this.props) {
|
||||
const { uploadDir } = this.props;
|
||||
|
||||
this.status = FileInputStatus.PROCESSING;
|
||||
|
||||
|
||||
const paths: string[] = [];
|
||||
const promises = totalSizeLimitedFiles.map(async file => {
|
||||
const destinationPath = path.join(uploadDir, file.name);
|
||||
@ -187,10 +187,10 @@ export class FilePicker extends React.Component<Props> {
|
||||
const { accept, label, multiple } = this.props;
|
||||
|
||||
return <div className="FilePicker">
|
||||
<label className="flex gaps align-center" htmlFor="file-upload">{label} {this.getIconRight()}</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
name="FilePicker"
|
||||
<label className="flex gaps align-center" htmlFor="file-upload">{label}{" "}{this.getIconRight()}</label>
|
||||
<input
|
||||
id="file-upload"
|
||||
name="FilePicker"
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
label.notColor {
|
||||
--flex-gap: #{$padding / 1.5};
|
||||
|
||||
position: relative;
|
||||
|
||||
@ -215,10 +215,7 @@ export class Input extends React.Component<InputProps, State> {
|
||||
|
||||
@autobind()
|
||||
onChange(evt: React.ChangeEvent<any>) {
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(evt.currentTarget.value, evt);
|
||||
}
|
||||
|
||||
this.props.onChange?.(evt.currentTarget.value, evt);
|
||||
this.validate();
|
||||
this.autoFitHeight();
|
||||
|
||||
@ -339,10 +336,14 @@ export class Input extends React.Component<InputProps, State> {
|
||||
);
|
||||
}
|
||||
|
||||
const labelClassNames = cssNames("input-area flex gaps align-center", {
|
||||
notColor: inputProps.type !== "color",
|
||||
});
|
||||
|
||||
return (
|
||||
<div id={componentId} className={className}>
|
||||
{tooltipError}
|
||||
<label className="input-area flex gaps align-center" id="">
|
||||
<label className={labelClassNames} id="">
|
||||
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
||||
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
||||
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
|
||||
|
||||
@ -6330,7 +6330,7 @@ graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb"
|
||||
integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw==
|
||||
|
||||
grapheme-splitter@^1.0.2:
|
||||
grapheme-splitter@^1.0.2, grapheme-splitter@^1.0.4:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz#9cf3a665c6247479896834af35cf1dbb4400767e"
|
||||
integrity sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==
|
||||
|
||||
Loading…
Reference in New Issue
Block a user