/** * 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 { createContext, useContext, useEffect, useMemo, useState, useRef, } from 'react'; import { observer, useLocalObservable } from 'mobx-react-lite'; import { type SelectComponent, type InputActionData, clsx, AssistantIcon, compareLabelFn, GitBranchIcon, CustomSelectorInput, LongArrowRightIcon, DividerWithText, SearchIcon, BaseCard, OpenIcon, Dialog, MarkdownTextViewer, Modal, } from '@finos/legend-art'; import { LEGEND_STUDIO_TEST_ID } from '../../__lib__/LegendStudioTesting.js'; import { type WorkspaceSetupPathParams, generateEditorRoute, LEGEND_STUDIO_ROUTE_PATTERN_TOKEN, } from '../../__lib__/LegendStudioNavigation.js'; import { flowResult } from 'mobx'; import { DEFAULT_TYPEAHEAD_SEARCH_LIMIT, useApplicationNavigationContext, } from '@finos/legend-application'; import { useParams } from '@finos/legend-application/browser'; import { LEGEND_STUDIO_DOCUMENTATION_KEY } from '../../__lib__/LegendStudioDocumentation.js'; import { CreateProjectModal } from './CreateProjectModal.js'; import { ActivityBarMenu, ColorThemeToggle } from '../editor/ActivityBar.js'; import { LEGEND_STUDIO_APPLICATION_NAVIGATION_CONTEXT_KEY } from '../../__lib__/LegendStudioApplicationNavigationContext.js'; import { CreateWorkspaceModal } from './CreateWorkspaceModal.js'; import { RecentWorkspacesPanel } from './RecentWorkspacesPanel.js'; import { useLegendStudioApplicationStore, useLegendStudioBaseStore, } from '../LegendStudioFrameworkProvider.js'; import { type ProjectOption, buildProjectOption, getProjectOptionLabelFormatter, } from './ProjectSelectorUtils.js'; import { type WorkspaceOption, buildWorkspaceOption, formatWorkspaceOptionLabel, } from './WorkspaceSelectorUtils.js'; import { debounce, guaranteeNonNullable, type PlainObject, } from '@finos/legend-shared'; import { Project } from '@finos/legend-server-sdlc'; import { WorkspaceSetupStore } from '../../stores/workspace-setup/WorkspaceSetupStore.js'; import { openShowcaseManager } from '../../stores/ShowcaseManagerState.js'; import { SHOWCASE_MANAGER_ENTRY_POINT } from '../../__lib__/LegendStudioTelemetryHelper.js'; const WorkspaceSetupStoreContext = createContext< WorkspaceSetupStore | undefined >(undefined); export const DEFAULT_WORKSPACE_SOURCE = 'HEAD'; enum ACTION_KEYS { ENTER = 'Enter', BACKSPACE = 'Backspace', DELETE = 'Delete', TAB = 'Tab', } const PROJECT_SELECTED_ACTION_KEYS: string[] = [ ACTION_KEYS.BACKSPACE, ACTION_KEYS.DELETE, ACTION_KEYS.TAB, ]; const handleKeyClicked = ( event: React.KeyboardEvent, inputHasSelection: boolean, ): void => { if (!inputHasSelection) { return; } if (!PROJECT_SELECTED_ACTION_KEYS.includes(event.key)) { event.preventDefault(); event.stopPropagation(); } }; export const ShowcaseCard: React.FC<{ hideDocumentation?: boolean }> = ( props, ) => { const applicationStore = useLegendStudioApplicationStore(); const appDocUrl = applicationStore.documentationService.url; return ( Review showcase projects with sample project code and re-use existing code snippets to quickly build your model.{' '} {!props.hideDocumentation && ( <> Review Studio{' '} documentation . )} } cardActions={[ { title: 'Showcase explorer', content: (
), action: () => openShowcaseManager( applicationStore, SHOWCASE_MANAGER_ENTRY_POINT.WORKSPACE_SETUP, ), }, ]} isStable={true} /> ); }; export const DocumentationCard: React.FC = () => { const applicationStore = useLegendStudioApplicationStore(); const appDocUrl = applicationStore.documentationService.url; return ( Review Studio{' '} documentation . } cardActions={[ { title: 'Review documentation', content: (
), action: () => { if (appDocUrl) { applicationStore.navigationService.navigator.visitAddress( appDocUrl, ); } }, }, ]} isStable={true} /> ); }; export const ProductionCard: React.FC = () => { const applicationStore = useLegendStudioApplicationStore(); const productionDocument = applicationStore.documentationService.getDocEntry( LEGEND_STUDIO_DOCUMENTATION_KEY.APPLICATION_PRODUCTION, ); return ( productionDocument?.title && productionDocument.markdownText && productionDocument.text && ( ), action: () => { if (productionDocument.url) { applicationStore.navigationService.navigator.visitAddress( productionDocument.url, ); } }, }, ]} isStable={true} /> ) ); }; export const FaqCard: React.FC = () => { const applicationStore = useLegendStudioApplicationStore(); const faqDocument = applicationStore.documentationService.getDocEntry( LEGEND_STUDIO_DOCUMENTATION_KEY.APPLICATION_FAQ, ); return ( faqDocument?.title && faqDocument.markdownText && faqDocument.text && ( ), action: () => { if (faqDocument.url) { applicationStore.navigationService.navigator.visitAddress( faqDocument.url, ); } }, }, ]} isStable={true} /> ) ); }; export const RuleEngagementCard: React.FC = () => { const applicationStore = useLegendStudioApplicationStore(); const ruleEngagementDocument = applicationStore.documentationService.getDocEntry( LEGEND_STUDIO_DOCUMENTATION_KEY.APPLICATION_RULE_ENGAGEMENT, ); return ( ruleEngagementDocument?.title && ruleEngagementDocument.markdownText && ruleEngagementDocument.text && ( ), action: () => { if (ruleEngagementDocument.url) { applicationStore.navigationService.navigator.visitAddress( ruleEngagementDocument.url, ); } }, }, ]} isStable={true} /> ) ); }; const WorkspaceSetupStoreProvider: React.FC<{ children: React.ReactNode; }> = ({ children }) => { const applicationStore = useLegendStudioApplicationStore(); const baseStore = useLegendStudioBaseStore(); const store = useLocalObservable( () => new WorkspaceSetupStore(applicationStore, baseStore.sdlcServerClient), ); return ( {children} ); }; export const useWorkspaceSetupStore = (): WorkspaceSetupStore => guaranteeNonNullable( useContext(WorkspaceSetupStoreContext), `Can't find workspace setup store in context`, ); const withWorkspaceSetupStore = (WrappedComponent: React.FC): React.FC => function WithWorkspaceSetupStore() { return ( ); }; const SandboxAccessModal = observer(() => { const setupStore = useWorkspaceSetupStore(); const closeModal = (): void => setupStore.setSandboxModal(false); const applicationStore = setupStore.applicationStore; const documentation = applicationStore.documentationService.getDocEntry( LEGEND_STUDIO_DOCUMENTATION_KEY.SETUP_CREATE_SANDBOX_UNAUTHORIZED, ); return (
Create Sandbox Project
You do not have access to create a Sandbox Project
{documentation?.markdownText && (
)}
); }); export const WorkspaceSetup = withWorkspaceSetupStore( observer(() => { const params = useParams(); const projectId = params[LEGEND_STUDIO_ROUTE_PATTERN_TOKEN.PROJECT_ID]; const workspaceId = params[LEGEND_STUDIO_ROUTE_PATTERN_TOKEN.WORKSPACE_ID]; const groupWorkspaceId = params[LEGEND_STUDIO_ROUTE_PATTERN_TOKEN.GROUP_WORKSPACE_ID]; const setupStore = useWorkspaceSetupStore(); const applicationStore = useLegendStudioApplicationStore(); const [projectSearchText, setProjectSearchText] = useState(''); const [isProjectSelectorMenuOpen, setIsProjectSelectorMenuOpen] = useState(false); const goButtonRef = useRef(null); const projectSelectorRef = useRef(null); const workspaceSelectorRef = useRef(null); //TODO: fix logo loading issue for localhost const logoPath = `${applicationStore.config.baseAddress}favicon.ico`; const toggleAssistant = (): void => applicationStore.assistantService.toggleAssistant(); // projects // Build a unified option list: recent projects (that aren't already in the // loaded set) are prepended so users can instantly re-open common work // without waiting for the SDLC search to round-trip. const { projects, projectMatchedById } = setupStore.projectSearchResult; const sandboxProject = setupStore.sandboxProject instanceof Project ? setupStore.sandboxProject : undefined; const sandboxProjectId = sandboxProject?.projectId; const loadedProjectOptions = projects .filter( (p) => p.projectId !== sandboxProjectId && p.projectId !== projectMatchedById?.projectId, ) .slice(0, DEFAULT_TYPEAHEAD_SEARCH_LIMIT) .map(buildProjectOption) .sort(compareLabelFn); const loadedProjectIds = new Set(projects.map((p) => p.projectId)); const recentProjectOptions: ProjectOption[] = setupStore.recentProjects .filter( (r) => !loadedProjectIds.has(r.projectId) && r.projectId !== sandboxProjectId && r.projectId !== projectMatchedById?.projectId, ) .map((r) => { // Rebuild a real Project from the cached metadata; no synthetic // fields needed since we persist everything the schema requires. const stub = Project.serialization.fromJson({ projectId: r.projectId, name: r.name, description: r.description, webUrl: r.webUrl, tags: r.tags, } as PlainObject); return { label: stub.name, value: stub }; }); const pinnedOptions: ProjectOption[] = []; if (projectMatchedById) { pinnedOptions.push(buildProjectOption(projectMatchedById)); } // The user's own sandbox project is fetched via a dedicated call // (`loadSandboxProject`) and excluded from the main search results, so // we surface it here as a labeled option pinned to the top of the list. if (sandboxProject) { pinnedOptions.push({ label: `${sandboxProject.name} (sandbox)`, value: sandboxProject, }); } const projectOptions: ProjectOption[] = [ ...pinnedOptions, ...recentProjectOptions, ...loadedProjectOptions, ]; const recentProjectIdSet = new Set( setupStore.recentProjects.map((p) => p.projectId), ); const selectedProjectOption = setupStore.currentProject ? buildProjectOption(setupStore.currentProject) : null; const onProjectChange = (val: ProjectOption | null): void => { setProjectSearchText(''); if (val) { // If the selection corresponds to a recent that hasn't been loaded // from search yet, fetch the project before switching. const isUnloadedRecent = !loadedProjectIds.has(val.value.projectId) && recentProjectIdSet.has(val.value.projectId) && val.value.projectId !== projectMatchedById?.projectId; if (isUnloadedRecent) { flowResult(setupStore.selectRecentProject(val.value.projectId)).catch( applicationStore.alertUnhandledError, ); } else { flowResult(setupStore.changeProject(val.value)).catch( applicationStore.alertUnhandledError, ); } } else { setupStore.resetProject(); setTimeout(() => projectSelectorRef.current?.focus(), 0); } }; const showCreateProjectModal = (): void => setupStore.setShowCreateProjectModal(true); const createSandboxProject = (): void => { flowResult(setupStore.createSandboxProject()).catch( applicationStore.alertUnhandledError, ); }; // project search text const debouncedLoadProjects = useMemo( () => debounce((input: string): void => { flowResult(setupStore.loadProjects(input)).catch( applicationStore.alertUnhandledError, ); }, 250), [applicationStore, setupStore], ); const onProjectSearchTextChange = ( value: string, actionChange: InputActionData, ): void => { if (actionChange.action !== 'input-change' || selectedProjectOption) { return; } if (value !== projectSearchText) { setProjectSearchText(value); setupStore.filterListOnInput(value); debouncedLoadProjects.cancel(); debouncedLoadProjects(value); } }; // workspaces // Recent (non-patch) workspaces for the currently selected project are // floated to the top of the dropdown so users can re-enter their // typical work without scanning the full list. const projectIdForRecents = setupStore.currentProject?.projectId; const recentWorkspaceKeys = new Set( projectIdForRecents ? setupStore.recentWorkspaces .filter((w) => w.projectId === projectIdForRecents) .map((w) => `${w.workspaceType}::${w.workspaceId}`) : [], ); const allWorkspaceOptions = setupStore.workspaces.map(buildWorkspaceOption); const recentWorkspaceOptions = allWorkspaceOptions .filter( (o) => o.value.source === undefined && recentWorkspaceKeys.has( `${o.value.workspaceType}::${o.value.workspaceId}`, ), ) .sort(compareLabelFn); const otherWorkspaceOptions = allWorkspaceOptions .filter( (o) => o.value.source !== undefined || !recentWorkspaceKeys.has( `${o.value.workspaceType}::${o.value.workspaceId}`, ), ) .sort(compareLabelFn); const workspaceOptions = [ ...recentWorkspaceOptions, ...otherWorkspaceOptions, ]; const selectedWorkspaceOption = setupStore.currentWorkspace ? buildWorkspaceOption(setupStore.currentWorkspace) : null; const onWorkspaceChange = (val: WorkspaceOption | null): void => { if (val) { setupStore.changeWorkspace(val.value); if (!setupStore.currentProjectConfigurationStatus?.isConfigured) { applicationStore.notificationService.notifyIllegalState( `Can't edit current workspace as the current project is not configured`, ); } } else { setupStore.resetWorkspace(); setTimeout(() => workspaceSelectorRef.current?.focus(), 0); } }; const showCreateWorkspaceModal = (): void => setupStore.setShowCreateWorkspaceModal(true); const handleProceed = (): void => { if (setupStore.currentProject && setupStore.currentWorkspace) { applicationStore.navigationService.navigator.goToLocation( generateEditorRoute( setupStore.currentProject.projectId, setupStore.currentWorkspace.source, setupStore.currentWorkspace.workspaceId, setupStore.currentWorkspace.workspaceType, ), ); } }; useEffect(() => { flowResult( setupStore.initialize(projectId, workspaceId, groupWorkspaceId), ).catch(applicationStore.alertUnhandledError); }, [ setupStore, applicationStore, projectId, workspaceId, groupWorkspaceId, ]); useEffect(() => { flowResult(setupStore.loadProjects('')).catch( applicationStore.alertUnhandledError, ); flowResult(setupStore.loadSandboxProject()).catch( applicationStore.alertUnhandledError, ); }, [setupStore, applicationStore]); useApplicationNavigationContext( LEGEND_STUDIO_APPLICATION_NAVIGATION_CONTEXT_KEY.SETUP, ); const sharedSelectorProps = { inputClassName: 'workspace-setup__selector__input', classNames: { placeholder: (): string => 'workspace-setup__selector__placeholder', }, isClearable: true, escapeClearsValue: true, darkMode: !applicationStore.layoutService.TEMPORARY__isLightColorThemeEnabled, optionCustomization: { rowHeight: window.innerHeight * 0.03, }, }; return (
{/* Spacer so the theme toggle sits at the bottom of the strip, mirroring how the editor's activity bar is laid out. */}
Welcome to Legend Studio
Search for an existing project {(setupStore.recentProjects.length > 0 || setupStore.recentWorkspaces.length > 0) && ( )}
, ) => handleKeyClicked( event, Boolean(selectedProjectOption), ) } menuIsOpen={isProjectSelectorMenuOpen} onFocus={() => setIsProjectSelectorMenuOpen(true)} onMenuOpen={() => setIsProjectSelectorMenuOpen(true) } onBlur={() => setIsProjectSelectorMenuOpen(false)} onChange={(option: ProjectOption | null) => { onProjectChange(option); setIsProjectSelectorMenuOpen(false); }} value={selectedProjectOption} placeholder="Search for project..." filterOption={(): boolean => true} formatOptionLabel={getProjectOptionLabelFormatter( applicationStore, setupStore.currentProjectConfigurationStatus, )} />
Choose an existing workspace
, ) => { if (event.key === ACTION_KEYS.ENTER) { goButtonRef.current?.focus(); handleProceed(); return; } handleKeyClicked( event, Boolean(selectedWorkspaceOption), ); }} disabled={ !setupStore.currentProject || !setupStore.currentProjectConfigurationStatus || !setupStore.currentProjectConfigurationStatus .isConfigured || setupStore.loadProjectsState.isInProgress || setupStore.loadWorkspacesState.isInProgress } isLoading={ setupStore.loadWorkspacesState.isInProgress } onChange={onWorkspaceChange} formatOptionLabel={formatWorkspaceOptionLabel} value={selectedWorkspaceOption} placeholder={ setupStore.loadWorkspacesState.isInProgress ? 'Loading workspaces...' : !setupStore.currentProject ? 'In order to choose a workspace, a project must be chosen' : workspaceOptions.length ? 'Choose an existing workspace' : 'You have no workspaces. Please create one to proceed...' } />
OR {setupStore.sandboxModal && }
{setupStore.supportsCreatingSandboxProject && ( )}
{/* NOTE: We do this to reset the initial state of the modals */} {setupStore.showCreateProjectModal && } {setupStore.showCreateWorkspaceModal && setupStore.currentProject && ( )}
); }), );