diff --git a/src/common/models/CondaSpecification.ts b/src/common/models/CondaSpecification.ts index 0512aeb0..f6208261 100644 --- a/src/common/models/CondaSpecification.ts +++ b/src/common/models/CondaSpecification.ts @@ -1,4 +1,5 @@ -import { CondaSpecificationPip } from "./CondaSpecificationPip"; +import type { CondaSpecificationPip } from "./CondaSpecificationPip"; +import type { Lockfile } from "./Lockfile"; export type CondaSpecification = { name: string; @@ -6,4 +7,5 @@ export type CondaSpecification = { dependencies: (string | CondaSpecificationPip)[]; variables: Record; prefix?: string | null; + lockfile?: Lockfile; }; diff --git a/src/common/models/Lockfile.ts b/src/common/models/Lockfile.ts new file mode 100644 index 00000000..292d6902 --- /dev/null +++ b/src/common/models/Lockfile.ts @@ -0,0 +1,2 @@ +// TODO: define lockfile type better +export type Lockfile = Record; diff --git a/src/common/models/index.ts b/src/common/models/index.ts index 34bd1cb4..cb7c4116 100644 --- a/src/common/models/index.ts +++ b/src/common/models/index.ts @@ -7,3 +7,4 @@ export * from "./Artifact"; export * from "./BuildPackage"; export * from "./BuildArtifact"; export * from "./Namespace"; +export * from "./Lockfile"; diff --git a/src/features/channels/channelsSlice.ts b/src/features/channels/channelsSlice.ts index 4833bd1c..6ab5c7d9 100644 --- a/src/features/channels/channelsSlice.ts +++ b/src/features/channels/channelsSlice.ts @@ -24,13 +24,22 @@ export const channelsSlice = createSlice({ { payload: { data: { - specification: { - spec: { channels } - } + specification: { spec } } } } ) => { + let channels = []; + + if (spec.channels) { + channels = spec.channels; + } else if (spec.lockfile?.metadata?.channels) { + channels = spec.lockfile.metadata.channels.map( + // Note: in the lockfile spec, a channel URL can be a string identifier like "conda-forge" + (channel: { url: string }) => channel.url + ); + } + state.channels = channels; } ); diff --git a/src/features/environmentCreate/components/EnvironmentCreate.tsx b/src/features/environmentCreate/components/EnvironmentCreate.tsx index f369202c..45003a9b 100644 --- a/src/features/environmentCreate/components/EnvironmentCreate.tsx +++ b/src/features/environmentCreate/components/EnvironmentCreate.tsx @@ -53,14 +53,28 @@ export const EnvironmentCreate = ({ environmentNotification }: IEnvCreate) => { dispatch(descriptionChanged(value)); }, 300); - const createEnvironment = async (code: ICreateEnvironmentArgs) => { + const createEnvironment = async ( + code: ICreateEnvironmentArgs, + isLockfile: boolean + ) => { const namespace = newEnvironment?.namespace; - const environmentInfo = { - namespace, - specification: `${stringify( - code - )}\ndescription: '${description}'\nname: ${name}\nprefix: null` - }; + let environmentInfo; + if (isLockfile) { + environmentInfo = { + namespace, + specification: stringify(code), + environment_name: name, + environment_description: description, + is_lockfile: true + }; + } else { + environmentInfo = { + namespace, + specification: `${stringify( + code + )}\ndescription: '${description}'\nname: ${name}\nprefix: null` + }; + } try { const { data } = await createOrUpdate(environmentInfo).unwrap(); diff --git a/src/features/environmentCreate/components/Specification/SpecificationCreate.tsx b/src/features/environmentCreate/components/Specification/SpecificationCreate.tsx index 7ca76b74..0c8f6eaf 100644 --- a/src/features/environmentCreate/components/Specification/SpecificationCreate.tsx +++ b/src/features/environmentCreate/components/Specification/SpecificationCreate.tsx @@ -1,5 +1,9 @@ import React, { useCallback, useEffect, useState } from "react"; import Box from "@mui/material/Box"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import Select, { SelectChangeEvent } from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; import { ChannelsEdit } from "../../../../features/channels"; import { BlockContainerEditMode } from "../../../../components"; import { StyledButtonPrimary } from "../../../../styles"; @@ -20,17 +24,24 @@ export const SpecificationCreate = ({ onCreateEnvironment }: any) => { state => state.environmentCreate ); const [show, setShow] = useState(false); + const [specificationType, setSpecificationType] = + React.useState("specification"); const [editorContent, setEditorContent] = useState<{ channels: string[]; dependencies: string[]; variables: Record; }>({ channels: [], dependencies: [], variables: {} }); + const [editorContentLockfile, setEditorContentLockfile] = useState({}); const buttonStyles = getStylesForStyleType( { padding: "5px 60px" }, { padding: "5px 48px" } ); + const onUpdateSpecificationType = (event: SelectChangeEvent) => { + setSpecificationType(event.target.value as string); + }; + const onUpdateChannels = useCallback((channels: string[]) => { dispatch(channelsChanged(channels)); }, []); @@ -71,21 +82,30 @@ export const SpecificationCreate = ({ onCreateEnvironment }: any) => { setEditorContent(code); }; + const onUpdateEditorLockfile = (lockfile: any) => { + setEditorContentLockfile(lockfile); + }; + const onToggleEditorView = (value: boolean) => { if (show) { - dispatch( - editorCodeUpdated({ - channels: editorContent.channels, - dependencies: editorContent.dependencies, - variables: editorContent.variables - }) - ); + if (specificationType === "specification") { + dispatch( + editorCodeUpdated({ + channels: editorContent.channels, + dependencies: editorContent.dependencies, + variables: editorContent.variables + }) + ); + } else { + // TODO: sync GUI with lockfile code + } } else { setEditorContent({ dependencies: requestedPackages, variables: environmentVariables, channels }); + // TODO: sync lockfile code with GUI } setShow(value); @@ -103,16 +123,36 @@ export const SpecificationCreate = ({ onCreateEnvironment }: any) => { return stringify({ channels, dependencies, variables }); }; + const formatCodeLockfile = () => { + return stringify({ + version: 1, + metadata: {}, + package: [] + }); + }; + const handleSubmit = () => { - const code = show - ? editorContent - : { - dependencies: requestedPackages, - variables: environmentVariables, - channels - }; - - onCreateEnvironment(code); + let code; + let isLockfile; + + if (show) { + if (specificationType === "specification") { + code = editorContent; + isLockfile = false; + } else { + code = editorContentLockfile; + isLockfile = true; + } + } else { + code = { + dependencies: requestedPackages, + variables: environmentVariables, + channels + }; + isLockfile = false; + } + + onCreateEnvironment(code, isLockfile); }; useEffect(() => { @@ -129,10 +169,36 @@ export const SpecificationCreate = ({ onCreateEnvironment }: any) => { > {show ? ( - + <> + + Format + + + {specificationType === "specification" ? ( + + ) : ( + + )} + ) : ( <> @@ -140,7 +206,7 @@ export const SpecificationCreate = ({ onCreateEnvironment }: any) => { requestedPackages={requestedPackages} /> - + { + const updateEnvironment = async ( + code: IUpdateEnvironmentArgs, + isLockfile: boolean + ) => { if (!selectedEnvironment) { return; } const namespace = selectedEnvironment.namespace.name; const environment = selectedEnvironment.name; - const environmentInfo = { - specification: `${stringify( - code - )}\ndescription: ${description}\nname: ${environment}\nprefix: null`, - namespace - }; + let environmentInfo; + if (isLockfile) { + environmentInfo = { + namespace, + specification: stringify(code), + environment_name: environment, + environment_description: description, + is_lockfile: true + }; + } else { + environmentInfo = { + specification: `${stringify( + code + )}\ndescription: ${description}\nname: ${environment}\nprefix: null`, + namespace + }; + } try { const { data } = await createOrUpdate(environmentInfo).unwrap(); diff --git a/src/features/environmentDetails/components/Specification/SpecificationEdit.tsx b/src/features/environmentDetails/components/Specification/SpecificationEdit.tsx index f4afd065..bafbf9b4 100644 --- a/src/features/environmentDetails/components/Specification/SpecificationEdit.tsx +++ b/src/features/environmentDetails/components/Specification/SpecificationEdit.tsx @@ -7,11 +7,16 @@ import React, { } from "react"; import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; +import FormControl from "@mui/material/FormControl"; +import InputLabel from "@mui/material/InputLabel"; +import Select, { SelectChangeEvent } from "@mui/material/Select"; +import MenuItem from "@mui/material/MenuItem"; import { cloneDeep, debounce } from "lodash"; import { stringify } from "yaml"; import { BlockContainerEditMode } from "../../../../components"; import { ChannelsEdit, updateChannels } from "../../../../features/channels"; import { updateEnvironmentVariables } from "../../../../features/environmentVariables"; +import { updateLockfile } from "../../../../features/lockfile"; import { Dependencies, pageChanged } from "../../../../features/dependencies"; import { modeChanged, @@ -24,13 +29,17 @@ import { import { CodeEditor } from "../../../../features/yamlEditor"; import { useAppDispatch, useAppSelector } from "../../../../hooks"; import { StyledButtonPrimary } from "../../../../styles"; -import { CondaSpecificationPip } from "../../../../common/models"; +import type { + CondaSpecificationPip, + Lockfile +} from "../../../../common/models"; + interface ISpecificationEdit { descriptionUpdated: boolean; defaultEnvVersIsChanged: boolean; onSpecificationIsChanged: (specificationIsChanged: boolean) => void; onDefaultEnvIsChanged: (defaultEnvVersIsChanged: boolean) => void; - onUpdateEnvironment: (specification: any) => void; + onUpdateEnvironment: (specification: any, isLockfile: boolean) => void; onShowDialogAlert: (showDialog: boolean) => void; } export const SpecificationEdit = ({ @@ -51,10 +60,14 @@ export const SpecificationEdit = ({ const { dependencies, size, count, page } = useAppSelector( state => state.dependencies ); + const { lockfile } = useAppSelector(state => state.lockfile); const hasMore = size * page <= count; const dispatch = useAppDispatch(); const [show, setShow] = useState(false); + const [specificationType, setSpecificationType] = React.useState( + Object.keys(lockfile).length === 0 ? "specification" : "lockfile" + ); const [code, setCode] = useState<{ dependencies: (string | CondaSpecificationPip)[]; channels: string[]; @@ -64,11 +77,13 @@ export const SpecificationEdit = ({ variables: environmentVariables, channels }); + const [codeLockfile, setCodeLockfile] = useState(lockfile); const [envIsUpdated, setEnvIsUpdated] = useState(false); const initialChannels = useRef(cloneDeep(channels)); const initialPackages = useRef(cloneDeep(requestedPackages)); const initialEnvironmentVariables = useRef(cloneDeep(environmentVariables)); + const initialLockfile = useRef(cloneDeep(lockfile)); const stringifiedInitialChannels = useMemo(() => { return JSON.stringify(initialChannels.current); @@ -82,6 +97,14 @@ export const SpecificationEdit = ({ return JSON.stringify(initialEnvironmentVariables.current); }, [initialEnvironmentVariables.current]); + const stringifiedInitialLockfile = useMemo(() => { + return JSON.stringify(initialLockfile.current); + }, [initialLockfile.current]); + + const onUpdateSpecificationType = (event: SelectChangeEvent) => { + setSpecificationType(event.target.value as string); + }; + const onUpdateChannels = useCallback((channels: string[]) => { dispatch(updateChannels(channels)); onDefaultEnvIsChanged(false); @@ -138,32 +161,64 @@ export const SpecificationEdit = ({ 200 ); + const onUpdateEditorLockfile = debounce((lockfile: Lockfile) => { + const isDifferentLockfile = + JSON.stringify(lockfile) !== stringifiedInitialLockfile; + + if (isDifferentLockfile) { + setEnvIsUpdated(true); + onUpdateDefaultEnvironment(false); + onSpecificationIsChanged(true); + } + + setCodeLockfile(lockfile); + }, 200); + const onToggleEditorView = (value: boolean) => { if (show) { - dispatch(updatePackages(code.dependencies)); - dispatch(updateChannels(code.channels)); - dispatch(updateEnvironmentVariables(code.variables)); + // Code Editor -> GUI + if (specificationType === "specification") { + dispatch(updatePackages(code.dependencies)); + dispatch(updateChannels(code.channels)); + dispatch(updateEnvironmentVariables(code.variables)); + } else { + // TODO: sync GUI with lockfile code + } } else { + // GUI -> Code Editor setCode({ dependencies: requestedPackages, variables: environmentVariables, channels }); + // TODO: sync lockfile code with GUI } setShow(value); }; const onEditEnvironment = () => { - const envContent = show - ? code - : { - dependencies: requestedPackages, - variables: environmentVariables, - channels - }; + let envContent; + let isLockfile; - onUpdateEnvironment(envContent); + if (show) { + if (specificationType === "specification") { + envContent = code; + isLockfile = false; + } else { + envContent = codeLockfile; + isLockfile = true; + } + } else { + envContent = { + dependencies: requestedPackages, + variables: environmentVariables, + channels + }; + isLockfile = false; + } + + onUpdateEnvironment(envContent, isLockfile); }; const onCancelEdition = () => { @@ -173,6 +228,7 @@ export const SpecificationEdit = ({ dispatch(updatePackages(initialPackages.current)); dispatch(updateChannels(initialChannels.current)); dispatch(updateEnvironmentVariables(initialEnvironmentVariables.current)); + dispatch(updateLockfile(initialLockfile.current)); }; useEffect(() => { @@ -187,17 +243,26 @@ export const SpecificationEdit = ({ const isDifferentEnvironmentVariables = JSON.stringify(environmentVariables) !== stringifiedInitialEnvironmentVariables; + const isDifferentLockfile = + JSON.stringify(lockfile) !== stringifiedInitialLockfile; if (defaultEnvVersIsChanged) { setEnvIsUpdated(false); } else if ( isDifferentChannels || isDifferentPackages || - isDifferentEnvironmentVariables + isDifferentEnvironmentVariables || + isDifferentLockfile ) { setEnvIsUpdated(true); } - }, [channels, requestedPackages, environmentVariables, descriptionUpdated]); + }, [ + channels, + requestedPackages, + environmentVariables, + lockfile, + descriptionUpdated + ]); return ( {show ? ( - + <> + + Format + + + {specificationType === "specification" ? ( + + ) : ( + + )} + ) : ( <> diff --git a/src/features/environmentVariables/environmentVariablesSlice.ts b/src/features/environmentVariables/environmentVariablesSlice.ts index 5c040ffa..229b68d3 100644 --- a/src/features/environmentVariables/environmentVariablesSlice.ts +++ b/src/features/environmentVariables/environmentVariablesSlice.ts @@ -24,13 +24,15 @@ export const environmentVariablesSlice = createSlice({ { payload: { data: { - specification: { - spec: { variables: environmentVariables } - } + specification: { spec } } } } ) => { + // variables can be undefined if a lockfile specification is provided + // TODO: parse the lockfile and populate the variables + const environmentVariables = spec?.variables ?? []; + state.environmentVariables = environmentVariables; } ); diff --git a/src/features/lockfile/index.tsx b/src/features/lockfile/index.tsx new file mode 100644 index 00000000..4ab472c0 --- /dev/null +++ b/src/features/lockfile/index.tsx @@ -0,0 +1 @@ +export * from "./lockfileSlice"; diff --git a/src/features/lockfile/lockfileSlice.ts b/src/features/lockfile/lockfileSlice.ts new file mode 100644 index 00000000..595b43bb --- /dev/null +++ b/src/features/lockfile/lockfileSlice.ts @@ -0,0 +1,42 @@ +import { createSlice } from "@reduxjs/toolkit"; +import { environmentDetailsApiSlice } from "../environmentDetails"; +import type { Lockfile } from "../../common/models/Lockfile"; + +export interface ILockfileState { + lockfile: Lockfile; +} + +const initialState: ILockfileState = { lockfile: {} }; + +export const lockfileSlice = createSlice({ + name: "lockfile", + initialState, + reducers: { + updateLockfile: (state, action) => { + const lockfile = action.payload; + state.lockfile = lockfile; + } + }, + extraReducers: builder => { + builder.addMatcher( + environmentDetailsApiSlice.endpoints.getBuild.matchFulfilled, + ( + state, + { + payload: { + data: { + specification: { spec } + } + } + } + ) => { + // checks if this is a lockfile specification + const lockfile = spec?.lockfile ?? {}; + + state.lockfile = lockfile; + } + ); + } +}); + +export const { updateLockfile } = lockfileSlice.actions; diff --git a/src/features/requestedPackages/requestedPackagesSlice.ts b/src/features/requestedPackages/requestedPackagesSlice.ts index 998022cf..83c6efdf 100644 --- a/src/features/requestedPackages/requestedPackagesSlice.ts +++ b/src/features/requestedPackages/requestedPackagesSlice.ts @@ -8,7 +8,6 @@ import { import { requestedPackageParser } from "../../utils/helpers"; import { dependenciesApiSlice } from "../dependencies"; import { environmentDetailsApiSlice } from "../environmentDetails"; - export interface IRequestedPackagesState { requestedPackages: (string | CondaSpecificationPip)[]; versionsWithoutConstraints: { [key: string]: string }; @@ -79,18 +78,26 @@ export const requestedPackagesSlice = createSlice({ { payload: { data: { - specification: { - spec: { dependencies } - } + specification: { spec } } } } ) => { + let dependencies = []; + + if (spec.dependencies) { + dependencies = spec.dependencies; + } else if (spec.lockfile?.package) { + dependencies = spec.lockfile.package + .filter((p: Record) => p.manager === "conda") + .map((p: Record) => `${p?.name}==${p?.version}`); + } + state.requestedPackages = dependencies; state.packagesWithLatestVersions = {}; state.versionsWithConstraints = {}; - dependencies.forEach(dep => { + dependencies.forEach((dep: string | CondaSpecificationPip) => { if (typeof dep === "string") { const { constraint, name, version } = requestedPackageParser(dep); diff --git a/src/features/yamlEditor/components/editor.tsx b/src/features/yamlEditor/components/editor.tsx index 77f015cd..a1665fa4 100644 --- a/src/features/yamlEditor/components/editor.tsx +++ b/src/features/yamlEditor/components/editor.tsx @@ -8,12 +8,8 @@ import { greenAccentTheme } from "../themes"; import { PrefContext } from "../../../preferences"; export interface ICodeEditor { - code: any; - onChangeEditor: (code: { - channels: string[]; - dependencies: string[]; - variables: Record; - }) => void; + code: string; + onChangeEditor: (parsedCode: unknown) => void; } export const CodeEditor = ({ code, onChangeEditor }: ICodeEditor) => { diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts index d0a427ef..b2e374de 100644 --- a/src/store/rootReducer.ts +++ b/src/store/rootReducer.ts @@ -4,6 +4,7 @@ import { dependenciesSlice } from "../features/dependencies"; import { environmentDetailsSlice } from "../features/environmentDetails"; import { requestedPackagesSlice } from "../features/requestedPackages"; import { environmentVariablesSlice } from "../features/environmentVariables"; +import { lockfileSlice } from "../features/lockfile"; import { tabsSlice } from "../features/tabs"; import { enviromentsSlice } from "../features/metadata"; import { environmentCreateSlice } from "../features/environmentCreate/environmentCreateSlice"; @@ -13,6 +14,7 @@ export const rootReducer = { channels: channelsSlice.reducer, requestedPackages: requestedPackagesSlice.reducer, environmentVariables: environmentVariablesSlice.reducer, + lockfile: lockfileSlice.reducer, tabs: tabsSlice.reducer, enviroments: enviromentsSlice.reducer, environmentDetails: environmentDetailsSlice.reducer, diff --git a/test/environmentCreate/SpecificationCreate.test.tsx b/test/environmentCreate/SpecificationCreate.test.tsx index a0b92cd5..3889e79b 100644 --- a/test/environmentCreate/SpecificationCreate.test.tsx +++ b/test/environmentCreate/SpecificationCreate.test.tsx @@ -49,11 +49,15 @@ describe("", () => { fireEvent.click(switchButton); fireEvent.click(createButton); - expect(mockOnCreateEnvironment).toHaveBeenCalledWith({ - channels: [], - dependencies: [], - variables: {} - }); + const isLockfile = false; + expect(mockOnCreateEnvironment).toHaveBeenCalledWith( + { + channels: [], + dependencies: [], + variables: {} + }, + isLockfile + ); }); it("should update channels and dependencies", async () => {