/**
* Copyright (c) 2020-present, Goldman Sachs
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef, useEffect, useState } from 'react';
import { observer } from 'mobx-react-lite';
import { LEGEND_STUDIO_TEST_ID } from '../../../__lib__/LegendStudioTesting.js';
import {
clsx,
CustomSelectorInput,
ShareIcon,
PanelLoadingIndicator,
ContextMenu,
PencilIcon,
InfoCircleIcon,
TimesIcon,
UsersIcon,
UserIcon,
ExternalLinkIcon,
Dialog,
PanelContent,
Modal,
ModalBody,
ModalFooter,
ModalFooterButton,
MenuContentItem,
MenuContent,
PanelFormBooleanField,
} from '@finos/legend-art';
import { PROJECT_OVERVIEW_ACTIVITY_MODE } from '../../../stores/editor/sidebar-state/ProjectOverviewState.js';
import {
generateEditorRoute,
generateViewProjectRoute,
generateViewVersionRoute,
generateReviewRoute,
} from '../../../__lib__/LegendStudioNavigation.js';
import { flowResult } from 'mobx';
import {
type Workspace,
type Version,
NewVersionType,
WorkspaceType,
areWorkspacesEquivalent,
Review,
ReviewState,
Patch,
} from '@finos/legend-server-sdlc';
import { useEditorStore } from '../EditorStoreProvider.js';
import { useApplicationStore } from '@finos/legend-application';
import { useLegendStudioApplicationStore } from '../../LegendStudioFrameworkProvider.js';
import {
ActionState,
assertErrorThrown,
guaranteeNonNullable,
LogEvent,
} from '@finos/legend-shared';
import type { VersionOption } from '../editor-group/project-configuration-editor/ProjectDependencyEditor.js';
import { DocumentationLink } from '@finos/legend-lego/application';
import { LEGEND_STUDIO_DOCUMENTATION_KEY } from '../../../__lib__/LegendStudioDocumentation.js';
import type { PatchOption } from '../../workspace-setup/CreateWorkspaceModal.js';
import { LEGEND_STUDIO_APP_EVENT } from '../../../__lib__/LegendStudioEvent.js';
const ShareProjectModal = observer(
(props: { open: boolean; closeModal: () => void }) => {
const { open, closeModal } = props;
const editorStore = useEditorStore();
const applicationStore = useLegendStudioApplicationStore();
const versions = editorStore.sdlcState.projectVersions;
const isDispatchingAction = editorStore.sdlcState.isFetchingProjectVersions;
const isFetchingProject = editorStore.sdlcState.isFetchingProject;
const [selectedVersion, setSelectedVersion] = useState<
Version | undefined
>();
const projectId = editorStore.sdlcState.activeProject.projectId;
const projectLink = selectedVersion
? applicationStore.navigationService.navigator.generateAddress(
generateViewVersionRoute(projectId, selectedVersion.id.id),
)
: applicationStore.navigationService.navigator.generateAddress(
generateViewProjectRoute(projectId),
);
const copyProjectElementLink = (): void => {
applicationStore.clipboardService
.copyTextToClipboard(projectLink)
.then(() =>
applicationStore.notificationService.notifySuccess(
'Copied project element link to clipboard',
),
)
.catch(applicationStore.alertUnhandledError)
.finally(() => closeModal());
};
const renderOptions = versions.map((version) => ({
label: version.id.id,
value: version,
}));
const onSelectionChange = (
val: { label: string; value: Version } | null,
): void => setSelectedVersion(val?.value);
return (
);
},
);
const WorkspaceViewerContextMenu = observer(
forwardRef<
HTMLDivElement,
{
workspace: Workspace;
}
>(function WorkspaceViewerContextMenu(props, ref) {
const { workspace } = props;
const editorStore = useEditorStore();
const applicationStore = useApplicationStore();
const deleteWorkspace = applicationStore.guardUnhandledError(() =>
flowResult(editorStore.projectOverviewState.deleteWorkspace(workspace)),
);
return (
Delete
);
}),
);
const WorkspaceViewer = observer((props: { workspace: Workspace }) => {
const { workspace } = props;
const editorStore = useEditorStore();
const applicationStore = useApplicationStore();
const isActive =
areWorkspacesEquivalent(editorStore.sdlcState.activeWorkspace, workspace) &&
workspace.source ===
editorStore.sdlcState.activePatch?.patchReleaseVersionId.id;
const [isSelectedFromContextMenu, setIsSelectedFromContextMenu] =
useState(false);
const onContextMenuOpen = (): void => setIsSelectedFromContextMenu(true);
const onContextMenuClose = (): void => setIsSelectedFromContextMenu(false);
return (
}
menuProps={{ elevation: 7 }}
onOpen={onContextMenuOpen}
onClose={onContextMenuClose}
>
);
});
const WorkspacesViewer = observer(() => {
const editorStore = useEditorStore();
const applicationStore = useApplicationStore();
const projectOverviewState = editorStore.projectOverviewState;
const workspaces = projectOverviewState.projectWorkspaces;
const isDispatchingAction =
projectOverviewState.isDeletingWorkspace ||
projectOverviewState.isFetchingProjectWorkspaces;
// since this can be affected by other users, we refresh it more proactively
useEffect(() => {
flowResult(projectOverviewState.fetchProjectWorkspaces()).catch(
applicationStore.alertUnhandledError,
);
}, [applicationStore, projectOverviewState]);
return (
{PROJECT_OVERVIEW_ACTIVITY_MODE.WORKSPACES}
{workspaces.length}
{workspaces.map((workspace) => (
))}
);
});
const ReleaseEditor = observer(() => {
const editorStore = useEditorStore();
const applicationStore = useLegendStudioApplicationStore();
const projectOverviewState = editorStore.projectOverviewState;
const commitedReviews =
projectOverviewState.committedReviewsBetweenMostRecentVersionAndProjectLatest;
const isDispatchingAction =
projectOverviewState.isFetchingLatestVersion ||
projectOverviewState.isFetchingCurrentProjectRevision ||
projectOverviewState.isCreatingVersion;
const { latestProjectVersion, currentProjectRevision } = projectOverviewState;
const revisionInput = projectOverviewState.releaseVersion;
const createMajorRelease = applicationStore.guardUnhandledError(() =>
flowResult(projectOverviewState.createVersion(NewVersionType.MAJOR)),
);
const createMinorRelease = applicationStore.guardUnhandledError(() =>
flowResult(projectOverviewState.createVersion(NewVersionType.MINOR)),
);
const createPatchRelease = applicationStore.guardUnhandledError(() =>
flowResult(projectOverviewState.createVersion(NewVersionType.PATCH)),
);
const changeNotes: React.ChangeEventHandler = (event) =>
revisionInput.setNotes(event.target.value);
const notFetchedLatestVersionAndCurrentRevision =
latestProjectVersion === undefined || currentProjectRevision === undefined;
const isCurrentProjectVersionLatest =
Boolean(latestProjectVersion) &&
latestProjectVersion?.revisionId === currentProjectRevision?.id;
const canCreateVersion =
!isCurrentProjectVersionLatest &&
!isDispatchingAction &&
editorStore.sdlcServerClient.features.canCreateVersion &&
editorStore.sdlcState.canCreateVersion;
const disabledCreateVersionTitle = isCurrentProjectVersionLatest
? `Can't create version: project version not the latest`
: !editorStore.sdlcServerClient.features.canCreateVersion
? `Can't create version: current svn system does not support creating versions`
: !editorStore.sdlcState.canCreateVersion
? `Can't create version: You do not have the rights to create a version`
: undefined;
// since this can be affected by other users, we refresh it more proactively
useEffect(() => {
flowResult(projectOverviewState.fetchLatestProjectVersion()).catch(
applicationStore.alertUnhandledError,
);
}, [
applicationStore,
editorStore.sdlcState.activePatch,
projectOverviewState,
]);
return (
{PROJECT_OVERVIEW_ACTIVITY_MODE.RELEASE}
{!notFetchedLatestVersionAndCurrentRevision && (
{latestProjectVersion && (
)}
{!latestProjectVersion && (
This project has no release
)}
{commitedReviews.map((review) => (
))}
)}
);
});
const PatchEditor = observer(() => {
const editorStore = useEditorStore();
const applicationStore = useLegendStudioApplicationStore();
const projectOverviewState = editorStore.projectOverviewState;
const [versionOptions] = useState(
editorStore.sdlcState.projectVersions.map((v) => ({
label: v.id.id,
value: v.id.id,
})),
);
const [selectedVersionOption, setSelectedVersionOption] =
useState(null);
const patches = projectOverviewState.patches;
const [selectedPatchOption, setSelectedPatchOption] =
useState(null);
const [workspaceName, setWorkspaceName] = useState('');
const [isGroupWorkspace, setIsGroupWorkspace] = useState(true);
const [committedReviews, setCommittedReviews] = useState([]);
const [fetchSelectedPatchCommittedReviews] = useState(ActionState.create());
const onPatchOptionChange = async (
val: PatchOption | null,
): Promise => {
if (
(val !== null || selectedPatchOption !== null) &&
(!val || !selectedPatchOption || val.value !== selectedPatchOption.value)
) {
setSelectedPatchOption(val);
fetchSelectedPatchCommittedReviews.inProgress();
try {
if (val && val.value instanceof Patch) {
const reviews = await editorStore.sdlcServerClient.getReviews(
projectOverviewState.sdlcState.activeProject.projectId,
val.value.patchReleaseVersionId.id,
{
state: ReviewState.COMMITTED,
},
);
setCommittedReviews(
reviews.map((v) => Review.serialization.fromJson(v)),
);
} else {
setCommittedReviews([]);
}
} catch (error) {
assertErrorThrown(error);
editorStore.applicationStore.logService.error(
LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE),
error,
);
} finally {
fetchSelectedPatchCommittedReviews.reset();
}
} else {
setCommittedReviews([]);
}
};
const onVersionOptionChange = (val: VersionOption | null): void => {
if (
(val !== null || selectedVersionOption !== null) &&
(!val ||
!selectedVersionOption ||
val.value !== selectedVersionOption.value)
) {
setSelectedVersionOption(val);
}
};
const createPatch = (): void => {
if (selectedVersionOption) {
flowResult(
projectOverviewState.createPatch(
selectedVersionOption.value,
workspaceName,
isGroupWorkspace ? WorkspaceType.GROUP : WorkspaceType.USER,
),
).catch(applicationStore.alertUnhandledError);
}
};
const toggleGroupWorkspace = (): void => {
setIsGroupWorkspace(!isGroupWorkspace);
};
const changeWorkspaceName: React.ChangeEventHandler = (
event,
) => setWorkspaceName(event.target.value);
const isDispatchingAction =
projectOverviewState.isFetchingLatestVersion ||
projectOverviewState.isFetchingCurrentProjectRevision ||
projectOverviewState.isCreatingVersion;
const { latestProjectVersion, currentProjectRevision } = projectOverviewState;
const createPatchRelease = applicationStore.guardUnhandledError(() =>
flowResult(
projectOverviewState.createPatchVersion(
guaranteeNonNullable(selectedPatchOption).label,
),
),
);
const isCurrentProjectVersionLatest =
Boolean(latestProjectVersion) &&
latestProjectVersion?.revisionId === currentProjectRevision?.id;
const canCreateVersion =
!isCurrentProjectVersionLatest &&
!isDispatchingAction &&
editorStore.sdlcServerClient.features.canCreateVersion;
const patchBlurb = `Releasing above mentioned version would delete the upstream branch created for doing this patch release. By doing so you won't be access any of the branches created for development for doing this patch release`;
useEffect(() => {
flowResult(projectOverviewState.fetchPatches()).catch(
applicationStore.alertUnhandledError,
);
}, [
applicationStore.alertUnhandledError,
applicationStore.notificationService,
editorStore.sdlcServerClient,
editorStore.sdlcState.activeProject.projectId,
projectOverviewState,
]);
return (
{projectOverviewState.createPatchState.message}
<>
Patch
({
label: p.patchReleaseVersionId.id,
value: p,
}))}
onChange={(val: PatchOption | null) => {
onPatchOptionChange(val).catch(
applicationStore.alertUnhandledError,
);
}}
value={selectedPatchOption}
placeholder={'Select patch you want to release'}
isClearable={true}
escapeClearsValue={true}
darkMode={
!applicationStore.layoutService
.TEMPORARY__isLightColorThemeEnabled
}
/>
{patchBlurb}
>
{committedReviews.length}
{committedReviews.map((review) => (
))}
);
});
const VersionsViewer = observer(() => {
const editorStore = useEditorStore();
const applicationStore = useLegendStudioApplicationStore();
const versions = editorStore.sdlcState.projectVersions;
const isDispatchingAction = editorStore.sdlcState.isFetchingProjectVersions;
// since this can be affected by other users, we refresh it more proactively
useEffect(() => {
flowResult(editorStore.sdlcState.fetchProjectVersions()).catch(
applicationStore.alertUnhandledError,
);
}, [applicationStore, editorStore]);
return (
{PROJECT_OVERVIEW_ACTIVITY_MODE.VERSIONS}
{versions.length}
{versions.map((version) => (
))}
);
});
const OverviewViewer = observer(() => {
const editorStore = useEditorStore();
const applicationStore = useApplicationStore();
const projectOverviewState = editorStore.projectOverviewState;
const sdlcState = editorStore.sdlcState;
const initialName = sdlcState.currentProject?.name ?? '';
const initialDescription = sdlcState.currentProject?.description ?? '';
const initialTags = sdlcState.currentProject?.tags ?? [];
const isDispatchingAction =
projectOverviewState.updatingProjectState.isInProgress;
const [projectIdentifier, setProjectIdentifier] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [itemValue, setItemValue] = useState('');
const [tagsArray, setTagsArray] = useState>(initialTags);
const changeDescription: React.ChangeEventHandler = (
event,
) => {
setDescription(event.target.value);
};
const changeProjectIdentifier: React.ChangeEventHandler = (
event,
) => setProjectIdentifier(event.target.value);
// NOTE: `showEditInput` is either boolean (to hide/show the add value button) or a number (index of the item being edited)
const [showEditInput, setShowEditInput] = useState(false);
const showAddItemInput = (): void => setShowEditInput(true);
const showEditItemInput =
(value: string, idx: number): (() => void) =>
(): void => {
setItemValue(value);
setShowEditInput(idx);
};
const hideAddOrEditItemInput = (): void => {
setShowEditInput(false);
setItemValue('');
};
const changeItemInputValue: React.ChangeEventHandler = (
event,
) => setItemValue(event.target.value);
const addValue = (): void => {
if (itemValue && !tagsArray.includes(itemValue)) {
setTagsArray([...tagsArray, itemValue]);
}
hideAddOrEditItemInput();
};
const updateValue =
(idx: number): (() => void) =>
(): void => {
if (itemValue && !tagsArray.includes(itemValue)) {
tagsArray[idx] = itemValue;
setTagsArray(tagsArray);
hideAddOrEditItemInput();
}
};
const deleteValue =
(idx: number): (() => void) =>
(): void => {
const tags = [...tagsArray];
tags.splice(idx, 1);
setTagsArray(tags);
// Since we keep track of the value currently being edited using the index, we have to account for it as we delete entry
if (typeof showEditInput === 'number' && showEditInput > idx) {
setShowEditInput(showEditInput - 1);
}
};
const handleUpdate = (event: React.MouseEvent): void => {
event.preventDefault();
flowResult(
projectOverviewState.updateProject(
projectIdentifier,
description,
tagsArray,
),
).catch(applicationStore.alertUnhandledError);
};
return (
{PROJECT_OVERVIEW_ACTIVITY_MODE.OVERVIEW}
Description
Description of the project
Tags
List of annotations to categorize projects
{tagsArray.map((value, idx) => (
// NOTE: since the value must be unique, we will use it as the key
{showEditInput === idx ? (
<>
>
) : (
<>
{value}
>
)}
))}
{/* ADD NEW VALUE */}
{showEditInput === true && (
)}
{showEditInput !== true && (
)}
);
});
interface ProjectOverviewActivityDisplay {
mode: PROJECT_OVERVIEW_ACTIVITY_MODE;
title: string;
}
export const ProjectOverviewActivityBar = observer(() => {
const editorStore = useEditorStore();
const projectOverviewState = editorStore.projectOverviewState;
const isInEmbeddedMode =
editorStore.projectConfigurationEditorState.isInEmbeddedMode;
const changeActivity =
(activity: PROJECT_OVERVIEW_ACTIVITY_MODE): (() => void) =>
(): void =>
projectOverviewState.setActivityMode(activity);
const activities: ProjectOverviewActivityDisplay[] = [
{ mode: PROJECT_OVERVIEW_ACTIVITY_MODE.OVERVIEW, title: 'Overview' },
{
mode: PROJECT_OVERVIEW_ACTIVITY_MODE.RELEASE,
title: 'Release',
},
{ mode: PROJECT_OVERVIEW_ACTIVITY_MODE.VERSIONS, title: 'Versions' },
{ mode: PROJECT_OVERVIEW_ACTIVITY_MODE.WORKSPACES, title: 'Workspaces' },
{ mode: PROJECT_OVERVIEW_ACTIVITY_MODE.PATCH, title: 'Patch' },
]
.filter((activity): activity is ProjectOverviewActivityDisplay =>
Boolean(activity),
)
.filter(
(act) =>
// releasing not supported in embedded mode
!(
act.mode === PROJECT_OVERVIEW_ACTIVITY_MODE.RELEASE &&
isInEmbeddedMode
),
);
return (
{activities.map((activity) => (
))}
);
});
export const ProjectOverview = observer(() => {
const editorStore = useEditorStore();
const applicationStore = useApplicationStore();
const [openShareModal, setOpenShareModal] = useState(false);
const showShareModal = (): void => setOpenShareModal(true);
const hideShareModal = (): void => setOpenShareModal(false);
const projectOverviewState = editorStore.projectOverviewState;
const openProjectWebUrl = (): void =>
applicationStore.navigationService.navigator.visitAddress(
editorStore.sdlcState.activeProject.webUrl,
);
const renderOverview = (): React.ReactNode => {
switch (projectOverviewState.activityMode) {
case PROJECT_OVERVIEW_ACTIVITY_MODE.OVERVIEW:
return ;
case PROJECT_OVERVIEW_ACTIVITY_MODE.RELEASE:
return ;
case PROJECT_OVERVIEW_ACTIVITY_MODE.VERSIONS:
return ;
case PROJECT_OVERVIEW_ACTIVITY_MODE.WORKSPACES:
return ;
case PROJECT_OVERVIEW_ACTIVITY_MODE.PATCH:
return ;
default:
return null;
}
};
return (
{editorStore.sdlcState.currentProject && (
)}
);
});