Skip to content

Commit

Permalink
[v17] Web/Teleterm: add support for requesting for kube namespaces (#…
Browse files Browse the repository at this point in the history
…48015)

* Web: add support for requesting for kube namespaces

* Teleterm: add support for access requesting kube namespaces (#47347)
  • Loading branch information
kimlisa authored Oct 28, 2024
1 parent 56142a7 commit 76d678d
Show file tree
Hide file tree
Showing 33 changed files with 1,374 additions and 302 deletions.
28 changes: 18 additions & 10 deletions web/packages/design/src/DataTable/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React, { ReactNode } from 'react';
import React, { ReactNode, PropsWithChildren } from 'react';

import { Box, Flex, Indicator, P1, Text } from 'design';
import * as Icons from 'design/Icon';
Expand Down Expand Up @@ -108,6 +108,22 @@ export default function Table<T>(props: TableProps<T>) {
return <LoadingIndicator colSpan={columns.length} />;
}
data.map((item, rowIdx) => {
const TableRow: React.FC<PropsWithChildren> = ({ children }) => (
<tr
key={rowIdx}
onClick={() => row?.onClick?.(item)}
style={row?.getStyle?.(item)}
>
{children}
</tr>
);

const customRow = row?.customRow?.(item);
if (customRow) {
rows.push(<TableRow key={rowIdx}>{customRow}</TableRow>);
return;
}

const cells = columns.flatMap((column, columnIdx) => {
if (column.isNonRender) {
return []; // does not include this column.
Expand All @@ -125,15 +141,7 @@ export default function Table<T>(props: TableProps<T>) {
</React.Fragment>
);
});
rows.push(
<tr
key={rowIdx}
onClick={() => row?.onClick?.(item)}
style={row?.getStyle?.(item)}
>
{cells}
</tr>
);
rows.push(<TableRow key={rowIdx}>{cells}</TableRow>);
});

if (rows.length) {
Expand Down
8 changes: 8 additions & 0 deletions web/packages/design/src/DataTable/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ export type TableProps<T> = {
* conditionally style a row (eg: cursor: pointer, disabled)
*/
getStyle?(row: T): React.CSSProperties;
/**
* conditionally render a custom row
* use case: by default all columns are represented by cells
* but certain rows you need all the columns to be merged
* into one cell to render other related elements like a
* dropdown selector.
*/
customRow?(row: T): JSX.Element;
};
};

Expand Down
1 change: 0 additions & 1 deletion web/packages/design/src/Link/Link.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ const StyledButtonLink = styled.a.attrs({
rel: 'noreferrer',
})`
color: ${({ theme }) => theme.colors.buttons.link.default};
font-weight: normal;
background: none;
text-decoration: underline;
text-transform: none;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import React from 'react';
import { Flex, Text } from 'design';
import { components, OptionProps } from 'react-select';

import { Option as BaseOption } from 'shared/components/Select';

export type Option = BaseOption & {
isAdded?: boolean;
kind: 'app' | 'user_group' | 'namespace';
};

export const CheckableOptionComponent = (
props: OptionProps<Option> & { data: Option }
) => {
const { data } = props;
return (
<components.Option {...props}>
<Flex alignItems="center" py="8px" px="12px">
<input
type="checkbox"
checked={data.isAdded || props.isSelected}
readOnly
name={data.value}
id={data.value}
/>{' '}
<Text ml={1}>{data.label}</Text>
</Flex>
</components.Option>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/**
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { Cross } from 'design/Icon';

import { Attempt } from 'shared/hooks/useAttemptNext';

export function CrossIcon<T>({
clearAttempt,
toggleResource,
item,
createAttempt,
}: {
clearAttempt: () => void;
toggleResource: (resource: T) => void;
item: T;
createAttempt: Attempt;
}) {
return (
<Cross
size="small"
borderRadius={2}
p={2}
onClick={() => {
clearAttempt();
toggleResource(item);
}}
disabled={createAttempt.status === 'processing'}
css={`
cursor: pointer;
background-color: ${({ theme }) =>
theme.colors.buttons.trashButton.default};
border-radius: 2px;
&:hover {
background-color: ${({ theme }) =>
theme.colors.buttons.trashButton.hover};
}
`}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
/**
* Teleport
* Copyright (C) 2024 Gravitational, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { useState } from 'react';
import styled from 'styled-components';
import { Box } from 'design';
import { ActionMeta } from 'react-select';

import { Option } from 'shared/components/Select';
import { FieldSelectAsync } from 'shared/components/FieldSelect';

import { requiredField } from 'shared/components/Validation/rules';

import { CheckableOptionComponent } from '../CheckableOption';

import { PendingListItem, PendingKubeResourceItem } from './RequestCheckout';

import type { KubeNamespaceRequest } from '../kube';

export function KubeNamespaceSelector({
kubeClusterItem,
fetchKubeNamespaces,
savedResourceItems,
toggleResource,
bulkToggleKubeResources,
namespaceRequired,
}: {
kubeClusterItem: PendingListItem;
fetchKubeNamespaces(p: KubeNamespaceRequest): Promise<string[]>;
savedResourceItems: PendingListItem[];
toggleResource: (resource: PendingListItem) => void;
bulkToggleKubeResources: (
resources: PendingKubeResourceItem[],
resource: PendingListItem
) => void;
namespaceRequired: boolean;
}) {
// Flag is used to determine if we need to perform batch action
// eg: When menu is open, we want to apply changes only after
// user closes the menu. Actions performed when menu is closed
// requires immediate changes such as clicking on delete or clear
// all button.
const [isMenuOpen, setIsMenuOpen] = useState(false);
// This is required to support loading options after a user has
// clicked open a dropdown, and supports saving this initial
// options for future (clicking the dropdown again).
const [initOptions, setInitOptions] = useState<Option[]>([]);

const currKubeClustersNamespaceItems = savedResourceItems.filter(
resource =>
resource.kind === 'namespace' && resource.id === kubeClusterItem.id
) as PendingKubeResourceItem[];

const [selectedOpts, setSelectedOpts] = useState<Option[]>(() =>
currKubeClustersNamespaceItems.map(namespace => ({
label: namespace.subResourceName,
value: namespace.subResourceName,
}))
);

function handleChange(options: Option[], actionMeta: ActionMeta<Option>) {
if (isMenuOpen) {
setSelectedOpts(options);
return;
}

switch (actionMeta.action) {
case 'clear':
bulkToggleKubeResources(
currKubeClustersNamespaceItems,
kubeClusterItem
);
return;
case 'remove-value':
toggleResource({
kind: 'namespace',
id: kubeClusterItem.id,
subResourceName: actionMeta.removedValue.value,
clusterName: kubeClusterItem.clusterName,
name: actionMeta.removedValue.value,
});
return;
}
}

const handleMenuClose = () => {
setIsMenuOpen(false);

const currNamespaces = currKubeClustersNamespaceItems.map(
n => n.subResourceName
);
const selectedNamespaceIds = selectedOpts.map(o => o.value);
const toKeep = selectedNamespaceIds.filter(id =>
currNamespaces.includes(id)
);

const toInsert = selectedNamespaceIds.filter(o => !toKeep.includes(o));
const toRemove = currNamespaces.filter(n => !toKeep.includes(n));

if (!toInsert.length && !toRemove.length) {
return;
}

bulkToggleKubeResources(
[...toRemove, ...toInsert].map(namespace => ({
kind: 'namespace',
id: kubeClusterItem.id,
subResourceName: namespace,
clusterName: kubeClusterItem.clusterName,
name: namespace,
})),
kubeClusterItem
);
};

async function handleLoadOptions(input: string) {
const namespaces = await fetchKubeNamespaces({
kubeCluster: kubeClusterItem.id,
search: input,
});

return namespaces.map(namespace => ({
kind: 'namespace',
value: namespace,
label: namespace,
}));
}

return (
<Box width="100%" mb={-3}>
<StyledSelect
label={`Namespaces${namespaceRequired ? ' (required)' : ''}:`}
inputId={kubeClusterItem.id}
width="100%"
placeholder="Start typing a namespace and press enter"
isMulti
isClearable={false}
isSearchable
closeMenuOnSelect={false}
hideSelectedOptions={false}
onMenuClose={handleMenuClose}
onMenuOpen={() => setIsMenuOpen(true)}
components={{
Option: CheckableOptionComponent,
}}
loadOptions={handleLoadOptions}
onChange={handleChange}
value={selectedOpts}
menuPosition="fixed" /* required to render dropdown out of its row */
rule={
namespaceRequired
? requiredField('namespace selection required')
: undefined
}
initOptionsOnMenuOpen={(opts: Option[]) => setInitOptions(opts)}
defaultOptions={initOptions}
/>
</Box>
);
}

const StyledSelect = styled(FieldSelectAsync)`
input[type='checkbox'] {
cursor: pointer;
}
.react-select__control {
font-size: ${p => p.theme.fontSizes[1]}px;
width: 350px;
background: ${p => p.theme.colors.levels.elevated};
&:hover {
background: ${p => p.theme.colors.levels.elevated};
}
}
.react-select__menu {
font-size: ${p => p.theme.fontSizes[1]}px;
width: 350px;
right: 0;
margin-bottom: 0;
}
.react-select__option {
padding: 0;
font-size: ${p => p.theme.fontSizes[1]}px;
}
.react-select__value-container {
position: static;
}
.react-select__placeholder {
color: ${p => p.theme.colors.text.main};
}
`;
Loading

0 comments on commit 76d678d

Please sign in to comment.