diff --git a/CHANGELOG.md b/CHANGELOG.md index dc650aaa..684b6eb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +# v0.8.0 + +### Features +* Install missing repositories from Workflows Gallery modal when importing a workflow [PR #180]. +* Create default user `admin@email.com`, password `admin` when platform is created [Issue #177]. +* Add search bar for Pieces in Workflows Editor page [Issue #168]. +* Workflows gallery with more examples and easy installation fo repositories. +* Installing multiple repositories on a new workspace if platform `github_token` provide. + + +### Fixes +* Improved terminal messages for `domino platform run-compose` and `domino platform stop-compose` CLI. +* Add optional platform level github token in `run-in-compose` CLI [Issue #176]. +* Fix token expiration date bug [Issue #147]. +* Fix validation bugs. +* Performance improved on `create_piece_repository` +* Allow for optional secrets. + + + # v0.7.0 ### Features diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml index 677fe4ae..14e1ddfc 100644 --- a/docker-compose-dev.yaml +++ b/docker-compose-dev.yaml @@ -313,6 +313,7 @@ services: - DOMINO_DEFAULT_PIECES_REPOSITORY_TOKEN=${DOMINO_DEFAULT_PIECES_REPOSITORY_TOKEN} - DOMINO_GITHUB_ACCESS_TOKEN_WORKFLOWS=${DOMINO_GITHUB_ACCESS_TOKEN_WORKFLOWS} - DOMINO_GITHUB_WORKFLOWS_REPOSITORY=${DOMINO_GITHUB_WORKFLOWS_REPOSITORY} + - CREATE_DEFAULT_USER=true - DOMINO_DEPLOY_MODE=local-compose - AIRFLOW_ADMIN_USERNAME=airflow - AIRFLOW_ADMIN_PASSWORD=airflow diff --git a/frontend/package.json b/frontend/package.json index 8dbc47b0..52d2f0ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@mui/x-data-grid": "^6.15.0", "@mui/x-date-pickers": "^6.5.0", "@types/react-dom": "^18.0.9", + "@types/react-plotly.js": "^2.6.3", "@types/uuid": "^9.0.0", "@uiw/react-textarea-code-editor": "^2.1.1", "@vitejs/plugin-react": "^4.1.0", @@ -29,14 +30,17 @@ "dotenv": "^16.3.1", "elkjs": "^0.8.2", "localforage": "^1.10.0", + "plotly.js": "^2.27.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-draggable": "^4.4.5", "react-hook-form": "^7.45.1", - "react-markdown": "^8.0.7", + "react-markdown": "9.0.0", + "react-plotly.js": "^2.6.0", "react-router-dom": "^6.6.0", "react-toastify": "^9.1.1", "reactflow": "^11.4.0", + "remark-gfm": "^4.0.0", "swr": "^2.0.0", "typescript": "*", "uuid": "^9.0.0", diff --git a/frontend/src/@types/piece/piece.d.ts b/frontend/src/@types/piece/piece.d.ts index cbdddb72..c04098cf 100644 --- a/frontend/src/@types/piece/piece.d.ts +++ b/frontend/src/@types/piece/piece.d.ts @@ -50,6 +50,7 @@ export interface Piece { source_image: string; source_url: string | null; + repository_url: string; dependency: { docker_image: string | null; dockerfile: string | null; diff --git a/frontend/src/@types/piece/properties.d.ts b/frontend/src/@types/piece/properties.d.ts index 73d8f79e..95eb7260 100644 --- a/frontend/src/@types/piece/properties.d.ts +++ b/frontend/src/@types/piece/properties.d.ts @@ -75,6 +75,10 @@ export type SimpleInputSchemaProperty = type AnyOfObjectProperty = DefaultPropertyProps & { anyOf: StringProperty[]; + default: + | ArrayObjectProperty.default + | NumberProperty.default + | StringProperty.default; }; export type InputSchemaProperty = diff --git a/frontend/src/components/Modal/index.tsx b/frontend/src/components/Modal/index.tsx index a65e4414..765e5ed3 100644 --- a/frontend/src/components/Modal/index.tsx +++ b/frontend/src/components/Modal/index.tsx @@ -1,3 +1,4 @@ +import CloseIcon from "@mui/icons-material/Close"; import { Button, Dialog, @@ -6,6 +7,7 @@ import { type DialogProps, DialogTitle, Grid, + IconButton, } from "@mui/material"; import React, { useCallback, useImperativeHandle, useState } from "react"; @@ -16,6 +18,9 @@ interface Props { fullWidth?: boolean; confirmFn?: () => void; cancelFn?: () => void; + onClose?: () => void; + confirmText?: string; + cancelText?: string; } export interface ModalRef { @@ -24,7 +29,20 @@ export interface ModalRef { } export const Modal = React.forwardRef( - ({ cancelFn, confirmFn, title, content, maxWidth, fullWidth }, ref) => { + ( + { + onClose, + cancelFn, + confirmFn, + title, + content, + maxWidth, + fullWidth, + confirmText, + cancelText, + }, + ref, + ) => { const [isOpen, setIsOpen] = useState(false); const open = () => { @@ -35,9 +53,12 @@ export const Modal = React.forwardRef( if (cancelFn) { cancelFn(); } + if (onClose) { + onClose(); + } setIsOpen(false); - }, [cancelFn]); + }, [cancelFn, onClose]); const handleConfirm = useCallback(() => { if (confirmFn) { @@ -59,6 +80,18 @@ export const Modal = React.forwardRef( maxWidth={maxWidth} fullWidth={fullWidth} > + theme.palette.grey[500], + }} + > + + {title} {content} @@ -66,15 +99,17 @@ export const Modal = React.forwardRef( {cancelFn && ( + + )} + {confirmFn && ( + + )} - - - diff --git a/frontend/src/context/authentication/api/postAuthLogin.ts b/frontend/src/context/authentication/api/postAuthLogin.ts index 003a0df9..bbf42904 100644 --- a/frontend/src/context/authentication/api/postAuthLogin.ts +++ b/frontend/src/context/authentication/api/postAuthLogin.ts @@ -10,6 +10,7 @@ interface IPostAuthLoginResponseInterface { user_id: string; group_ids: number[]; access_token: string; + token_expires_in: number; } /** @@ -29,4 +30,5 @@ export const postAuthLoginMockResponse: IPostAuthLoginResponseInterface = { user_id: "some_id", group_ids: [0], access_token: "MOCK ACCESS TOKEN", + token_expires_in: new Date().getTime() + 10000, }; diff --git a/frontend/src/context/authentication/api/postAuthRegister.ts b/frontend/src/context/authentication/api/postAuthRegister.ts index 1f021ee8..d5b875d0 100644 --- a/frontend/src/context/authentication/api/postAuthRegister.ts +++ b/frontend/src/context/authentication/api/postAuthRegister.ts @@ -7,8 +7,9 @@ interface IPostAuthRegisterParams { } interface IPostAuthRegisterResponseInterface { - id: string; + user_id: string; email: string; + token_expires_in: number; groups: Array<{ group_id: number; group_name: string }>; } @@ -27,7 +28,8 @@ export const postAuthRegister: ( export const postAuthRegisterMockResponse: IPostAuthRegisterResponseInterface = { - id: "some_id", + user_id: "some_id", email: "some@email.com", + token_expires_in: 3600, groups: [{ group_id: 0, group_name: "some group" }], }; diff --git a/frontend/src/context/authentication/authentication.context.tsx b/frontend/src/context/authentication/authentication.context.tsx index 0b2af220..ee56f1c6 100644 --- a/frontend/src/context/authentication/authentication.context.tsx +++ b/frontend/src/context/authentication/authentication.context.tsx @@ -1,3 +1,4 @@ +import Loading from "components/Loading"; import React, { type ReactNode, useCallback, @@ -13,6 +14,7 @@ import { createCustomContext } from "utils"; import { postAuthLogin, postAuthRegister } from "./api"; import { + authStatus, type IAuthenticationContext, type IAuthenticationStore, } from "./authentication.interface"; @@ -29,6 +31,7 @@ export const AuthenticationProvider: React.FC<{ children: ReactNode }> = ({ children, }) => { const navigate = useNavigate(); + const [status, setStatus] = useState(authStatus.Loading); const [authLoading, setAuthLoading] = useState(false); const [store, setStore] = useState({ token: localStorage.getItem("auth_token"), @@ -38,15 +41,25 @@ export const AuthenticationProvider: React.FC<{ children: ReactNode }> = ({ const isLogged = useRef(!!store.token); const login = useCallback( - (token: string, userId: string, redirect = "") => { + (token: string, userId: string, tokenExpiresIn: number, redirect = "") => { isLogged.current = true; setStore((store) => ({ ...store, token, userId, + tokenExpiresIn, })); + const currentDate = new Date(); + const tokenExpirationDate = new Date( + currentDate.getTime() + tokenExpiresIn * 1000, + ); localStorage.setItem("auth_token", token); localStorage.setItem("userId", userId); + localStorage.setItem( + "tokenExpiresAtTimestamp", + tokenExpirationDate.getTime().toString(), + ); + setStatus(authStatus.SignedIn); navigate(redirect); }, [navigate], @@ -60,19 +73,21 @@ export const AuthenticationProvider: React.FC<{ children: ReactNode }> = ({ ...store, token: null, })); + setStatus(authStatus.SignedOut); navigate("/sign-in"); }, [navigate]); - /** - * @todo improve error handling - */ const authenticate = useCallback( async (email: string, password: string) => { setAuthLoading(true); void postAuthLogin({ email, password }) .then((res) => { if (res.status === 200) { - login(res.data.access_token, res.data.user_id); + login( + res.data.access_token, + res.data.user_id, + res.data.token_expires_in, + ); } }) .finally(() => { @@ -107,16 +122,15 @@ export const AuthenticationProvider: React.FC<{ children: ReactNode }> = ({ [authenticate], ); - const value = useMemo((): IAuthenticationContext => { - return { - store, - isLogged: isLogged.current, - authLoading, - logout, - authenticate, - register, - }; - }, [store, logout, authenticate, register, authLoading]); + const tokenExpired = useCallback(() => { + const tokenTimestamp = localStorage.getItem("tokenExpiresAtTimestamp"); + if (tokenTimestamp) { + const date1 = Number(tokenTimestamp); + const date2 = new Date().getTime(); + return date1 <= date2; + } + return true; + }, []); /** * Listen to "logout" event and handles it (ie. unauthorized request) @@ -132,6 +146,31 @@ export const AuthenticationProvider: React.FC<{ children: ReactNode }> = ({ }; }, [logout]); + useEffect(() => { + const expired = tokenExpired(); + + if (expired) { + logout(); + } else { + setStatus(authStatus.SignedIn); + } + }, [tokenExpired]); + + const value = useMemo((): IAuthenticationContext => { + return { + store, + isLogged: isLogged.current, + authLoading, + logout, + authenticate, + register, + }; + }, [store, logout, authenticate, register, authLoading]); + + if (status === authStatus.Loading) { + return ; + } + return ( {children} diff --git a/frontend/src/context/authentication/authentication.interface.ts b/frontend/src/context/authentication/authentication.interface.ts index 5d8fd984..b768146f 100644 --- a/frontend/src/context/authentication/authentication.interface.ts +++ b/frontend/src/context/authentication/authentication.interface.ts @@ -3,6 +3,12 @@ export interface IAuthenticationStore { userId: string | null; } +export enum authStatus { + Loading, + SignedIn, + SignedOut, +} + export interface IAuthenticationContext { store: IAuthenticationStore; isLogged: boolean; diff --git a/frontend/src/context/workspaces/api/getWorkspaceMembers.ts b/frontend/src/context/workspaces/api/getWorkspaceMembers.ts index d5a06313..2493041f 100644 --- a/frontend/src/context/workspaces/api/getWorkspaceMembers.ts +++ b/frontend/src/context/workspaces/api/getWorkspaceMembers.ts @@ -12,22 +12,39 @@ interface IGetWorkspaceMembers { pageSize: number; } +const getWorkspaceUsersUrl = ( + auth: boolean, + workspaceId: string, + page: number, + pageSize: number, +) => { + // TODO: get workspaceId from context - this is a temporary solution + const workspace = localStorage.getItem("workspace"); + return auth && workspaceId && workspace + ? `/workspaces/${workspaceId}/users?page=${page}&page_size=${pageSize}` + : null; +}; + /** * Get workspaces using GET /workspaces * @returns workspaces */ const getWorkspaceUsers: ( + auth: boolean, workspaceId: string, page: number, pageSize: number, -) => Promise> = async ( +) => Promise | undefined> = async ( + auth, workspaceId, page, pageSize, ) => { - return await dominoApiClient.get( - `/workspaces/${workspaceId}/users?page=${page}&page_size=${pageSize}`, - ); + if (auth && workspaceId && !isNaN(page) && !isNaN(pageSize)) { + const url = getWorkspaceUsersUrl(auth, workspaceId, page, pageSize); + + if (url) return await dominoApiClient.get(url); + } }; /** @@ -37,30 +54,31 @@ const getWorkspaceUsers: ( export const useAuthenticatedGetWorkspaceUsers = ( params: IGetWorkspaceMembers, ) => { - const fetcher = useCallback(async (params: IGetWorkspaceMembers) => { - return await getWorkspaceUsers( - params.workspaceId, - params.page, - params.pageSize, - ).then((data) => data.data); - }, []); - - const auth = useAuthentication(); - if (!params.page) { params.page = 0; } if (!params.pageSize) { params.pageSize = 10; } + + const auth = useAuthentication(); + + const fetcher = useCallback(async () => { + return await getWorkspaceUsers( + auth.isLogged, + params.workspaceId, + params.page, + params.pageSize, + ).then((data) => data?.data); + }, [params]); + return useSWR( - auth.isLogged && params.workspaceId - ? `/workspaces/${params.workspaceId}/users?page=${params.page}&page_size=${params.pageSize}` - : null, - async () => await fetcher(params), - { - revalidateOnFocus: false, - revalidateOnReconnect: false, - }, + getWorkspaceUsersUrl( + auth.isLogged, + params.workspaceId, + params.page, + params.pageSize, + ), + fetcher, ); }; diff --git a/frontend/src/context/workspaces/api/patchWorkspace.ts b/frontend/src/context/workspaces/api/patchWorkspace.ts index 7f58e942..9a7b77d7 100644 --- a/frontend/src/context/workspaces/api/patchWorkspace.ts +++ b/frontend/src/context/workspaces/api/patchWorkspace.ts @@ -33,10 +33,7 @@ const patchWorkspace: ( export const useAuthenticatedPatchWorkspace = () => { const { workspace } = useWorkspaces(); - if (!workspace) - throw new Error( - "Impossible to run workflows without specifying a workspace", - ); + if (!workspace) return async (_params: PatchWorkspaceParams) => {}; const fetcher = async (params: PatchWorkspaceParams) => await patchWorkspace(params).then((data) => data); diff --git a/frontend/src/context/workspaces/api/postPiecesRepositories.ts b/frontend/src/context/workspaces/api/postPiecesRepositories.ts index 93b8886d..ec0d9c7f 100644 --- a/frontend/src/context/workspaces/api/postPiecesRepositories.ts +++ b/frontend/src/context/workspaces/api/postPiecesRepositories.ts @@ -28,7 +28,7 @@ export const useAuthenticatedPostPiecesRepository = (params: { workspace: string; }) => { if (!params?.workspace) - throw new Error("Impossible to add repositories without a workspace!"); + return async (_params: IPostWorkspaceRepositoryPayload) => {}; const fetcher = async (payload: IPostWorkspaceRepositoryPayload) => await postPiecesRepository({ diff --git a/frontend/src/context/workspaces/repositories.tsx b/frontend/src/context/workspaces/repositories.tsx index bc99e8de..8c24563d 100644 --- a/frontend/src/context/workspaces/repositories.tsx +++ b/frontend/src/context/workspaces/repositories.tsx @@ -2,20 +2,46 @@ import { type IGetRepoPiecesResponseInterface, useAuthenticatedGetPieceRepositories, useFetchAuthenticatedGetRepoIdPieces, + type IGetPiecesRepositoriesResponseInterface, + type IGetPiecesRepositoriesReleasesParams, + type IGetPiecesRepositoriesReleasesResponseInterface, + useAuthenticatedGetPieceRepositoriesReleases, + useAuthenticatedDeleteRepository, } from "features/myWorkflows/api"; -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import { toast } from "react-toastify"; import localForage from "services/config/localForage.config"; +import { type KeyedMutator } from "swr"; import { createCustomContext } from "utils"; +import { useAuthenticatedPostPiecesRepository } from "./api"; +import { + type IPostWorkspaceRepositoryPayload, + type IPostWorkspaceRepositoryResponseInterface, +} from "./types"; +import { useWorkspaces } from "./workspaces"; + export interface IPiecesContext { repositories: PieceRepository[]; - repositoriesError: boolean; - repositoriesLoading: boolean; + defaultRepositories: PieceRepository[]; repositoryPieces: PiecesRepository; + repositoriesLoading: boolean; + + selectedRepositoryId: null | number; + setSelectedRepositoryId: React.Dispatch>; + + handleRefreshRepositories: KeyedMutator< + IGetPiecesRepositoriesResponseInterface | undefined + >; + handleAddRepository: ( + params: Omit, + ) => Promise; - search: string; - handleSearch: (word: string) => void; + handleFetchRepoReleases: ( + params: IGetPiecesRepositoriesReleasesParams, + ) => Promise; + + handleDeleteRepository: (id: string) => Promise; fetchRepoById: (params: { id: string; @@ -29,60 +55,101 @@ export const [PiecesContext, usesPieces] = const PiecesProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { - const [search, handleSearch] = useState(""); + const [selectedRepositoryId, setSelectedRepositoryId] = useState< + number | null + >(null); const [repositoryPieces, setRepositoryPieces] = useState( {}, ); + const { workspace, handleRefreshWorkspaces } = useWorkspaces(); + const fetchRepoById = useFetchAuthenticatedGetRepoIdPieces(); const { - data, + data: repositories, error: repositoriesError, isValidating: repositoriesLoading, - // mutate: repositoriesRefresh, + mutate: handleRefreshRepositories, } = useAuthenticatedGetPieceRepositories({}); - const repositories: PieceRepository[] = useMemo( - () => data?.data.filter((repo) => repo.name.includes(search)) ?? [], - [data, search], - ); - - const fetchForagePieceById = useCallback(async (id: number) => { - const pieces = await localForage.getItem("pieces"); - if (pieces !== null) { - return pieces[id]; - } - }, []); - useEffect(() => { - const updateRepositoriesPieces = async () => { + let active = true; + void loadRepositoriesPieces(); + return () => { + active = false; + }; + + async function loadRepositoriesPieces() { const repositoryPiecesAux: PiecesRepository = {}; const foragePieces: PieceForageSchema = {}; - if (!repositories?.length) { + if (!active) { + return; + } + if (!repositories?.data?.length) { void localForage.setItem("pieces", foragePieces); + setRepositoryPieces(repositoryPiecesAux); } else { - for (const repo of repositories) { - fetchRepoById({ id: repo.id }) + for (const repo of repositories.data) { + await fetchRepoById({ id: repo.id }) .then((pieces: any) => { repositoryPiecesAux[repo.id] = []; for (const op of pieces) { repositoryPiecesAux[repo.id].push(op); foragePieces[op.id] = op; } - setRepositoryPieces(repositoryPiecesAux); void localForage.setItem("pieces", foragePieces); }) .catch((e) => { console.log(e); }); - // Set piece item to storage -> {piece_id: Piece} } + setRepositoryPieces(repositoryPiecesAux); } - }; - void updateRepositoriesPieces(); + } }, [repositories, fetchRepoById]); + const { data: defaultRepositories } = useAuthenticatedGetPieceRepositories({ + source: "default", + }); + + const fetchForagePieceById = useCallback(async (id: number) => { + const pieces = await localForage.getItem("pieces"); + if (pieces !== null) { + return pieces[id]; + } + }, []); + + const postRepository = useAuthenticatedPostPiecesRepository({ + workspace: workspace?.id ?? "", + }); + + const handleAddRepository = useCallback( + async (payload: Omit) => + await postRepository({ ...payload, workspace_id: workspace?.id ?? "" }) + .then(async (data) => { + toast.success(`Repository added successfully!`); + handleRefreshWorkspaces(); + await handleRefreshRepositories(); + return data; + }) + .catch((e) => { + if (e.response?.status === 403) { + toast.error( + `You don't have permission to add repositories to this workspace.`, + ); + return; + } + toast.error(`Error adding repository, try again later.`); + }), + [postRepository, handleRefreshWorkspaces, workspace?.id], + ); + + const handleFetchRepoReleases = + useAuthenticatedGetPieceRepositoriesReleases(); + + const handleDeleteRepository = useAuthenticatedDeleteRepository(); + useEffect(() => { if (repositoriesError) { toast.error("Error loading repositories, try again later"); @@ -90,14 +157,21 @@ const PiecesProvider: React.FC<{ children: React.ReactNode }> = ({ }, [repositoriesError]); const value: IPiecesContext = { + repositories: repositories?.data ?? [], + defaultRepositories: defaultRepositories?.data ?? [], + repositoryPieces, + repositoriesLoading, + + selectedRepositoryId, + setSelectedRepositoryId, + + handleRefreshRepositories, + handleAddRepository, + handleFetchRepoReleases, + handleDeleteRepository, + fetchForagePieceById, fetchRepoById, - handleSearch, - repositories, - repositoriesError, - repositoriesLoading, - repositoryPieces, - search, }; return ( diff --git a/frontend/src/features/myWorkflows/api/piece/getPiecesRepositoriesReleases.request.ts b/frontend/src/features/myWorkflows/api/piece/getPiecesRepositoriesReleases.request.ts index a0b573e3..f4cf28c8 100644 --- a/frontend/src/features/myWorkflows/api/piece/getPiecesRepositoriesReleases.request.ts +++ b/frontend/src/features/myWorkflows/api/piece/getPiecesRepositoriesReleases.request.ts @@ -15,14 +15,15 @@ import { const getPiecesRepositoriesReleases: ( params: IGetPiecesRepositoriesReleasesParams, ) => Promise< - AxiosResponse + AxiosResponse | undefined > = async ({ source, path, workspaceId }) => { + if (!workspaceId) { + return; + } const search = new URLSearchParams(); search.set("source", source); search.set("path", path); - if (workspaceId) { - search.set("workspace_id", workspaceId); - } + search.set("workspace_id", workspaceId); return await dominoApiClient.get( `/pieces-repositories/releases?${search.toString()}`, @@ -36,16 +37,11 @@ const getPiecesRepositoriesReleases: ( export const useAuthenticatedGetPieceRepositoriesReleases = () => { const { workspace } = useWorkspaces(); - if (!workspace) - throw new Error( - "Impossible to fetch pieces repositories without specifying a workspace", - ); - return async (params: IGetPiecesRepositoriesReleasesParams) => await getPiecesRepositoriesReleases({ ...params, - workspaceId: workspace.id, + workspaceId: workspace?.id, }).then((data) => { - return data.data; + return data?.data; }); }; diff --git a/frontend/src/features/myWorkflows/api/repository/deletePieceRepository.request.ts b/frontend/src/features/myWorkflows/api/repository/deletePieceRepository.request.ts index 33bb30d1..1c33f61e 100644 --- a/frontend/src/features/myWorkflows/api/repository/deletePieceRepository.request.ts +++ b/frontend/src/features/myWorkflows/api/repository/deletePieceRepository.request.ts @@ -20,10 +20,7 @@ const deleteRepository: (id: string) => Promise = async (id) => { export const useAuthenticatedDeleteRepository = () => { const { workspace } = useWorkspaces(); - if (!workspace) - throw new Error( - "Impossible to run workflows without specifying a workspace", - ); + if (!workspace) return async (_id: string) => {}; const fetcher = async (id: string) => await deleteRepository(id).then((data) => data); diff --git a/frontend/src/features/myWorkflows/api/repository/patchPieceRepositorySecret.request.ts b/frontend/src/features/myWorkflows/api/repository/patchPieceRepositorySecret.request.ts index 9c8c452b..6b774972 100644 --- a/frontend/src/features/myWorkflows/api/repository/patchPieceRepositorySecret.request.ts +++ b/frontend/src/features/myWorkflows/api/repository/patchPieceRepositorySecret.request.ts @@ -34,10 +34,7 @@ const patchRepositorySecret: ( export const useAuthenticatedPatchRepositorySecret = () => { const { workspace } = useWorkspaces(); - if (!workspace) - throw new Error( - "Impossible to run workflows without specifying a workspace", - ); + if (!workspace) return async (_params: PatchRepositorySecretParams) => {}; const fetcher = async (params: PatchRepositorySecretParams) => await patchRepositorySecret(params).then((data) => data); diff --git a/frontend/src/features/myWorkflows/api/workflow/postWorkflowRunId.ts b/frontend/src/features/myWorkflows/api/workflow/postWorkflowRunId.ts index a3d37088..12517a8e 100644 --- a/frontend/src/features/myWorkflows/api/workflow/postWorkflowRunId.ts +++ b/frontend/src/features/myWorkflows/api/workflow/postWorkflowRunId.ts @@ -36,10 +36,7 @@ const postWorkflowRunId: ( export const useAuthenticatedPostWorkflowRunId = () => { const { workspace } = useWorkspaces(); - if (!workspace) - throw new Error( - "Impossible to run workflows without specifying a workspace", - ); + if (!workspace) return async (_params: IPostWorkflowRunIdParams) => {}; const fetcher = async (params: IPostWorkflowRunIdParams) => await postWorkflowRunId(workspace.id, params) diff --git a/frontend/src/features/myWorkflows/components/WorkflowDetail/CustomTabMenu/TaskResult.tsx b/frontend/src/features/myWorkflows/components/WorkflowDetail/CustomTabMenu/TaskResult.tsx index 981a7dab..a1bf4a37 100644 --- a/frontend/src/features/myWorkflows/components/WorkflowDetail/CustomTabMenu/TaskResult.tsx +++ b/frontend/src/features/myWorkflows/components/WorkflowDetail/CustomTabMenu/TaskResult.tsx @@ -1,6 +1,8 @@ import { CircularProgress, Container, Typography } from "@mui/material"; import { type CSSProperties } from "react"; import ReactMarkdown from "react-markdown"; +import Plot from "react-plotly.js"; +import remarkGfm from "remark-gfm"; import "./styles.css"; // import { PDFViewer, Page, Text, View, Document, StyleSheet } from '@react-pdf/renderer'; @@ -67,7 +69,10 @@ export const TaskResult = (props: ITaskResultProps) => { style={{ overflow: "auto", maxWidth: "100%", width: "100%" }} className="markdown-container" > - + {window.atob(base64_content)} ; @@ -95,6 +100,17 @@ export const TaskResult = (props: ITaskResultProps) => { // style={{ width: '100%', height: '100%' }} // /> ); + case "plotly_json": { + const utf8String = atob(base64_content); + const decodedJSON = JSON.parse(utf8String); + return ( + + ); + } default: return
Unsupported file type
; } diff --git a/frontend/src/features/myWorkflows/components/WorkflowDetail/WorkflowRunTableFooter/index.tsx b/frontend/src/features/myWorkflows/components/WorkflowDetail/WorkflowRunTableFooter/index.tsx index 8613b32f..e61f8abb 100644 --- a/frontend/src/features/myWorkflows/components/WorkflowDetail/WorkflowRunTableFooter/index.tsx +++ b/frontend/src/features/myWorkflows/components/WorkflowDetail/WorkflowRunTableFooter/index.tsx @@ -1,3 +1,4 @@ +import RefreshIcon from "@mui/icons-material/Refresh"; import { Button, Grid } from "@mui/material"; import { type GridSlotsComponentsProps, @@ -8,15 +9,17 @@ import React from "react"; declare module "@mui/x-data-grid" { interface FooterPropsOverrides { triggerRun: () => void; + refresh: () => void; } } interface Props extends NonNullable { triggerRun: () => void; + refresh: () => void; } export const WorkflowRunTableFooter = React.forwardRef( - ({ triggerRun }) => { + ({ triggerRun, refresh }, ref) => { const rootProps = useGridRootProps(); const paginationElement = rootProps.pagination && @@ -26,7 +29,7 @@ export const WorkflowRunTableFooter = React.forwardRef( ); return ( - + ( - - - - diff --git a/frontend/src/features/myWorkflows/components/WorkflowDetail/WorkflowRunsTable.tsx b/frontend/src/features/myWorkflows/components/WorkflowDetail/WorkflowRunsTable.tsx index ccb4b181..90251537 100644 --- a/frontend/src/features/myWorkflows/components/WorkflowDetail/WorkflowRunsTable.tsx +++ b/frontend/src/features/myWorkflows/components/WorkflowDetail/WorkflowRunsTable.tsx @@ -19,6 +19,7 @@ interface Props { selectedRun: IWorkflowRuns | null; onSelectedRunChange: (run: IWorkflowRuns | null) => void; triggerRun: () => void; + refresh: () => void; } export interface WorkflowRunsTableRef { @@ -32,6 +33,7 @@ export const WorkflowRunsTable = forwardRef( selectedRun, onSelectedRunChange: setSelectedRun, triggerRun, + refresh, }, ref, ) => { @@ -164,7 +166,7 @@ export const WorkflowRunsTable = forwardRef( footer: WorkflowRunTableFooter, }} slotProps={{ - footer: { triggerRun }, + footer: { triggerRun, refresh }, }} sx={{ "&.MuiDataGrid-root .MuiDataGrid-cell:focus": { diff --git a/frontend/src/features/myWorkflows/components/WorkflowDetail/index.tsx b/frontend/src/features/myWorkflows/components/WorkflowDetail/index.tsx index 6afd1431..451b7b39 100644 --- a/frontend/src/features/myWorkflows/components/WorkflowDetail/index.tsx +++ b/frontend/src/features/myWorkflows/components/WorkflowDetail/index.tsx @@ -214,6 +214,7 @@ export const WorkflowDetail: React.FC = () => { setAutoUpdate(true); } }} + refresh={refresh} selectedRun={selectedRun} ref={workflowRunsTableRef} onSelectedRunChange={handleSelectRun} diff --git a/frontend/src/features/workflowEditor/components/DifferencesModal/index.tsx b/frontend/src/features/workflowEditor/components/DifferencesModal/index.tsx new file mode 100644 index 00000000..1a216ed1 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/DifferencesModal/index.tsx @@ -0,0 +1,194 @@ +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; +import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline"; +import { + Button, + CircularProgress, + Grid, + List, + ListItem, + ListItemIcon, + ListItemText, + Tooltip, + Typography, +} from "@mui/material"; +import { Modal, type ModalRef } from "components/Modal"; +import { useWorkspaces, usesPieces } from "context/workspaces"; +import { type Differences } from "features/workflowEditor/utils/importWorkflow"; +import React, { forwardRef, useCallback, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { toast } from "react-toastify"; + +interface Props { + incompatiblesPieces: Differences[]; +} + +enum installStateEnum { + notInstalled = 0, + installing = 1, + installed = 2, +} + +export const DifferencesModal = forwardRef( + ({ incompatiblesPieces }, ref) => { + const { workspace } = useWorkspaces(); + const { handleAddRepository } = usesPieces(); + const [installState, setInstallState] = useState(0); + + const { installedPieces, uninstalledPieces } = useMemo(() => { + return { + installedPieces: incompatiblesPieces.filter((p) => p.installedVersion), + uninstalledPieces: incompatiblesPieces.filter( + (p) => !p.installedVersion, + ), + }; + }, [incompatiblesPieces]); + + const installRepositories = useCallback( + async (e: Omit) => { + const addRepository = { + workspace_id: workspace?.id ?? "", + source: "github", + path: e.source, + version: e.requiredVersion, + url: `https://github.com/${e.source}`, + }; + + return await handleAddRepository(addRepository).catch((e) => { + console.log(e); + }); + }, + [handleAddRepository], + ); + + const handleInstallMissingRepositories = useCallback(async () => { + try { + setInstallState(1); + await Promise.all(uninstalledPieces.map(installRepositories)); + setInstallState(2); + } catch (e) { + toast.error(e as string); + setInstallState(0); + } + }, [installRepositories, uninstalledPieces]); + + return ( + + + + Some of the pieces necessary to run this workflow are not + present in this workspace or mismatch the correct version. + {!!installedPieces.length && ( + <> + Incorrect version pieces need to be manually update on + workspace settings, + + )} + + + + + {installedPieces.map((item) => ( + + + + + + Change to {item.requiredVersion} + + + } + > + + + ))} + {uninstalledPieces.map((item) => ( + + {installState === 2 ? ( + <> + + + Installed {item.requiredVersion} + + + ) : ( + <> + + + + + Install {item.requiredVersion} + + + )} + + } + > + + + ))} + + + {!!uninstalledPieces.length && ( + + + + + + )} + + } + onClose={() => { + setInstallState(0); + }} + ref={ref} + /> + ); + }, +); + +DifferencesModal.displayName = "DifferencesModal"; diff --git a/frontend/src/features/workflowEditor/components/DrawerMenu/index.tsx b/frontend/src/features/workflowEditor/components/DrawerMenu/index.tsx index d0538d28..45565532 100644 --- a/frontend/src/features/workflowEditor/components/DrawerMenu/index.tsx +++ b/frontend/src/features/workflowEditor/components/DrawerMenu/index.tsx @@ -7,7 +7,6 @@ import { Box, Divider, IconButton, - ListItem, Typography, useTheme, } from "@mui/material"; @@ -61,14 +60,12 @@ export const PermanentDrawerRightWorkflows: FC< {openDrawer && ( <> - - - - - + + + )} diff --git a/frontend/src/features/workflowEditor/components/DrawerMenu/pieceDocsPopover.tsx b/frontend/src/features/workflowEditor/components/DrawerMenu/pieceDocsPopover.tsx index e4a3a897..72a15991 100644 --- a/frontend/src/features/workflowEditor/components/DrawerMenu/pieceDocsPopover.tsx +++ b/frontend/src/features/workflowEditor/components/DrawerMenu/pieceDocsPopover.tsx @@ -22,8 +22,8 @@ function renderPieceProperties( } return ( - <> - + + {key} - {typeName} @@ -35,7 +35,7 @@ function renderPieceProperties( )} - + ); }); } diff --git a/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarAddNode.tsx b/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarAddNode.tsx index dd048a22..9ea310b4 100644 --- a/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarAddNode.tsx +++ b/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarAddNode.tsx @@ -5,21 +5,16 @@ import { AccordionSummary, Alert, Box, + TextField, ToggleButton, ToggleButtonGroup, Typography, } from "@mui/material"; import { usesPieces } from "context/workspaces"; -import { type FC, useState } from "react"; +import { type FC, useState, useMemo, useEffect } from "react"; import PiecesSidebarNode from "./sidebarNode"; -/** - * @todo cleanup comments when no longer needed - * @todo move pieces rules to create workflow context - * @todo improve loading/error/empty states - */ - interface Props { setOrientation: React.Dispatch< React.SetStateAction<"horizontal" | "vertical"> @@ -30,24 +25,63 @@ interface Props { const SidebarAddNode: FC = ({ setOrientation, orientation }) => { const { repositories, repositoriesLoading, repositoryPieces } = usesPieces(); - const [piecesMap, setPiecesMap] = useState>({}); - const [expandedRepos, setExpandedRepos] = useState([]); + const [filter, setFilter] = useState(""); + const [expanded, setExpanded] = useState>({}); + + const filteredRepositoryPieces = useMemo(() => { + function filterPieces( + repository: PiecesRepository, + searchTerm: string, + ): PiecesRepository { + const filteredRepository: PiecesRepository = {}; + + Object.keys(repository).forEach((key) => { + const filteredPieces = repository[key].filter((piece) => { + return ( + piece.name.toLowerCase().includes(searchTerm.toLowerCase()) || + piece.description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + }); + + filteredRepository[key] = filteredPieces; + if (filteredPieces.length) { + setExpanded((e) => ({ ...e, [key]: true })); + } + }); - /** controls if an accordion is loading Pieces */ - const [loadingPieces, setLoadingPieces] = useState(false); + return filteredRepository; + } + + return filterPieces(repositoryPieces, filter); + }, [filter, repositoryPieces, setExpanded]); + + useEffect(() => { + if (!filter) { + setExpanded((e) => { + const newExpanded = Object.keys(e).reduce>( + (acc, next) => { + acc[next] = false; + return acc; + }, + {}, + ); + + return newExpanded; + }); + } + }, [filter]); return ( - + {repositoriesLoading && ( Loading repositories... )} {!repositoriesLoading && ( { - console.log("value", value); if (value) setOrientation(value); }} > @@ -59,69 +93,63 @@ const SidebarAddNode: FC = ({ setOrientation, orientation }) => { )} + { + setFilter(e.target.value); + }} + fullWidth + variant="filled" + label="search" + /> {!repositoriesLoading && - repositories.map((repo) => ( - { - if (loadingPieces) return; - setLoadingPieces(repo.id); - - // Check if the repo is currently expanded - const isExpanded = expandedRepos.includes(repo.id); + repositories.map((repo) => { + if (!filteredRepositoryPieces[repo.id]?.length) { + return null; + } - // If the repo is already expanded, remove it from the expandedRepos array - // Otherwise, add it to the expandedRepos array - setExpandedRepos( - isExpanded - ? (prev) => prev.filter((id) => id !== repo.id) - : (prev) => [...prev, repo.id], - ); - - // If the repo is not currently expanded, load its pieces - if (!isExpanded) { - setPiecesMap((prev) => ({ - ...prev, - [repo.id]: repositoryPieces[repo.id], - })); - } - - setLoadingPieces(false); - }} - > - }> - { + setExpanded((e) => ({ ...e, [repo.id]: expanded })); + }} + TransitionProps={{ unmountOnExit: true }} + key={repo.id} + > + }> + + {repo.label} + + + - {repo.label} - - - - {!!loadingPieces && loadingPieces === repo.id && ( + {/* {!!loadingPieces && loadingPieces === repo.id && ( Loading Pieces... - )} - {expandedRepos.includes(repo.id) && - piecesMap[repo.id]?.length && - piecesMap[repo.id].map((piece) => ( - - ))} - - - ))} + )} */} + + {Boolean(filteredRepositoryPieces[repo.id]?.length) && + filteredRepositoryPieces[repo.id].map((piece) => ( + + ))} + + + ); + })} ); }; diff --git a/frontend/src/features/workflowEditor/components/MyWorkflowsGalleryModal/index.tsx b/frontend/src/features/workflowEditor/components/MyWorkflowsGalleryModal/index.tsx index efb52154..37206e43 100644 --- a/frontend/src/features/workflowEditor/components/MyWorkflowsGalleryModal/index.tsx +++ b/frontend/src/features/workflowEditor/components/MyWorkflowsGalleryModal/index.tsx @@ -46,18 +46,18 @@ const MyWorkflowExamplesGalleryModal = forwardRef( return ( - + {cardsContents.map((card, index) => ( - + diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/ContainerResourceForm.tsx b/frontend/src/features/workflowEditor/components/SidebarForm/ContainerResourceForm.tsx index 6bc4a237..d1d92796 100644 --- a/frontend/src/features/workflowEditor/components/SidebarForm/ContainerResourceForm.tsx +++ b/frontend/src/features/workflowEditor/components/SidebarForm/ContainerResourceForm.tsx @@ -73,7 +73,7 @@ const ContainerResourceForm: React.FC = () => { { = ({ } } } + if (checkedFromUpstream) { let options: Option[] = []; if ( @@ -89,8 +90,9 @@ const PieceFormItem: React.FC = ({ /> ); } else if ( - "type" in schema && - (schema.type === "number" || schema.type === "float") + ("type" in schema && + (schema.type === "number" || schema.type === "float")) || + anyOfType === "float" ) { inputElement = ( @@ -100,7 +102,10 @@ const PieceFormItem: React.FC = ({ defaultValue={schema?.default ?? 10.5} /> ); - } else if ("type" in schema && schema.type === "integer") { + } else if ( + ("type" in schema && schema.type === "integer") || + anyOfType === "integer" + ) { inputElement = ( name={`inputs.${itemKey}.value`} @@ -223,10 +228,11 @@ const PieceFormItem: React.FC = ({ /> ); } else if ( - ("type" in schema && - "widget" in schema && - schema.type === "string" && - schema.widget === "textarea")) { + "type" in schema && + "widget" in schema && + schema.type === "string" && + schema.widget === "textarea" + ) { inputElement = ( variant="outlined" diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/upstreamOptions.ts b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/upstreamOptions.ts index 69c34594..980edaf8 100644 --- a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/upstreamOptions.ts +++ b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/upstreamOptions.ts @@ -112,6 +112,18 @@ export const getUpstreamOptions = ( currentType === "array" || (Array.isArray(currentType) && currentType.includes("array")) ) { + if ("anyOf" in currentSchema && currentSchema.anyOf.length === 2) { + const hasNullType = currentSchema.anyOf.some( + (item: any) => item.type === "null", + ); + if (hasNullType) { + const notNullAnyOf = currentSchema.anyOf.find( + (item: any) => item.type !== "null", + ); + currentSchema.items = notNullAnyOf.items; + } + } + let itemsSchema = currentSchema?.items; if (currentSchema?.items?.$ref) { const subItemSchemaName = currentSchema.items.$ref.split("/").pop(); @@ -119,7 +131,7 @@ export const getUpstreamOptions = ( } const $array = getOptions(upstreamPieces, currentType); - if (itemsSchema.type === "object") { + if (itemsSchema.type === "object" && itemsSchema.properties) { const __data: any = {}; Object.keys(itemsSchema.properties).forEach((subKey) => { const subSchema = itemsSchema.properties[subKey]; diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/validation.ts b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/validation.ts index b4d7313a..ab00dc08 100644 --- a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/validation.ts +++ b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/validation.ts @@ -161,7 +161,7 @@ function getValidationValueBySchemaType(schema: any, required: boolean) { if (fromUpstream) { return yup.mixed().notRequired(); } - return required ? yup.string().required() : yup.string(); + return required ? yup.string().required() : yup.string().nullable(); }), }); } else if (schema.type === "object") { diff --git a/frontend/src/features/workflowEditor/components/WorkflowEditor.tsx b/frontend/src/features/workflowEditor/components/WorkflowEditor.tsx index 15ce9e0a..4870bc9f 100644 --- a/frontend/src/features/workflowEditor/components/WorkflowEditor.tsx +++ b/frontend/src/features/workflowEditor/components/WorkflowEditor.tsx @@ -8,7 +8,7 @@ import Menu from "@mui/material/Menu"; import MenuItem from "@mui/material/MenuItem"; import { AxiosError } from "axios"; import Loading from "components/Loading"; -import { Modal, type ModalRef } from "components/Modal"; +import { type ModalRef } from "components/Modal"; import { type WorkflowPanelRef, WorkflowPanel, @@ -28,10 +28,13 @@ import { type GenerateWorkflowsParams } from "../context/workflowsEditor"; import { containerResourcesSchema } from "../schemas/containerResourcesSchemas"; import { extractDefaultInputValues, extractDefaultValues } from "../utils"; import { + type Differences, importJsonWorkflow, validateJsonImported, + findDifferencesInJsonImported, } from "../utils/importWorkflow"; +import { DifferencesModal } from "./DifferencesModal"; import { PermanentDrawerRightWorkflows } from "./DrawerMenu"; import { MyWorkflowExamplesGalleryModal } from "./MyWorkflowsGalleryModal"; import SidebarPieceForm from "./SidebarForm"; @@ -87,7 +90,9 @@ export const WorkflowsEditorComponent: React.FC = () => { const incompatiblePiecesModalRef = useRef(null); const workflowsGalleryModalRef = useRef(null); const myWorkflowsGalleryModalRef = useRef(null); - const [incompatiblesPieces, setIncompatiblesPieces] = useState([]); + const [incompatiblesPieces, setIncompatiblesPieces] = useState( + [], + ); const { workspace } = useWorkspaces(); @@ -244,12 +249,15 @@ export const WorkflowsEditorComponent: React.FC = () => { async (json: GenerateWorkflowsParams) => { try { if (json) { - const differences = await validateJsonImported(json); + await validateJsonImported(json); + + const differences = await findDifferencesInJsonImported(json); - if (differences) { + if (differences.length) { toast.error( "Some repositories are missing or incompatible version", ); + setIncompatiblesPieces(differences); incompatiblePiecesModalRef.current?.open(); } else { @@ -491,24 +499,6 @@ export const WorkflowsEditorComponent: React.FC = () => { ref={fileInputRef} /> Import - - {incompatiblesPieces.map((item) => ( -
  • - {`${item.split("ghcr.io/")[1].split(":")[0]}: ${ - item - .split("ghcr.io/")[1] - .split(":")[1] - .split("-")[0] - }`} -
  • - ))} - - } - ref={incompatiblePiecesModalRef} - /> { void handleImportedJson(json); }} /> +