[Feat] Plugin marketplace UI in Web App (#9319)

* feat(marketplace): introduce plugin marketplace UI and state

Adds the foundational UI components and initial state management for the Plugin Marketplace feature. This includes:
- A placeholder component (`PluginMarketplaceWrapperComponent`) for the marketplace view in the integrations section.
- Basic styling for the placeholder component.
- Initialization of necessary services and state within `desktop-ui-lib/src/lib/settings/plugins/plugins.module.ts`, including `ElectronService` and `ToastrNotificationService`.
This commit lays the groundwork for the full plugin marketplace functionality, enabling future development of CRUD operations, subscriptions, and advanced filtering.

* feat(integrations): add plugin marketplace placeholder UI

Introduces a new UI component for the plugin marketplace, displaying a placeholder message while the full functionality is being integrated.
This commit adds the HTML template, SCSS styling, and a basic TypeScript component structure for the `PluginMarketplaceWrapperComponent`. The placeholder informs users that the marketplace features, including CRUD operations and subscriptions, are under development.

* refactor(integrations): remove plugin marketplace placeholder

Remove the placeholder component for the plugin marketplace. This component was used to indicate that the feature was under development. The actual plugin marketplace functionality will be implemented separately.

* refactor(plugins): restructure routing and module organization

Move plugin routes into a dedicated `plugin.route.ts` file and refactor `PluginRoutingModule` to use `RouterModule.forChild` with lazy loading for a cleaner module structure. This change separates routing definitions from module configurations and improves modularity within the desktop UI library.

* refactor(routing): update plugin route configuration to use PluginsModule

This commit refactors the plugin routing configuration across multiple applications. Instead of loading a component and then separately loading plugin routes, it now directly uses `PluginsModule` for loading children. This simplifies the route definitions and ensures consistent plugin integration.

* refactor(integrations): simplify UI and remove tabbed navigation

Refactor the integrations component UI to remove the tabbed navigation for plugins. The integrations list is now displayed directly, with a clear link to the 'Plugins' section. This change simplifies the user interface and removes unnecessary complexity.
SCSS styles related to the removed tabbed navigation have also been updated.

* refactor(plugins): streamline routing and layout components

Refactor plugin routing and layout components to improve efficiency and maintainability. This change updates the plugin installed routes to use direct component imports instead of lazy loading. The 'installed' tab in the plugin layout is now conditionally added based on whether the application is running in a desktop environment. The main plugin routing module also adopts direct component references for the plugin layout and its child routes.
The default redirection for the plugin route has been changed from 'installed' to 'marketplace'.

* refactor(plugin-marketplace): modularize tab modules and update routing

Removes `RouterModule.forChild` from individual plugin marketplace tab modules.
Updates the main `plugin-marketplace-routing.module.ts` to directly import tab components instead of lazy loading their modules.
The `PluginsModule` is updated to import these tab modules directly.
This change simplifies the routing configuration and improves the modularity of the plugin marketplace feature.

* feat(plugins): update services with providedIn: 'root' and desktop checks

Updates subscription-related services to use `providedIn: 'root'` for improved dependency injection.
Adds checks for `isDesktop` within `PluginElectronService` to ensure that Electron-specific methods are only invoked in a desktop environment, preventing potential runtime errors in non-desktop builds.

* refactor(plugins): use relative routing for marketplace navigation

Refactors the plugin marketplace navigation to utilize relative routing. This change involves injecting `ActivatedRoute` and using it with `router.navigate` to construct paths relative to the current route.
This approach provides more flexibility and robustness when navigating within the plugin marketplace, especially when routes are nested or change. It avoids hardcoding absolute paths, making the codebase easier to maintain and less prone to breaking if the application's routing structure evolves.
The following navigations have been updated:
- Navigating to plugin details.
- Navigating back from an error state.
- Navigating back from plugin item view.
- Navigating to version history.
- Navigating back from version history view.

* refactor(integrations): use styled button for plugins navigation

Refactor the integrations page header to replace the plain div link for "Plugins" with a styled `nb-button` component. This change improves the visual consistency and provides a more interactive user experience for navigating to the plugins section.
The button now includes an icon and uses specific styling to align with the application's design language.

* refactor(plugins): correct plugin category effects import and remove unused LanguageModule

The import path for `PluginCategoryEffects` was incorrect and has been updated to point to the correct location within the `+state/effects` directory. Additionally, the `LanguageModule` was not being used and has been removed from the imports and the `imports` array to clean up the module.

* feat: add PluginsModule to app modules

Integrate the PluginsModule into the imports of the AppModule for desktop-timer, desktop, server-api, and server applications. This change ensures that plugin functionality is available across these application contexts.

* feat(i18n): add plugins translation

Adds the "PLUGINS" key to the English translation file. This allows for the display of plugins in the UI.

* feat(plugins): add environment validation for plugin installation

Introduce environment checks at the effect level to ensure plugins are compatible with the current environment (web, mobile, desktop) before proceeding with installation.
This change enhances the robustness of plugin installation by:
- Checking `pluginEnvironmentService.canInstallPlugin(plugin)` to determine compatibility.
- Displaying a warning toast with a user-friendly message if the plugin is not compatible.
- Disabling the plugin toggle if the environment check fails, preventing further installation attempts.
- Optimistically enabling the plugin toggle only after the environment validation passes.

* feat(i18n): add plugin environment validation error strings

Adds new internationalization strings for various plugin environment validation errors. These strings will be used to provide clearer feedback to users when a plugin cannot be installed due to environment mismatches.

* refactor(plugins): rename PluginEnvironmentService and move validation

Rename `pluginEnvironmentService` to `environmentService` for brevity.
Move plugin environment validation logic from the start of the `install` effect to after the optimistic toggle and before opening the installation dialog. This ensures the toggle is set immediately, and the validation occurs just before user interaction or deeper installation steps. The validation now directly calls `notifyEnvironmentMismatch` which encapsulates toastr and translation logic.

* feat(plugins): introduce PluginEnvironmentService

Introduce `PluginEnvironmentService` to manage plugin environment detection and validation. This service provides methods to determine if the current environment is desktop, mobile, or web, and to validate if a plugin is compatible with the current environment. It also handles notifying the user of environment mismatches.

* feat(plugins): implement environment compatibility checks and warnings

Implement checks to ensure plugins are compatible with the current environment before installation. This includes:
- Disabling installation buttons and toggles if a plugin is not compatible.
- Displaying tooltips that explain the environment mismatch.
- Injecting `PluginEnvironmentService` into relevant components to utilize the new checks.
- Refactoring `PluginEnvironmentService` to provide a `getEnvironmentMismatchWarning` method.

* fix(plugins): update plugin environment mismatch translation keys

Updates translation keys used for plugin environment mismatches. This change aims to provide more specific and accurate error messages to the user when a plugin's environment requirements are not met.
The keys have been moved to the `PLUGIN.ERRORS` namespace to better categorize plugin-related errors. Specific keys have also been updated for clarity, including a new key for unsupported plugin types.

* fix(plugin-marketplace): type getContextMenuItems return value

The `getContextMenuItems` method in the `PluginMarketplaceDetailComponent` was missing a return type annotation. This commit adds `NbMenuItem[]` as the return type to improve code clarity and maintainability.

* refactor(plugins): use relative paths for plugin navigation

Replace hardcoded absolute paths in plugin layout and marketplace detail
components with routes generated relative to the current path. This
ensures navigation links remain functional regardless of the parent
route configuration.

* fix(plugins): disable install toggle only when explicitly disabled

The install toggle and subscribe button in the plugin marketplace were being disabled not only when the `disabled` input was true, but also when the `canInstallInEnvironment()` method returned false. This commit removes the dependency on `canInstallInEnvironment()` for disabling these elements, ensuring they are only disabled when the `disabled` input is explicitly set.

* fix(plugins): refine environment check for plan selection

Update the plugin subscription plan selection logic to correctly handle
environment compatibility. Ensure the ability to proceed without a
subscription is gated by the environment check and refine the button
disabled state for free plans when the environment is incompatible.

* chore(deps): remove unused RouterModule imports

The `RouterModule` and `Routes` imports were removed from several Angular modules as they were not being used. This is a cleanup task that does not affect the functionality of the application.
Additionally, a typo in the `.cspell.json` file has been corrected by adding "Mobi" to the list of allowed words.

* refactor(desktop-ui-lib): use inject and improve return type consistency

Update PluginElectronService to use the Angular inject function for
dependency injection.
Ensure all methods return consistent types (resolved Promises or EMPTY
Observables) instead of undefined when not in a desktop environment to
improve type safety.

* refactor(plugin-layout): improve tab list initialization

Use an explicit conditional check to populate the tabs array, avoiding
the inclusion of boolean values in the list. Additionally, mark the
router property as readonly and reorder imports for better consistency.

* refactor(plugins): use optional chaining for plugin type check

Update the canInstallPlugin method to use the optional chaining operator
instead of manual null and property checks to improve code readability.

* refactor(integrations): remove unused OnDestroy and cleanup code

Remove the empty ngOnDestroy lifecycle hook and its interface
implementation. Additionally, add the readonly modifier to the
searchElement ViewChild.

* refactor(integrations): move search styles and remove ng-deep wrapper

Relocate the #integrationSearch styling from the :host ::ng-deep block
to the main component stylesheet block to simplify the SCSS structure.

* refactor(plugins): use optional chaining for plugin type check

Update the null check in getEnvironmentMismatchMessage to use the
optional chaining operator for improved readability and conciseness.

* fix(plugins): update fallback redirect path

Change the wildcard route redirection from 'installed' to an empty
string to ensure that unknown sub-paths correctly resolve to the
base plugins route.
This commit is contained in:
Kifungo A
2026-01-15 11:22:39 +01:00
committed by GitHub
parent 3775252c18
commit ac34064d05
43 changed files with 562 additions and 246 deletions
+2 -1
View File
@@ -810,7 +810,8 @@
"Liskov",
"backoff",
"autodrain",
"pluginname"
"pluginname",
"Mobi"
],
"useGitignore": true,
"ignorePaths": [
+2 -4
View File
@@ -27,13 +27,11 @@ const routes: Routes = [
{
path: 'plugins',
canActivate: [AuthConnectionGuard],
loadComponent: () => import('@gauzy/desktop-ui-lib').then((m) => m.PluginLayoutComponent),
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.pluginRoutes)
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.PluginsModule)
},
{
path: 'settings',
component: SettingsComponent,
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.pluginRoutes)
component: SettingsComponent
},
{
path: 'updater',
+1 -3
View File
@@ -25,7 +25,6 @@ import {
NgxLoginModule,
NoAuthGuard,
OrganizationInterceptor,
PluginsModule,
RefreshTokenInterceptor,
ScreenCaptureModule,
ServerConnectionService,
@@ -120,8 +119,7 @@ if (environment.SENTRY_DSN) {
NbDatepickerModule.forRoot(),
AboutModule,
AgentDashboardModule,
AlwaysOnModule,
PluginsModule
AlwaysOnModule
],
providers: [
AppService,
@@ -49,8 +49,7 @@ const routes: Routes = [
{
path: 'plugins',
canActivate: [AuthConnectionGuard],
loadComponent: () => import('@gauzy/desktop-ui-lib').then((m) => m.PluginLayoutComponent),
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.pluginRoutes)
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.PluginsModule)
},
{
path: 'updater',
+2 -2
View File
@@ -121,9 +121,9 @@ if (environment.SENTRY_DSN) {
NbDatepickerModule.forRoot(),
AboutModule,
ActivityWatchModule,
PluginsModule,
RecapModule,
TaskTableModule
TaskTableModule,
PluginsModule
],
providers: [
AppService,
+2 -4
View File
@@ -34,13 +34,11 @@ const routes: Routes = [
{
path: 'plugins',
canActivate: [AuthConnectionGuard],
loadComponent: () => import('@gauzy/desktop-ui-lib').then((m) => m.PluginLayoutComponent),
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.pluginRoutes)
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.PluginsModule)
},
{
path: 'settings',
component: SettingsComponent,
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.pluginRoutes)
component: SettingsComponent
},
{
path: 'updater',
@@ -1,6 +1,16 @@
<nb-card [nbSpinner]="isLoading$ | async" class="card-scroll">
<nb-card-header>
<h4>{{ 'INTEGRATIONS.AVAILABLE_INTEGRATIONS' | translate }}</h4>
<button
nbButton
status="basic"
size="medium"
class="plugins-navigation-button"
[routerLink]="['..', 'plugins']"
>
<nb-icon icon="cube-outline" pack="eva"></nb-icon>
<span>{{ 'INTEGRATIONS.PLUGINS' | translate }}</span>
</button>
</nb-card-header>
<nb-card-body>
<ng-template [ngxPermissionsOnly]="['INTEGRATION_ADD', 'INTEGRATION_EDIT']">
@@ -5,10 +5,53 @@
flex-wrap: wrap;
}
:host ::ng-deep {
nb-card-header {
display: flex;
justify-content: space-between;
align-items: center;
.plugins-navigation-button {
display: flex;
align-items: center;
gap: 0.5rem;
border-radius: var(--border-radius);
background-color: var(--gauzy-card-2);
color: var(--gauzy-text-color-1);
border: 1px solid var(--gauzy-border-default-color);
transition: all 0.3s ease;
nb-icon {
font-size: 1.25rem;
}
span {
font-weight: 500;
}
&:hover {
background-color: var(--gauzy-card-1);
box-shadow: var(--gauzy-shadow);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
#integrationSearch {
flex: 1 1 70%;
@include nb-ltr(margin-right, 20px);
@include nb-rtl(margin-right, 0px);
}
}
/**
* By setting min-height of the card to this value, we ensure the card will properly adjust it's height
*/
:host>nb-card {
:host > nb-card {
min-height: 47.5rem;
}
@@ -24,16 +67,6 @@
margin-right: 20px;
}
:host {
::ng-deep {
#integrationSearch {
flex: 1 1 70%;
@include nb-ltr(margin-right, 20px);
@include nb-rtl(margin-right, 0px);
}
}
}
a.disabled {
pointer-events: none;
cursor: default;
@@ -64,7 +97,6 @@ a.disabled {
}
}
img {
width: 100%;
}
@@ -117,7 +149,6 @@ img {
}
:host {
nb-card,
nb-card-body {
background-color: var(--gauzy-card-2);
@@ -125,8 +156,8 @@ img {
::ng-deep {
nb-select {
&.integration-selector{
min-width: 10rem;
&.integration-selector {
min-width: 10rem;
}
span {
color: var(--gauzy-text-color-2);
@@ -136,12 +167,9 @@ img {
letter-spacing: 0em;
text-align: left;
}
}
@include nb-select-overrides(2rem,
$default-button-radius,
$default-box-shadow );
@include nb-select-overrides(2rem, $default-button-radius, $default-box-shadow);
}
h4 {
@@ -169,7 +197,6 @@ label {
line-height: 13px;
letter-spacing: -0.01em;
text-align: left;
}
nb-option {
@@ -1,22 +1,30 @@
import { Component, ElementRef, OnInit, Renderer2, ViewChild } from '@angular/core';
import { Observable } from 'rxjs';
import { Component, ElementRef, OnInit, Renderer2, ViewChild, computed, signal } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { IIntegrationViewModel, IntegrationFilterEnum } from '@gauzy/contracts';
import { InitialFilter, IntegrationsStoreService } from '@gauzy/ui-core/core';
import { Observable } from 'rxjs';
@Component({
selector: 'ngx-integrations',
templateUrl: './integrations.component.html',
styleUrls: ['./integrations.component.scss'],
standalone: false
selector: 'ngx-integrations',
templateUrl: './integrations.component.html',
styleUrls: ['./integrations.component.scss'],
standalone: false
})
export class IntegrationsComponent implements OnInit {
// Integrations observables
integrations$: Observable<IIntegrationViewModel[]> = this._integrationsStore.integrations$;
integrationGroups$: Observable<any[]> = this._integrationsStore.integrationGroups$;
selectedIntegrationTypeId$: Observable<string> = this._integrationsStore.selectedIntegrationTypeId$;
selectedIntegrationFilter$: Observable<string> = this._integrationsStore.selectedIntegrationFilter$;
isLoading$: Observable<boolean> = this._integrationsStore.isLoading$;
@ViewChild('searchElement', { static: true }) searchElement: ElementRef;
// Tab management using signals
protected readonly activeTab = signal<'integrations' | 'plugins'>('integrations');
protected readonly isIntegrationsTab = computed(() => this.activeTab() === 'integrations');
protected readonly isPluginsTab = computed(() => this.activeTab() === 'plugins');
@ViewChild('searchElement', { static: false }) readonly searchElement: ElementRef;
public filters = [
{
label: IntegrationFilterEnum.ALL,
@@ -32,24 +40,65 @@ export class IntegrationsComponent implements OnInit {
}
];
constructor(private readonly _integrationsStore: IntegrationsStoreService, private readonly renderer: Renderer2) {}
constructor(
private readonly _integrationsStore: IntegrationsStoreService,
private readonly renderer: Renderer2,
private readonly router: Router,
private readonly activatedRoute: ActivatedRoute
) {}
ngOnInit() {}
ngOnInit() {
// Check if we should show plugins tab based on current URL
if (this.router.url.includes('/integrations/plugins')) {
this.activeTab.set('plugins');
}
}
setSelectedIntegrationType(integrationTypeId) {
/**
* Change active tab
*/
changeTab(tab: 'integrations' | 'plugins'): void {
this.activeTab.set(tab);
// Navigate to the appropriate route
if (tab === 'plugins') {
// Navigate to plugins route
this.router.navigate(['/pages/integrations/plugins']);
} else {
// Navigate back to integrations
this.router.navigate(['/pages/integrations/new']);
}
}
/**
* Set selected integration type
*/
setSelectedIntegrationType(integrationTypeId: string): void {
this._integrationsStore.setSelectedIntegrationTypeId(integrationTypeId);
}
setSelectedIntegrationFilter(filter: string) {
/**
* Set selected integration filter
*/
setSelectedIntegrationFilter(filter: string): void {
this._integrationsStore.setSelectedIntegrationFilter(filter);
}
doSearch({ target: { value } }) {
this._integrationsStore.searchIntegration(value);
/**
* Search integrations
*/
doSearch(event: Event): void {
const target = event.target as HTMLInputElement;
this._integrationsStore.searchIntegration(target.value);
}
clearFilter() {
/**
* Clear filters
*/
clearFilter(): void {
this._integrationsStore.clearFilters();
this.renderer.setProperty(this.searchElement.nativeElement, 'value', InitialFilter.searchQuery);
if (this.searchElement?.nativeElement) {
this.renderer.setProperty(this.searchElement.nativeElement, 'value', InitialFilter.searchQuery);
}
}
}
@@ -1,23 +1,24 @@
import { Inject, NgModule } from '@angular/core';
import { ROUTES, RouterModule } from '@angular/router';
import { PageRouteRegistryService } from '@gauzy/ui-core/core';
import { SharedModule, SmartDataViewLayoutModule, TableComponentsModule } from '@gauzy/ui-core/shared';
import {
NbButtonGroupModule,
NbButtonModule,
NbCardModule,
NbIconModule,
NbInputModule,
NbSelectModule,
NbSpinnerModule,
NbTabsetModule,
NbTooltipModule
} from '@nebular/theme';
import { NgxPermissionsModule } from 'ngx-permissions';
import { TranslateModule } from '@ngx-translate/core';
import { PageRouteRegistryService } from '@gauzy/ui-core/core';
import { SmartDataViewLayoutModule, SharedModule, TableComponentsModule } from '@gauzy/ui-core/shared';
import { createIntegrationsRoutes } from './integrations.routes';
import { IntegrationsComponent } from './integrations.component';
import { IntegrationLayoutComponent } from './layout/layout.component';
import { NgxPermissionsModule } from 'ngx-permissions';
import { IntegrationListComponent } from './components/integration-list/list.component';
import { IntegrationsComponent } from './integrations.component';
import { createIntegrationsRoutes } from './integrations.routes';
import { IntegrationLayoutComponent } from './layout/layout.component';
@NgModule({
declarations: [IntegrationLayoutComponent, IntegrationListComponent, IntegrationsComponent],
imports: [
@@ -28,6 +29,8 @@ import { IntegrationListComponent } from './components/integration-list/list.com
NbSelectModule,
NbSpinnerModule,
NbTooltipModule,
NbTabsetModule,
NbButtonGroupModule,
RouterModule.forChild([]),
NgxPermissionsModule.forChild(),
TranslateModule.forChild(),
@@ -135,6 +138,14 @@ export class IntegrationsModule {
loadChildren: () => import('@gauzy/plugin-integration-github-ui').then((m) => m.IntegrationGithubUiModule)
});
// Register the routes for plugins
this._pageRouteRegistryService.registerPageRoute({
data: { selectors: false },
location: 'integrations',
path: 'plugins',
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.PluginsModule)
});
// Register the routes for activepieces integration
this._pageRouteRegistryService.registerPageRoute({
// Data to be passed to the component
@@ -1,8 +1,8 @@
import { Route } from '@angular/router';
import { PageRouteRegistryService } from '@gauzy/ui-core/core';
import { IntegrationLayoutComponent } from './layout/layout.component';
import { IntegrationsComponent } from './integrations.component';
import { IntegrationListComponent } from './components/integration-list/list.component';
import { IntegrationsComponent } from './integrations.component';
import { IntegrationLayoutComponent } from './layout/layout.component';
/**
* Create and configures routes for the integrations module.
@@ -22,13 +22,11 @@ const routes: Routes = [
{
path: 'plugins',
canActivate: [AuthConnectionGuard],
loadComponent: () => import('@gauzy/desktop-ui-lib').then((m) => m.PluginLayoutComponent),
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.pluginRoutes)
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.PluginsModule)
},
{
path: 'settings',
component: SettingsComponent,
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.pluginRoutes)
component: SettingsComponent
},
{
path: 'updater',
+2 -4
View File
@@ -22,13 +22,11 @@ const routes: Routes = [
{
path: 'plugins',
canActivate: [AuthConnectionGuard],
loadComponent: () => import('@gauzy/desktop-ui-lib').then((m) => m.PluginLayoutComponent),
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.pluginRoutes)
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.PluginsModule)
},
{
path: 'settings',
component: SettingsComponent,
loadChildren: () => import('@gauzy/desktop-ui-lib').then((m) => m.pluginRoutes)
component: SettingsComponent
},
{
path: 'updater',
+3
View File
@@ -40,6 +40,9 @@ export * from './lib/server-dashboard/server-dashboard.module';
export * from './lib/server-down/server-down.module';
export * from './lib/services';
export * from './lib/settings/plugins/component/plugin-layout/plugin-layout.component';
export * from './lib/settings/plugins/component/plugin-marketplace/+state';
export * from './lib/settings/plugins/component/plugin-marketplace/plugin-marketplace-routing.module';
export * from './lib/settings/plugins/component/plugin-marketplace/plugin-marketplace.component';
export * from './lib/settings/plugins/plugin-routing.module';
export * from './lib/settings/plugins/plugins.module';
export * from './lib/settings/settings.component';
@@ -1,20 +1,22 @@
import { Inject, Injectable } from '@angular/core';
import { Inject, Injectable, Optional } from '@angular/core';
import { ElectronService } from '../electron/services';
import { NotificationService } from './notification.service';
import { GAUZY_ENV } from '../constants';
@Injectable({
providedIn: 'root',
providedIn: 'root'
})
export class NativeNotificationService extends NotificationService {
constructor(
private readonly _electronService: ElectronService,
@Inject(GAUZY_ENV) environment: any
@Optional()
@Inject(GAUZY_ENV)
environment: any
) {
super();
this._notification = {
title: environment.DESCRIPTION,
message: '',
title: environment?.DESCRIPTION || 'Gauzy',
message: ''
};
}
@@ -1,20 +1,22 @@
import { Inject, Injectable } from '@angular/core';
import { Inject, Injectable, Optional } from '@angular/core';
import { NbToastrService } from '@nebular/theme';
import { GAUZY_ENV } from '../constants';
import { NotificationService } from './notification.service';
@Injectable({
providedIn: 'root',
providedIn: 'root'
})
export class ToastrNotificationService extends NotificationService {
constructor(
private readonly _toastrService: NbToastrService,
@Inject(GAUZY_ENV) environment: any
@Optional()
@Inject(GAUZY_ENV)
environment: any
) {
super();
this._notification = {
title: environment.DESCRIPTION,
message: '',
title: environment?.DESCRIPTION || 'Gauzy',
message: ''
};
}
@@ -1,14 +1,16 @@
import { Routes } from '@angular/router';
import { PluginComponent } from './plugin/plugin.component';
import { PluginListComponent } from './plugin-list/plugin-list.component';
export const pluginInstalledRoutes: Routes = [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./plugin-list/plugin-list.component').then((m) => m.PluginListComponent)
component: PluginListComponent
},
{
path: ':name',
loadComponent: () => import('./plugin/plugin.component').then((m) => m.PluginComponent)
component: PluginComponent
},
{
path: '**',
@@ -1,7 +1,9 @@
import { Component, OnDestroy, OnInit } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { NbRouteTab } from '@nebular/theme';
import { TranslateService } from '@ngx-translate/core';
import { Subject, takeUntil } from 'rxjs';
import { PluginElectronService } from '../../services/plugin-electron.service';
@Component({
selector: 'ngx-plugin-layout',
@@ -13,7 +15,12 @@ export class PluginLayoutComponent implements OnInit, OnDestroy {
public tabs: NbRouteTab[] = [];
private destroy$ = new Subject<void>();
constructor(private readonly translateService: TranslateService) {}
constructor(
private readonly translateService: TranslateService,
private readonly route: ActivatedRoute,
private readonly router: Router,
private readonly pluginElectronService: PluginElectronService
) {}
ngOnInit() {
// Initialize tabs immediately
@@ -29,25 +36,32 @@ export class PluginLayoutComponent implements OnInit, OnDestroy {
this.destroy$.complete();
}
private get baseRoute(): string {
return this.router.createUrlTree(['./'], { relativeTo: this.route }).toString();
}
private updateTabs() {
this.tabs = [
{
title: this.translateService.instant('PLUGIN.LAYOUT.DISCOVER'),
route: '/plugins/marketplace',
route: `${this.baseRoute}/marketplace`,
icon: 'search-outline',
responsive: true,
activeLinkOptions: {
exact: false
}
},
{
}
];
if (this.pluginElectronService.isDesktop) {
this.tabs.push({
title: this.translateService.instant('PLUGIN.LAYOUT.INSTALLED'),
route: '/plugins/installed',
route: `${this.baseRoute}/installed`,
icon: 'checkmark-circle-2-outline',
activeLinkOptions: {
exact: false
}
}
];
});
}
}
}
@@ -28,17 +28,18 @@ import {
ServerInstallPluginCommand
} from '../../../../domain/commands';
import { PluginElectronService } from '../../../../services/plugin-electron.service';
import { PluginEnvironmentService } from '../../../../services/plugin-environment.service';
import { PluginService } from '../../../../services/plugin.service';
import { DialogInstallationValidationComponent } from '../../plugin-marketplace-item/dialog-installation-validation/dialog-installation-validation.component';
import { InstallationValidationChainBuilder } from '../../services';
import { PluginInstallationActions } from '../actions/plugin-installation.action';
import { PluginMarketplaceActions } from '../actions/plugin-marketplace.action';
import { PluginSubscriptionActions } from '../actions/plugin-subscription.action';
import { PluginToggleActions } from '../actions/plugin-toggle.action';
import { PluginMarketplaceQuery } from '../queries/plugin-marketplace.query';
import { PluginInstallationStore } from '../stores/plugin-installation.store';
import { PluginMarketplaceStore } from '../stores/plugin-market.store';
import { PluginToggleStore } from '../stores/plugin-toggle.store';
import { PluginSubscriptionActions } from '../actions/plugin-subscription.action';
@Injectable({ providedIn: 'root' })
export class PluginInstallationEffects {
@@ -50,6 +51,7 @@ export class PluginInstallationEffects {
private readonly pluginToggleStore: PluginToggleStore,
private readonly pluginService: PluginService,
private readonly pluginElectronService: PluginElectronService,
private readonly environmentService: PluginEnvironmentService,
private readonly toastrService: ToastrNotificationService,
private readonly translateService: TranslateService,
private readonly downloadCommand: DownloadPluginCommand,
@@ -76,11 +78,12 @@ export class PluginInstallationEffects {
() =>
this.action$.pipe(
ofType(PluginMarketplaceActions.install),
// Optimistically enable toggle
tap(({ plugin }) => this.pluginToggleStore.setToggle(plugin.id, true)),
// Prevent concurrent installations - exhaust until current completes
exhaustMap(({ plugin, isUpdate }) =>
this.installationValidationChainBuilder.canProceedWithInstallation(plugin, isUpdate).pipe(
exhaustMap(({ plugin, isUpdate }) => {
// Optimistically enable toggle after environment passes
this.pluginToggleStore.setToggle(plugin.id, true);
return this.installationValidationChainBuilder.canProceedWithInstallation(plugin, isUpdate).pipe(
// Flatten validation result into dialog flow
switchMap((canProceed) => {
// Early exit if validation fails
@@ -90,6 +93,13 @@ export class PluginInstallationEffects {
PluginToggleActions.toggle({ pluginId: plugin.id, enabled: false })
);
}
// Environment validation at effect level (web/mobile/desktop)
if (!this.environmentService.canInstallPlugin(plugin)) {
this.environmentService.notifyEnvironmentMismatch(plugin);
return of(PluginToggleActions.toggle({ pluginId: plugin.id, enabled: false }));
}
// Open confirmation dialog and wait for user decision
return this.openInstallationDialog(plugin.id).pipe(
// Map dialog result to installation action or cancellation
@@ -118,8 +128,8 @@ export class PluginInstallationEffects {
);
return of(PluginToggleActions.toggle({ pluginId: plugin.id, enabled: false }));
})
)
)
);
})
),
{ dispatch: true }
);
@@ -262,6 +262,8 @@
[checked]="checked"
(checkedChange)="togglePlugin($event)"
class="install-toggle"
[nbTooltip]="getEnvironmentMismatchTooltip()"
[nbTooltipDisabled]="canInstallInEnvironment()"
>
<span class="toggle-label">
@if(installed) {
@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, inject, Input, OnInit } from '@angular/core';
import { Router } from '@angular/router';
import { ActivatedRoute, Router } from '@angular/router';
import { IPlugin, PluginStatus, PluginSubscriptionType } from '@gauzy/contracts';
import { NbMenuService } from '@nebular/theme';
import { NbMenuItem, NbMenuService } from '@nebular/theme';
import { Actions } from '@ngneat/effects-ng';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { TranslateService } from '@ngx-translate/core';
@@ -16,6 +16,7 @@ import { PluginInstallationQuery } from '../+state/queries/plugin-installation.q
import { PluginMarketplaceQuery } from '../+state/queries/plugin-marketplace.query';
import { PluginToggleQuery } from '../+state/queries/plugin-toggle.query';
import { Store } from '../../../../../services';
import { PluginEnvironmentService } from '../../../services/plugin-environment.service';
import { PluginMarketplaceUtilsService } from '../plugin-marketplace-utils.service';
@UntilDestroy()
@@ -33,6 +34,7 @@ export class PluginMarketplaceDetailComponent implements OnInit {
@Input()
public viewMode: 'grid' | 'list' = 'grid';
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly action = inject(Actions);
private readonly translate = inject(TranslateService);
@@ -43,7 +45,8 @@ export class PluginMarketplaceDetailComponent implements OnInit {
private readonly menuService: NbMenuService,
public readonly marketplaceQuery: PluginMarketplaceQuery,
public readonly installationQuery: PluginInstallationQuery,
public readonly toggleQuery: PluginToggleQuery
public readonly toggleQuery: PluginToggleQuery,
private readonly environmentService: PluginEnvironmentService
) {}
ngOnInit(): void {
@@ -84,7 +87,7 @@ export class PluginMarketplaceDetailComponent implements OnInit {
// Installation validation and dialog orchestration moved to effects.
public async openPlugin(): Promise<void> {
this.action.dispatch(PluginVersionActions.selectVersion(this.plugin.version));
await this.router.navigate([`/plugins/marketplace/${this.plugin.id}`]);
await this.router.navigate([this.plugin.id], { relativeTo: this.route });
}
public editPlugin(): void {
@@ -144,17 +147,21 @@ export class PluginMarketplaceDetailComponent implements OnInit {
/**
* Get context menu items for more actions
*/
public getContextMenuItems() {
public getContextMenuItems(): NbMenuItem[] {
// Return empty array if plugin is not available
if (!this.plugin) {
return [];
}
const items: any[] = [
// Construct absolute path from activated route
const baseRoute = this.router.createUrlTree(['./'], { relativeTo: this.route }).toString();
// Get items
const items: NbMenuItem[] = [
{
title: this.translate.instant('PLUGIN.ACTIONS.VIEW_DETAILS'),
icon: 'eye-outline',
link: `/plugins/marketplace/${this.plugin.id}`
link: `${baseRoute}/${this.plugin.id}`
}
];
@@ -260,4 +267,17 @@ export class PluginMarketplaceDetailComponent implements OnInit {
private confirmDeletePlugin(): void {
this.action.dispatch(PluginMarketplaceActions.delete(this.plugin.id));
}
/**
* Check if the plugin can be installed in the current environment
*/
public canInstallInEnvironment(): boolean {
return this.environmentService.canInstallPlugin(this.plugin);
}
/**
* Get the environment mismatch tooltip message
*/
public getEnvironmentMismatchTooltip(): string {
return this.environmentService.getEnvironmentMismatchWarning(this.plugin);
}
}
@@ -165,9 +165,13 @@
status="success"
size="medium"
class="install-button"
[disabled]="disabled"
[disabled]="disabled || !canInstallInEnvironment()"
(click)="installPlugin()"
[nbTooltip]="'PLUGIN.TOOLTIP.INSTALL_WITH_SUBSCRIPTION' | translate"
[nbTooltip]="
!canInstallInEnvironment()
? getEnvironmentMismatchTooltip()
: ('PLUGIN.TOOLTIP.INSTALL_WITH_SUBSCRIPTION' | translate)
"
>
<nb-icon
*gauzySpinnerButton="installing && isSelected"
@@ -185,7 +189,11 @@
class="subscribe-button"
[disabled]="disabled"
(click)="installPlugin()"
[nbTooltip]="'PLUGIN.TOOLTIP.SUBSCRIBE_TO_INSTALL' | translate"
[nbTooltip]="
!canInstallInEnvironment()
? getEnvironmentMismatchTooltip()
: ('PLUGIN.TOOLTIP.SUBSCRIBE_TO_INSTALL' | translate)
"
>
<nb-icon
*gauzySpinnerButton="(installing && isSelected) || subscriptionLoading"
@@ -201,9 +209,13 @@
status="success"
size="medium"
class="install-button"
[disabled]="disabled"
[disabled]="disabled || !canInstallInEnvironment()"
(click)="installPlugin()"
[nbTooltip]="'PLUGIN.TOOLTIP.INSTALL_FREE' | translate"
[nbTooltip]="
!canInstallInEnvironment()
? getEnvironmentMismatchTooltip()
: ('PLUGIN.TOOLTIP.INSTALL_FREE' | translate)
"
>
<nb-icon
*gauzySpinnerButton="installing && isSelected"
@@ -28,6 +28,7 @@ import { PluginSourceQuery } from '../+state/queries/plugin-source.query';
import { PluginVersionQuery } from '../+state/queries/plugin-version.query';
import { PluginQuery } from '../../+state/plugin.query';
import { Store, ToastrNotificationService } from '../../../../../services';
import { PluginEnvironmentService } from '../../../services/plugin-environment.service';
import { PluginMarketplaceUtilsService } from '../plugin-marketplace-utils.service';
// Installation and subscription side-effects moved to effects
@@ -71,7 +72,8 @@ export class PluginMarketplaceItemComponent implements OnInit, OnDestroy {
public readonly sourceQuery: PluginSourceQuery,
public readonly subscriptionQuery: PluginSubscriptionQuery,
public readonly accessFacade: PluginSubscriptionAccessFacade,
private readonly utils: PluginMarketplaceUtilsService
private readonly utils: PluginMarketplaceUtilsService,
private readonly environmentService: PluginEnvironmentService
) {}
ngOnInit(): void {
@@ -125,7 +127,7 @@ export class PluginMarketplaceItemComponent implements OnInit, OnDestroy {
private async handleError(error: any): Promise<void> {
this.toastrService.error(error);
await this.router.navigate(['/plugins/marketplace']);
await this.router.navigate(['..'], { relativeTo: this.route });
}
// Delegate utility methods to PluginMarketplaceUtilsService
@@ -180,11 +182,11 @@ export class PluginMarketplaceItemComponent implements OnInit, OnDestroy {
}
public async navigateBack(): Promise<void> {
await this.router.navigate(['plugins', 'marketplace']);
await this.router.navigate(['..'], { relativeTo: this.route });
}
public async navigateToHistory(): Promise<void> {
await this.router.navigate(['plugins', 'marketplace', this.pluginId, 'versions']);
await this.router.navigate(['versions'], { relativeTo: this.route });
}
/**
@@ -326,4 +328,18 @@ export class PluginMarketplaceItemComponent implements OnInit, OnDestroy {
public get installedVersionId$(): Observable<ID> {
return this.pluginQuery.currentPluginVersionId(this.pluginId);
}
/**
* Check if the plugin can be installed in the current environment
*/
public canInstallInEnvironment(): boolean {
return this.environmentService.canInstallPlugin(this.plugin);
}
/**
* Get the environment mismatch tooltip message
*/
public getEnvironmentMismatchTooltip(): string {
return this.environmentService.getEnvironmentMismatchWarning(this.plugin);
}
}
@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, OnDestroy } from '@angular/core';
import { Router } from '@angular/router';
import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { IPlugin, IPluginVersion, PluginStatus, PluginType } from '@gauzy/contracts';
import { Actions } from '@ngneat/effects-ng';
import { Observable, Subject } from 'rxjs';
@@ -23,6 +23,8 @@ export class OverviewTabComponent implements OnDestroy {
plugin$: Observable<IPlugin>;
selectedVersion$: Observable<IPluginVersion>;
private readonly route = inject(ActivatedRoute);
constructor(
private readonly router: Router,
private readonly store: Store,
@@ -68,6 +70,6 @@ export class OverviewTabComponent implements OnDestroy {
if (!pluginId) return;
// Navigate to version history
await this.router.navigate(['plugins', 'marketplace', pluginId, 'versions']);
await this.router.navigate(['../versions'], { relativeTo: this.route });
}
}
@@ -1,7 +1,6 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { RouterModule, Routes } from '@angular/router';
import {
NbBadgeModule,
NbButtonModule,
@@ -15,19 +14,11 @@ import { DesktopDirectiveModule } from '../../../../../../../directives/desktop-
import { PipeModule } from '../../../../../../../time-tracker/pipes/pipe.module';
import { OverviewTabComponent } from './overview-tab.component';
const routes: Routes = [
{
path: '',
component: OverviewTabComponent
}
];
@NgModule({
declarations: [OverviewTabComponent],
imports: [
CommonModule,
FormsModule,
RouterModule.forChild(routes),
TranslateModule,
NbCardModule,
NbIconModule,
@@ -39,4 +30,4 @@ const routes: Routes = [
PipeModule
]
})
export class OverviewTabModule {}
export class OverviewTabModule { }
@@ -1,27 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { NbAlertModule, NbButtonModule, NbCardModule, NbIconModule } from '@nebular/theme';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsTabComponent } from './settings-tab.component';
const routes: Routes = [
{
path: '',
component: SettingsTabComponent
}
];
@NgModule({
declarations: [SettingsTabComponent],
imports: [
CommonModule,
RouterModule.forChild(routes),
TranslateModule,
NbCardModule,
NbButtonModule,
NbIconModule,
NbAlertModule
]
imports: [CommonModule, TranslateModule, NbCardModule, NbButtonModule, NbIconModule, NbAlertModule]
})
export class SettingsTabModule {}
export class SettingsTabModule { }
@@ -1,27 +1,11 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { NbBadgeModule, NbCardModule, NbIconModule, NbTooltipModule } from '@nebular/theme';
import { TranslateModule } from '@ngx-translate/core';
import { SourceCodeTabComponent } from './source-code-tab.component';
const routes: Routes = [
{
path: '',
component: SourceCodeTabComponent
}
];
@NgModule({
declarations: [SourceCodeTabComponent],
imports: [
CommonModule,
RouterModule.forChild(routes),
TranslateModule,
NbCardModule,
NbIconModule,
NbBadgeModule,
NbTooltipModule
]
imports: [CommonModule, TranslateModule, NbCardModule, NbIconModule, NbBadgeModule, NbTooltipModule]
})
export class SourceCodeTabModule {}
export class SourceCodeTabModule { }
@@ -1,6 +1,5 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import {
NbAlertModule,
NbBadgeModule,
@@ -18,18 +17,10 @@ import { InfiniteScrollDirective } from '../../../../../../../directives/infinit
import { PipeModule } from '../../../../../../../time-tracker/pipes/pipe.module';
import { UserManagementTabComponent } from './user-management-tab.component';
const routes: Routes = [
{
path: '',
component: UserManagementTabComponent
}
];
@NgModule({
declarations: [UserManagementTabComponent],
imports: [
CommonModule,
RouterModule.forChild(routes),
TranslateModule,
NbCardModule,
NbButtonModule,
@@ -45,4 +36,4 @@ const routes: Routes = [
InfiniteScrollDirective
]
})
export class UserManagementTabModule {}
export class UserManagementTabModule { }
@@ -78,7 +78,7 @@ export class VersionHistoryComponent implements OnInit, OnDestroy {
}
public async navigateBack(): Promise<void> {
await this.router.navigate(['plugins', 'marketplace', this.pluginId]);
await this.router.navigate(['..'], { relativeTo: this.route });
}
public edit(version: IPluginVersion): void {
@@ -1,24 +1,25 @@
import { Routes } from '@angular/router';
import { PluginMarketplaceItemComponent } from './plugin-marketplace-item/plugin-marketplace-item.component';
import { OverviewTabComponent } from './plugin-marketplace-item/tabs/overview-tab/overview-tab.component';
import { SettingsTabComponent } from './plugin-marketplace-item/tabs/settings-tab/settings-tab.component';
import { SourceCodeTabComponent } from './plugin-marketplace-item/tabs/source-code-tab/source-code-tab.component';
import { UserManagementTabComponent } from './plugin-marketplace-item/tabs/user-management-tab/user-management-tab.component';
import { VersionHistoryComponent } from './plugin-marketplace-item/version-history/version-history.component';
import { PluginMarketplaceComponent } from './plugin-marketplace.component';
export const pluginMarketplaceRoutes: Routes = [
{
path: '',
pathMatch: 'full',
loadComponent: () => import('./plugin-marketplace.component').then((m) => m.PluginMarketplaceComponent)
component: PluginMarketplaceComponent
},
{
path: ':id/versions',
loadComponent: () =>
import('./plugin-marketplace-item/version-history/version-history.component').then(
(m) => m.VersionHistoryComponent
)
component: VersionHistoryComponent
},
{
path: ':id',
loadComponent: () =>
import('./plugin-marketplace-item/plugin-marketplace-item.component').then(
(m) => m.PluginMarketplaceItemComponent
),
component: PluginMarketplaceItemComponent,
children: [
{
path: '',
@@ -27,31 +28,19 @@ export const pluginMarketplaceRoutes: Routes = [
},
{
path: 'overview',
loadChildren: () =>
import('./plugin-marketplace-item/tabs/overview-tab/overview-tab.module').then(
(m) => m.OverviewTabModule
)
component: OverviewTabComponent
},
{
path: 'source-code',
loadChildren: () =>
import('./plugin-marketplace-item/tabs/source-code-tab/source-code-tab.module').then(
(m) => m.SourceCodeTabModule
)
component: SourceCodeTabComponent
},
{
path: 'user-management',
loadChildren: () =>
import('./plugin-marketplace-item/tabs/user-management-tab/user-management-tab.module').then(
(m) => m.UserManagementTabModule
)
component: UserManagementTabComponent
},
{
path: 'settings',
loadChildren: () =>
import('./plugin-marketplace-item/tabs/settings-tab/settings-tab.module').then(
(m) => m.SettingsTabModule
)
component: SettingsTabComponent
}
]
},
@@ -167,8 +167,10 @@
status="basic"
size="small"
class="action warning action-btn install-btn"
[disabled]="disabled"
[disabled]="disabled || !canInstallInEnvironment()"
(click)="install()"
[nbTooltip]="getEnvironmentMismatchTooltip()"
[nbTooltipDisabled]="canInstallInEnvironment()"
>
<nb-icon *gauzySpinnerButton="installing" icon="cloud-download-outline" class="mr-1"></nb-icon>
{{ 'BUTTONS.INSTALL' | translate }}
@@ -16,6 +16,7 @@ import { filter, map, pairwise, shareReplay, startWith, take, tap } from 'rxjs/o
import { PluginInstallationQuery } from '../+state';
import { PluginMarketplaceActions } from '../+state/actions/plugin-marketplace.action';
import { PluginSubscriptionFacade } from '../+state/plugin-subscription.facade';
import { PluginEnvironmentService } from '../../../services/plugin-environment.service';
import { IPlanViewModel, PlanFormatterService } from '../plugin-subscription-plan-selection';
import { SubscriptionFormService, SubscriptionPlanService, SubscriptionStatusService } from '../shared';
@@ -55,7 +56,8 @@ export class PluginSubscriptionManagerComponent implements OnInit, OnDestroy {
public readonly statusService: SubscriptionStatusService,
public readonly formatter: PlanFormatterService,
public readonly installationQuery: PluginInstallationQuery,
private readonly actions: Actions
private readonly actions: Actions,
private readonly environmentService: PluginEnvironmentService
) {
this.subscriptionForm = this.formService.createSubscriptionForm();
this.isLoading$ = this.facade.loading$;
@@ -377,4 +379,18 @@ This change will take effect at the end of your current billing period. You'll r
public getStatusIcon(status: string): string {
return this.statusService.getStatusIcon(status);
}
/**
* Check if the plugin can be installed in the current environment
*/
public canInstallInEnvironment(): boolean {
return this.environmentService.canInstallPlugin(this.plugin);
}
/**
* Get the environment mismatch tooltip message
*/
public getEnvironmentMismatchTooltip(): string {
return this.environmentService.getEnvironmentMismatchWarning(this.plugin);
}
}
@@ -424,10 +424,19 @@ hasExistingSubscription$ | async;
nbButton
[status]="isPlanUpgrade(currentSubscription.plan, selectedPlanViewModel) ? 'success' : 'warning'"
(click)="onSubscribeAndInstall()"
[disabled]="!subscriptionForm.valid || creating || updating || !selectedPlanViewModel || error"
[disabled]="
!subscriptionForm.valid ||
creating ||
updating ||
!selectedPlanViewModel ||
error ||
!canInstallInEnvironment()
"
*gauzySpinnerButton="creating || updating"
[nbTooltip]="
isPlanUpgrade(currentSubscription.plan, selectedPlanViewModel)
!canInstallInEnvironment()
? getEnvironmentMismatchTooltip()
: isPlanUpgrade(currentSubscription.plan, selectedPlanViewModel)
? 'Upgrade to get instant access to new features'
: 'Downgrade will take effect at the end of your current billing cycle'
"
@@ -449,8 +458,18 @@ hasExistingSubscription$ | async;
nbButton
status="primary"
(click)="onSubscribeAndInstall()"
[disabled]="!subscriptionForm.valid || creating || updating || !selectedPlanViewModel || error"
[disabled]="
!subscriptionForm.valid ||
creating ||
updating ||
!selectedPlanViewModel ||
error ||
(!canInstallInEnvironment() && selectedPlanViewModel?.isFree)
"
*gauzySpinnerButton="creating || updating"
[nbTooltip]="getEnvironmentMismatchTooltip()"
[nbTooltipDisabled]="canInstallInEnvironment()"
nbTooltipPlacement="top"
>
@if (selectedPlanViewModel?.isFree) {
<nb-icon icon="download-outline"></nb-icon>
@@ -26,6 +26,7 @@ import {
} from 'rxjs';
import { PluginSubscriptionFacade } from '../+state';
import { PluginSubscriptionActions } from '../+state/actions/plugin-subscription.action';
import { PluginEnvironmentService } from '../../../services/plugin-environment.service';
import { PluginSubscriptionService } from '../../../services/plugin-subscription.service';
import { SubscriptionPlanService, SubscriptionStatusService } from '../shared';
import { IPlanViewModel, ISubscriptionPreviewViewModel } from './models/plan-view.model';
@@ -128,7 +129,8 @@ export class PluginSubscriptionPlanSelectionComponent implements OnInit, OnDestr
private readonly facade: PluginSubscriptionFacade,
private readonly planComparison: PlanComparisonService,
public readonly planService: SubscriptionPlanService,
public readonly statusService: SubscriptionStatusService
public readonly statusService: SubscriptionStatusService,
private readonly environmentService: PluginEnvironmentService
) {
this.initializeForm();
this.setupKeyboardShortcuts();
@@ -689,7 +691,9 @@ export class PluginSubscriptionPlanSelectionComponent implements OnInit, OnDestr
// Computed properties for template
public get canProceedWithoutSubscription$(): Observable<boolean> {
return this.hasFreePlan$.pipe(map((hasFreePlans) => hasFreePlans || !this.plugin?.hasPlan));
return this.hasFreePlan$.pipe(
map((hasFreePlans) => (hasFreePlans || !this.plugin?.hasPlan) && this.canInstallInEnvironment())
);
}
/**
@@ -954,4 +958,18 @@ export class PluginSubscriptionPlanSelectionComponent implements OnInit, OnDestr
})
);
}
/**
* Check if the plugin can be installed in the current environment
*/
public canInstallInEnvironment(): boolean {
return this.environmentService.canInstallPlugin(this.plugin);
}
/**
* Get the environment mismatch tooltip message
*/
public getEnvironmentMismatchTooltip(): string {
return this.environmentService.getEnvironmentMismatchWarning(this.plugin);
}
}
@@ -6,7 +6,9 @@ import { PluginBillingPeriod } from '../../../../services/plugin-subscription.se
* Service responsible for subscription form creation and validation
* Following Single Responsibility Principle
*/
@Injectable()
@Injectable({
providedIn: 'root'
})
export class SubscriptionFormService {
constructor(private readonly formBuilder: FormBuilder) {}
@@ -9,7 +9,9 @@ import {
* Service responsible for plan-related business logic
* Following Single Responsibility Principle
*/
@Injectable()
@Injectable({
providedIn: 'root'
})
export class SubscriptionPlanService {
/**
* Calculate subscription type hierarchy index
@@ -5,7 +5,9 @@ import { IPluginSubscription, PluginSubscriptionStatus } from '../../../../servi
* Service for subscription status icon and badge mapping
* Following Single Responsibility Principle
*/
@Injectable()
@Injectable({
providedIn: 'root'
})
export class SubscriptionStatusService {
/**
* Get icon for subscription status
@@ -1,26 +1,18 @@
import { Routes } from '@angular/router';
import { PluginUploadResolver } from './services/resolvers/plugin-upload.resolver';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { PluginLayoutComponent } from './component/plugin-layout/plugin-layout.component';
import { routes } from './plugin.route';
export const pluginRoutes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'installed'
},
{
path: 'marketplace',
loadChildren: () =>
import('./component/plugin-marketplace/plugin-marketplace-routing.module').then(
(m) => m.pluginMarketplaceRoutes
),
resolve: { isUploadAvailable: PluginUploadResolver }
},
{
path: 'installed',
loadChildren: () => import('./component/plugin-installed-routing.module').then((m) => m.pluginInstalledRoutes)
},
{
path: '**',
redirectTo: 'installed'
}
];
@NgModule({
imports: [
RouterModule.forChild([
{
path: '',
component: PluginLayoutComponent,
children: routes
}
])
],
exports: [RouterModule]
})
export class PluginRoutingModule {}
@@ -0,0 +1,25 @@
import { Routes } from '@angular/router';
import { pluginInstalledRoutes } from './component/plugin-installed-routing.module';
import { pluginMarketplaceRoutes } from './component/plugin-marketplace/plugin-marketplace-routing.module';
import { PluginUploadResolver } from './services/resolvers/plugin-upload.resolver';
export const routes: Routes = [
{
path: '',
pathMatch: 'full',
redirectTo: 'marketplace'
},
{
path: 'marketplace',
children: pluginMarketplaceRoutes,
resolve: { isUploadAvailable: PluginUploadResolver }
},
{
path: 'installed',
children: pluginInstalledRoutes
},
{
path: '**',
redirectTo: ''
}
];
@@ -14,7 +14,6 @@ import {
NbContextMenuModule,
NbDatepickerModule,
NbDialogModule,
NbDialogService,
NbFormFieldModule,
NbIconModule,
NbInfiniteListDirective,
@@ -29,7 +28,6 @@ import {
NbStepperModule,
NbTabsetModule,
NbTagModule,
NbToastrService,
NbToggleModule,
NbTooltipModule,
NbUserModule
@@ -127,13 +125,18 @@ import { PluginUserAssignmentService } from './services/plugin-user-assignment.s
import { PluginService } from './services/plugin.service';
import { SourceContainerComponent } from './shared/ui/source-container/source-container.component';
// Shared subscription components and services
import { LanguageModule } from '../../language/language.module';
import { Store } from '../../services';
import { PluginCategoryEffects } from './component/plugin-marketplace/+state';
import { ElectronService } from '../../electron/services';
import { PluginCategoryEffects } from './component/plugin-marketplace/+state/effects/plugin-category.effect';
import { PluginToggleEffects } from './component/plugin-marketplace/+state/effects/plugin-toggle.effects';
import { PluginUploadIntentEffects } from './component/plugin-marketplace/+state/effects/plugin-upload-intent.effect';
import { PluginPlanQuery } from './component/plugin-marketplace/+state/queries/plugin-plan.query';
import { PluginUploadIntentQuery } from './component/plugin-marketplace/+state/queries/plugin-upload-intent.query';
import { PluginPlanStore } from './component/plugin-marketplace/+state/stores/plugin-plan.store';
import { PluginUploadIntentStore } from './component/plugin-marketplace/+state/stores/plugin-upload-intent.store';
import { OverviewTabModule } from './component/plugin-marketplace/plugin-marketplace-item/tabs/overview-tab/overview-tab.module';
import { SettingsTabModule } from './component/plugin-marketplace/plugin-marketplace-item/tabs/settings-tab/settings-tab.module';
import { SourceCodeTabModule } from './component/plugin-marketplace/plugin-marketplace-item/tabs/source-code-tab/source-code-tab.module';
import { UserManagementTabModule } from './component/plugin-marketplace/plugin-marketplace-item/tabs/user-management-tab/user-management-tab.module';
import { PluginSubscriptionHierarchyComponent } from './component/plugin-marketplace/plugin-subscription-hierarchy/plugin-subscription-hierarchy.component';
import {
BillingContactSectionComponent,
@@ -148,6 +151,7 @@ import {
SubscriptionStatusService
} from './component/plugin-marketplace/shared';
import { UploadSelectionComponent } from './component/upload-selection/upload-selection.component';
import { PluginRoutingModule } from './plugin-routing.module';
@NgModule({
declarations: [
@@ -204,7 +208,6 @@ import { UploadSelectionComponent } from './component/upload-selection/upload-se
SubscriptionConsentSectionComponent,
UploadSelectionComponent
],
exports: [PluginLayoutComponent],
imports: [
CommonModule,
NbLayoutModule,
@@ -212,13 +215,13 @@ import { UploadSelectionComponent } from './component/upload-selection/upload-se
PaginationModule,
NbButtonModule,
NbButtonGroupModule,
NbDialogModule,
NbDialogModule.forChild(),
NbInputModule,
NbCardModule,
DesktopDirectiveModule,
NoDataMessageModule,
NbIconModule,
TranslateModule,
TranslateModule.forChild(),
RouterModule,
NbBadgeModule,
FormsModule,
@@ -230,7 +233,7 @@ import { UploadSelectionComponent } from './component/upload-selection/upload-se
NbSelectModule,
NbTooltipModule,
NbSpinnerModule,
NbDatepickerModule,
NbDatepickerModule.forRoot(), // This might need to be forChild() depending on your setup
NbStepperModule,
NbAlertModule,
NbCheckboxModule,
@@ -246,10 +249,15 @@ import { UploadSelectionComponent } from './component/upload-selection/upload-se
NbTagModule,
InfiniteScrollDirective,
NbPopoverModule,
LanguageModule.forChild()
OverviewTabModule,
SourceCodeTabModule,
UserManagementTabModule,
SettingsTabModule,
PluginRoutingModule
],
providers: [
PluginLoaderService,
ElectronService,
PluginElectronService,
PluginService,
PluginSubscriptionService,
@@ -286,6 +294,8 @@ import { UploadSelectionComponent } from './component/upload-selection/upload-se
),
PluginQuery,
PluginStore,
PluginPlanStore,
PluginPlanQuery,
PluginSettingsQuery,
PluginSettingsStore,
PluginSubscriptionQuery,
@@ -301,10 +311,7 @@ import { UploadSelectionComponent } from './component/upload-selection/upload-se
UserManagementFacade,
PluginUploadIntentStore,
PluginUploadIntentQuery,
NbInfiniteListDirective,
NbToastrService,
NbDialogService,
Store
NbInfiniteListDirective
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
@@ -1,6 +1,6 @@
import { inject, Injectable } from '@angular/core';
import { ID, PluginOSArch, PluginOSType } from '@gauzy/contracts';
import { Observable } from 'rxjs';
import { EMPTY, Observable } from 'rxjs';
import { ElectronService } from '../../../electron/services';
import type { IPlugin } from './plugin-loader.service';
@@ -8,45 +8,68 @@ import type { IPlugin } from './plugin-loader.service';
providedIn: 'root'
})
export class PluginElectronService {
private electronService = inject(ElectronService);
private readonly electronService = inject(ElectronService, { optional: true });
public get isDesktop(): boolean {
if (!this.electronService) {
return false;
}
return this.electronService.isElectron;
}
public get plugins(): Promise<IPlugin[]> {
if (!this.isDesktop) {
return Promise.resolve([]);
}
return this.electronService.ipcRenderer.invoke('plugins::getAll');
}
public plugin(name: string): Promise<IPlugin> {
if (!this.isDesktop) {
return Promise.resolve(null);
}
return this.electronService.ipcRenderer.invoke('plugins::getOne', name);
}
public checkInstallation(marketplaceId: ID): Promise<IPlugin> {
if (!this.isDesktop) {
return Promise.resolve(null);
}
return this.electronService.ipcRenderer.invoke('plugins::check', marketplaceId);
}
public activate(plugin: IPlugin) {
if (!this.isDesktop) return;
this.electronService.ipcRenderer.send('plugin::activate', plugin.name);
}
public load() {
if (!this.isDesktop) return;
this.electronService.ipcRenderer.send('plugins::load');
}
public initialize() {
if (!this.isDesktop) return;
this.electronService.ipcRenderer.send('plugins::initialize');
}
public deactivate(plugin: IPlugin) {
if (!this.isDesktop) return;
this.electronService.ipcRenderer.send('plugin::deactivate', plugin.name);
}
public downloadAndInstall<T>(config: T) {
if (!this.isDesktop) return;
this.electronService.ipcRenderer.send('plugin::download', config);
}
public uninstall(input: { marketplaceId: ID; name?: string; id?: ID }) {
if (!this.isDesktop) return;
this.electronService.ipcRenderer.send('plugin::uninstall', input);
}
public completeInstallation(marketplaceId: string, installationId: string): void {
if (!this.isDesktop) return;
this.electronService.ipcRenderer.send('plugin::complete::installation', {
marketplaceId,
installationId
@@ -54,10 +77,16 @@ export class PluginElectronService {
}
public lazyLoader(pluginPath: string) {
if (!this.isDesktop) {
return Promise.resolve(null);
}
return this.electronService.ipcRenderer.invoke('plugins::lazy-loader', pluginPath);
}
public progress<T, U>(callBack?: (message?: string) => T): Observable<{ message?: string; data: U }> {
if (!this.isDesktop) {
return EMPTY;
}
return new Observable<{ message?: string; data: U }>((observer) => {
const channel = 'plugin::status';
@@ -90,6 +119,9 @@ export class PluginElectronService {
}
public get status(): Observable<{ status: string; message?: string }> {
if (!this.isDesktop) {
return EMPTY;
}
return new Observable((observer) => {
const channel = 'plugin::status';
const listener = (_, arg: { status: string; message?: string }) => {
@@ -105,6 +137,9 @@ export class PluginElectronService {
}
public getOS(): Promise<{ platform: PluginOSType; arch: PluginOSArch }> {
if (!this.isDesktop) {
return Promise.resolve(null);
}
return this.electronService.ipcRenderer.invoke('plugins::get-os');
}
}
@@ -0,0 +1,77 @@
import { Injectable, Optional } from '@angular/core';
import { IPlugin, PluginType } from '@gauzy/contracts';
import { TranslateService } from '@ngx-translate/core';
import { ToastrNotificationService } from '../../../services/toastr-notification.service';
import { PluginElectronService } from './plugin-electron.service';
@Injectable({ providedIn: 'root' })
export class PluginEnvironmentService {
constructor(
private readonly translateService: TranslateService,
private readonly toastrService: ToastrNotificationService,
@Optional()
private readonly pluginElectronService?: PluginElectronService
) {}
/** Determine if running inside the Electron desktop shell */
public isDesktop(): boolean {
return !!this.pluginElectronService?.isDesktop;
}
/** Basic mobile detection using the user agent */
public isMobile(): boolean {
if (typeof navigator === 'undefined') {
return false;
}
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent || '');
}
/** Web is any non-desktop, non-mobile environment in the browser */
public isWeb(): boolean {
return !this.isDesktop() && !this.isMobile();
}
/** Whether the current environment supports installing the given plugin type */
public canInstallPlugin(plugin: IPlugin): boolean {
if (!plugin?.type) {
return false;
}
switch (plugin.type) {
case PluginType.DESKTOP:
return this.isDesktop();
case PluginType.MOBILE:
return this.isMobile();
case PluginType.WEB:
return this.isWeb();
default:
return true;
}
}
/** Translation key describing the environment mismatch for the plugin type */
private getEnvironmentMismatchMessage(plugin: IPlugin): string {
if (!plugin?.type) {
return 'PLUGIN.ERRORS.CANNOT_INSTALL';
}
switch (plugin.type) {
case PluginType.DESKTOP:
return 'PLUGIN.ERRORS.DESKTOP_PLUGIN_REQUIRES_DESKTOP_ENVIRONMENT';
case PluginType.WEB:
return 'PLUGIN.ERRORS.WEB_PLUGIN_REQUIRES_WEB_ENVIRONMENT';
case PluginType.MOBILE:
return 'PLUGIN.ERRORS.MOBILE_PLUGIN_REQUIRES_MOBILE_ENVIRONMENT';
default:
return 'PLUGIN.ERRORS.UNSUPPORTED_PLUGIN_TYPE';
}
}
public getEnvironmentMismatchWarning(plugin: IPlugin): string {
return this.translateService.instant(this.getEnvironmentMismatchMessage(plugin));
}
public notifyEnvironmentMismatch(plugin: IPlugin): void {
this.toastrService.warn(this.getEnvironmentMismatchWarning(plugin));
}
}
@@ -1593,6 +1593,7 @@
"SEARCH_INTEGRATIONS": "Search integrations",
"PAID": "Paid",
"INTEGRATION": "Integration",
"PLUGINS": "Plugins",
"MESSAGE": {
"SETTINGS_UPDATED": "Updated settings for '{{provider}}' integration",
"INTEGRATION_DELETED": "Integration '{{provider}}' deleted successfully",
@@ -5832,6 +5833,13 @@
"SYNC_ERROR": "Sync error",
"ERROR": "Validation error",
"START_ERROR": "Start error"
},
"ERRORS": {
"CANNOT_INSTALL": "Cannot install this plugin",
"DESKTOP_PLUGIN_REQUIRES_DESKTOP_ENVIRONMENT": "This desktop plugin can only be installed in a desktop environment",
"WEB_PLUGIN_REQUIRES_WEB_ENVIRONMENT": "This web plugin can only be installed in a web browser environment",
"MOBILE_PLUGIN_REQUIRES_MOBILE_ENVIRONMENT": "This mobile plugin can only be installed in a mobile environment",
"UNSUPPORTED_PLUGIN_TYPE": "This plugin type is not supported"
}
},
"SERVER_DOWN": {