| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949 |
- import React, { useContext, useEffect, useState } from "react";
- import {
- GetFinalVariablesFunction,
- GetMetadataFunction,
- KeyValueArrayField,
- KeyValueArrayFieldState,
- PopulatedEnvGroup,
- } from "../types";
- import sliders from "../../../assets/sliders.svg";
- import upload from "../../../assets/upload.svg";
- import styled, { keyframes } from "styled-components";
- import useFormField from "../hooks/useFormField";
- import Modal from "../../../main/home/modals/Modal";
- import LoadEnvGroupModal from "../../../main/home/modals/LoadEnvGroupModal";
- import EnvEditorModal from "../../../main/home/modals/EnvEditorModal";
- import { hasSetValue } from "../utils";
- import _, { isObject, differenceBy, omit } from "lodash";
- import Helper from "components/form-components/Helper";
- import Heading from "components/form-components/Heading";
- import Loading from "components/Loading";
- import api from "shared/api";
- import { Context } from "shared/Context";
- import { dotenv_parse } from "shared/string_utils";
- interface Props extends KeyValueArrayField {
- id: string;
- }
- const KeyValueArray: React.FC<Props> = (props) => {
- const { state, setState, variables } = useFormField<KeyValueArrayFieldState>(
- props.id,
- {
- initState: () => {
- let values = props.value[0];
- const normalValues = Object.entries(values?.normal || {});
- values = omit(values, ["normal", "synced", "build"]);
- return {
- values: hasSetValue(props)
- ? ([...Object.entries(values), ...normalValues]?.map(([k, v]) => {
- return { key: k, value: v };
- }) as any[])
- : [],
- showEnvModal: false,
- showEditorModal: false,
- synced_env_groups: props.settings?.options?.enable_synced_env_groups
- ? null
- : [],
- };
- },
- }
- );
- const { currentProject } = useContext(Context);
- // If the variable includes normal it means that the form corresponds to an old job template version
- // The "normal" keyword doesn't exist for applications as well as the enable_synced_env_groups setting.
- // This is why we have to check if the form corresponds to a job or not.
- const enableSyncedEnvGroups = props.variable.includes("normal")
- ? !!props.settings?.options?.enable_synced_env_groups
- : true;
- useEffect(() => {
- if (hasSetValue(props) && !Array.isArray(state?.synced_env_groups)) {
- const values = props.value[0];
- // console.log(values);
- const envGroups = values?.synced || [];
- const promises = Promise.all(
- envGroups.map(async (envGroup: any) => {
- const res = await api.getEnvGroup(
- "<token>",
- {},
- {
- id: currentProject.id,
- cluster_id: variables.clusterId,
- namespace: variables.namespace,
- name: envGroup?.name,
- version: envGroup.version,
- }
- );
- return res.data;
- })
- );
- promises.then((populatedEnvGroups) => {
- setState(() => ({
- synced_env_groups: Array.isArray(populatedEnvGroups)
- ? populatedEnvGroups
- : [],
- }));
- });
- }
- }, [
- props.value[0],
- variables?.clusterId,
- variables?.namespace,
- currentProject?.id,
- ]);
- if (state == undefined) return <></>;
- if (!Array.isArray(state.synced_env_groups) && enableSyncedEnvGroups) {
- return <Loading />;
- }
- const parseEnv = (src: any, options: any) => {
- return dotenv_parse(src);
- };
- const readFile = (env: string) => {
- let envObj = parseEnv(env, null);
- let push = true;
- for (let key in envObj) {
- for (var i = 0; i < state.values.length; i++) {
- let existingKey = state.values[i]["key"];
- if (key === existingKey) {
- state.values[i]["value"] = envObj[key];
- push = false;
- }
- }
- if (push) {
- setState((prev) => {
- return {
- values: [...prev.values, { key, value: envObj[key] }],
- };
- });
- }
- }
- };
- const renderEditorModal = () => {
- if (state.showEditorModal) {
- return (
- <Modal
- onRequestClose={() =>
- setState(() => {
- return { showEditorModal: false };
- })
- }
- width="60%"
- height="80%"
- >
- <EnvEditorModal
- closeModal={() =>
- setState(() => {
- return { showEditorModal: false };
- })
- }
- setEnvVariables={(envFile: string) => readFile(envFile)}
- />
- </Modal>
- );
- }
- };
- const getProcessedValues = (
- objectArray: { key: string; value: string }[]
- ): any => {
- let obj = {} as any;
- objectArray?.forEach(({ key, value }) => {
- obj[key] = value;
- });
- return obj;
- };
- const renderEnvModal = () => {
- if (state.showEnvModal) {
- return (
- <Modal
- onRequestClose={() =>
- setState(() => {
- return { showEnvModal: false };
- })
- }
- width="800px"
- height="542px"
- >
- <LoadEnvGroupModal
- existingValues={getProcessedValues(state.values)}
- enableSyncedEnvGroups={enableSyncedEnvGroups}
- syncedEnvGroups={state.synced_env_groups}
- namespace={variables.namespace}
- clusterId={variables.clusterId}
- closeModal={() =>
- setState(() => {
- return {
- showEnvModal: false,
- };
- })
- }
- setSyncedEnvGroups={(value) => {
- setState((prev) => {
- return {
- synced_env_groups: [...(prev.synced_env_groups || []), value],
- };
- });
- }}
- setValues={(values) => {
- setState((prev) => {
- // Transform array to object similar on what we receive from setValues
- const prevValues = prev.values.reduce((acc, currentValue) => {
- acc[currentValue.key] = currentValue.value;
- return acc;
- }, {} as Record<string, string>);
- // Deconstruct the two records/objects inside one to merge their values (this will override the old duped vars too)
- // and convert the new object back to an array usable for the component
- const newValues = Object.entries({
- ...prevValues,
- ...values,
- })?.map(([k, v]) => {
- return {
- key: k,
- value: v,
- };
- });
- return {
- values: [...newValues],
- };
- });
- }}
- />
- </Modal>
- );
- }
- };
- const renderDeleteButton = (i: number) => {
- if (!props.isReadOnly) {
- return (
- <DeleteButton
- onClick={() => {
- state.values.splice(i, 1);
- setState((prev) => {
- return {
- values: prev.values
- .slice(0, i + 1)
- .concat(prev.values.slice(i + 1, prev.values.length)),
- };
- });
- }}
- >
- <i className="material-icons">cancel</i>
- </DeleteButton>
- );
- }
- };
- const renderHiddenOption = (hidden: boolean, i: number) => {
- if (props.secretOption && hidden) {
- return (
- <HideButton>
- <i className="material-icons">lock</i>
- </HideButton>
- );
- }
- };
- const checkOverridedKey = (key: string) => {
- const env_group = state.synced_env_groups.find(
- (env) => env?.variables && env?.variables[key]
- );
- if (env_group) {
- return (
- <Wrapper>
- <Helper color="#f5cb42" style={{ marginLeft: "10px" }}>
- Overridden by the env group "{env_group?.name}"
- </Helper>
- </Wrapper>
- );
- }
- return null;
- };
- const renderInputList = () => {
- return (
- <>
- {state.values?.map((entry: any, i: number) => {
- // Preprocess non-string env values set via raw Helm values
- let { value } = entry;
- if (typeof value === "object") {
- value = JSON.stringify(value);
- } else if (typeof value === "number" || typeof value === "boolean") {
- value = value.toString();
- }
- return (
- <InputWrapper key={i}>
- <KeyInput
- placeholder="ex: key"
- width="270px"
- value={entry.key}
- onChange={(e: any) => {
- e.persist();
- setState((prev) => {
- return {
- values: prev.values?.map((t, j) => {
- if (j == i) {
- return {
- ...t,
- key: e.target.value,
- };
- }
- return t;
- }),
- };
- });
- }}
- disabled={props.isReadOnly || value.includes("PORTERSECRET")}
- spellCheck={false}
- borderColor={
- checkOverridedKey(entry.key) ? "#f5cb42" : undefined
- }
- />
- <Spacer />
- {value?.includes("PORTERSECRET") ? (
- <KeyInput
- placeholder="ex: value"
- width="270px"
- disabled
- type={"password"}
- spellCheck={false}
- />
- ) : (
- <MultiLineInput
- placeholder="ex: value"
- width="270px"
- value={value}
- onChange={(e: any) => {
- e.persist();
- setState((prev) => {
- return {
- values: prev.values?.map((t, j) => {
- if (j == i) {
- return {
- ...t,
- value: e.target.value,
- };
- }
- return t;
- }),
- };
- });
- }}
- disabled={props.isReadOnly}
- spellCheck={false}
- rows={value?.split("\n").length}
- />
- )}
- {renderDeleteButton(i)}
- {renderHiddenOption(value.includes("PORTERSECRET"), i)}
- {checkOverridedKey(entry.key)}
- </InputWrapper>
- );
- })}
- </>
- );
- };
- return (
- <>
- <StyledInputArray>
- <Label>{props.label}</Label>
- {state.values.length === 0 ? <></> : renderInputList()}
- {props.isReadOnly ? (
- <></>
- ) : (
- <InputWrapper>
- <AddRowButton
- onClick={() => {
- setState((prev) => {
- return {
- values: [...prev.values, { key: "", value: "" }],
- };
- });
- }}
- >
- <i className="material-icons">add</i> Add Row
- </AddRowButton>
- <Spacer />
- {variables.namespace && props.envLoader && (
- <LoadButton
- onClick={() =>
- setState((prev) => {
- return {
- showEnvModal: !prev.showEnvModal,
- };
- })
- }
- >
- <img src={sliders} /> Load from Env Group
- </LoadButton>
- )}
- {props.fileUpload && (
- <UploadButton
- onClick={() => {
- setState((prev) => {
- return {
- showEditorModal: true,
- };
- });
- }}
- >
- <img src={upload} /> Copy from File
- </UploadButton>
- )}
- </InputWrapper>
- )}
- {enableSyncedEnvGroups && !!state.synced_env_groups?.length && (
- <>
- <Heading>Synced Environment Groups</Heading>
- <Br />
- {state.synced_env_groups?.map((envGroup: any) => {
- return (
- <ExpandableEnvGroup
- key={envGroup?.name}
- envGroup={envGroup}
- onDelete={() => {
- setState((prev) => {
- const synced = prev.synced_env_groups?.filter(
- (env) => env.name !== envGroup.name
- );
- return {
- ...prev,
- synced_env_groups: synced,
- };
- });
- }}
- />
- );
- })}
- </>
- )}
- </StyledInputArray>
- {renderEnvModal()}
- {renderEditorModal()}
- </>
- );
- };
- export const getFinalVariablesForKeyValueArray: GetFinalVariablesFunction = (
- vars,
- props: KeyValueArrayField,
- state: KeyValueArrayFieldState
- ) => {
- if (!state) {
- return {
- [props.variable]: hasSetValue(props) ? props.value[0] : [],
- };
- }
- const isNumber = (s: string) => {
- return !isNaN(!s ? NaN : Number(String(s).trim()));
- };
- const rg = /(?:^|[^\\])(\\n)/g;
- const fixNewlines = (s: string) => {
- while (rg.test(s)) {
- s = s.replace(rg, (str) => {
- if (str.length == 2) return "\n";
- if (str[0] != "\\") return str[0] + "\n";
- return "\\n";
- });
- }
- return s;
- };
- if (props.variable.includes("env")) {
- let obj = {
- normal: {},
- } as any;
- state.values.forEach((entry: any, i: number) => {
- if (isNumber(entry.value)) {
- obj.normal[entry.key] = entry.value;
- } else {
- obj.normal[entry.key] = fixNewlines(entry.value);
- }
- });
- if (Array.isArray(props.value) && props.value[0]?.build) {
- obj.build = props.value[0].build;
- }
- if (state.synced_env_groups?.length) {
- obj.synced = state.synced_env_groups.map((envGroup) => ({
- name: envGroup?.name,
- version: envGroup?.version,
- keys: Object.entries(envGroup?.variables || {}).map(([key, val]) => ({
- name: key,
- secret: val.includes("PORTERSECRET"),
- })),
- }));
- }
- const variableContent = props.variable.split(".");
- let variable = props.variable;
- if (variable.includes("normal")) {
- variable = `${variableContent[0]}.${variableContent[1]}`;
- }
- return {
- [variable]: obj,
- };
- } else {
- let obj = {} as any;
- state.values.forEach((entry: any, i: number) => {
- if (isNumber(entry.value)) {
- obj[entry.key] = entry.value;
- } else {
- obj[entry.key] = fixNewlines(entry.value);
- }
- });
- return {
- [props.variable]: obj,
- };
- }
- };
- type KeyValueArrayMetadata = {
- [variable: string]: {
- added: { name: string }[];
- deleted: { name: string }[];
- };
- };
- export const getMetadata: GetMetadataFunction<KeyValueArrayMetadata> = (
- vars,
- props: KeyValueArrayField,
- state: KeyValueArrayFieldState
- ) => {
- // We don't need any metadata for other key-value-array fields yet so we return null for that variable
- if (!state || !props?.variable?.includes("env")) {
- return {
- [props.variable]: null,
- };
- }
- const originalSyncedEnvGroups: { name: string }[] =
- props.value[0]?.synced || [];
- const currSynced = state?.synced_env_groups || [];
- let obj: KeyValueArrayMetadata[""] = {
- added: [],
- deleted: [],
- };
- obj.added = differenceBy(currSynced, originalSyncedEnvGroups, "name");
- obj.deleted = differenceBy(originalSyncedEnvGroups, currSynced, "name");
- // This will assure that the variable is always "container.env" and not "container.env.normal" as it is
- // for some old versions of the jobs chart.
- const variableContent = props.variable.split(".");
- let variable = props.variable;
- if (variable.includes("normal")) {
- variable = `${variableContent[0]}.${variableContent[1]}`;
- }
- return {
- [variable]: obj,
- };
- };
- export default KeyValueArray;
- const ExpandableEnvGroup: React.FC<{
- envGroup: PopulatedEnvGroup;
- onDelete: () => void;
- }> = ({ envGroup, onDelete }) => {
- const [isExpanded, setIsExpanded] = useState(false);
- return (
- <>
- <StyledCard>
- <Flex>
- <ContentContainer>
- <EventInformation>
- <EventName>{envGroup.name}</EventName>
- </EventInformation>
- </ContentContainer>
- <ActionContainer>
- <ActionButton onClick={() => onDelete()}>
- <span className="material-icons">delete</span>
- </ActionButton>
- <ActionButton onClick={() => setIsExpanded((prev) => !prev)}>
- <i className="material-icons">
- {isExpanded ? "arrow_drop_up" : "arrow_drop_down"}
- </i>
- </ActionButton>
- </ActionContainer>
- </Flex>
- {isExpanded && (
- <>
- <Buffer />
- {isObject(envGroup.variables) ? (
- <>
- {Object.entries(envGroup.variables || {})?.map(
- ([key, value], i: number) => {
- // Preprocess non-string env values set via raw Helm values
- if (typeof value === "object") {
- value = JSON.stringify(value);
- } else {
- value = String(value);
- }
- return (
- <InputWrapper key={i}>
- <KeyInput
- placeholder="ex: key"
- width="270px"
- value={key}
- disabled
- />
- <Spacer />
- {value?.includes("PORTERSECRET") ? (
- <KeyInput
- placeholder="ex: value"
- width="270px"
- value={value}
- disabled
- type={
- value.includes("PORTERSECRET")
- ? "password"
- : "text"
- }
- />
- ) : (
- <MultiLineInput
- placeholder="ex: value"
- width="270px"
- value={value}
- disabled
- rows={value?.split("\n").length}
- spellCheck={false}
- ></MultiLineInput>
- )}
- </InputWrapper>
- );
- }
- )}
- </>
- ) : (
- <NoVariablesTextWrapper>
- This env group has no variables yet
- </NoVariablesTextWrapper>
- )}
- <Br />
- </>
- )}
- </StyledCard>
- </>
- );
- };
- const Br = styled.div`
- width: 100%;
- height: 1px;
- `;
- const Buffer = styled.div`
- width: 100%;
- height: 10px;
- `;
- const Spacer = styled.div`
- width: 10px;
- height: 20px;
- `;
- const AddRowButton = styled.div`
- display: flex;
- align-items: center;
- width: 270px;
- font-size: 13px;
- color: #aaaabb;
- height: 32px;
- border-radius: 3px;
- cursor: pointer;
- background: #ffffff11;
- :hover {
- background: #ffffff22;
- }
- > i {
- color: #ffffff44;
- font-size: 16px;
- margin-left: 8px;
- margin-right: 10px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- `;
- const LoadButton = styled(AddRowButton)`
- background: none;
- border: 1px solid #ffffff55;
- > i {
- color: #ffffff44;
- font-size: 16px;
- margin-left: 8px;
- margin-right: 10px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- > img {
- width: 14px;
- margin-left: 10px;
- margin-right: 12px;
- }
- `;
- const UploadButton = styled(AddRowButton)`
- background: none;
- position: relative;
- margin-left: 10px;
- border: 1px solid #ffffff55;
- > i {
- color: #ffffff44;
- font-size: 16px;
- margin-left: 8px;
- margin-right: 10px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- > img {
- width: 14px;
- margin-left: 10px;
- margin-right: 12px;
- }
- `;
- const DeleteButton = styled.div`
- width: 15px;
- height: 15px;
- display: flex;
- align-items: center;
- margin-left: 8px;
- margin-top: -3px;
- justify-content: center;
- > i {
- font-size: 17px;
- color: #ffffff44;
- display: flex;
- align-items: center;
- justify-content: center;
- cursor: pointer;
- :hover {
- color: #ffffff88;
- }
- }
- `;
- const HideButton = styled(DeleteButton)`
- margin-top: -5px;
- > i {
- font-size: 19px;
- cursor: default;
- :hover {
- color: #ffffff44;
- }
- }
- `;
- const Wrapper = styled.div`
- margin-left: 5px;
- height: 20px;
- display: flex;
- align-items: center;
- margin-top: -7px;
- `;
- const InputWrapper = styled.div`
- display: flex;
- align-items: center;
- margin-top: 5px;
- `;
- type InputProps = {
- disabled?: boolean;
- width: string;
- borderColor?: string;
- };
- const KeyInput = styled.input<InputProps>`
- outline: none;
- border: none;
- margin-bottom: 5px;
- font-size: 13px;
- background: #ffffff11;
- border: 1px solid
- ${(props) => (props.borderColor ? props.borderColor : "#ffffff55")};
- border-radius: 3px;
- width: ${(props) => (props.width ? props.width : "270px")};
- color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
- padding: 5px 10px;
- height: 35px;
- `;
- export const MultiLineInput = styled.textarea<InputProps>`
- outline: none;
- border: none;
- margin-bottom: 5px;
- font-size: 13px;
- background: #ffffff11;
- border: 1px solid
- ${(props) => (props.borderColor ? props.borderColor : "#ffffff55")};
- border-radius: 3px;
- min-width: ${(props) => (props.width ? props.width : "270px")};
- max-width: ${(props) => (props.width ? props.width : "270px")};
- color: ${(props) => (props.disabled ? "#ffffff44" : "white")};
- padding: 8px 10px 5px 10px;
- min-height: 35px;
- max-height: 100px;
- white-space: nowrap;
- ::-webkit-scrollbar {
- width: 8px;
- :horizontal {
- height: 8px;
- }
- }
- ::-webkit-scrollbar-corner {
- width: 10px;
- background: #ffffff11;
- color: white;
- }
- ::-webkit-scrollbar-track {
- width: 10px;
- -webkit-box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
- box-shadow: inset 0 0 6px rgba(0, 0, 0, 0.3);
- }
- ::-webkit-scrollbar-thumb {
- background-color: darkgrey;
- outline: 1px solid slategrey;
- }
- `;
- const Label = styled.div`
- color: #ffffff;
- margin-bottom: 10px;
- `;
- const StyledInputArray = styled.div`
- margin-bottom: 15px;
- margin-top: 22px;
- `;
- const fadeIn = keyframes`
- from {
- opacity: 0;
- }
- to {
- opacity: 1;
- }
- `;
- const StyledCard = styled.div`
- border: 1px solid #ffffff44;
- background: #ffffff11;
- margin-bottom: 5px;
- border-radius: 8px;
- margin-top: 15px;
- padding: 10px 14px;
- overflow: hidden;
- font-size: 13px;
- animation: ${fadeIn} 0.5s;
- `;
- const Flex = styled.div`
- display: flex;
- height: 25px;
- align-items: center;
- justify-content: space-between;
- `;
- const ContentContainer = styled.div`
- display: flex;
- height: 40px;
- width: 100%;
- align-items: center;
- `;
- const EventInformation = styled.div`
- display: flex;
- flex-direction: column;
- justify-content: space-around;
- height: 100%;
- `;
- const EventName = styled.div`
- font-family: "Work Sans", sans-serif;
- font-weight: 500;
- color: #ffffff;
- `;
- const ActionContainer = styled.div`
- display: flex;
- align-items: center;
- white-space: nowrap;
- height: 100%;
- `;
- const ActionButton = styled.button`
- position: relative;
- border: none;
- background: none;
- color: white;
- padding: 5px;
- width: 30px;
- height: 30px;
- margin-left: 5px;
- display: flex;
- justify-content: center;
- align-items: center;
- border-radius: 50%;
- cursor: pointer;
- color: #aaaabb;
- border: 1px solid #ffffff00;
- :hover {
- background: #ffffff11;
- border: 1px solid #ffffff44;
- }
- > span {
- font-size: 20px;
- }
- `;
- const NoVariablesTextWrapper = styled.div`
- display: flex;
- align-items: center;
- justify-content: center;
- color: #ffffff99;
- `;
|