From 46a9bd352695cdab5deaa5dd839990ccacdb304d Mon Sep 17 00:00:00 2001 From: Axel RICHARD Date: Wed, 11 Jan 2023 09:26:12 +0100 Subject: [PATCH] [1567] Add support for project templates Bug: https://github.com/eclipse-sirius/sirius-components/issues/1567 Signed-off-by: Axel RICHARD --- CHANGELOG.adoc | 3 + ...nCreateProjectFromTemplateDataFetcher.java | 58 ++++ .../ProjectTemplateImageURLDataFetcher.java | 35 +++ .../user/UserProjectTemplatesDataFetcher.java | 99 +++++++ .../main/resources/schema/siriusweb.graphqls | 34 +++ .../CreateProjectFromTemplateInput.java | 25 ++ ...eateProjectFromTemplateSuccessPayload.java | 65 ++++ .../api/projects/IProjectService.java | 9 +- .../projects/IProjectTemplateInitializer.java | 31 ++ .../projects/IProjectTemplateProvider.java | 25 ++ .../api/projects/IProjectTemplateService.java | 45 +++ .../api/projects/ProjectTemplate.java | 95 ++++++ .../web/services/projects/ProjectService.java | 57 +++- .../projects/ProjectTemplateService.java | 50 ++++ .../projects/ProjectServiceTests.java | 20 +- .../ProjectTemplatesModal.tsx | 277 ++++++++++++++++++ .../ProjectTemplatesModal.types.ts | 49 ++++ .../ProjectTemplatesModalMachine.ts | 223 ++++++++++++++ .../ProjectTemplateCard.tsx | 129 ++++++++ .../ProjectTemplateCard.types.ts | 21 ++ .../src/views/projects/ProjectsView.tsx | 215 ++++++++++++-- .../src/views/projects/ProjectsView.types.ts | 48 ++- .../src/views/projects/ProjectsViewMachine.ts | 53 +++- 23 files changed, 1632 insertions(+), 34 deletions(-) create mode 100644 packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/mutation/MutationCreateProjectFromTemplateDataFetcher.java create mode 100644 packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/project/ProjectTemplateImageURLDataFetcher.java create mode 100644 packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/user/UserProjectTemplatesDataFetcher.java create mode 100644 packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/CreateProjectFromTemplateInput.java create mode 100644 packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/CreateProjectFromTemplateSuccessPayload.java create mode 100644 packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateInitializer.java create mode 100644 packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateProvider.java create mode 100644 packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateService.java create mode 100644 packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/ProjectTemplate.java create mode 100644 packages/sirius-web/backend/sirius-web-services/src/main/java/org/eclipse/sirius/web/services/projects/ProjectTemplateService.java create mode 100644 packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModal.tsx create mode 100644 packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModal.types.ts create mode 100644 packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModalMachine.ts create mode 100644 packages/sirius-web/frontend/sirius-web/src/views/project-template-card/ProjectTemplateCard.tsx create mode 100644 packages/sirius-web/frontend/sirius-web/src/views/project-template-card/ProjectTemplateCard.types.ts diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 5d5ae5638b0..007a3264c84 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -14,6 +14,9 @@ - https://github.com/eclipse-sirius/sirius-components/issues/1377[#1377] [core] Switch to Java 17 +=== New Features + +- https://github.com/eclipse-sirius/sirius-components/issues/1567[#1567] [project] Add support for project templates == v2023.1.0 diff --git a/packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/mutation/MutationCreateProjectFromTemplateDataFetcher.java b/packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/mutation/MutationCreateProjectFromTemplateDataFetcher.java new file mode 100644 index 00000000000..c84666dff7b --- /dev/null +++ b/packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/mutation/MutationCreateProjectFromTemplateDataFetcher.java @@ -0,0 +1,58 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.sirius.web.graphql.datafetchers.mutation; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.util.Objects; + +import org.eclipse.sirius.components.annotations.spring.graphql.MutationDataFetcher; +import org.eclipse.sirius.components.core.api.IPayload; +import org.eclipse.sirius.components.graphql.api.IDataFetcherWithFieldCoordinates; +import org.eclipse.sirius.components.graphql.api.IExceptionWrapper; +import org.eclipse.sirius.web.services.api.projects.CreateProjectFromTemplateInput; +import org.eclipse.sirius.web.services.api.projects.IProjectService; + +import graphql.schema.DataFetchingEnvironment; + +/** + * The data fetcher used to create a project from a template. + * + * @author pcdavid + */ +@MutationDataFetcher(type = "Mutation", field = "createProjectFromTemplate") +public class MutationCreateProjectFromTemplateDataFetcher implements IDataFetcherWithFieldCoordinates { + + private static final String INPUT_ARGUMENT = "input"; + + private final ObjectMapper objectMapper; + + private final IExceptionWrapper exceptionWrapper; + + private final IProjectService projectService; + + public MutationCreateProjectFromTemplateDataFetcher(ObjectMapper objectMapper, IExceptionWrapper exceptionWrapper, IProjectService projectService) { + this.objectMapper = Objects.requireNonNull(objectMapper); + this.exceptionWrapper = Objects.requireNonNull(exceptionWrapper); + this.projectService = Objects.requireNonNull(projectService); + } + + @Override + public IPayload get(DataFetchingEnvironment environment) throws Exception { + Object argument = environment.getArgument(INPUT_ARGUMENT); + var input = this.objectMapper.convertValue(argument, CreateProjectFromTemplateInput.class); + + return this.exceptionWrapper.wrap(() -> this.projectService.createProject(input), input); + } + +} diff --git a/packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/project/ProjectTemplateImageURLDataFetcher.java b/packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/project/ProjectTemplateImageURLDataFetcher.java new file mode 100644 index 00000000000..3dedfb5c16f --- /dev/null +++ b/packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/project/ProjectTemplateImageURLDataFetcher.java @@ -0,0 +1,35 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.sirius.web.graphql.datafetchers.project; + +import org.eclipse.sirius.components.annotations.spring.graphql.QueryDataFetcher; +import org.eclipse.sirius.components.graphql.api.IDataFetcherWithFieldCoordinates; +import org.eclipse.sirius.components.graphql.api.URLConstants; +import org.eclipse.sirius.web.services.api.projects.ProjectTemplate; + +import graphql.schema.DataFetchingEnvironment; + +/** + * The data fetcher used to concatenate the server image URL to the project template image path. + * + * @author pcdavid + */ +@QueryDataFetcher(type = "ProjectTemplate", field = "imageURL") +public class ProjectTemplateImageURLDataFetcher implements IDataFetcherWithFieldCoordinates { + + @Override + public String get(DataFetchingEnvironment environment) throws Exception { + ProjectTemplate projectTemplate = environment.getSource(); + return URLConstants.IMAGE_BASE_PATH + projectTemplate.getImageURL(); + } +} diff --git a/packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/user/UserProjectTemplatesDataFetcher.java b/packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/user/UserProjectTemplatesDataFetcher.java new file mode 100644 index 00000000000..f579adb41a3 --- /dev/null +++ b/packages/sirius-web/backend/sirius-web-graphql/src/main/java/org/eclipse/sirius/web/graphql/datafetchers/user/UserProjectTemplatesDataFetcher.java @@ -0,0 +1,99 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.sirius.web.graphql.datafetchers.user; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.eclipse.sirius.components.annotations.spring.graphql.QueryDataFetcher; +import org.eclipse.sirius.components.graphql.api.IDataFetcherWithFieldCoordinates; +import org.eclipse.sirius.web.graphql.pagination.PageInfoWithCount; +import org.eclipse.sirius.web.services.api.projects.IProjectTemplateProvider; +import org.eclipse.sirius.web.services.api.projects.ProjectTemplate; + +import graphql.relay.Connection; +import graphql.relay.ConnectionCursor; +import graphql.relay.DefaultConnection; +import graphql.relay.DefaultConnectionCursor; +import graphql.relay.DefaultEdge; +import graphql.relay.Edge; +import graphql.relay.PageInfo; +import graphql.relay.Relay; +import graphql.schema.DataFetchingEnvironment; + +/** + * The data fetcher used to retrieve all the project templates accessible to a given viewer. + * + * @author pcdavid + */ +@QueryDataFetcher(type = "User", field = "projectTemplates") +public class UserProjectTemplatesDataFetcher implements IDataFetcherWithFieldCoordinates> { + private static final String PAGE_ARGUMENT = "page"; + + private static final String LIMIT_ARGUMENT = "limit"; + + private final List projectTemplateProviders; + + public UserProjectTemplatesDataFetcher(List projectTemplateProviders) { + this.projectTemplateProviders = Objects.requireNonNull(projectTemplateProviders); + } + + @Override + public Connection get(DataFetchingEnvironment environment) throws Exception { + int page = this.getPage(environment); + int limit = this.getLimit(environment); + + // @formatter:off + List allProjectTemplates = this.projectTemplateProviders.stream() + .flatMap(projectTemplateProvider -> projectTemplateProvider.getProjectTemplates().stream()) + .sorted(Comparator.comparing(ProjectTemplate::getLabel)) + .collect(Collectors.toList()); + List> projectTemplateEdges = allProjectTemplates.subList(page * limit, Math.min((page + 1) * limit, allProjectTemplates.size())).stream() + .map(projectTemplate -> { + String value = new Relay().toGlobalId("ProjectTemplate", projectTemplate.getId()); + ConnectionCursor cursor = new DefaultConnectionCursor(value); + return new DefaultEdge<>(projectTemplate, cursor); + }) + .collect(Collectors.toList()); + // @formatter:on + + ConnectionCursor startCursor = projectTemplateEdges.stream().findFirst().map(Edge::getCursor).orElse(null); + ConnectionCursor endCursor = null; + if (!projectTemplateEdges.isEmpty()) { + endCursor = projectTemplateEdges.get(projectTemplateEdges.size() - 1).getCursor(); + } + PageInfo pageInfo = new PageInfoWithCount(startCursor, endCursor, false, false, allProjectTemplates.size()); + return new DefaultConnection<>(projectTemplateEdges, pageInfo); + } + + private int getPage(DataFetchingEnvironment environment) { + // @formatter:off + return Optional. ofNullable(environment.getArgument(PAGE_ARGUMENT)) + .filter(page -> page.intValue() > 0) + .orElse(0) + .intValue(); + // @formatter:on + } + + private int getLimit(DataFetchingEnvironment environment) { + // @formatter:off + return Optional. ofNullable(environment.getArgument(LIMIT_ARGUMENT)) + .filter(limit -> limit.intValue() > 0) + .orElse(20) + .intValue(); + // @formatter:on + } +} diff --git a/packages/sirius-web/backend/sirius-web-graphql/src/main/resources/schema/siriusweb.graphqls b/packages/sirius-web/backend/sirius-web-graphql/src/main/resources/schema/siriusweb.graphqls index 5c619baddba..c0f589e0dd4 100644 --- a/packages/sirius-web/backend/sirius-web-graphql/src/main/resources/schema/siriusweb.graphqls +++ b/packages/sirius-web/backend/sirius-web-graphql/src/main/resources/schema/siriusweb.graphqls @@ -37,16 +37,37 @@ enum Visibility { PUBLIC } +type ProjectTemplate { + id: ID! + label: String! + imageURL: String! +} + +extend interface Viewer { + projectTemplates(page: Int, limit: Int): ViewerProjectTemplateConnection! +} + +type ViewerProjectTemplateConnection { + edges: [ViewerProjectTemplateEdge!]! + pageInfo: PageInfo! +} + +type ViewerProjectTemplateEdge { + node: ProjectTemplate! +} + type User implements Viewer { id: ID! username: String! editingContext(editingContextId: ID!): EditingContext project(projectId: ID!): Project projects(page: Int): ViewerProjectConnection! + projectTemplates(page: Int, limit: Int): ViewerProjectTemplateConnection! } extend type Mutation { createProject(input: CreateProjectInput!): CreateProjectPayload! + createProjectFromTemplate(input: CreateProjectFromTemplateInput!): CreateProjectFromTemplatePayload! deleteProject(input: DeleteProjectInput!): DeleteProjectPayload! renameProject(input: RenameProjectInput!): RenameProjectPayload! uploadProject(input: UploadProjectInput!): UploadProjectPayload! @@ -72,6 +93,19 @@ type CreateProjectSuccessPayload { project: Project! } +input CreateProjectFromTemplateInput { + id: ID! + templateId: ID! +} + +union CreateProjectFromTemplatePayload = ErrorPayload | CreateProjectFromTemplateSuccessPayload + +type CreateProjectFromTemplateSuccessPayload { + id: ID! + project: Project! + representationToOpen: RepresentationMetadata +} + input DeleteProjectInput { id: ID! projectId: ID! diff --git a/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/CreateProjectFromTemplateInput.java b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/CreateProjectFromTemplateInput.java new file mode 100644 index 00000000000..1a1d4c7659f --- /dev/null +++ b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/CreateProjectFromTemplateInput.java @@ -0,0 +1,25 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.sirius.web.services.api.projects; + +import java.util.UUID; + +import org.eclipse.sirius.components.core.api.IInput; + +/** + * The input object of the create project from a template mutation. + * + * @author pcdavid + */ +public record CreateProjectFromTemplateInput(UUID id, String templateId) implements IInput { +} diff --git a/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/CreateProjectFromTemplateSuccessPayload.java b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/CreateProjectFromTemplateSuccessPayload.java new file mode 100644 index 00000000000..c2d1c86da58 --- /dev/null +++ b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/CreateProjectFromTemplateSuccessPayload.java @@ -0,0 +1,65 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.sirius.web.services.api.projects; + +import java.text.MessageFormat; +import java.util.Objects; +import java.util.UUID; + +import org.eclipse.sirius.components.core.RepresentationMetadata; +import org.eclipse.sirius.components.core.api.IPayload; + +/** + * The payload of the create project from template mutation. + * + * @author pcdavid + */ +public final class CreateProjectFromTemplateSuccessPayload implements IPayload { + + private final UUID id; + + private final Project project; + + private final RepresentationMetadata representationToOpen; + + public CreateProjectFromTemplateSuccessPayload(UUID id, Project project, RepresentationMetadata representationToOpen) { + this.id = Objects.requireNonNull(id); + this.project = Objects.requireNonNull(project); + this.representationToOpen = representationToOpen; + } + + @Override + public UUID getId() { + return this.id; + } + + public Project getProject() { + return this.project; + } + + public RepresentationMetadata getRepresentationToOpen() { + return this.representationToOpen; + } + + @Override + public String toString() { + if (this.representationToOpen != null) { + String pattern = "{0} '{'id: {1}, project: '{'id: {2}, name: {3} '}', representationToOpen: '{' id: {4}, label: {5} '}' '}'"; + return MessageFormat.format(pattern, this.getClass().getSimpleName(), this.id, this.project.getId(), this.project.getName(), this.representationToOpen.getId(), + this.representationToOpen.getLabel()); + } else { + String pattern = "{0} '{'id: {1}, project: '{'id: {2}, name: {3} '}', representationToOpen: null '}'"; + return MessageFormat.format(pattern, this.getClass().getSimpleName(), this.id, this.project.getId(), this.project.getName()); + } + } +} diff --git a/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectService.java b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectService.java index b6a2919ddbc..0a24764d6a4 100644 --- a/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectService.java +++ b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectService.java @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2019, 2022 Obeo. + * Copyright (c) 2019, 2023 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -31,6 +31,8 @@ public interface IProjectService { IPayload createProject(CreateProjectInput input); + IPayload createProject(CreateProjectFromTemplateInput input); + void delete(UUID projectId); Optional renameProject(UUID projectId, String newName); @@ -57,6 +59,11 @@ public IPayload createProject(CreateProjectInput input) { return null; } + @Override + public IPayload createProject(CreateProjectFromTemplateInput input) { + return null; + } + @Override public void delete(UUID projectId) { } diff --git a/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateInitializer.java b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateInitializer.java new file mode 100644 index 00000000000..bb011b982e1 --- /dev/null +++ b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateInitializer.java @@ -0,0 +1,31 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.sirius.web.services.api.projects; + +import java.util.Optional; + +import org.eclipse.sirius.components.core.RepresentationMetadata; +import org.eclipse.sirius.components.core.api.IEditingContext; + +/** + * Initializes the contents of a new project created from a project template. The initializer can add new documents to + * the project, create initial representations, and optionally indicate such a representation to automatically open when + * the user is redirected to the newly create project. + * + * @author pcdavid + */ +public interface IProjectTemplateInitializer { + boolean canHandle(String templateId); + + Optional handle(String templateId, IEditingContext editingContext); +} diff --git a/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateProvider.java b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateProvider.java new file mode 100644 index 00000000000..9e3c37229b8 --- /dev/null +++ b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateProvider.java @@ -0,0 +1,25 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.sirius.web.services.api.projects; + +import java.util.List; + +/** + * Used to register project templates in the backend. This only considers templates' metadata, see + * {@link IProjectTemplateInitializer} for the definition of the actual contents of a project created from a template. + * + * @author pcdavid + */ +public interface IProjectTemplateProvider { + List getProjectTemplates(); +} diff --git a/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateService.java b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateService.java new file mode 100644 index 00000000000..fd075a7ed27 --- /dev/null +++ b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/IProjectTemplateService.java @@ -0,0 +1,45 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.sirius.web.services.api.projects; + +import java.util.List; + +/** + * Aggregates all the project templates related beans into a single service to reduce the number of dependencies needed. + * + * @author sbegaudeau + */ +public interface IProjectTemplateService { + List getProjectTemplateProviders(); + + List getProjectTemplateInitializers(); + + /** + * Empty implementation for testing/mocking. + * + * @author pcdavid + */ + class NoOp implements IProjectTemplateService { + + @Override + public List getProjectTemplateProviders() { + return null; + } + + @Override + public List getProjectTemplateInitializers() { + return null; + } + + } +} diff --git a/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/ProjectTemplate.java b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/ProjectTemplate.java new file mode 100644 index 00000000000..5206c31fa34 --- /dev/null +++ b/packages/sirius-web/backend/sirius-web-services-api/src/main/java/org/eclipse/sirius/web/services/api/projects/ProjectTemplate.java @@ -0,0 +1,95 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.sirius.web.services.api.projects; + +import java.text.MessageFormat; +import java.util.Objects; + +import org.eclipse.sirius.components.annotations.Immutable; + +/** + * DTO representing a project template. + * + * @author pcdavid + */ +@Immutable +public final class ProjectTemplate { + private String id; + + private String label; + + private String imageURL; + + private ProjectTemplate() { + // Prevent instantiation + } + + public String getId() { + return this.id; + } + + public String getLabel() { + return this.label; + } + + public String getImageURL() { + return this.imageURL; + } + + public static Builder newProjectTemplate(String id) { + return new Builder(id); + } + + @Override + public String toString() { + String pattern = "{0} '{'id: {1}, label: {2}, imageURL: {3}'}'"; + return MessageFormat.format(pattern, this.getClass().getSimpleName(), this.id, this.label, this.imageURL); + + } + + /** + * The builder used to create a ProjectTemplate. + * + * @author pcdavod + */ + @SuppressWarnings("checkstyle:HiddenField") + public static final class Builder { + private String id; + + private String label; + + private String imageURL; + + private Builder(String id) { + this.id = Objects.requireNonNull(id); + } + + public Builder label(String label) { + this.label = Objects.requireNonNull(label); + return this; + } + + public Builder imageURL(String imageURL) { + this.imageURL = Objects.requireNonNull(imageURL); + return this; + } + + public ProjectTemplate build() { + ProjectTemplate projectTemplate = new ProjectTemplate(); + projectTemplate.id = Objects.requireNonNull(this.id); + projectTemplate.label = Objects.requireNonNull(this.label); + projectTemplate.imageURL = this.imageURL; + return projectTemplate; + } + } +} diff --git a/packages/sirius-web/backend/sirius-web-services/src/main/java/org/eclipse/sirius/web/services/projects/ProjectService.java b/packages/sirius-web/backend/sirius-web-services/src/main/java/org/eclipse/sirius/web/services/projects/ProjectService.java index cbc9db8ded8..3993fa6ed18 100644 --- a/packages/sirius-web/backend/sirius-web-services/src/main/java/org/eclipse/sirius/web/services/projects/ProjectService.java +++ b/packages/sirius-web/backend/sirius-web-services/src/main/java/org/eclipse/sirius/web/services/projects/ProjectService.java @@ -20,15 +20,21 @@ import java.util.stream.Collectors; import org.eclipse.sirius.components.core.api.ErrorPayload; +import org.eclipse.sirius.components.core.api.IEditingContextPersistenceService; +import org.eclipse.sirius.components.core.api.IEditingContextSearchService; import org.eclipse.sirius.components.core.api.IPayload; import org.eclipse.sirius.web.persistence.entities.AccountEntity; import org.eclipse.sirius.web.persistence.entities.ProjectEntity; import org.eclipse.sirius.web.persistence.entities.VisibilityEntity; import org.eclipse.sirius.web.persistence.repositories.IAccountRepository; import org.eclipse.sirius.web.persistence.repositories.IProjectRepository; +import org.eclipse.sirius.web.services.api.projects.CreateProjectFromTemplateInput; +import org.eclipse.sirius.web.services.api.projects.CreateProjectFromTemplateSuccessPayload; import org.eclipse.sirius.web.services.api.projects.CreateProjectInput; import org.eclipse.sirius.web.services.api.projects.CreateProjectSuccessPayload; import org.eclipse.sirius.web.services.api.projects.IProjectService; +import org.eclipse.sirius.web.services.api.projects.IProjectTemplateProvider; +import org.eclipse.sirius.web.services.api.projects.IProjectTemplateService; import org.eclipse.sirius.web.services.api.projects.Project; import org.eclipse.sirius.web.services.api.projects.Visibility; import org.eclipse.sirius.web.services.messages.IServicesMessageService; @@ -51,12 +57,22 @@ public class ProjectService implements IProjectService { private final IAccountRepository accountRepository; + private final IProjectTemplateService projectTemplateService; + + private final IEditingContextSearchService editingContextSearchService; + + private final IEditingContextPersistenceService editingContextPersistenceService; + private final ProjectMapper projectMapper; - public ProjectService(IServicesMessageService messageService, IProjectRepository projectRepository, IAccountRepository accountRepository) { + public ProjectService(IServicesMessageService messageService, IProjectRepository projectRepository, IAccountRepository accountRepository, IProjectTemplateService projectTemplateService, + IEditingContextSearchService editingContextSearchService, IEditingContextPersistenceService editingContextPersistenceService) { this.messageService = Objects.requireNonNull(messageService); this.projectRepository = Objects.requireNonNull(projectRepository); this.accountRepository = Objects.requireNonNull(accountRepository); + this.projectTemplateService = Objects.requireNonNull(projectTemplateService); + this.editingContextSearchService = Objects.requireNonNull(editingContextSearchService); + this.editingContextPersistenceService = Objects.requireNonNull(editingContextPersistenceService); this.projectMapper = new ProjectMapper(); } @@ -100,6 +116,45 @@ public IPayload createProject(CreateProjectInput input) { return payload; } + @Override + public IPayload createProject(CreateProjectFromTemplateInput input) { + IPayload result = new ErrorPayload(input.id(), this.messageService.unexpectedError()); + // @formatter:off + var optionalTemplate = this.projectTemplateService.getProjectTemplateProviders().stream() + .map(IProjectTemplateProvider::getProjectTemplates) + .flatMap(List::stream) + .filter(template -> template.getId().equals(input.templateId())) + .findFirst(); + + var optionalProjectTemplateInitializer = this.projectTemplateService.getProjectTemplateInitializers().stream() + .filter(initializer -> initializer.canHandle(input.templateId())) + .findFirst(); + // @formatter:on + if (optionalTemplate.isPresent() && optionalProjectTemplateInitializer.isPresent()) { + var template = optionalTemplate.get(); + var projectTemplateInitializer = optionalProjectTemplateInitializer.get(); + + var createProjectInput = new CreateProjectInput(UUID.randomUUID(), template.getLabel(), Visibility.PRIVATE); + var payload = this.createProject(createProjectInput); + if (payload instanceof CreateProjectSuccessPayload) { + var createProjectSuccessPayload = (CreateProjectSuccessPayload) payload; + var projectId = createProjectSuccessPayload.getProject().getId(); + + var optionalEditingContext = this.editingContextSearchService.findById(projectId.toString()); + if (optionalEditingContext.isPresent()) { + var editingContext = optionalEditingContext.get(); + var representationToOpen = projectTemplateInitializer.handle(input.templateId(), editingContext).orElse(null); + + this.editingContextPersistenceService.persist(editingContext); + result = new CreateProjectFromTemplateSuccessPayload(createProjectInput.id(), createProjectSuccessPayload.getProject(), representationToOpen); + } + } else { + result = payload; + } + } + return result; + } + private ProjectEntity createProjectEntity(String projectName, AccountEntity owner, Visibility visibility) { ProjectEntity projectEntity = new ProjectEntity(); projectEntity.setName(projectName); diff --git a/packages/sirius-web/backend/sirius-web-services/src/main/java/org/eclipse/sirius/web/services/projects/ProjectTemplateService.java b/packages/sirius-web/backend/sirius-web-services/src/main/java/org/eclipse/sirius/web/services/projects/ProjectTemplateService.java new file mode 100644 index 00000000000..e16c55e553d --- /dev/null +++ b/packages/sirius-web/backend/sirius-web-services/src/main/java/org/eclipse/sirius/web/services/projects/ProjectTemplateService.java @@ -0,0 +1,50 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ +package org.eclipse.sirius.web.services.projects; + +import java.util.List; +import java.util.Objects; + +import org.eclipse.sirius.web.services.api.projects.IProjectTemplateInitializer; +import org.eclipse.sirius.web.services.api.projects.IProjectTemplateProvider; +import org.eclipse.sirius.web.services.api.projects.IProjectTemplateService; +import org.springframework.stereotype.Service; + +/** + * Aggregates all the project templates related beans into a single service to reduce the number of dependencies needed. + * + * @author pcdavid + */ +@Service +public class ProjectTemplateService implements IProjectTemplateService { + + private final List projectTemplateProviders; + + private final List projectTemplateInitializers; + + public ProjectTemplateService(List projectTemplateProviders, List projectTemplateInitializers) { + this.projectTemplateProviders = Objects.requireNonNull(projectTemplateProviders); + this.projectTemplateInitializers = Objects.requireNonNull(projectTemplateInitializers); + } + + @Override + public List getProjectTemplateProviders() { + return this.projectTemplateProviders; + } + + @Override + public List getProjectTemplateInitializers() { + return this.projectTemplateInitializers; + } + +} diff --git a/packages/sirius-web/backend/sirius-web-services/src/test/java/org/eclipse/sirius/web/services/projects/ProjectServiceTests.java b/packages/sirius-web/backend/sirius-web-services/src/test/java/org/eclipse/sirius/web/services/projects/ProjectServiceTests.java index 8b36dd9b872..d7d4e0ff4e7 100644 --- a/packages/sirius-web/backend/sirius-web-services/src/test/java/org/eclipse/sirius/web/services/projects/ProjectServiceTests.java +++ b/packages/sirius-web/backend/sirius-web-services/src/test/java/org/eclipse/sirius/web/services/projects/ProjectServiceTests.java @@ -20,6 +20,9 @@ import java.util.UUID; import org.eclipse.sirius.components.core.api.ErrorPayload; +import org.eclipse.sirius.components.core.api.IEditingContext; +import org.eclipse.sirius.components.core.api.IEditingContextPersistenceService; +import org.eclipse.sirius.components.core.api.IEditingContextSearchService; import org.eclipse.sirius.components.core.api.IPayload; import org.eclipse.sirius.web.persistence.entities.AccountEntity; import org.eclipse.sirius.web.persistence.entities.ProjectEntity; @@ -27,6 +30,7 @@ import org.eclipse.sirius.web.persistence.repositories.IProjectRepository; import org.eclipse.sirius.web.services.api.projects.CreateProjectInput; import org.eclipse.sirius.web.services.api.projects.CreateProjectSuccessPayload; +import org.eclipse.sirius.web.services.api.projects.IProjectTemplateService; import org.eclipse.sirius.web.services.api.projects.Visibility; import org.eclipse.sirius.web.services.messages.IServicesMessageService; import org.junit.jupiter.api.Test; @@ -55,6 +59,19 @@ public S save(S entity) { } }; + private IEditingContextSearchService noOpEditingContextSearchService = new IEditingContextSearchService() { + + @Override + public Optional findById(String editingContextId) { + return Optional.empty(); + } + + @Override + public boolean existsById(String editingContextId) { + return false; + } + }; + private IAccountRepository fakeAccountRepository = new NoOpAccountRepository() { @Override public Optional findByUsername(String userName) { @@ -69,7 +86,8 @@ public Optional findByUsername(String userName) { } }; - private ProjectService projectService = new ProjectService(this.noOpMessageService, this.noOpProjectRepository, this.fakeAccountRepository); + private ProjectService projectService = new ProjectService(this.noOpMessageService, this.noOpProjectRepository, this.fakeAccountRepository, new IProjectTemplateService.NoOp(), + this.noOpEditingContextSearchService, new IEditingContextPersistenceService.NoOp()); @Test public void testProjectCreationWithInvalidName() { diff --git a/packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModal.tsx b/packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModal.tsx new file mode 100644 index 00000000000..ae1bc3e03a4 --- /dev/null +++ b/packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModal.tsx @@ -0,0 +1,277 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import { useMutation, useQuery } from '@apollo/client'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import IconButton from '@material-ui/core/IconButton'; +import Snackbar from '@material-ui/core/Snackbar'; +import { makeStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import CloseIcon from '@material-ui/icons/Close'; +import Pagination from '@material-ui/lab/Pagination'; +import { useMachine } from '@xstate/react'; +import gql from 'graphql-tag'; +import { useEffect } from 'react'; +import { Redirect } from 'react-router-dom'; +import { v4 as uuid } from 'uuid'; +import { + NewProjectCard, + ProjectTemplateCard, + UploadProjectCard, +} from '../../views/project-template-card/ProjectTemplateCard'; +import { + GQLCreateProjectFromTemplateMutationData, + GQLCreateProjectFromTemplatePayload, + GQLCreateProjectFromTemplateSuccessPayload, +} from '../../views/projects/ProjectsView.types'; +import { GQLErrorPayload, ProjectTemplatesModalProps } from './ProjectTemplatesModal.types'; +import { + ChangePageEvent, + FetchedProjectTemplatesEvent, + HideToastEvent, + InvokeTemplateEvent, + ProjectTemplatesModalContext, + ProjectTemplatesModalEvent, + projectTemplatesModalMachine, + RedirectEvent, + SchemaValue, + ShowToastEvent, +} from './ProjectTemplatesModalMachine'; + +export const getProjectTemplatesQuery = gql` + query getProjectTemplates($page: Int!) { + viewer { + projectTemplates(page: $page, limit: 12) { + edges { + node { + id + label + imageURL + } + } + pageInfo { + count + } + } + } + } +`; + +export const createProjectFromTemplateMutation = gql` + mutation createProjectFromTemplate($input: CreateProjectFromTemplateInput!) { + createProjectFromTemplate(input: $input) { + __typename + ... on CreateProjectFromTemplateSuccessPayload { + project { + id + } + representationToOpen { + id + } + } + ... on ErrorPayload { + message + } + } + } +`; + +const useProjectTemplatesModalStyles = makeStyles((theme) => ({ + content: { + display: 'grid', + gridTemplateRows: '1fr min-content', + gap: theme.spacing(2), + }, + templateCards: { + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr', + gridTemplateRows: '1fr 1fr 1fr 1fr', + }, + navigation: { + justifySelf: 'center', + }, +})); + +const isProjectTemplateErrorPayload = (payload: GQLCreateProjectFromTemplatePayload): payload is GQLErrorPayload => + payload.__typename === 'ErrorPayload'; + +const isProjectTemplateSuccessPayload = ( + payload: GQLCreateProjectFromTemplatePayload +): payload is GQLCreateProjectFromTemplateSuccessPayload => + payload.__typename === 'CreateProjectFromTemplateSuccessPayload'; + +export const ProjectTemplatesModal = ({ onClose }: ProjectTemplatesModalProps) => { + const [{ value, context }, dispatch] = useMachine( + projectTemplatesModalMachine + ); + const { toast, projectTemplatesModal } = value as SchemaValue; + const { page, templates, templatesCount, runningTemplate, redirectURL, message } = context; + + // Index of the last page, including all templates *and* the 2 special cards + const lastPage = templatesCount ? Math.ceil((templatesCount + 2) / 12) : 0; + // Index of the last page which actually contains templates (and not just special cards) + const lastPageWithTemplates = templatesCount ? Math.ceil(templatesCount / 12) : 0; + + const { loading, data, error, refetch } = useQuery(getProjectTemplatesQuery, { + variables: { page: page - 1 }, + skip: !!templatesCount && page > lastPageWithTemplates, + }); + useEffect(() => { + if (!loading) { + if (data) { + const fetchedTemplatesEvent: FetchedProjectTemplatesEvent = { type: 'HANDLE_FETCHED_TEMPLATES', data: data }; + dispatch(fetchedTemplatesEvent); + } + if (error) { + const showToastEvent: ShowToastEvent = { + type: 'SHOW_TOAST', + message: 'An unexpected error has occurred, please refresh the page', + }; + dispatch(showToastEvent); + } + } + }, [loading, data, error, dispatch]); + + const [ + createProjectFromTemplate, + { loading: templateExecuting, data: templateInvocationResult, error: templateInvocationError }, + ] = useMutation(createProjectFromTemplateMutation); + useEffect(() => { + if (!templateExecuting) { + if (templateInvocationResult) { + if (isProjectTemplateSuccessPayload(templateInvocationResult.createProjectFromTemplate)) { + const projectId = templateInvocationResult.createProjectFromTemplate.project.id; + const representationId = templateInvocationResult.createProjectFromTemplate.representationToOpen?.id; + const redirectEvent: RedirectEvent = { type: 'REDIRECT', projectId, representationId }; + dispatch(redirectEvent); + } else if (isProjectTemplateErrorPayload(templateInvocationResult.createProjectFromTemplate)) { + const { message } = templateInvocationResult.createProjectFromTemplate; + const showToastEvent: ShowToastEvent = { + type: 'SHOW_TOAST', + message, + }; + dispatch(showToastEvent); + } + } + } + }, [templateInvocationResult, templateInvocationError, templateExecuting, dispatch]); + + const styles = useProjectTemplatesModalStyles(); + + if (redirectURL) { + return ; + } + + const cards = []; + if (page <= lastPageWithTemplates) { + templates + .map((template) => ( + { + const event: InvokeTemplateEvent = { type: 'INVOKE_TEMPLATE', template }; + dispatch(event); + const variables = { + input: { + id: uuid(), + templateId: template.id, + }, + }; + createProjectFromTemplate({ variables }); + }} + /> + )) + .forEach((card) => cards.push(card)); + if (cards.length < 12) { + cards.push(); + } + if (cards.length < 12) { + cards.push(); + } + } else if (templatesCount % 12 === 11) { + cards.push(); + } else if (templatesCount % 12 === 0) { + cards.push(); + cards.push(); + } + + let content; + if (projectTemplatesModal === 'loading') { + content = Loading...; + } else { + content = ( + <> +
+ {cards} +
+ { + const newPage = value; + const changePageEvent: ChangePageEvent = { type: 'CHANGE_PAGE', page: newPage }; + dispatch(changePageEvent); + if (newPage <= lastPageWithTemplates) { + refetch(); + } + }} + /> + + ); + } + + return ( + <> + { + if (projectTemplatesModal !== 'executingTemplate') { + onClose(); + } + }} + aria-labelledby="dialog-title" + data-testid="project-templates-modal" + maxWidth="md" + fullWidth> + Select a project template + {content} + + dispatch({ type: 'HIDE_TOAST' } as HideToastEvent)} + message={message} + action={ + dispatch({ type: 'HIDE_TOAST' } as HideToastEvent)}> + + + } + data-testid="error" + /> + + ); +}; diff --git a/packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModal.types.ts b/packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModal.types.ts new file mode 100644 index 00000000000..521d4ba742f --- /dev/null +++ b/packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModal.types.ts @@ -0,0 +1,49 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +export interface ProjectTemplatesModalProps { + onClose: () => void; +} + +export interface GQLgetProjectTemplatesQueryData { + viewer: GQLViewer; +} + +export interface GQLViewer { + projectTemplates: GQLViewerProjectTemplateConnection; +} + +export interface GQLViewerProjectTemplateConnection { + edges: GQLViewerProjectTemplateEdge[]; + pageInfo: GQLPageInfo; +} +export interface GQLViewerProjectTemplateEdge { + node: GQLProjectTemplate; +} + +export interface GQLProjectTemplate { + id: string; + label: string; + imageURL: string; +} + +export interface GQLPageInfo { + hasPreviousPage: boolean; + hasNextPage: boolean; + count: number; +} + +export interface GQLErrorPayload { + __typename: string; + message: string; +} diff --git a/packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModalMachine.ts b/packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModalMachine.ts new file mode 100644 index 00000000000..39c6b18a7cd --- /dev/null +++ b/packages/sirius-web/frontend/sirius-web/src/modals/project-templates/ProjectTemplatesModalMachine.ts @@ -0,0 +1,223 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import { ProjectTemplate } from 'views/projects/ProjectsView.types'; +import { assign, Machine } from 'xstate'; +import { GQLgetProjectTemplatesQueryData } from './ProjectTemplatesModal.types'; + +export interface ProjectTemplatesModalStateSchema { + states: { + toast: { + states: { + visible: {}; + hidden: {}; + }; + }; + projectTemplatesModal: { + states: { + loading: {}; + loaded: {}; + executingTemplate: {}; + templateCreated: {}; + empty: {}; + }; + }; + }; +} + +export type SchemaValue = { + toast: 'visible' | 'hidden'; + projectTemplatesModal: 'loading' | 'loaded' | 'executingTemplate' | 'templateCreated' | 'empty'; +}; + +export interface ProjectTemplatesModalContext { + // Current page index, from 1 + page: number; + // The templates to display on the current page. May be empty for the last page. + templates: ProjectTemplate[]; + // Total number of templates (unknown before the first load) + templatesCount: number; + // The template which is currently executed on the backend; we're waiting for its result + runningTemplate: ProjectTemplate | null; + // The URL to redirect to once we received the success payload for the executed template. + redirectURL: string | null; + // The message to display in the toast. + message: string | null; +} + +export type ShowToastEvent = { type: 'SHOW_TOAST'; message: string }; +export type HideToastEvent = { type: 'HIDE_TOAST' }; +export type FetchedProjectTemplatesEvent = { type: 'HANDLE_FETCHED_TEMPLATES'; data: GQLgetProjectTemplatesQueryData }; +export type ChangePageEvent = { type: 'CHANGE_PAGE'; page: number }; +export type InvokeTemplateEvent = { type: 'INVOKE_TEMPLATE'; template: ProjectTemplate }; +export type RedirectEvent = { type: 'REDIRECT'; projectId: string; representationId: string | null }; +export type ProjectTemplatesModalEvent = + | FetchedProjectTemplatesEvent + | ChangePageEvent + | ShowToastEvent + | InvokeTemplateEvent + | HideToastEvent + | RedirectEvent; + +export const projectTemplatesModalMachine = Machine< + ProjectTemplatesModalContext, + ProjectTemplatesModalStateSchema, + ProjectTemplatesModalEvent +>( + { + type: 'parallel', + context: { + page: 1, + templates: [], + templatesCount: 0, + runningTemplate: null, + redirectURL: null, + message: null, + }, + states: { + toast: { + initial: 'hidden', + states: { + hidden: { + on: { + SHOW_TOAST: { + target: 'visible', + actions: 'setMessage', + }, + }, + }, + visible: { + on: { + HIDE_TOAST: { + target: 'hidden', + actions: 'clearMessage', + }, + }, + }, + }, + }, + projectTemplatesModal: { + initial: 'loading', + states: { + loading: { + on: { + HANDLE_FETCHED_TEMPLATES: [ + { + cond: 'isEmpty', + target: 'empty', + actions: 'updateTemplates', + }, + { + target: 'loaded', + actions: 'updateTemplates', + }, + ], + }, + }, + loaded: { + on: { + HANDLE_FETCHED_TEMPLATES: [ + { + cond: 'isEmpty', + target: 'empty', + actions: 'updateTemplates', + }, + { + target: 'loaded', + actions: 'updateTemplates', + }, + ], + CHANGE_PAGE: [ + { + actions: 'changePage', + }, + ], + INVOKE_TEMPLATE: [ + { + target: 'executingTemplate', + actions: 'invokeTemplate', + }, + ], + }, + }, + executingTemplate: { + on: { + REDIRECT: { + target: 'templateCreated', + actions: 'redirect', + }, + }, + }, + templateCreated: { + type: 'final', + }, + empty: { + type: 'final', + }, + }, + }, + }, + }, + { + guards: { + isEmpty: (_, event) => { + const { + data: { + viewer: { projectTemplates }, + }, + } = event as FetchedProjectTemplatesEvent; + return projectTemplates.edges.length === 0 && !projectTemplates.pageInfo.hasPreviousPage; + }, + }, + actions: { + updateTemplates: assign((context, event) => { + const { + data: { + viewer: { projectTemplates }, + }, + } = event as FetchedProjectTemplatesEvent; + + if (projectTemplates.edges.length === 0 && projectTemplates.pageInfo.hasPreviousPage) { + return { page: context.page - 1 }; + } + return { + templatesCount: projectTemplates.pageInfo.count, + templates: projectTemplates.edges.map((edge) => edge.node), + }; + }), + changePage: assign((_, event) => { + const { page } = event as ChangePageEvent; + return { page }; + }), + setMessage: assign((_, event) => { + const { message } = event as ShowToastEvent; + return { message }; + }), + clearMessage: assign((_) => { + return { message: null }; + }), + invokeTemplate: assign((_, event) => { + const { template } = event as InvokeTemplateEvent; + return { runningTemplate: template }; + }), + redirect: assign((_, event) => { + const { projectId, representationId } = event as RedirectEvent; + if (representationId) { + return { redirectURL: `/projects/${projectId}/edit/${representationId}` }; + } else { + return { redirectURL: `/projects/${projectId}/edit` }; + } + }), + }, + } +); diff --git a/packages/sirius-web/frontend/sirius-web/src/views/project-template-card/ProjectTemplateCard.tsx b/packages/sirius-web/frontend/sirius-web/src/views/project-template-card/ProjectTemplateCard.tsx new file mode 100644 index 00000000000..fcc5d385e4a --- /dev/null +++ b/packages/sirius-web/frontend/sirius-web/src/views/project-template-card/ProjectTemplateCard.tsx @@ -0,0 +1,129 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import { ServerContext } from '@eclipse-sirius/sirius-components-core'; +import Button from '@material-ui/core/Button'; +import Card from '@material-ui/core/Card'; +import CardActions from '@material-ui/core/CardActions'; +import CardContent from '@material-ui/core/CardContent'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import { makeStyles } from '@material-ui/core/styles'; +import Typography from '@material-ui/core/Typography'; +import AddIcon from '@material-ui/icons/Add'; +import CloudUploadOutlinedIcon from '@material-ui/icons/CloudUploadOutlined'; +import { useContext } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { ProjectTemplateCardProps } from './ProjectTemplateCard.types'; + +const useProjectTemplateStyles = makeStyles((theme) => ({ + projectTemplateCard: { + width: theme.spacing(30), + height: theme.spacing(18), + display: 'grid', + gridTemplateRows: '1fr min-content', + }, + templateCardContent: { display: 'flex', alignItems: 'center', justifyContent: 'center' }, + projectTemplateLabel: { + textTransform: 'none', + fontWeight: 400, + fontSize: theme.spacing(2), + }, +})); + +export const ProjectTemplateCard = ({ template, running, disabled, onCreateProject }: ProjectTemplateCardProps) => { + const classes = useProjectTemplateStyles(); + const { httpOrigin } = useContext(ServerContext); + return ( + + ); +}; + +const useProjectCardStyles = makeStyles((theme) => ({ + projectCard: { + width: theme.spacing(30), + height: theme.spacing(18), + display: 'grid', + gridTemplateRows: '1fr min-content', + }, + projectCardLabel: { + textTransform: 'none', + fontWeight: 400, + fontSize: theme.spacing(2), + }, + projectCardIcon: { + fontSize: theme.spacing(8), + }, + blankProjectCard: { + backgroundColor: theme.palette.primary.main, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + uploadProjectCard: { + backgroundColor: theme.palette.divider, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, +})); + +export const NewProjectCard = () => { + const classes = useProjectCardStyles(); + return ( + + ); +}; + +export const UploadProjectCard = () => { + const classes = useProjectCardStyles(); + return ( + + ); +}; diff --git a/packages/sirius-web/frontend/sirius-web/src/views/project-template-card/ProjectTemplateCard.types.ts b/packages/sirius-web/frontend/sirius-web/src/views/project-template-card/ProjectTemplateCard.types.ts new file mode 100644 index 00000000000..980af7d83c5 --- /dev/null +++ b/packages/sirius-web/frontend/sirius-web/src/views/project-template-card/ProjectTemplateCard.types.ts @@ -0,0 +1,21 @@ +/******************************************************************************* + * Copyright (c) 2023 Obeo. + * This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v2.0 + * which accompanies this distribution, and is available at + * https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Obeo - initial API and implementation + *******************************************************************************/ + +import { ProjectTemplate } from 'views/projects/ProjectsView.types'; + +export interface ProjectTemplateCardProps { + template: ProjectTemplate; + running: boolean; + disabled: boolean; + onCreateProject: () => void; +} diff --git a/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsView.tsx b/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsView.tsx index 908bc15eef3..7dc36bc3bb9 100644 --- a/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsView.tsx +++ b/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsView.tsx @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2019, 2022 Obeo. + * Copyright (c) 2019, 2023 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -10,10 +10,13 @@ * Contributors: * Obeo - initial API and implementation *******************************************************************************/ -import { gql, useQuery } from '@apollo/client'; +import { gql, useMutation, useQuery } from '@apollo/client'; import { DeleteProjectModal, RenameProjectModal } from '@eclipse-sirius/sirius-components'; import { ServerContext } from '@eclipse-sirius/sirius-components-core'; import Button from '@material-ui/core/Button'; +import Card from '@material-ui/core/Card'; +import CardActions from '@material-ui/core/CardActions'; +import CardContent from '@material-ui/core/CardContent'; import Container from '@material-ui/core/Container'; import Grid from '@material-ui/core/Grid'; import IconButton from '@material-ui/core/IconButton'; @@ -40,21 +43,34 @@ import GetAppIcon from '@material-ui/icons/GetApp'; import MoreHorizIcon from '@material-ui/icons/MoreHoriz'; import { useMachine } from '@xstate/react'; import React, { useContext, useEffect } from 'react'; -import { Link as RouterLink } from 'react-router-dom'; +import { Link as RouterLink, Redirect } from 'react-router-dom'; +import { v4 as uuid } from 'uuid'; import { Footer } from '../../footer/Footer'; +import { ProjectTemplatesModal } from '../../modals/project-templates/ProjectTemplatesModal'; import { NavigationBar } from '../../navigationBar/NavigationBar'; import { + NewProjectCard, + ProjectTemplateCard, + UploadProjectCard, +} from '../../views/project-template-card/ProjectTemplateCard'; +import { + GQLCreateProjectFromTemplateMutationData, + GQLCreateProjectFromTemplatePayload, + GQLCreateProjectFromTemplateSuccessPayload, + GQLErrorPayload, GQLGetProjectsQueryData, GQLGetProjectsQueryVariables, Project, ProjectContextMenuProps, ProjectsTableProps, + ProjectTemplate, } from './ProjectsView.types'; import { CloseMenuEvent, CloseModalEvent, FetchedProjectsEvent, HideToastEvent, + InvokeTemplateEvent, OpenMenuEvent, OpenModalEvent, ProjectsViewContext, @@ -67,6 +83,15 @@ import { const getProjectsQuery = gql` query getProjects { viewer { + projectTemplates(page: 0, limit: 3) { + edges { + node { + id + label + imageURL + } + } + } projects(page: 0) { edges { node { @@ -85,6 +110,25 @@ const getProjectsQuery = gql` } `; +const createProjectFromTemplateMutation = gql` + mutation createProjectFromTemplate($input: CreateProjectFromTemplateInput!) { + createProjectFromTemplate(input: $input) { + __typename + ... on CreateProjectFromTemplateSuccessPayload { + project { + id + } + representationToOpen { + id + } + } + ... on ErrorPayload { + message + } + } + } +`; + const useProjectsViewStyles = makeStyles((theme) => ({ projectsView: { display: 'grid', @@ -99,6 +143,7 @@ const useProjectsViewStyles = makeStyles((theme) => ({ projectsViewContainer: { display: 'flex', flexDirection: 'column', + paddingBottom: theme.spacing(5), }, header: { display: 'flex', @@ -114,13 +159,58 @@ const useProjectsViewStyles = makeStyles((theme) => ({ marginLeft: theme.spacing(2), }, }, + projectCardsContainer: { + display: 'flex', + gap: theme.spacing(6), + }, + projectCard: { + width: theme.spacing(30), + height: theme.spacing(18), + display: 'grid', + gridTemplateRows: '1fr min-content', + }, + projectCardLabel: { + textTransform: 'none', + fontWeight: 400, + fontSize: theme.spacing(2), + }, + projectCardIcon: { + fontSize: theme.spacing(8), + }, + blankProjectCard: { + backgroundColor: theme.palette.primary.main, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + uploadProjectCard: { + backgroundColor: theme.palette.divider, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, + showAlltemplatesCard: { + backgroundColor: theme.palette.divider, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }, })); export const ProjectsView = () => { const classes = useProjectsViewStyles(); const [{ value, context }, dispatch] = useMachine(projectsViewMachine); const { toast, projectsView } = value as SchemaValue; - const { projects, selectedProject, menuAnchor, modalToDisplay, message } = context; + const { + projects, + selectedProject, + menuAnchor, + modalToDisplay, + projectTemplates, + runningTemplate, + message, + redirectUrl, + } = context; const { loading, data, error, refetch } = useQuery( getProjectsQuery, @@ -154,6 +244,43 @@ export const ProjectsView = () => { refetch(); }; + const isProjectTemplateErrorPayload = (payload: GQLCreateProjectFromTemplatePayload): payload is GQLErrorPayload => + payload.__typename === 'ErrorPayload'; + + const isProjectTemplateSuccessPayload = ( + payload: GQLCreateProjectFromTemplatePayload + ): payload is GQLCreateProjectFromTemplateSuccessPayload => + payload.__typename === 'CreateProjectFromTemplateSuccessPayload'; + + const [ + createProjectFromTemplate, + { loading: templateExecuting, data: templateInvocationResult, error: templateInvocationError }, + ] = useMutation(createProjectFromTemplateMutation); + useEffect(() => { + if (!templateExecuting) { + if (templateInvocationError) { + const showToastEvent: ShowToastEvent = { + type: 'SHOW_TOAST', + message: 'An unexpected error has occurred, please refresh the page', + }; + dispatch(showToastEvent); + } + if (templateInvocationResult) { + if (isProjectTemplateSuccessPayload(templateInvocationResult.createProjectFromTemplate)) { + dispatch({ + type: 'REDIRECT', + projectId: templateInvocationResult.createProjectFromTemplate.project.id, + representationId: templateInvocationResult.createProjectFromTemplate.representationToOpen?.id, + }); + } else if (isProjectTemplateErrorPayload(templateInvocationResult.createProjectFromTemplate)) { + const { message } = templateInvocationResult.createProjectFromTemplate; + const showToastEvent: ShowToastEvent = { type: 'SHOW_TOAST', message }; + dispatch(showToastEvent); + } + } + } + }, [dispatch, templateInvocationResult, templateInvocationError, templateExecuting]); + let main = null; if (projectsView === 'loaded') { let contextMenu = null; @@ -184,6 +311,15 @@ export const ProjectsView = () => { modal = ; } } + if (modalToDisplay === 'ProjectTemplates') { + modal = ( + { + dispatch({ type: 'CLOSE_MODAL' } as CloseModalEvent); + }} + /> + ); + } main = ( <> @@ -195,6 +331,23 @@ export const ProjectsView = () => { main = ; } + const onCreateProject = (template: ProjectTemplate) => { + if (!runningTemplate) { + dispatch({ type: 'INVOKE_TEMPLATE', template } as InvokeTemplateEvent); + const variables = { + input: { + id: uuid(), + templateId: template.id, + }, + }; + createProjectFromTemplate({ variables }); + } + }; + + if (redirectUrl) { + return ; + } + return ( <>
@@ -205,25 +358,41 @@ export const ProjectsView = () => {
- Projects -
- - -
+ Create a new project +
+
+ {projectTemplates.map((template) => ( + onCreateProject(template)} + /> + ))} + + + +
+
+
+
+ Existing Projects
{main}
diff --git a/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsView.types.ts b/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsView.types.ts index c6b454a86d0..07f7f427f0b 100644 --- a/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsView.types.ts +++ b/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsView.types.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2021, 2022 Obeo. + * Copyright (c) 2021, 2023 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -15,12 +15,33 @@ export interface Project { name: string; } +export interface ProjectTemplate { + id: string; + label: string; + imageURL: string; +} + export interface GQLGetProjectsQueryData { viewer: GQLViewer; } export interface GQLViewer { projects: GQLViewerProjectConnection; + projectTemplates: GQLViewerProjectTemplateConnection; +} + +export interface GQLViewerProjectTemplateConnection { + edges: GQLViewerProjectTemplateEdge[]; + pageInfo: GQLPageInfo; +} +export interface GQLViewerProjectTemplateEdge { + node: GQLProjectTemplate; +} + +export interface GQLProjectTemplate { + id: string; + label: string; + imageURL: string; } export interface GQLViewerProjectConnection { @@ -46,6 +67,31 @@ export interface GQLGetProjectsQueryVariables { page: number; } +export interface GQLCreateProjectFromTemplateMutationData { + createProjectFromTemplate: GQLCreateProjectFromTemplatePayload; +} + +export interface GQLCreateProjectFromTemplatePayload { + __typename: string; +} + +export interface GQLCreateProjectFromTemplateSuccessPayload extends GQLCreateProjectFromTemplatePayload { + project: GQLProjectCreatedFromTemplate; + representationToOpen: GQLRepresentationToOpen; +} + +export interface GQLProjectCreatedFromTemplate { + id: string; +} + +export interface GQLRepresentationToOpen { + id: string; +} + +export interface GQLErrorPayload extends GQLCreateProjectFromTemplatePayload { + message: string; +} + export interface ProjectsTableProps { projects: Project[]; onMore: (event: React.MouseEvent, project: Project) => void; diff --git a/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsViewMachine.ts b/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsViewMachine.ts index 3aa8e0f93bb..afe397e88ce 100644 --- a/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsViewMachine.ts +++ b/packages/sirius-web/frontend/sirius-web/src/views/projects/ProjectsViewMachine.ts @@ -1,5 +1,5 @@ /******************************************************************************* - * Copyright (c) 2021, 2022 Obeo. + * Copyright (c) 2021, 2023 Obeo. * This program and the accompanying materials * are made available under the terms of the Eclipse Public License v2.0 * which accompanies this distribution, and is available at @@ -11,7 +11,7 @@ * Obeo - initial API and implementation *******************************************************************************/ import { assign, Machine } from 'xstate'; -import { GQLGetProjectsQueryData, Project } from './ProjectsView.types'; +import { GQLGetProjectsQueryData, Project, ProjectTemplate } from './ProjectsView.types'; export interface ProjectsViewStateSchema { states: { @@ -36,10 +36,13 @@ export type SchemaValue = { projectsView: 'loading' | 'loaded' | 'empty'; }; -export type ProjectsViewModal = 'Rename' | 'Delete'; +export type ProjectsViewModal = 'Rename' | 'Delete' | 'ProjectTemplates'; export interface ProjectsViewContext { projects: Project[]; + projectTemplates: ProjectTemplate[]; + runningTemplate: ProjectTemplate | null; + redirectUrl: string | null; selectedProject: Project | null; menuAnchor: HTMLElement | null; modalToDisplay: ProjectsViewModal | null; @@ -53,6 +56,8 @@ export type OpenMenuEvent = { type: 'OPEN_MENU'; menuAnchor: HTMLElement; projec export type CloseMenuEvent = { type: 'CLOSE_MENU' }; export type OpenModalEvent = { type: 'OPEN_MODAL'; modalToDisplay: ProjectsViewModal }; export type CloseModalEvent = { type: 'CLOSE_MODAL' }; +export type InvokeTemplateEvent = { type: 'INVOKE_TEMPLATE'; template: ProjectTemplate }; +export type RedirectEvent = { type: 'REDIRECT'; projectId: string; representationId: string | null }; export type ProjectsViewEvent = | FetchedProjectsEvent | ShowToastEvent @@ -60,13 +65,18 @@ export type ProjectsViewEvent = | OpenMenuEvent | CloseMenuEvent | OpenModalEvent - | CloseModalEvent; + | CloseModalEvent + | InvokeTemplateEvent + | RedirectEvent; export const projectsViewMachine = Machine( { type: 'parallel', context: { projects: [], + projectTemplates: [], + runningTemplate: null, + redirectUrl: null, selectedProject: null, menuAnchor: null, modalToDisplay: null, @@ -145,10 +155,23 @@ export const projectsViewMachine = Machine { const { data: { - viewer: { projects }, + viewer: { projects, projectTemplates }, }, } = event as FetchedProjectsEvent; - return { projects: projects.edges.map((edge) => edge.node) }; + return { + projects: projects.edges.map((edge) => edge.node), + pageInfo: projects.pageInfo, + projectTemplates: projectTemplates.edges.map((edge) => edge.node), + }; }), openMenu: assign((_, event) => { const { menuAnchor, project } = event as OpenMenuEvent; @@ -195,6 +222,18 @@ export const projectsViewMachine = Machine { return { message: null }; }), + invokeTemplate: assign((_, event) => { + const { template } = event as InvokeTemplateEvent; + return { runningTemplate: template }; + }), + redirect: assign((_, event) => { + const { projectId, representationId } = event as RedirectEvent; + if (representationId) { + return { redirectUrl: `/projects/${projectId}/edit/${representationId}` }; + } else { + return { redirectUrl: `/projects/${projectId}/edit` }; + } + }), }, } );