Added initial commit - Added lazy load for versions

This commit is contained in:
Muhsin Shah 2024-04-05 13:04:21 +05:30
parent 858dff3606
commit 7a109fb1cd
18 changed files with 389 additions and 87 deletions

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import cx from 'classnames';
import { appVersionService } from '@/_services';
import { CustomSelect } from './CustomSelect';
@ -6,6 +6,7 @@ import { toast } from 'react-hot-toast';
import { shallow } from 'zustand/shallow';
import { useAppVersionStore } from '@/_stores/appVersionStore';
import { useEditorStore } from '@/_stores/editorStore';
import { useEnvironmentsAndVersionsStore } from '@/_stores/environmentsAndVersionsStore';
const appVersionLoadingStatus = Object.freeze({
loading: 'loading',
@ -20,18 +21,56 @@ export const AppVersionsManager = function ({
isEditable = true,
isViewer,
}) {
const [appVersionStatus, setGetAppVersionStatus] = useState(appVersionLoadingStatus.loading);
const { initializedEnvironmentDropdown, versionsPromotedToEnvironment, lazyLoadAppVersions, appVersionsLazyLoaded } =
useEnvironmentsAndVersionsStore(
(state) => ({
appVersionsLazyLoaded: state.appVersionsLazyLoaded,
initializedEnvironmentDropdown: state.initializedEnvironmentDropdown,
versionsPromotedToEnvironment: state.versionsPromotedToEnvironment,
lazyLoadAppVersions: state.actions.lazyLoadAppVersions,
}),
shallow
);
if (initializedEnvironmentDropdown) {
return (
<RenderComponent
appId={appId}
setAppDefinitionFromVersion={setAppDefinitionFromVersion}
onVersionDelete={onVersionDelete}
isEditable={isEditable}
isViewer={isViewer}
versionsPromotedToEnvironment={versionsPromotedToEnvironment}
lazyLoadAppVersions={lazyLoadAppVersions}
appVersionsLazyLoaded={appVersionsLazyLoaded}
/>
);
} else {
return <></>;
}
};
const RenderComponent = ({
appId,
isEditable,
isViewer,
setAppDefinitionFromVersion,
onVersionDelete,
versionsPromotedToEnvironment,
lazyLoadAppVersions,
appVersionsLazyLoaded,
}) => {
const [appVersionStatus, setGetAppVersionStatus] = useState(appVersionLoadingStatus.loaded);
const [deleteVersion, setDeleteVersion] = useState({
versionId: '',
versionName: '',
showModal: false,
});
const [forceMenuOpen, setForceMenuOpen] = useState(false);
const { releasedVersionId, editingVersion, appVersions, setAppVersions } = useAppVersionStore(
const { releasedVersionId, editingVersion } = useAppVersionStore(
(state) => ({
editingVersion: state.editingVersion,
appVersions: state.appVersions,
setAppVersions: state.actions?.setAppVersions,
releasedVersionId: state.releasedVersionId,
}),
shallow
@ -43,16 +82,6 @@ export const AppVersionsManager = function ({
shallow
);
useEffect(() => {
if (appVersions && appVersions.length > 0) {
setGetAppVersionStatus(appVersionLoadingStatus.loaded);
}
return () => {
setGetAppVersionStatus(appVersionLoadingStatus.loading);
};
}, [appVersions]);
const darkMode = localStorage.getItem('darkMode') === 'true';
const selectVersion = (id) => {
@ -93,13 +122,12 @@ export const AppVersionsManager = function ({
})
.finally(() => {
appVersionService.getAll(appId, true).then((data) => {
setAppVersions(data.versions);
onVersionDelete();
});
});
};
const options = appVersions.map((appVersion) => ({
const options = versionsPromotedToEnvironment.map((appVersion) => ({
value: appVersion.id,
isReleasedVersion: appVersion.id === releasedVersionId,
appVersionName: appVersion.name,
@ -139,10 +167,17 @@ export const AppVersionsManager = function ({
),
}));
const onMenuOpen = async () => {
if (!appVersionsLazyLoaded) {
setGetAppVersionStatus(appVersionLoadingStatus.loading);
await lazyLoadAppVersions(appId);
setGetAppVersionStatus(appVersionLoadingStatus.loaded);
}
setForceMenuOpen(!forceMenuOpen);
};
const customSelectProps = {
appId,
appVersions,
setAppVersions,
setAppDefinitionFromVersion,
editingVersion,
setDeleteVersion,
@ -175,6 +210,9 @@ export const AppVersionsManager = function ({
{...customSelectProps}
className={` ${darkMode && 'dark-theme'}`}
isEditable={isEditable}
onMenuOpen={onMenuOpen}
onMenuClose={() => setForceMenuOpen(false)}
menuIsOpen={forceMenuOpen}
/>
</div>
</div>

View file

@ -707,8 +707,6 @@ const EditorComponent = (props) => {
useAppVersionStore.getState().actions.updateReleasedVersionId(data.current_version_id);
}
const appVersions = await appEnvironmentService.getVersionsByEnvironment(data?.id);
setAppVersions(appVersions.appVersions);
const currentOrgId = data?.organization_id || data?.organizationId;
updateState({

View file

@ -0,0 +1,27 @@
import { useEnvironmentsAndVersionsActions } from '@/_stores/environmentsAndVersionsStore';
import React, { useEffect } from 'react';
import { shallow } from 'zustand/shallow';
import { useAppVersionStore } from '@/_stores/appVersionStore';
const EnvironmentManager = () => {
const { editingVersionId } = useAppVersionStore(
(state) => ({
editingVersionId: state?.editingVersion?.id,
}),
shallow
);
const { init, setEnvironmentDropdownStatus } = useEnvironmentsAndVersionsActions();
useEffect(() => {
initComponent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const initComponent = async () => {
await init(editingVersionId);
setEnvironmentDropdownStatus(true);
};
return <div></div>;
};
export default EnvironmentManager;

View file

@ -0,0 +1,2 @@
import EnvironmentManager from './EnvironmentsManager';
export default EnvironmentManager;

View file

@ -0,0 +1,7 @@
import React from 'react';
const PromoteVersionButton = () => {
return <></>;
};
export default PromoteVersionButton;

View file

@ -8,7 +8,7 @@ import { ConfirmDialog } from '@/_components/ConfirmDialog';
import { shallow } from 'zustand/shallow';
import { ButtonSolid } from '@/_ui/AppButton/AppButton';
export const ReleaseVersionButton = function DeployVersionButton({ appId, appName, fetchApp, onVersionRelease }) {
export const ReleaseVersionButton = function DeployVersionButton({ onVersionRelease }) {
const [isReleasing, setIsReleasing] = useState(false);
const { isVersionReleased, editingVersion } = useAppVersionStore(
(state) => ({
@ -24,17 +24,15 @@ export const ReleaseVersionButton = function DeployVersionButton({ appId, appNam
setShowPageDeletionConfirmation(false);
setIsReleasing(true);
const { id: versionToBeReleased, name, app_id } = editingVersion;
appsService
.saveApp(appId, {
name: appName,
current_version_id: editingVersion.id,
})
.releaseVersion(app_id, versionToBeReleased)
.then(() => {
toast(`Version ${editingVersion.name} released`, {
toast(`Version ${name} released`, {
icon: '🚀',
});
fetchApp && fetchApp();
onVersionRelease(editingVersion.id);
onVersionRelease(versionToBeReleased);
setIsReleasing(false);
})
.catch((_error) => {

View file

@ -0,0 +1,101 @@
import React, { useEffect } from 'react';
import { ReleaseVersionButton } from './ReleaseVersionButton';
import { Link } from 'react-router-dom';
import { useAppInfo, useAppDataActions } from '@/_stores/appDataStore';
import { ManageAppUsers } from './ManageAppUsers';
import { useAppVersionStore } from '@/_stores/appVersionStore';
import { shallow } from 'zustand/shallow';
import queryString from 'query-string';
import { isEmpty } from 'lodash';
import { useCurrentStateStore } from '@/_stores/currentStateStore';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import { useEnvironmentsAndVersionsStore } from '@/_stores/environmentsAndVersionsStore';
import PromoteVersionButton from './PromoteVersionButton';
const RightTopHeaderButtons = ({ onVersionRelease }) => {
return (
<div className="d-flex justify-content-end navbar-right-section" style={{ width: '300px', paddingRight: '12px' }}>
<div className=" release-buttons navbar-nav flex-row">
<PreviewAndShareIcons />
<PromoteAndReleaseButton onVersionRelease={onVersionRelease} />
</div>
</div>
);
};
const PreviewAndShareIcons = () => {
const { appId, app, slug, isPublic, appVersionPreviewLink, currentVersionId } = useAppInfo();
const { setAppPreviewLink } = useAppDataActions();
const { isVersionReleased, editingVersion } = useAppVersionStore(
(state) => ({
isVersionReleased: state.isVersionReleased,
editingVersion: state.editingVersion,
}),
shallow
);
const { pageHandle } = useCurrentStateStore(
(state) => ({
pageHandle: state?.page?.handle,
}),
shallow
);
const darkMode = localStorage.getItem('darkMode') === 'true';
useEffect(() => {
const previewQuery = queryString.stringify({ version: editingVersion.name });
const appVersionPreviewLink = editingVersion.id
? `/applications/${slug || appId}/${pageHandle}${!isEmpty(previewQuery) ? `?${previewQuery}` : ''}`
: '';
setAppPreviewLink(appVersionPreviewLink);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [slug, currentVersionId, editingVersion]);
return (
<div className="preview-share-wrap navbar-nav flex-row" style={{ gap: '4px' }}>
<div className="nav-item">
{appId && (
<ManageAppUsers
app={app}
appId={appId}
slug={slug}
darkMode={darkMode}
isVersionReleased={isVersionReleased}
pageHandle={pageHandle}
isPublic={isPublic ?? false}
/>
)}
</div>
<div className="nav-item">
<Link
title="Preview"
to={appVersionPreviewLink}
target="_blank"
rel="noreferrer"
data-cy="preview-link-button"
className="editor-header-icon tj-secondary-btn"
>
<SolidIcon name="eyeopen" width="14" fill="#3E63DD" />
</Link>
</div>
</div>
);
};
const PromoteAndReleaseButton = ({ onVersionRelease }) => {
const { shouldRenderPromoteButton, shouldRenderReleaseButton } = useEnvironmentsAndVersionsStore(
(state) => ({
shouldRenderPromoteButton: state.shouldRenderPromoteButton,
shouldRenderReleaseButton: state.shouldRenderReleaseButton,
}),
shallow
);
return (
<div className="nav-item dropdown promote-release-btn">
{shouldRenderPromoteButton && <PromoteVersionButton />}
{shouldRenderReleaseButton && <ReleaseVersionButton onVersionRelease={onVersionRelease} />}
</div>
);
};
export default RightTopHeaderButtons;

View file

@ -0,0 +1,2 @@
import RightTopHeaderButtons from './RightTopHeaderButtons';
export default RightTopHeaderButtons;

View file

@ -1,12 +1,8 @@
import React, { useEffect } from 'react';
import { Link } from 'react-router-dom';
import AppLogo from '@/_components/AppLogo';
import EditAppName from './EditAppName';
import HeaderActions from './HeaderActions';
import RealtimeAvatars from '../RealtimeAvatars';
import { AppVersionsManager } from '@/Editor/AppVersionsManager/AppVersionsManager';
import { ManageAppUsers } from '../ManageAppUsers';
import { ReleaseVersionButton } from '../ReleaseVersionButton';
import cx from 'classnames';
import config from 'config';
// eslint-disable-next-line import/no-unresolved
@ -16,10 +12,11 @@ import { useCurrentStateStore } from '@/_stores/currentStateStore';
import { shallow } from 'zustand/shallow';
import { useAppDataActions, useAppInfo, useCurrentUser } from '@/_stores/appDataStore';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import { redirectToDashboard } from '@/_helpers/routes';
import queryString from 'query-string';
import { isEmpty } from 'lodash';
import LogoNavDropdown from '@/_components/LogoNavDropdown';
import RightTopHeaderButtons from './RightTopHeaderButtons';
import EnvironmentManager from './RightTopHeaderButtons/EnvironmentManager';
export default function EditorHeader({
M,
@ -31,15 +28,13 @@ export default function EditorHeader({
onNameChanged,
setAppDefinitionFromVersion,
onVersionRelease,
saveEditingVersion,
onVersionDelete,
slug,
darkMode,
isSocketOpen,
}) {
const currentUser = useCurrentUser();
const { isSaving, appId, appName, app, isPublic, appVersionPreviewLink, currentVersionId } = useAppInfo();
const { isSaving, appId, appName, isPublic, currentVersionId } = useAppInfo();
const { setAppPreviewLink } = useAppDataActions();
const { isVersionReleased, editingVersion } = useAppVersionStore(
(state) => ({
@ -148,6 +143,8 @@ export default function EditorHeader({
</div>
<div className="navbar-seperator"></div>
<EnvironmentManager />
{editingVersion && (
<AppVersionsManager
appId={appId}
@ -157,52 +154,7 @@ export default function EditorHeader({
/>
)}
</div>
<div
className="d-flex justify-content-end navbar-right-section"
style={{ width: '300px', paddingRight: '12px' }}
>
<div className=" release-buttons navbar-nav flex-row">
<div className="preview-share-wrap navbar-nav flex-row" style={{ gap: '4px' }}>
<div className="nav-item">
{appId && (
<ManageAppUsers
app={app}
appId={appId}
slug={slug}
darkMode={darkMode}
isVersionReleased={isVersionReleased}
pageHandle={pageHandle}
M={M}
isPublic={isPublic ?? false}
/>
)}
</div>
<div className="nav-item">
<Link
title="Preview"
to={appVersionPreviewLink}
target="_blank"
rel="noreferrer"
data-cy="preview-link-button"
className="editor-header-icon tj-secondary-btn"
>
<SolidIcon name="eyeopen" width="14" fill="#3E63DD" />
</Link>
</div>
</div>
{isSocketOpen && (
<div className="nav-item dropdown promote-release-btn">
<ReleaseVersionButton
appId={appId}
appName={appName}
onVersionRelease={onVersionRelease}
saveEditingVersion={saveEditingVersion}
/>
</div>
)}
</div>
</div>
<RightTopHeaderButtons onVersionRelease={onVersionRelease} />
</div>
</div>
</header>

View file

@ -5,6 +5,7 @@ import queryString from 'query-string';
export const appEnvironmentService = {
getAllEnvironments,
getVersionsByEnvironment,
init,
};
function getAllEnvironments() {
@ -29,3 +30,9 @@ function getVersionsByEnvironment(appId, environmentId /* not needed for CE */)
requestOptions
).then(handleResponse);
}
function init(editing_version_id = null) {
const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' };
const query = queryString.stringify({ editing_version_id });
return fetch(`${config.apiUrl}/app-environments/init?${query}`, requestOptions).then(handleResponse);
}

View file

@ -25,6 +25,7 @@ export const appsService = {
getAppUsers,
getVersions,
getTables,
releaseVersion,
};
function validateReleasedApp(slug) {
@ -204,3 +205,14 @@ function getTables(id) {
const requestOptions = { method: 'GET', headers: authHeader(), credentials: 'include' };
return fetch(`${config.apiUrl}/apps/${id}/tables`, requestOptions).then(handleResponse);
}
function releaseVersion(appId, versionToBeReleased) {
const requestOptions = {
method: 'PUT',
headers: authHeader(),
body: JSON.stringify({ versionToBeReleased }),
credentials: 'include',
};
return fetch(`${config.apiUrl}/v2/apps/${appId}/release`, requestOptions).then(handleResponse);
}

View file

@ -0,0 +1,60 @@
import create from 'zustand';
import { zustandDevTools } from './utils';
import { appEnvironmentService } from '../_services/app_environment.service';
const initialState = {
selectedVersion: null,
selectedEnvironment: null,
appVersionEnvironment: null,
versionsPromotedToEnvironment: [],
environments: [],
shouldRenderPromoteButton: false,
shouldRenderReleaseButton: false,
initializedEnvironmentDropdown: false,
initializedVersionsDropdown: false,
environmentsLazyLoaded: false,
appVersionsLazyLoaded: false,
};
export const useEnvironmentsAndVersionsStore = create(
zustandDevTools(
(set, get) => ({
...initialState,
actions: {
init: async (editingVersionId) => {
try {
const response = await appEnvironmentService.init(editingVersionId);
set((state) => ({
...state,
selectedEnvironment: response.editorEnvironment,
selectedVersion: response.editorVersion,
appVersionEnvironment: response.appVersionEnvironment,
shouldRenderPromoteButton: response.shouldRenderPromoteButton,
shouldRenderReleaseButton: response.shouldRenderReleaseButton,
environments: [response.editorEnvironment],
versionsPromotedToEnvironment: [response.editorVersion],
}));
} catch (error) {
console.error('Error while initializing the environment dropdown', error);
}
},
setEnvironmentDropdownStatus: (state) => set({ initializedEnvironmentDropdown: state }),
lazyLoadAppVersions: async (appId) => {
try {
const response = await appEnvironmentService.getVersionsByEnvironment(appId, get().selectedEnvironment.id);
set((state) => ({
...state,
versionsPromotedToEnvironment: response.appVersions,
appVersionsLazyLoaded: true,
}));
} catch (error) {
console.error('Error while getting the versions', error);
}
},
},
}),
{ name: 'App Version Manager Store' }
)
);
export const useEnvironmentsAndVersionsActions = () => useEnvironmentsAndVersionsStore((state) => state.actions);

View file

@ -1,4 +1,4 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { decamelizeKeys } from 'humps';
import { JwtAuthGuard } from '../modules/auth/jwt-auth.guard';
import { ForbiddenException } from '@nestjs/common';
@ -17,6 +17,16 @@ export class AppEnvironmentsController {
private orgEnvironmentVariablesAbilityFactory: OrgEnvironmentVariablesAbilityFactory
) {}
@UseGuards(JwtAuthGuard)
@Get('init')
async init(@User() user, @Query('editing_version_id') editingVersionId: string) {
/*
init is a method in the AppEnvironmentService class that is used to initialize the app environment mananger.
Should not use for any other purpose.
*/
return await this.appEnvironmentServices.init(editingVersionId);
}
@UseGuards(JwtAuthGuard)
@Get()
async index(@User() user, @Query('app_id') appId: string) {
@ -39,9 +49,13 @@ export class AppEnvironmentsController {
}
@UseGuards(JwtAuthGuard)
@Get('versions')
async getVersions(@User() user, @Query('app_id') appId: string) {
const appVersions = await this.appEnvironmentServices.getVersionsByEnvironment(user?.organizationId, appId);
@Get(':id/versions')
async getVersionsByEnvironment(@User() user, @Param('id') environmentId: string, @Query('app_id') appId: string) {
const appVersions = await this.appEnvironmentServices.getVersionsByEnvironment(
user?.organizationId,
appId,
environmentId
);
return { appVersions };
}
}

View file

@ -32,6 +32,7 @@ import { PageService } from '@services/page.service';
import { EventsService } from '@services/events_handler.service';
import { AppVersionUpdateDto } from '@dto/app-version-update.dto';
import { CreateEventHandlerDto, UpdateEventHandlerDto } from '@dto/event-handler.dto';
import { VersionReleaseDto } from '@dto/version-release.dto';
@Controller({
path: 'apps',
@ -499,4 +500,20 @@ export class AppsControllerV2 {
return await this.eventService.deleteEvent(eventId, versionId);
}
@UseGuards(JwtAuthGuard)
@UseInterceptors(ValidAppInterceptor)
@Put(':id/release')
async releaseVersion(
@User() user,
@Param('id') id,
@AppDecorator() app: App,
@Body() versionReleaseDto: VersionReleaseDto
) {
const ability = await this.appsAbilityFactory.appsActions(user, app.id);
if (!ability.can('updateParams', app)) {
throw new ForbiddenException('You do not have permissions to perform this action');
}
return await this.appsService.releaseVersion(app.id, versionReleaseDto);
}
}

View file

@ -0,0 +1,10 @@
import { IsNotEmpty, IsUUID } from 'class-validator';
import { Transform } from 'class-transformer';
import { sanitizeInput } from 'src/helpers/utils.helper';
export class VersionReleaseDto {
@IsNotEmpty()
@IsUUID()
@Transform(({ value }) => sanitizeInput(value))
versionToBeReleased: string;
}

View file

@ -7,8 +7,43 @@ import { OrganizationConstant } from 'src/entities/organization_constants.entity
import { EntityManager, FindOneOptions, In, DeleteResult } from 'typeorm';
import { AppVersion } from 'src/entities/app_version.entity';
export interface AppEnvironmentResponse {
editorVersion: Partial<AppVersion>;
editorEnvironment: AppEnvironment;
appVersionEnvironment: AppEnvironment;
shouldRenderPromoteButton: boolean;
shouldRenderReleaseButton: boolean;
}
@Injectable()
export class AppEnvironmentService {
async init(editingVersionId: string, manager?: EntityManager): Promise<AppEnvironmentResponse> {
return await dbTransactionWrap(async (manager: EntityManager) => {
const editorVersion = await manager.findOne(AppVersion, {
select: ['id', 'name', 'currentEnvironmentId'],
where: { id: editingVersionId },
});
const editorEnvironment = await manager.findOne(AppEnvironment, { id: editorVersion.currentEnvironmentId });
const { shouldRenderPromoteButton, shouldRenderReleaseButton } =
this.calculateButtonVisibility(editorEnvironment);
const response: AppEnvironmentResponse = {
editorVersion,
editorEnvironment,
appVersionEnvironment: editorEnvironment,
shouldRenderPromoteButton,
shouldRenderReleaseButton,
};
return response;
}, manager);
}
calculateButtonVisibility(appVersionEnvironment: AppEnvironment) {
/* Further conditions can handle from here */
const shouldRenderPromoteButton = false;
const shouldRenderReleaseButton = true;
return { shouldRenderPromoteButton, shouldRenderReleaseButton };
}
async get(
organizationId: string,
id?: string,

View file

@ -28,6 +28,7 @@ import { Layout } from 'src/entities/layout.entity';
import { Component } from 'src/entities/component.entity';
import { EventHandler } from 'src/entities/event_handler.entity';
import { VersionReleaseDto } from '@dto/version-release.dto';
const uuid = require('uuid');
@Injectable()
@ -1044,4 +1045,25 @@ export class AppsService {
});
});
}
async releaseVersion(appId: string, versionReleaseDto: VersionReleaseDto, manager?: EntityManager) {
return await dbTransactionWrap(async (manager: EntityManager) => {
const { versionToBeReleased } = versionReleaseDto;
//check if the app version is eligible for release
const currentEnvironment: AppEnvironment = await manager
.createQueryBuilder(AppEnvironment, 'app_environments')
.select(['app_environments.id', 'app_environments.isDefault'])
.innerJoinAndSelect('app_versions', 'app_versions', 'app_versions.current_environment_id = app_environments.id')
.where('app_versions.id = :versionToBeReleased', {
versionToBeReleased,
})
.getOne();
if (!currentEnvironment?.isDefault) {
throw new BadRequestException('You can only release when the version is promoted to production');
}
return await manager.update(App, appId, { currentVersionId: versionToBeReleased });
}, manager);
}
}