Merge branch 'master' into extensions-api

This commit is contained in:
Lauri Nevala committed 2020-10-06 14:52:27 +03:00
commit 0f57ea91b1
56 files changed
+815 -616

No files matched your search

@@ -1,9 +1,9 @@
import fs from "fs";
import mockFs from "mock-fs";
import yaml from "js-yaml";
import { Cluster } from "../main/cluster";
import { ClusterStore } from "./cluster-store";
import { workspaceStore } from "./workspace-store";
import { Cluster } from "../../main/cluster";
import { ClusterStore } from "../cluster-store";
import { workspaceStore } from "../workspace-store";
const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png")
@@ -12,7 +12,7 @@ console.log("") // fix bug
let clusterStore: ClusterStore;
describe("empty config", () => {
beforeAll(() => {
beforeEach(() => {
ClusterStore.resetInstance();
const mockOpts = {
'tmp': {
@@ -24,109 +24,120 @@ describe("empty config", () => {
return clusterStore.load();
})
afterAll(() => {
afterEach(() => {
mockFs.restore();
})
it("adds new cluster to store", async () => {
const cluster = new Cluster({
id: "foo",
contextName: "minikube",
preferences: {
terminalCWD: "/tmp",
icon: "data:;base64,iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
clusterName: "minikube"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", "fancy foo config"),
workspace: workspaceStore.currentWorkspaceId
describe("with foo cluster added", () => {
beforeEach(() => {
clusterStore.addCluster(
new Cluster({
id: "foo",
contextName: "minikube",
preferences: {
terminalCWD: "/tmp",
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
clusterName: "minikube"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", "fancy foo config"),
workspace: workspaceStore.currentWorkspaceId
})
);
})
it("adds new cluster to store", async () => {
const storedCluster = clusterStore.getById("foo");
expect(storedCluster.id).toBe("foo");
expect(storedCluster.preferences.terminalCWD).toBe("/tmp");
expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5");
})
it("adds cluster to default workspace", () => {
const storedCluster = clusterStore.getById("foo");
expect(storedCluster.workspace).toBe("default");
})
it("removes cluster from store", async () => {
await clusterStore.removeById("foo");
expect(clusterStore.getById("foo")).toBeUndefined();
})
it("sets active cluster", () => {
clusterStore.setActive("foo");
expect(clusterStore.activeCluster.id).toBe("foo");
})
})
describe("with prod and dev clusters added", () => {
beforeEach(() => {
clusterStore.addCluster(
new Cluster({
id: "prod",
contextName: "prod",
preferences: {
clusterName: "prod"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", "fancy config"),
workspace: "workstation"
}),
new Cluster({
id: "dev",
contextName: "dev",
preferences: {
clusterName: "dev"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", "fancy config"),
workspace: "workstation"
})
)
})
it("check if store can contain multiple clusters", () => {
expect(clusterStore.hasClusters()).toBeTruthy();
expect(clusterStore.clusters.size).toBe(2);
});
clusterStore.addCluster(cluster);
const storedCluster = clusterStore.getById(cluster.id);
expect(storedCluster.id).toBe(cluster.id);
expect(storedCluster.preferences.terminalCWD).toBe(cluster.preferences.terminalCWD);
expect(storedCluster.preferences.icon).toBe(cluster.preferences.icon);
})
it("adds cluster to default workspace", () => {
const storedCluster = clusterStore.getById("foo");
expect(storedCluster.workspace).toBe("default");
})
it("gets clusters by workspaces", () => {
const wsClusters = clusterStore.getByWorkspaceId("workstation");
const defaultClusters = clusterStore.getByWorkspaceId("default");
expect(defaultClusters.length).toBe(0);
expect(wsClusters.length).toBe(2);
expect(wsClusters[0].id).toBe("prod");
expect(wsClusters[1].id).toBe("dev");
})
it("check if store can contain multiple clusters", () => {
const prodCluster = new Cluster({
id: "prod",
contextName: "prod",
preferences: {
clusterName: "prod"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", "fancy config"),
workspace: "workstation"
});
const devCluster = new Cluster({
id: "dev",
contextName: "dev",
preferences: {
clusterName: "dev"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", "fancy config"),
workspace: "workstation"
});
clusterStore.addCluster(prodCluster);
clusterStore.addCluster(devCluster);
expect(clusterStore.hasClusters()).toBeTruthy();
expect(clusterStore.clusters.size).toBe(3);
});
it("check if cluster's kubeconfig file saved", () => {
const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig");
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
})
it("gets clusters by workspaces", () => {
const wsClusters = clusterStore.getByWorkspaceId("workstation");
const defaultClusters = clusterStore.getByWorkspaceId("default");
expect(defaultClusters.length).toBe(1);
expect(wsClusters.length).toBe(2);
expect(wsClusters[0].id).toBe("prod");
expect(wsClusters[1].id).toBe("dev");
})
it("check if reorderring works for same from and to", () => {
clusterStore.swapIconOrders("workstation", 1, 1)
it("sets active cluster", () => {
clusterStore.setActive("foo");
expect(clusterStore.activeCluster.id).toBe("foo");
})
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("prod")
expect(clusters[0].preferences.iconOrder).toBe(0)
expect(clusters[1].id).toBe("dev")
expect(clusters[1].preferences.iconOrder).toBe(1)
})
it("check if cluster's kubeconfig file saved", () => {
const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig");
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
})
it("check if reorderring works for different from and to", () => {
clusterStore.swapIconOrders("workstation", 0, 1)
it("check if reorderring works for same from and to", () => {
clusterStore.swapIconOrders("workstation", 1, 1)
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("dev")
expect(clusters[0].preferences.iconOrder).toBe(0)
expect(clusters[1].id).toBe("prod")
expect(clusters[1].preferences.iconOrder).toBe(1)
})
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("prod")
expect(clusters[0].preferences.iconOrder).toBe(0)
expect(clusters[1].id).toBe("dev")
expect(clusters[1].preferences.iconOrder).toBe(1)
});
it("check if after icon reordering, changing workspaces still works", () => {
clusterStore.swapIconOrders("workstation", 1, 1)
clusterStore.getById("prod").workspace = "default"
it("check if reorderring works for different from and to", () => {
clusterStore.swapIconOrders("workstation", 0, 1)
const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("dev")
expect(clusters[0].preferences.iconOrder).toBe(0)
expect(clusters[1].id).toBe("prod")
expect(clusters[1].preferences.iconOrder).toBe(1)
});
it("check if after icon reordering, changing workspaces still works", () => {
clusterStore.swapIconOrders("workstation", 1, 1)
clusterStore.getById("prod").workspace = "default"
expect(clusterStore.getByWorkspaceId("workstation").length).toBe(1);
expect(clusterStore.getByWorkspaceId("default").length).toBe(2);
});
it("removes cluster from store", async () => {
await clusterStore.removeById("foo");
expect(clusterStore.getById("foo")).toBeUndefined();
expect(clusterStore.getByWorkspaceId("workstation").length).toBe(1);
expect(clusterStore.getByWorkspaceId("default").length).toBe(1);
})
})
})
@@ -10,7 +10,7 @@ jest.mock("electron", () => {
}
})
import { UserStore } from "./user-store"
import { UserStore } from "../user-store"
import { SemVer } from "semver"
import electron from "electron"
@@ -10,7 +10,7 @@ jest.mock("electron", () => {
}
})
import { WorkspaceStore } from "./workspace-store"
import { WorkspaceStore } from "../workspace-store"
describe("workspace store tests", () => {
describe("for an empty config", () => {
+11
View File
@@ -18,6 +18,17 @@ export const clusterIpc = {
},
}),
setFrameId: createIpcChannel({
channel: "cluster:set-frame-id",
handle: (clusterId: ClusterId, frameId?: number) => {
const cluster = clusterStore.getById(clusterId);
if (cluster) {
if (frameId) cluster.frameId = frameId; // save cluster's webFrame.routingId to be able to send push-updates
return cluster.pushState();
}
},
}),
refresh: createIpcChannel({
channel: "cluster:refresh",
handle: (clusterId: ClusterId) => {
@@ -1,4 +1,4 @@
import { splitArray } from "./splitArray";
import { splitArray } from "../splitArray";
describe("split array on element tests", () => {
test("empty array", () => {
@@ -16,7 +16,7 @@ describe("split array on element tests", () => {
test("one elements, in array", () => {
expect(splitArray([1], 1)).toStrictEqual([[], [], true]);
});
test("ten elements, in front array", () => {
expect(splitArray([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 0)).toStrictEqual([[], [1, 2, 3, 4, 5, 6, 7, 8, 9], true]);
});
+2 -1
View File
@@ -59,6 +59,7 @@ export class Cluster implements ClusterModel {
@observable online = false;
@observable accessible = false;
@observable ready = false;
@observable reconnecting = false;
@observable disconnected = true;
@observable failureReason: string;
@observable nodes = 0;
@@ -110,7 +111,7 @@ export class Cluster implements ClusterModel {
protected bindEvents() {
logger.info(`[CLUSTER]: bind events`, this.getMeta());
const refreshTimer = setInterval(() => this.online && this.refresh(), 30000); // every 30s
const refreshTimer = setInterval(() => !this.disconnected && this.refresh(), 30000); // every 30s
this.eventDisposers.push(
reaction(this.getState, this.pushState),
@@ -1,11 +1,11 @@
import { IKubeApiParsed, parseKubeApi } from "./kube-api-parse";
import { IKubeApiParsed, parseKubeApi } from "../kube-api-parse";
interface KubeApi_Parse_Test {
interface KubeApiParseTestData {
url: string;
expected: Required<IKubeApiParsed>;
}
const tests: KubeApi_Parse_Test[] = [
const tests: KubeApiParseTestData[] = [
{
url: "/apis/apiextensions.k8s.io/v1beta1/customresourcedefinitions/prometheuses.monitoring.coreos.com",
expected: {
@@ -125,11 +125,10 @@ const tests: KubeApi_Parse_Test[] = [
},
];
describe.only("parseApi unit tests", () => {
for (const i in tests) {
const { url: tUrl, expected:tExpect} = tests[i];
test(`test #${parseInt(i)+1}`, () => {
expect(parseKubeApi(tUrl)).toStrictEqual(tExpect);
describe("parseApi unit tests", () => {
for (const { url, expected } of tests) {
test(`testing "${url}"`, () => {
expect(parseKubeApi(url)).toStrictEqual(expected);
});
}
});
+1 -1
View File
@@ -13,7 +13,7 @@ import { LensApp } from "./lens-app";
import { getLensRuntime } from "../extensions/lens-runtime";
type AppComponent = React.ComponentType & {
init?(): void;
init?(): Promise<void>;
}
export async function bootstrap(App: AppComponent) {
@@ -4,7 +4,7 @@ import { Input } from "../../input";
import { observable, autorun } from "mobx";
import { observer, disposeOnUnmount } from "mobx-react";
import { SubTitle } from "../../layout/sub-title";
import { isRequired } from "../../input/input.validators";
import { isRequired } from "../../input/input_validators";
interface Props {
cluster: Cluster;
@@ -33,7 +33,7 @@ export class ClusterNameSetting extends React.Component<Props> {
render() {
return (
<>
<SubTitle title="Cluster Name"/>
<SubTitle title="Cluster Name" />
<p>Define cluster name.</p>
<Input
theme="round-black"
@@ -3,7 +3,7 @@ import { observable, autorun } from "mobx";
import { observer, disposeOnUnmount } from "mobx-react";
import { Cluster } from "../../../../main/cluster";
import { Input } from "../../input";
import { isUrl } from "../../input/input.validators";
import { isUrl } from "../../input/input_validators";
import { SubTitle } from "../../layout/sub-title";
interface Props {
@@ -33,7 +33,7 @@ export class ClusterProxySetting extends React.Component<Props> {
render() {
return (
<>
<SubTitle title="HTTP Proxy"/>
<SubTitle title="HTTP Proxy" />
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
<Input
theme="round-black"
+3 -2
View File
@@ -14,14 +14,15 @@ import { podsStore } from "../+workloads-pods/pods.store";
import { clusterStore } from "./cluster.store";
import { eventStore } from "../+events/event.store";
import { isAllowedResource } from "../../../common/rbac";
import { getHostedCluster } from "../../../common/cluster-store";
@observer
export class Cluster extends React.Component {
private dependentStores = [nodesStore, podsStore];
private watchers = [
interval(60, () => clusterStore.getMetrics()),
interval(20, () => eventStore.loadAll())
interval(60, () => { getHostedCluster().available && clusterStore.getMetrics()}),
interval(20, () => { getHostedCluster().available && eventStore.loadAll()})
];
@computed get isLoaded() {
@@ -8,7 +8,7 @@ import { _i18n } from "../../i18n";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Input } from "../input";
import { systemName } from "../input/input.validators";
import { systemName } from "../input/input_validators";
import { IResourceQuotaValues, resourceQuotaApi } from "../../api/endpoints/resource-quota.api";
import { Select } from "../select";
import { Icon } from "../icon";
@@ -73,7 +73,7 @@ export class AddQuotaDialog extends React.Component<Props> {
const isCount = quota.startsWith("count/");
const icon = isCompute ? "memory" : isStorage ? "storage" : isCount ? "looks_one" : "";
return {
label: icon ? <span className="nobr"><Icon material={icon}/> {quota}</span> : quota,
label: icon ? <span className="nobr"><Icon material={icon} /> {quota}</span> : quota,
value: quota,
};
});
@@ -151,7 +151,7 @@ export class AddQuotaDialog extends React.Component<Props> {
/>
</div>
<SubTitle title={<Trans>Namespace</Trans>}/>
<SubTitle title={<Trans>Namespace</Trans>} />
<NamespaceSelect
value={this.namespace}
placeholder={_i18n._(t`Namespace`)}
@@ -160,7 +160,7 @@ export class AddQuotaDialog extends React.Component<Props> {
onChange={({ value }) => this.namespace = value}
/>
<SubTitle title={<Trans>Values</Trans>}/>
<SubTitle title={<Trans>Values</Trans>} />
<div className="flex gaps align-center">
<Select
className="quota-select"
@@ -191,7 +191,7 @@ export class AddQuotaDialog extends React.Component<Props> {
<div key={quota} className="quota flex gaps inline align-center">
<div className="name">{quota}</div>
<div className="value">{value}</div>
<Icon material="clear" onClick={() => this.quotas[quota] = ""}/>
<Icon material="clear" onClick={() => this.quotas[quota] = ""} />
</div>
)
})}
@@ -8,7 +8,7 @@ import { _i18n } from "../../i18n";
import { Dialog, DialogProps } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Input } from "../input";
import { systemName } from "../input/input.validators";
import { systemName } from "../input/input_validators";
import { Secret, secretsApi, SecretType } from "../../api/endpoints";
import { SubTitle } from "../layout/sub-title";
import { NamespaceSelect } from "../+namespaces/namespace-select";
@@ -184,7 +184,7 @@ export class AddSecretDialog extends React.Component<Props> {
<Wizard header={header} done={this.close}>
<WizardStep contentClass="flow column" nextLabel={<Trans>Create</Trans>} next={this.createSecret}>
<div className="secret-name">
<SubTitle title={<Trans>Secret name</Trans>}/>
<SubTitle title={<Trans>Secret name</Trans>} />
<Input
autoFocus required
placeholder={_i18n._(t`Name`)}
@@ -194,7 +194,7 @@ export class AddSecretDialog extends React.Component<Props> {
</div>
<div className="flex auto gaps">
<div className="secret-namespace">
<SubTitle title={<Trans>Namespace</Trans>}/>
<SubTitle title={<Trans>Namespace</Trans>} />
<NamespaceSelect
themeName="light"
value={namespace}
@@ -202,7 +202,7 @@ export class AddSecretDialog extends React.Component<Props> {
/>
</div>
<div className="secret-type">
<SubTitle title={<Trans>Secret type</Trans>}/>
<SubTitle title={<Trans>Secret type</Trans>} />
<Select
themeName="light"
options={this.types}
@@ -10,7 +10,7 @@ import { Wizard, WizardStep } from "../wizard";
import { namespaceStore } from "./namespace.store";
import { Namespace } from "../../api/endpoints";
import { Input } from "../input";
import { systemName } from "../input/input.validators";
import { systemName } from "../input/input_validators";
import { Notifications } from "../notifications";
interface Props extends DialogProps {
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { Trans } from '@lingui/macro';
import { isPath } from '../input/input.validators';
import { isPath } from '../input/input_validators';
import { Checkbox } from '../checkbox';
import { Input } from '../input';
import { SubTitle } from '../layout/sub-title';
@@ -10,7 +10,7 @@ import { Wizard, WizardStep } from "../wizard";
import { SubTitle } from "../layout/sub-title";
import { serviceAccountsStore } from "./service-accounts.store";
import { Input } from "../input";
import { systemName } from "../input/input.validators";
import { systemName } from "../input/input_validators";
import { NamespaceSelect } from "../+namespaces/namespace-select";
import { Notifications } from "../notifications";
import { showDetails } from "../../navigation";
@@ -62,14 +62,14 @@ export class CreateServiceAccountDialog extends React.Component<Props> {
>
<Wizard header={header} done={this.close}>
<WizardStep nextLabel={<Trans>Create</Trans>} next={this.createAccount}>
<SubTitle title={<Trans>Account Name</Trans>}/>
<SubTitle title={<Trans>Account Name</Trans>} />
<Input
autoFocus required
placeholder={_i18n._(t`Enter a name`)}
validators={systemName}
value={name} onChange={v => this.name = v.toLowerCase()}
/>
<SubTitle title={<Trans>Namespace</Trans>}/>
<SubTitle title={<Trans>Namespace</Trans>} />
<NamespaceSelect
themeName="light"
value={namespace}
@@ -10,7 +10,7 @@ import { CronJob, cronJobApi, jobApi, Job } from "../../api/endpoints";
import { Notifications } from "../notifications";
import { cssNames } from "../../utils";
import { Input } from "../input";
import { systemName, maxLength } from "../input/input.validators";
import { systemName, maxLength } from "../input/input_validators";
interface Props extends Partial<DialogProps> {
}
@@ -12,7 +12,7 @@ import { Icon } from "../icon";
import { Input } from "../input";
import { cssNames, prevDefault } from "../../utils";
import { Button } from "../button";
import { isRequired, Validator } from "../input/input.validators";
import { isRequired, Validator } from "../input/input_validators";
@observer
export class Workspaces extends React.Component {
@@ -7,7 +7,6 @@
.theme-light & {
border: 1px solid gainsboro;
border-radius: $radius;
.ace_scrollbar {
@include custom-scrollbar(dark);
@@ -19,7 +18,6 @@
width: inherit;
height: inherit;
font-size: 90%;
border-radius: $radius;
}
// --Theme customization
+2 -2
View File
@@ -45,8 +45,8 @@ export class App extends React.Component {
const clusterId = getHostedClusterId();
logger.info(`[APP]: Init dashboard, clusterId=${clusterId}, frameId=${frameId}`)
await Terminal.preloadFonts()
await clusterIpc.activate.invokeFromRenderer(clusterId, frameId);
await getHostedCluster().whenReady; // cluster.refresh() is done at this point
await clusterIpc.setFrameId.invokeFromRenderer(clusterId, frameId);
await getHostedCluster().whenReady; // cluster.activate() is done at this point
}
get startURL() {
-7
View File
@@ -18,13 +18,6 @@ export class Badge extends React.Component<Props> {
{label}
{children}
</span>
{ /**
* This is a zero-width-space. It makes there be a word seperation
* between adjacent Badge's because <span>'s are ignored for browers'
* word detection algorithmns use for determining the extent of the
* text to highlight on multi-click sequences.
*/}
&#8203;
</>
}
}
@@ -41,6 +41,17 @@
}
}
&.outlined {
color: inherit;
background: transparent;
&.active,
&:focus {
color: inherit;
box-shadow: 0 0 0 1px inset;
}
}
&.big {
font-size: 2.2 * $unit;
border-radius: 50px;
+3 -2
View File
@@ -9,6 +9,7 @@ export interface ButtonProps extends ButtonHTMLAttributes<any>, TooltipDecorator
primary?: boolean;
accent?: boolean;
plain?: boolean;
outlined?: boolean;
hidden?: boolean;
active?: boolean;
big?: boolean;
@@ -23,12 +24,12 @@ export class Button extends React.PureComponent<ButtonProps, {}> {
private button: HTMLButtonElement;
render() {
const { className, waiting, label, primary, accent, plain, hidden, active, big, round, tooltip, children, ...props } = this.props;
const { className, waiting, label, primary, accent, plain, hidden, active, big, round, outlined, tooltip, children, ...props } = this.props;
const btnProps = props as Partial<ButtonProps>;
if (hidden) return null;
btnProps.className = cssNames('Button', className, {
waiting, primary, accent, plain, active, big, round,
waiting, primary, accent, plain, active, big, round, outlined
});
const btnContent: ReactNode = (
@@ -39,6 +39,7 @@ export class CreateResource extends React.Component<Props> {
create = async () => {
if (this.error) return;
if (!this.data.trim()) return; // do not save when field is empty
const resources = jsYaml.safeLoadAll(this.data)
.filter(v => !!v) // skip empty documents if "---" pasted at the beginning or end
const createdResources: string[] = [];
@@ -54,12 +55,14 @@ export class CreateResource extends React.Component<Props> {
errors.forEach(Notifications.error);
if (!createdResources.length) throw errors[0];
}
return (
const successMessage = (
<p>
<Plural value={createdResources.length} one="Resource" other="Resources"/>{" "}
<Trans><b>{createdResources.join(", ")}</b> successfully created</Trans>
</p>
)
Notifications.ok(successMessage)
return successMessage
}
render() {
@@ -67,11 +70,6 @@ export class CreateResource extends React.Component<Props> {
const { className } = this.props;
return (
<div className={cssNames("CreateResource flex column", className)}>
<EditorPanel
tabId={tabId}
value={data}
onChange={onChange}
/>
<InfoPanel
tabId={tabId}
error={error}
@@ -79,6 +77,11 @@ export class CreateResource extends React.Component<Props> {
submitLabel={_i18n._(t`Create`)}
showNotifications={false}
/>
<EditorPanel
tabId={tabId}
value={data}
onChange={onChange}
/>
</div>
)
}
+1 -22
View File
@@ -14,10 +14,6 @@
left: 0;
bottom: 0;
z-index: 100;
> .resizer {
display: none;
}
}
}
@@ -65,24 +61,7 @@
}
}
.resizer {
$height: 12px;
position: absolute;
top: -$height / 2;
left: 0;
right: 0;
bottom: 100%;
height: $height;
cursor: row-resize;
z-index: 10;
&.disabled {
pointer-events: none;
}
}
.AceEditor {
border: none;
}
}
}
+12 -13
View File
@@ -27,8 +27,8 @@ export class DockStore {
];
protected storage = createStorage("dock", {}); // keep settings in localStorage
public defaultTabId = this.initialTabs[0].id;
public minHeight = 100;
public readonly defaultTabId = this.initialTabs[0].id;
public readonly minHeight = 100;
@observable isOpen = false;
@observable fullSize = false;
@@ -45,11 +45,12 @@ export class DockStore {
}
get maxHeight() {
const mainLayoutHeader = 40;
const mainLayoutTabs = 33;
const mainLayoutMargin = 16;
const dockTabs = 33;
return window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs;
const mainLayoutHeader = 40
const mainLayoutTabs = 33
const mainLayoutMargin = 16
const dockTabs = 33
const preferedMax = window.innerHeight - mainLayoutHeader - mainLayoutTabs - mainLayoutMargin - dockTabs
return Math.max(preferedMax, this.minHeight) // don't let max < min
}
constructor() {
@@ -65,7 +66,6 @@ export class DockStore {
});
// adjust terminal height if window size changes
this.checkMaxHeight();
window.addEventListener("resize", throttle(this.checkMaxHeight, 250));
}
@@ -171,20 +171,19 @@ export class DockStore {
@action
selectTab(tabId: TabId) {
const tab = this.getTabById(tabId);
this.selectedTabId = tab ? tab.id : null;
this.selectedTabId = this.getTabById(tabId)?.id ?? null;
}
@action
setHeight(height: number) {
this.height = Math.max(0, Math.min(height, this.maxHeight));
setHeight(height?: number) {
this.height = Math.max(this.minHeight, Math.min(height || this.minHeight, this.maxHeight));
}
@action
reset() {
this.selectedTabId = this.defaultTabId;
this.tabs.replace(this.initialTabs);
this.height = this.defaultHeight;
this.setHeight(this.defaultHeight);
this.close();
}
}
+23 -38
View File
@@ -4,7 +4,7 @@ import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { autobind, cssNames, prevDefault } from "../../utils";
import { Draggable, DraggableState } from "../draggable";
import { ResizingAnchor, ResizeDirection } from "../resizing-anchor";
import { Icon } from "../icon";
import { Tabs } from "../tabs/tabs";
import { MenuItem } from "../menu";
@@ -29,28 +29,8 @@ interface Props {
@observer
export class Dock extends React.Component<Props> {
onResizeStart = () => {
const { isOpen, open, setHeight, minHeight } = dockStore;
if (!isOpen) {
open();
setHeight(minHeight);
}
}
onResize = ({ offsetY }: DraggableState) => {
const { isOpen, close, height, setHeight, minHeight, defaultHeight } = dockStore;
const newHeight = height + offsetY;
if (height > newHeight && newHeight < minHeight) {
setHeight(defaultHeight);
close();
}
else if (isOpen) {
setHeight(newHeight);
}
}
onKeydown = (evt: React.KeyboardEvent<HTMLElement>) => {
const { close, closeTab, selectedTab, fullSize, toggleFillSize } = dockStore;
const { close, closeTab, selectedTab } = dockStore;
if (!selectedTab) return;
const { code, ctrlKey, shiftKey } = evt.nativeEvent;
if (shiftKey && code === "Escape") {
@@ -71,13 +51,13 @@ export class Dock extends React.Component<Props> {
@autobind()
renderTab(tab: IDockTab) {
if (isTerminalTab(tab)) {
return <TerminalTab value={tab}/>
return <TerminalTab value={tab} />
}
if (isCreateResourceTab(tab) || isEditResourceTab(tab)) {
return <DockTab value={tab} icon="edit"/>
return <DockTab value={tab} icon="edit" />
}
if (isInstallChartTab(tab) || isUpgradeChartTab(tab)) {
return <DockTab value={tab} icon={<Icon svg="install"/>}/>
return <DockTab value={tab} icon={<Icon svg="install" />} />
}
}
@@ -86,11 +66,11 @@ export class Dock extends React.Component<Props> {
if (!isOpen || !tab) return;
return (
<div className="tab-content" style={{ flexBasis: height }}>
{isCreateResourceTab(tab) && <CreateResource tab={tab}/>}
{isEditResourceTab(tab) && <EditResource tab={tab}/>}
{isInstallChartTab(tab) && <InstallChart tab={tab}/>}
{isUpgradeChartTab(tab) && <UpgradeChart tab={tab}/>}
{isTerminalTab(tab) && <TerminalWindow tab={tab}/>}
{isCreateResourceTab(tab) && <CreateResource tab={tab} />}
{isEditResourceTab(tab) && <EditResource tab={tab} />}
{isInstallChartTab(tab) && <InstallChart tab={tab} />}
{isUpgradeChartTab(tab) && <UpgradeChart tab={tab} />}
{isTerminalTab(tab) && <TerminalWindow tab={tab} />}
</div>
)
}
@@ -104,11 +84,16 @@ export class Dock extends React.Component<Props> {
onKeyDown={this.onKeydown}
tabIndex={-1}
>
<Draggable
className={cssNames("resizer", { disabled: !hasTabs() })}
horizontal={false}
onStart={this.onResizeStart}
onEnter={this.onResize}
<ResizingAnchor
disabled={!hasTabs()}
getCurrentExtent={() => dockStore.height}
minExtent={dockStore.minHeight}
maxExtent={dockStore.maxHeight}
direction={ResizeDirection.VERTICAL}
onStart={dockStore.open}
onMinExtentSubceed={dockStore.close}
onMinExtentExceed={dockStore.open}
onDrag={dockStore.setHeight}
/>
<div className="tabs-container flex align-center" onDoubleClick={prevDefault(toggle)}>
<Tabs
@@ -121,11 +106,11 @@ export class Dock extends React.Component<Props> {
<div className="dock-menu box grow">
<MenuActions usePortal triggerIcon={{ material: "add", className: "new-dock-tab", tooltip: <Trans>New tab</Trans> }} closeOnScroll={false}>
<MenuItem className="create-terminal-tab" onClick={() => createTerminalTab()}>
<Icon small svg="terminal" size={15}/>
<Icon small svg="terminal" size={15} />
<Trans>Terminal session</Trans>
</MenuItem>
<MenuItem className="create-resource-tab" onClick={() => createResourceTab()}>
<Icon small material="create"/>
<Icon small material="create" />
<Trans>Create resource</Trans>
</MenuItem>
</MenuActions>
@@ -133,7 +118,7 @@ export class Dock extends React.Component<Props> {
{hasTabs() && (
<>
<Icon
material={fullSize ? "fullscreen_exit": "fullscreen"}
material={fullSize ? "fullscreen_exit" : "fullscreen"}
tooltip={fullSize ? <Trans>Exit full size mode</Trans> : <Trans>Fit to window</Trans>}
onClick={toggleFillSize}
/>
@@ -90,11 +90,6 @@ export class EditResource extends React.Component<Props> {
const { kind, getNs, getName } = resource;
return (
<div className={cssNames("EditResource flex column", this.props.className)}>
<EditorPanel
tabId={tabId}
value={draft}
onChange={onChange}
/>
<InfoPanel
tabId={tabId}
error={error}
@@ -109,6 +104,11 @@ export class EditResource extends React.Component<Props> {
</div>
)}
/>
<EditorPanel
tabId={tabId}
value={draft}
onChange={onChange}
/>
</div>
)
}
+2 -27
View File
@@ -2,7 +2,7 @@
@include hidden-scrollbar;
background: $dockInfoBackground;
border-top: 1px solid $dockInfoBorderColor;
border-bottom: 1px solid $dockInfoBorderColor;
padding: $padding $padding * 2;
flex-shrink: 0;
@@ -12,37 +12,12 @@
> .controls {
white-space: nowrap;
&:empty {
display: none;
}
flex: 1 1;
&:not(:empty) + .info {
border: 1px solid $borderColor;
border-top: 0;
border-bottom: 0;
min-height: 25px;
padding-left: $padding;
padding-right: $padding;
}
}
> .info {
@include hidden-scrollbar;
min-width: 40px; // min-space for icon
flex: 1 1;
white-space: nowrap;
text-overflow: ellipsis;
> div {
padding-right: $padding;
flex-shrink: 0;
}
.Icon {
margin: 0;
margin-right: $padding;
}
}
}
+14 -30
View File
@@ -38,35 +38,29 @@ export class InfoPanel extends Component<Props> {
showNotifications: true,
}
@observable.ref result: ReactNode;
@observable error = "";
@observable waiting = false;
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.props.tabId, () => {
this.result = ""
this.error = ""
this.waiting = false
})
])
}
@computed get errorInfo() {
return this.error || this.props.error;
return this.props.error;
}
submit = async () => {
const { showNotifications } = this.props;
this.result = "";
this.error = "";
this.waiting = true;
try {
this.result = await this.props.submit()
if (showNotifications) Notifications.ok(this.result);
const result = await this.props.submit();
if (showNotifications) Notifications.ok(result);
} catch (error) {
this.error = error.toString();
if (showNotifications) Notifications.error(this.error);
if (showNotifications) Notifications.error(error.toString());
} finally {
this.waiting = false
}
@@ -81,27 +75,15 @@ export class InfoPanel extends Component<Props> {
dockStore.closeTab(this.props.tabId);
}
renderInfo() {
if (!this.props.showInlineInfo) {
renderErrorIcon() {
if (!this.props.showInlineInfo || !this.errorInfo) {
return;
}
const { result, errorInfo } = this;
return (
<>
{result && (
<div className="success flex align-center">
<Icon material="done" />
<span>{result}</span>
</div>
)}
{errorInfo && (
<div className="error flex align-center">
<Icon material="error_outline" />
<span>{errorInfo}</span>
</div>
)}
</>
)
<div className="error">
<Icon material="error_outline" tooltip={this.errorInfo}/>
</div>
);
}
render() {
@@ -114,11 +96,13 @@ export class InfoPanel extends Component<Props> {
{controls}
</div>
<div className="info flex gaps align-center">
{waiting ? <><Spinner /> {submittingMessage}</> : this.renderInfo()}
{waiting ? <><Spinner /> {submittingMessage}</> : this.renderErrorIcon()}
</div>
<Button plain label={<Trans>Cancel</Trans>} onClick={close} />
<Button
primary active
active
outlined={showSubmitClose}
primary={!showSubmitClose}// one button always should be primary (blue)
label={submitLabel}
onClick={submit}
disabled={isDisabled}
@@ -174,11 +174,6 @@ export class InstallChart extends Component<Props> {
return (
<div className="InstallChart flex column">
<EditorPanel
tabId={tabId}
value={values}
onChange={this.onValuesChange}
/>
<InfoPanel
tabId={tabId}
controls={panelControls}
@@ -188,6 +183,11 @@ export class InstallChart extends Component<Props> {
submittingMessage={_i18n._(t`Installing...`)}
showSubmitClose={false}
/>
<EditorPanel
tabId={tabId}
value={values}
onChange={this.onValuesChange}
/>
</div>
);
}
+4 -3
View File
@@ -121,6 +121,8 @@ export class Terminal {
}
fit = () => {
// Since this function is debounced we need to read this value as late as possible
if (!this.isActive) return;
this.fitAddon.fit();
const { cols, rows } = this.xterm;
this.api.sendTerminalSize(cols, rows);
@@ -150,7 +152,6 @@ export class Terminal {
}
onResize = () => {
if (!this.isActive) return;
this.fitLazy();
this.focus();
}
@@ -176,8 +177,8 @@ export class Terminal {
if (this.xterm.hasSelection()) return false;
break;
// Ctrl+W: prevent unexpected terminal tab closing, e.g. editing file in vim
// https://github.com/kontena/lens-app/issues/156#issuecomment-534906480
// Ctrl+W: prevent unexpected terminal tab closing, e.g. editing file in vim
// https://github.com/kontena/lens-app/issues/156#issuecomment-534906480
case "KeyW":
evt.preventDefault();
break;
@@ -106,18 +106,13 @@ export class UpgradeChart extends React.Component<Props> {
themeName="outlined"
value={version}
options={versions}
formatOptionLabel={this.formatVersionLabel}
formatOptionLabel={this.formatVersionLabel}
onChange={({ value }: SelectOption) => this.version = value}
/>
</div>
)
return (
<div className={cssNames("UpgradeChart flex column", className)}>
<EditorPanel
tabId={tabId}
value={value}
onChange={onChange}
/>
<InfoPanel
tabId={tabId}
error={error}
@@ -126,6 +121,11 @@ export class UpgradeChart extends React.Component<Props> {
submittingMessage={_i18n._(t`Updating..`)}
controls={controlsAndInfo}
/>
<EditorPanel
tabId={tabId}
value={value}
onChange={onChange}
/>
</div>
)
}
@@ -1,5 +0,0 @@
body.dragging {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
}
@@ -1,119 +0,0 @@
import "./draggable.scss";
import React from "react";
import { cssNames, IClassName, noop } from "../../utils";
import throttle from "lodash/throttle";
export interface DraggableEventHandler {
(state: DraggableState): void;
}
interface Props {
className?: IClassName;
vertical?: boolean;
horizontal?: boolean;
onStart?: DraggableEventHandler;
onEnter?: DraggableEventHandler;
onEnd?: DraggableEventHandler;
}
export interface DraggableState {
inited?: boolean;
changed?: boolean;
initX?: number;
initY?: number;
pageX?: number;
pageY?: number;
offsetX?: number;
offsetY?: number;
}
const initState: DraggableState = {
inited: false,
changed: false,
offsetX: 0,
offsetY: 0,
};
export class Draggable extends React.PureComponent<Props, DraggableState> {
public state = initState;
static IS_DRAGGING = "dragging"
static defaultProps: Props = {
vertical: true,
horizontal: true,
onStart: noop,
onEnter: noop,
onEnd: noop,
};
constructor(props: Props) {
super(props);
document.addEventListener("mousemove", this.onDrag);
document.addEventListener("mouseup", this.onDragEnd);
}
componentWillUnmount() {
document.removeEventListener("mousemove", this.onDrag);
document.removeEventListener("mouseup", this.onDragEnd);
}
onDragInit = (evt: React.MouseEvent<any>) => {
document.body.classList.add(Draggable.IS_DRAGGING);
const { pageX, pageY } = evt;
this.setState({
inited: true,
initX: pageX,
initY: pageY,
pageX: pageX,
pageY: pageY,
})
}
onDrag = throttle((evt: MouseEvent) => {
const { vertical, horizontal, onEnter, onStart } = this.props;
const { inited, pageX, pageY } = this.state;
const offsetX = pageX - evt.pageX;
const offsetY = pageY - evt.pageY;
let changed = false;
if (horizontal && offsetX !== 0) changed = true;
if (vertical && offsetY !== 0) changed = true;
if (inited && changed) {
const start = !this.state.changed;
const state = Object.assign({}, this.state, {
changed: true,
pageX: evt.pageX,
pageY: evt.pageY,
offsetX: offsetX,
offsetY: offsetY,
});
if (start) onStart(state);
this.setState(state, () => onEnter(state));
}
}, 100)
onDragEnd = (evt: MouseEvent) => {
const { pageX, pageY } = evt;
const { inited, changed, initX, initY } = this.state;
if (inited) {
document.body.classList.remove(Draggable.IS_DRAGGING);
this.setState(initState, () => {
if (!changed) return;
const state = Object.assign({}, this.state, {
offsetX: initX - pageX,
offsetY: initY - pageY,
});
this.props.onEnd(state);
});
}
}
render() {
const { className, children } = this.props;
return (
<div className={cssNames("Draggable", className)} onMouseDown={this.onDragInit}>
{children}
</div>
);
}
}
@@ -1 +0,0 @@
export * from './draggable'
@@ -48,6 +48,11 @@
float: left;
margin: $spacing;
&:after {
content: " ";
display: block;
}
&.disabled {
opacity: 0.5;
}
@@ -1,4 +1,4 @@
import { isEmail, systemName } from "./input.validators";
import { isEmail, systemName } from "../input_validators";
describe("input validation tests", () => {
describe("isEmail tests", () => {
+4 -4
View File
@@ -3,7 +3,7 @@ import "./input.scss";
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
import { autobind, cssNames, debouncePromise } from "../../utils";
import { Icon } from "../icon";
import { conditionalValidators, Validator } from "./input.validators";
import { conditionalValidators, Validator } from "./input_validators";
import isString from "lodash/isString"
import isFunction from "lodash/isFunction"
import isBoolean from "lodash/isBoolean"
@@ -288,9 +288,9 @@ export class Input extends React.Component<InputProps, State> {
return (
<div className={className}>
<label className="input-area flex gaps align-center">
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
{multiLine ? <textarea {...inputProps as any}/> : <input {...inputProps as any}/>}
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
{isString(iconLeft) ? <Icon material={iconLeft} /> : iconLeft}
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
{isString(iconRight) ? <Icon material={iconRight} /> : iconRight}
</label>
<div className="input-info flex gaps">
{!valid && dirty && (
@@ -0,0 +1 @@
export * from './resizing-anchor'
@@ -0,0 +1,46 @@
body.resizing {
user-select: none;
-moz-user-select: none;
-webkit-user-select: none;
}
.ResizingAnchor {
$dimension: 12px;
position: absolute;
z-index: 10;
&.disabled {
display: none;
}
&.vertical {
left: 0;
right: 0;
cursor: row-resize;
height: $dimension;
&.leading {
top: -$dimension / 2;
}
&.trailing {
bottom: -$dimension / 2;
}
}
&.horizontal {
top: 0;
bottom: 0;
cursor: col-resize;
width: $dimension;
&.leading {
left: -$dimension / 2;
}
&.trailing {
right: -$dimension / 2;
}
}
}
@@ -0,0 +1,281 @@
import "./resizing-anchor.scss";
import React from "react";
import { action, observable } from "mobx";
import _ from "lodash"
import { findDOMNode } from "react-dom";
import { cssNames, noop } from "../../utils";
export enum ResizeDirection {
HORIZONTAL = "horizontal",
VERTICAL = "vertical",
}
/**
* ResizeSide is for customizing where the area should be rendered.
* That location is determined in conjunction with the `ResizeDirection` using the following table:
*
* +----------+------------+----------+
* | | HORIZONTAL | VERTICAL |
* +----------+------------+----------+
* | LEADING | left | top |
* +----------+------------+----------+
* | TRAILING | right | bottom |
* +----------+------------+----------+
*/
export enum ResizeSide {
LEADING = "leading",
TRAILING = "trailing",
}
/**
* ResizeGrowthDirection determines how the anchor interprets the drag.
*
* Because the origin of the screen is top left a drag from bottom to top
* results in a negative directional delta. However, if the component being
* dragged grows in the opposite direction, this needs to be compensated for.
*/
export enum ResizeGrowthDirection {
TOP_TO_BOTTOM = 1,
BOTTOM_TO_TOP = -1,
LEFT_TO_RIGHT = 1,
RIGHT_TO_LEFT = -1,
}
interface Props {
direction: ResizeDirection;
/**
* getCurrentExtent should return the current prominent dimension in the
* given resizing direction. Width for HORIZONTAL and height for VERTICAL
*/
getCurrentExtent: () => number;
disabled?: boolean;
placement?: ResizeSide;
growthDirection?: ResizeGrowthDirection;
// Ability to restrict which mouse buttons are allowed to resize this component
// Reference: https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/buttons
onlyButtons?: number;
// onStart is called when the ResizeAnchor is first clicked (mouse down)
onStart?: () => void;
// onEnd is called when the ResizeAnchor is released (mouse up)
onEnd?: () => void;
/**
* onDrag is called whenever there is a mousemove event. All calls will be
* bounded by matching `onStart` and `onEnd` calls.
*/
onDrag?: (newExtent: number) => void;
// onDoubleClick is called when the the ResizeAnchor is double clicked
onDoubleClick?: () => void;
/**
* The following two extents represent the max and min values set to `onDrag`
*/
maxExtent?: number;
minExtent?: number;
/**
* The following events are triggerred with respect to the above values.
* - The "__Exceed" call will be made when the unbounded extent goes from
* < the above to >= the above
* - The "__Subceed" call is similar but is triggered when the unbounded
* extent goes from >= the above to < the above.
*/
onMaxExtentExceed?: () => void;
onMaxExtentSubceed?: () => void;
onMinExtentSubceed?: () => void;
onMinExtentExceed?: () => void;
}
interface Position {
readonly pageX: number;
readonly pageY: number;
}
/**
* Return the direction delta, but ignore drags leading up to a moved item
* 1. `->|` => return `false`
* 2. `<-|` => return `directed length (M, P2)` (negative)
* 3. `-|>` => return `directed length (M, P2)` (positive)
* 4. `<|-` => return `directed length (M, P2)` (negative)
* 5. `|->` => return `directed length (M, P2)` (positive)
* 6. `|<-` => return `false`
* @param P1 the starting position on the number line
* @param P2 the ending position on the number line
* @param M a third point that determines if the delta is meaningful
* @returns the directional difference between including appropriate sign.
*/
function directionDelta(P1: number, P2: number, M: number): number | false {
const delta = Math.abs(M - P2)
if (P1 < M) {
if (P2 >= M) {
// case 3
return delta
}
if (P2 < P1) {
// case 2
return -delta
}
// case 1
return false
}
if (P2 < M) {
// case 4
return -delta
}
if (P1 < P2) {
// case 5
return delta
}
// case 6
return false
}
export class ResizingAnchor extends React.PureComponent<Props> {
@observable lastMouseEvent?: MouseEvent
@observable.ref ref?: React.RefObject<HTMLDivElement>;
static defaultProps = {
onStart: noop,
onDrag: noop,
onEnd: noop,
onMaxExtentExceed: noop,
onMinExtentExceed: noop,
onMinExtentSubceed: noop,
onMaxExtentSubceed: noop,
onDoubleClick: noop,
disabled: false,
growthDirection: ResizeGrowthDirection.BOTTOM_TO_TOP,
maxExtent: Number.POSITIVE_INFINITY,
minExtent: 0,
placement: ResizeSide.LEADING,
}
static IS_RESIZING = "resizing"
constructor(props: Props) {
super(props)
if (props.maxExtent < props.minExtent) {
throw new Error("maxExtent must be >= minExtent")
}
this.ref = React.createRef<HTMLDivElement>()
}
componentWillUnmount() {
document.removeEventListener("mousemove", this.onDrag)
document.removeEventListener("mouseup", this.onDragEnd)
}
@action
onDragInit = (event: React.MouseEvent) => {
const { onStart, onlyButtons } = this.props
if (typeof onlyButtons === "number" && onlyButtons !== event.buttons) {
return
}
document.addEventListener("mousemove", this.onDrag)
document.addEventListener("mouseup", this.onDragEnd)
document.body.classList.add(ResizingAnchor.IS_RESIZING)
this.lastMouseEvent = undefined
onStart()
}
calculateDelta(from: Position, to: Position): number | false {
const node = this.ref.current
if (!node) {
return false
}
const boundingBox = node.getBoundingClientRect()
if (this.props.direction === ResizeDirection.HORIZONTAL) {
const barX = Math.round(boundingBox.x + (boundingBox.width / 2))
return directionDelta(from.pageX, to.pageX, barX)
} else { // direction === ResizeDirection.VERTICAL
const barY = Math.round(boundingBox.y + (boundingBox.height / 2))
return directionDelta(from.pageY, to.pageY, barY)
}
}
onDrag = _.throttle((event: MouseEvent) => {
/**
* Some notes to help understand the following:
* - A browser's origin point is in the top left of the screen
* - X increases going from left to right
* - Y increases going from top to bottom
* - Since the resize bar should always be a rectangle, use its centre
* line (in the resizing direction) as the line for determining if
* the bar has "jumped around"
*
* Desire:
* - Always ignore movement in the non-resizing direction
* - Figure out how much the user has "dragged" the resize bar
* - If the resize bar has jumped around, compensate by ignoring movement
* in the resizing direction if it is moving "towards" the resize bar's
* new location.
*/
if (!this.lastMouseEvent) {
this.lastMouseEvent = event
return
}
const { maxExtent, minExtent, getCurrentExtent, growthDirection } = this.props
const { onDrag, onMaxExtentExceed, onMinExtentSubceed, onMaxExtentSubceed, onMinExtentExceed } = this.props
const delta = this.calculateDelta(this.lastMouseEvent, event)
// always update the last mouse event
this.lastMouseEvent = event
if (delta === false) {
return
}
const previousExtent = getCurrentExtent()
const unboundedExtent = previousExtent + (delta * growthDirection)
const boundedExtent = Math.round(Math.max(minExtent, Math.min(maxExtent, unboundedExtent)))
onDrag(boundedExtent)
if (previousExtent <= minExtent && minExtent <= unboundedExtent) {
onMinExtentExceed()
} else if (previousExtent >= minExtent && minExtent >= unboundedExtent) {
onMinExtentSubceed()
}
if (previousExtent <= maxExtent && maxExtent <= unboundedExtent) {
onMaxExtentExceed()
} else if (previousExtent >= maxExtent && maxExtent >= unboundedExtent) {
onMaxExtentSubceed()
}
}, 100)
@action
onDragEnd = (_event: MouseEvent) => {
this.props.onEnd()
document.removeEventListener("mousemove", this.onDrag)
document.removeEventListener("mouseup", this.onDragEnd)
document.body.classList.remove(ResizingAnchor.IS_RESIZING)
}
render() {
const { disabled, direction, placement, onDoubleClick } = this.props
return <div
ref={this.ref}
className={cssNames("ResizingAnchor", direction, placement, { disabled })}
onMouseDown={this.onDragInit}
onDoubleClick={onDoubleClick}
/>
}
}
@@ -28,6 +28,7 @@ export function withTooltip<T extends React.ComponentType<any>>(Target: T): T {
const tooltipProps: TooltipProps = {
targetId: tooltipId,
tooltipOnParentHover: tooltipOverrideDisabled,
formatters: { narrow: true },
...(isReactNode(tooltip) ? { children: tooltip } : tooltip),
};
targetProps.id = tooltipId;
+4
View File
@@ -18,7 +18,11 @@ if (ipcRenderer) {
}
export function navigate(location: LocationDescriptor) {
const currentLocation = navigation.getPath();
navigation.push(location);
if (currentLocation === navigation.getPath()) {
navigation.goBack(); // prevent sequences of same url in history
}
}
export interface IURLParams<P = {}, Q = {}> {
+2 -2
View File
@@ -58,8 +58,8 @@
"colorVague": "#36393e",
"colorTerminated": "#4c5053",
"dockHeadBackground": "#2e3136",
"dockInfoBackground": "#111111",
"dockInfoBorderColor": "#4c5053",
"dockInfoBackground": "#1e2125",
"dockInfoBorderColor": "#303136",
"terminalBackground": "#000000",
"terminalForeground": "#ffffff",
"terminalCursor": "#ffffff",
@@ -1,4 +1,4 @@
import { formatDuration } from "./formatDuration";
import { formatDuration } from "../formatDuration";
const second = 1000;
const minute = 60 * second;
@@ -9,34 +9,34 @@ const week = 7 * day;
describe("human format durations", () => {
test("long formatted durations less than 24 hours long shouldn't have a 'd' component", () => {
const res = formatDuration(19 * 60 * 60 * 1000, false);
expect(res).not.toContain("d");
expect(res).toBe("19h");
});
test("long formatted durations more than a week have correct day count", () => {
const res = formatDuration(2 * week + 2 * day, false);
expect(res).toBe("2w 2d");
});
test("durations > 1/2 week shouldn't show 1w has passed", () => {
const res = formatDuration(5 * 24 * 60 * 60 * 1000, false);
expect(res).not.toContain("w");
expect(res).toBe("5d");
});
test("durations shouldn't include zero magnitude parts", () => {
const res = formatDuration(6 * day + 2 * minute, false);
expect(res).not.toContain("h");
expect(res).toBe("6d 2m");
});
test("seconds are ignored unless they are significant (< 1m)", () => {
const insignificant = formatDuration(1 * hour + 2 * minute + 31 * second, false);
expect(insignificant).not.toContain("s");
expect(insignificant).toBe("1h 2m");
@@ -1,4 +1,4 @@
import { metricUnitsToNumber } from "./metricUnitsToNumber";
import { metricUnitsToNumber } from "../metricUnitsToNumber";
describe("metricUnitsToNumber tests", () => {
test("plain number", () => {