Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,14 @@
|
||||
# @toolctrl/web
|
||||
|
||||
Runtime owner for the React Router web application.
|
||||
|
||||
This package owns the web lifecycle commands (`dev`, `build`, `typegen`, `typecheck`, `test`, `start`), the React Router source tree under `apps/web/app`, and static web assets under `apps/web/public`.
|
||||
|
||||
New runtime code should treat `apps/web` as the project boundary. Root-level `app` and `public` paths are no longer the active source tree.
|
||||
|
||||
Ownership:
|
||||
|
||||
- Authenticated web UI.
|
||||
- Host-aware app and admin routes.
|
||||
- Tenant-facing browser workflows.
|
||||
- Web-specific deployment entrypoint.
|
||||
@@ -0,0 +1,6 @@
|
||||
!.eslintrc
|
||||
!utils/services/.server/
|
||||
!utils/services/.server/urlService.test.ts
|
||||
!utils/services/apiService.entity-resolver.test.ts
|
||||
!test/isolation/fixtures/user-session-isolation.test.ts
|
||||
!test/isolation/fixtures/user-session-isolation.ts
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Constants from "./Constants";
|
||||
|
||||
describe("Constants", () => {
|
||||
it("Constants exports the correct values", () => {
|
||||
expect(Constants.DEFAULT_PAGE_SIZE).toEqual(10);
|
||||
expect(Constants.DEFAULT_ROW_VISIBILITY).toEqual("tenant");
|
||||
expect(Constants.MAX_PAGE_SIZE).toEqual(100);
|
||||
expect(Constants.MAX_EXPORT_PAGE_SIZE).toEqual(100000);
|
||||
expect(Constants.PAGE_SIZE_OPTIONS).toEqual([5, 10, 25, 50, 100]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { DefaultVisibility } from "./dtos/shared/DefaultVisibility";
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
const DEFAULT_ROW_VISIBILITY = DefaultVisibility.Tenant;
|
||||
const MAX_PAGE_SIZE = 100;
|
||||
const MAX_EXPORT_PAGE_SIZE = 100000;
|
||||
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50, 100];
|
||||
const VERSION = "1.7.0";
|
||||
|
||||
export const DARK_MODE_IN_APP = true;
|
||||
// export const DARK_SIDEBAR_IN_LIGHT_MODE = true;
|
||||
|
||||
export default {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
DEFAULT_ROW_VISIBILITY,
|
||||
MAX_PAGE_SIZE,
|
||||
MAX_EXPORT_PAGE_SIZE,
|
||||
PAGE_SIZE_OPTIONS,
|
||||
VERSION,
|
||||
};
|
||||
|
||||
export const DARK_SIDEBAR_IN_LIGHT_MODE = ({ layout }: { layout?: "app" | "admin" | "docs" }) => {
|
||||
if (layout === "admin") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { repoPathExists } from "~/utils/testing/repoPaths";
|
||||
|
||||
type FeatureManifest = {
|
||||
id: string;
|
||||
classification: string;
|
||||
pruning: {
|
||||
action: string;
|
||||
};
|
||||
};
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const docs = {
|
||||
migrationStatus: "docs/architecture-migration-status.md",
|
||||
featureInventory: "docs/feature-inventory.md",
|
||||
manifestReadme: "docs/feature-manifests/README.md",
|
||||
agentEntrypoints: "docs/agent-entrypoints.md",
|
||||
} as const;
|
||||
|
||||
const requiredArchitectureClosureSources = [
|
||||
"docs/architecture-migration-status.md",
|
||||
"docs/feature-inventory.md",
|
||||
"docs/feature-manifests/",
|
||||
];
|
||||
|
||||
const documentedS04Evidence = ["apps/web/app/utils/build/bundleScan.ts", "apps/web/app/utils/build/manualChunks.ts"];
|
||||
|
||||
const requiredS04Evidence = [...documentedS04Evidence, "apps/web/app/utils/build/viteConfigIntegration.test.ts"];
|
||||
|
||||
function readProjectFile(filePath: string) {
|
||||
return fs.readFileSync(path.join(rootDir, filePath), "utf8");
|
||||
}
|
||||
|
||||
function readManifest(fileName: string) {
|
||||
return JSON.parse(readProjectFile(path.join("docs/feature-manifests", fileName))) as FeatureManifest;
|
||||
}
|
||||
|
||||
describe("Architecture Closure closure documentation", () => {
|
||||
it("keeps architecture status anchored to boundaries, pruning manifests and S04 evidence", () => {
|
||||
const status = readProjectFile(docs.migrationStatus);
|
||||
|
||||
expect(status).toContain("## Architecture Closure final closure status");
|
||||
expect(status).toMatch(/handoff contract/i);
|
||||
expect(status).toContain("apps/api");
|
||||
expect(status).toContain("apps/worker");
|
||||
expect(status).toContain("packages/db");
|
||||
expect(status).toContain("packages/mail");
|
||||
expect(status).toContain("docs/feature-manifests/");
|
||||
expect(status).toContain("docs/feature-inventory.md");
|
||||
expect(status).toContain("core`, `plugin-candidate`, `remove-candidate");
|
||||
documentedS04Evidence.forEach((evidencePath) => {
|
||||
expect(status, evidencePath).toContain(evidencePath);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the feature inventory aligned with Architecture Closure status and pruning actions", () => {
|
||||
const inventory = readProjectFile(docs.featureInventory);
|
||||
|
||||
expect(inventory).toContain("docs/architecture-migration-status.md");
|
||||
expect(inventory).toContain("docs/feature-manifests/");
|
||||
expect(inventory).toContain("`core` bleibt die aktive Boilerplate-Basis");
|
||||
expect(inventory).toContain("`product-core` beschreibt Blueprint-, Plan-, Custom-Plan-, Tenant-Override- und Tenant-Rollen-Gates");
|
||||
expect(inventory).toContain("`plugin-candidate` beschreibt nur noch Extraktionsvertraege");
|
||||
expect(inventory).toContain("`remove-candidate` beschreibt Loesch- oder Review-Vertraege");
|
||||
expect(inventory).toContain("API-Split");
|
||||
expect(inventory).toContain("Worker/API/Mail-Split");
|
||||
});
|
||||
|
||||
it("documents the executor-facing manifest contract and source alignment", () => {
|
||||
const readme = readProjectFile(docs.manifestReadme);
|
||||
|
||||
requiredArchitectureClosureSources.forEach((sourcePath) => {
|
||||
expect(readme, sourcePath).toContain(sourcePath);
|
||||
});
|
||||
expect(readme).toContain("current API, worker, package, pruning, and bundle handoff status");
|
||||
expect(readme).toContain("machine-readable Core/Plugin/Removal contract");
|
||||
expect(readme).toContain("ownership, dependencies, configuration, provider, worker, UAT, and pruning boundaries");
|
||||
expect(readme).toContain("pruning.action");
|
||||
expect(readme).toContain("keep`, `extract-plugin`, `remove`, or `review");
|
||||
});
|
||||
|
||||
it("points agents at every canonical Architecture Closure planning source before feature work", () => {
|
||||
const entrypoints = readProjectFile(docs.agentEntrypoints);
|
||||
|
||||
expect(entrypoints).toContain("Architecture Closure closure/status contract");
|
||||
expect(entrypoints).toContain("docs/architecture-migration-status.md");
|
||||
expect(entrypoints).toContain("docs/feature-inventory.md");
|
||||
expect(entrypoints).toContain("docs/feature-manifests/*.json");
|
||||
expect(entrypoints).toContain("before planning implementation, extraction, or removal work");
|
||||
});
|
||||
|
||||
it("keeps the complete S04 bundle evidence set present for future verification", () => {
|
||||
requiredS04Evidence.forEach((evidencePath) => {
|
||||
expect(repoPathExists(evidencePath), evidencePath).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Architecture Closure feature manifest pruning contract", () => {
|
||||
const manifestDir = path.join(rootDir, "docs/feature-manifests");
|
||||
const manifestFiles = fs
|
||||
.readdirSync(manifestDir)
|
||||
.filter((file) => file.endsWith(".json") && file !== "schema.json")
|
||||
.sort();
|
||||
|
||||
it("keeps every non-schema feature manifest JSON-parseable", () => {
|
||||
expect(manifestFiles.length).toBeGreaterThan(0);
|
||||
|
||||
manifestFiles.forEach((file) => {
|
||||
expect(() => JSON.parse(readProjectFile(path.join("docs/feature-manifests", file))), file).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes the canonical core/plugin/removal pruning actions", () => {
|
||||
expect(readManifest("core.json")).toMatchObject({
|
||||
id: "core",
|
||||
classification: "core",
|
||||
pruning: { action: "keep" },
|
||||
});
|
||||
expect(readManifest("plugin-candidates.json")).toMatchObject({
|
||||
id: "plugin-candidates",
|
||||
classification: "plugin-candidate",
|
||||
pruning: { action: "extract-plugin" },
|
||||
});
|
||||
expect(readManifest("remove-candidates.json")).toMatchObject({
|
||||
id: "remove-candidates",
|
||||
classification: "remove-candidate",
|
||||
pruning: { action: "remove" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const rootDir = process.cwd();
|
||||
const docs = {
|
||||
handoffSummary: "docs/archive/architecture-handoff-summary.md",
|
||||
agentEntrypoints: "docs/agent-entrypoints.md",
|
||||
} as const;
|
||||
|
||||
const requiredCanonicalSources = [
|
||||
"docs/architecture-migration-status.md",
|
||||
"docs/feature-inventory.md",
|
||||
"docs/feature-manifests/README.md",
|
||||
"docs/feature-manifests/*.json",
|
||||
"docs/agent-entrypoints.md",
|
||||
"apps/web/app/application/architectureClosureDocs.test.ts",
|
||||
"apps/web/app/application/featureManifests.test.ts",
|
||||
];
|
||||
|
||||
const requiredGateCommands = [
|
||||
"pnpm exec vitest run apps/web/app/application/architectureClosureDocs.test.ts apps/web/app/application/featureManifests.test.ts",
|
||||
"pnpm exec eslint --ext .tsx,.ts",
|
||||
"pnpm typecheck",
|
||||
];
|
||||
|
||||
function readProjectFile(filePath: string) {
|
||||
return fs.readFileSync(path.join(rootDir, filePath), "utf8");
|
||||
}
|
||||
|
||||
describe("Architecture Closure handoff summary contract", () => {
|
||||
it("keeps the executor-facing handoff summary present", () => {
|
||||
expect(fs.existsSync(path.join(rootDir, docs.handoffSummary))).toBe(true);
|
||||
});
|
||||
|
||||
it("anchors the handoff to every canonical closure source", () => {
|
||||
const handoff = readProjectFile(docs.handoffSummary);
|
||||
|
||||
requiredCanonicalSources.forEach((sourcePath) => {
|
||||
expect(handoff, sourcePath).toContain(sourcePath);
|
||||
});
|
||||
});
|
||||
|
||||
it("documents Architecture Closure requirement coverage and prior-slice closure continuity", () => {
|
||||
const handoff = readProjectFile(docs.handoffSummary);
|
||||
|
||||
expect(handoff).toContain("R001");
|
||||
expect(handoff).toContain("R002");
|
||||
expect(handoff).toMatch(/S04 bundle\/performance continuity/i);
|
||||
expect(handoff).toMatch(/sequential follow-up order/i);
|
||||
expect(handoff).toMatch(/avoiding any assumption that downstream feature slices can run in parallel/i);
|
||||
});
|
||||
|
||||
it("preserves the non-E2E full gate and manual build follow-up wording", () => {
|
||||
const handoff = readProjectFile(docs.handoffSummary);
|
||||
|
||||
requiredGateCommands.forEach((command) => {
|
||||
expect(handoff, command).toContain(command);
|
||||
});
|
||||
expect(handoff).toMatch(/No E2E, browser, Cypress, or Playwright execution/i);
|
||||
expect(handoff).toMatch(/pnpm build.*optional manual follow-up/i);
|
||||
expect(handoff).toMatch(/not an Auto-Mode completion gate/i);
|
||||
});
|
||||
|
||||
it("keeps the agent entrypoint pointed at the Architecture Closure handoff in closure/status context", () => {
|
||||
const entrypoints = readProjectFile(docs.agentEntrypoints);
|
||||
|
||||
expect(entrypoints).toContain("Architecture Closure closure/status contract");
|
||||
expect(entrypoints).toContain(docs.handoffSummary);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { CookieCategory } from "./CookieCategory";
|
||||
|
||||
export type ApplicationCookie = {
|
||||
category: CookieCategory;
|
||||
name: string;
|
||||
domain?: string;
|
||||
expiry?: string;
|
||||
expiryKey?: string;
|
||||
description?: string;
|
||||
descriptionKey?: string;
|
||||
type?: "http" | "https";
|
||||
href?: string;
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import {describe, it, expect} from 'vitest'
|
||||
import { CookieCategory } from "./CookieCategory";
|
||||
import { allApplicationCookies, allCookieCategories } from "./ApplicationCookies";
|
||||
|
||||
describe("CookieData", () => {
|
||||
describe("allApplicationCookies", () => {
|
||||
it("should contain at least one cookie", () => {
|
||||
expect(allApplicationCookies.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should have unique cookie names", () => {
|
||||
const cookieNames = allApplicationCookies.map((cookie) => cookie.name);
|
||||
const uniqueCookieNames = new Set(cookieNames);
|
||||
expect(uniqueCookieNames.size).toEqual(cookieNames.length);
|
||||
});
|
||||
|
||||
it("should provide translation keys for rendered expiry values", () => {
|
||||
expect(allApplicationCookies.filter((cookie) => cookie.expiry && !cookie.expiryKey)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allCookieCategories", () => {
|
||||
it("should contain all categories present in allApplicationCookies", () => {
|
||||
const expectedCategories = [
|
||||
CookieCategory.REQUIRED,
|
||||
CookieCategory.FUNCTIONAL,
|
||||
CookieCategory.ANALYTICS,
|
||||
CookieCategory.ADVERTISEMENT,
|
||||
];
|
||||
expect(allCookieCategories).toEqual(expectedCategories);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { ApplicationCookie } from "./ApplicationCookie";
|
||||
import { CookieCategory } from "./CookieCategory";
|
||||
|
||||
// I used https://www.cookieserve.com/report
|
||||
const allApplicationCookies: ApplicationCookie[] = [
|
||||
{
|
||||
category: CookieCategory.REQUIRED,
|
||||
name: "RJ_session",
|
||||
expiry: "1 month",
|
||||
expiryKey: "cookies.expiry.oneMonth",
|
||||
descriptionKey: "cookies.items.session.description",
|
||||
},
|
||||
{
|
||||
category: CookieCategory.FUNCTIONAL,
|
||||
name: "crisp-client/*",
|
||||
expiry: "6 months",
|
||||
expiryKey: "cookies.expiry.sixMonths",
|
||||
descriptionKey: "cookies.items.crisp.description",
|
||||
href: "https://help.crisp.chat/en/article/crisp-chatbox-cookie-ip-policy-1147xor/",
|
||||
},
|
||||
{
|
||||
category: CookieCategory.ANALYTICS,
|
||||
name: "app_analytics",
|
||||
expiry: "1 month",
|
||||
expiryKey: "cookies.expiry.oneMonth",
|
||||
type: "https",
|
||||
descriptionKey: "cookies.items.analytics.description",
|
||||
},
|
||||
{
|
||||
category: CookieCategory.ANALYTICS,
|
||||
name: "CONSENT",
|
||||
domain: ".youtube.com",
|
||||
expiry: "2 years",
|
||||
expiryKey: "cookies.expiry.twoYears",
|
||||
descriptionKey: "cookies.items.youtubeConsent.description",
|
||||
href: "https://policies.google.com/technologies/cookies",
|
||||
},
|
||||
{
|
||||
category: CookieCategory.ADVERTISEMENT,
|
||||
name: "VISITOR_INFO1_LIVE",
|
||||
domain: ".youtube.com",
|
||||
expiry: "5 months 27 days",
|
||||
expiryKey: "cookies.expiry.fiveMonths27Days",
|
||||
descriptionKey: "cookies.items.youtubeVisitor.description",
|
||||
href: "https://policies.google.com/technologies/cookies",
|
||||
},
|
||||
{
|
||||
category: CookieCategory.ADVERTISEMENT,
|
||||
name: "YSC",
|
||||
domain: ".youtube.com",
|
||||
expiry: "Session",
|
||||
expiryKey: "cookies.expiry.session",
|
||||
descriptionKey: "cookies.items.youtubeYsc.description",
|
||||
href: "https://policies.google.com/technologies/cookies",
|
||||
},
|
||||
{
|
||||
category: CookieCategory.ADVERTISEMENT,
|
||||
name: "yt-remote-device-id",
|
||||
domain: ".youtube.com",
|
||||
expiry: "Never",
|
||||
expiryKey: "cookies.expiry.never",
|
||||
descriptionKey: "cookies.items.youtubeRemoteDevice.description",
|
||||
href: "https://policies.google.com/technologies/cookies",
|
||||
},
|
||||
{
|
||||
category: CookieCategory.ADVERTISEMENT,
|
||||
name: "yt-remote-connected-devices",
|
||||
domain: ".youtube.com",
|
||||
expiry: "Never",
|
||||
expiryKey: "cookies.expiry.never",
|
||||
descriptionKey: "cookies.items.youtubeRemoteConnected.description",
|
||||
href: "https://policies.google.com/technologies/cookies",
|
||||
},
|
||||
];
|
||||
|
||||
const allCookieCategories: CookieCategory[] = [];
|
||||
|
||||
allApplicationCookies.forEach((cookie) => {
|
||||
if (!allCookieCategories.includes(cookie.category)) {
|
||||
allCookieCategories.push(cookie.category);
|
||||
}
|
||||
});
|
||||
|
||||
export { allCookieCategories, allApplicationCookies };
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum CookieCategory {
|
||||
REQUIRED, // userId, lightOrDarkMode, lng, csrf, consent
|
||||
FUNCTIONAL, // chat, feedback
|
||||
ANALYTICS, // analytics
|
||||
ADVERTISEMENT, // ads
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type ApiKeyEntityPermissionDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
create: boolean;
|
||||
read: boolean;
|
||||
update: boolean;
|
||||
delete: boolean;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum ContactStatus {
|
||||
Lead = "Lead",
|
||||
Prospect = "Prospect",
|
||||
Customer = "Customer",
|
||||
Partner = "Partner",
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type ColumnDto = {
|
||||
name: string;
|
||||
title: string;
|
||||
visible: boolean;
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Colors } from "~/application/enums/shared/Colors";
|
||||
|
||||
export interface FilterablePropertyDto {
|
||||
name: string;
|
||||
title: string;
|
||||
manual?: boolean;
|
||||
condition?: string;
|
||||
value?: string | null;
|
||||
options?: { name: string; value: string; color?: Colors }[];
|
||||
isNumber?: boolean;
|
||||
isBoolean?: boolean;
|
||||
hideSearch?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { FilterablePropertyDto } from "./FilterablePropertyDto";
|
||||
|
||||
export interface FiltersDto {
|
||||
properties: FilterablePropertyDto[];
|
||||
query?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SortedByDto } from "./SortedByDto";
|
||||
|
||||
export interface PaginationDto {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
sortedBy?: SortedByDto[];
|
||||
query?: string | undefined;
|
||||
}
|
||||
|
||||
export interface PaginationData<T> {
|
||||
items: T[];
|
||||
pagination: PaginationDto | undefined;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface PaginationResultDto {
|
||||
totalItems: number;
|
||||
totalPages: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
sortedBy: any;
|
||||
query: any;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Entity, Property } from "@toolctrl/db/prisma-browser";
|
||||
import { EntityRelationshipWithDetails } from "~/utils/db/entities/entityRelationships.db.server";
|
||||
|
||||
export interface RowFiltersDto {
|
||||
customRow: boolean;
|
||||
entity: Entity;
|
||||
query: string | null;
|
||||
properties: {
|
||||
property?: Property;
|
||||
name?: string;
|
||||
value: string | string[] | null;
|
||||
condition?: string;
|
||||
match?: "and" | "or";
|
||||
parentEntity?: EntityRelationshipWithDetails;
|
||||
}[];
|
||||
tags: string[];
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export interface RowHeaderActionDto<T> {
|
||||
title?: string | ReactNode;
|
||||
onClick?: (idx: number, item: T) => void;
|
||||
onClickRoute?: (idx: number, item: T) => string | undefined;
|
||||
onClickRouteTarget?: undefined | "_blank";
|
||||
disabled?: (item: T) => boolean | boolean;
|
||||
hidden?: (item: T) => boolean;
|
||||
destructive?: boolean;
|
||||
firstColumn?: boolean;
|
||||
renderTitle?: (item: T) => ReactNode;
|
||||
renderIsDestructive?: (item: T) => boolean;
|
||||
prefetch?: "intent" | "render" | "none";
|
||||
confirmation?: (item: T) => {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ReactNode } from "react";
|
||||
import { InputType } from "~/application/enums/shared/InputType";
|
||||
|
||||
export type RowHeaderDisplayDto<T> = {
|
||||
title: string;
|
||||
name?: string;
|
||||
type?: InputType;
|
||||
value: (item: T, idx: number) => any;
|
||||
href?: (item: T) => string | undefined;
|
||||
formattedValue?: (item: T, idx?: number) => string | ReactNode;
|
||||
options?: { name: string; value: number | string; disabled?: boolean }[];
|
||||
setValue?: (value: any, idx: number) => void;
|
||||
editable?: (item: T, idx?: number) => boolean;
|
||||
className?: string;
|
||||
sortable?: boolean;
|
||||
breakpoint?: "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
inputNumberStep?: string;
|
||||
inputBorderless?: boolean;
|
||||
inputOptional?: boolean;
|
||||
sortBy?: string;
|
||||
align?: "left" | "center" | "right";
|
||||
hidden?: boolean;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Entity, Property } from "@toolctrl/db/prisma-browser";
|
||||
|
||||
export interface SortedByDto {
|
||||
name: string;
|
||||
direction: "asc" | "desc";
|
||||
property?: Property;
|
||||
entity?: Entity;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export type EmailTemplate = {
|
||||
type: "standard" | "layout";
|
||||
name: string;
|
||||
alias: string;
|
||||
subject: string;
|
||||
htmlBody: string;
|
||||
active: boolean;
|
||||
associatedServerId: number;
|
||||
templateId: number;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
export type MediaDto = {
|
||||
id?: string;
|
||||
title: string;
|
||||
name: string;
|
||||
file: string;
|
||||
type: string;
|
||||
publicUrl?: string | null;
|
||||
storageBucket?: string | null;
|
||||
storageProvider?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Entity, Property, RowValue } from "@toolctrl/db/prisma-browser";
|
||||
|
||||
export interface RowDynamicValuesDto {
|
||||
form: Entity | undefined;
|
||||
name: string;
|
||||
total: number;
|
||||
headers: (RowValue & { property: Property })[];
|
||||
// details: RequestDetailDto[];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type RowPermissionsDto = {
|
||||
canRead?: boolean;
|
||||
canComment: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
isOwner: boolean;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { RowMedia, RowValueMultiple, RowValueRange } from "@toolctrl/db/prisma-browser";
|
||||
import { PropertyWithDetails } from "~/utils/db/entities/entities.db.server";
|
||||
|
||||
export type RowValueDto = {
|
||||
id?: string | null;
|
||||
property: PropertyWithDetails;
|
||||
propertyId: string;
|
||||
textValue?: string | undefined;
|
||||
numberValue?: number | undefined;
|
||||
dateValue?: Date | undefined;
|
||||
booleanValue?: boolean | undefined;
|
||||
selectedOption?: string | undefined;
|
||||
media?: RowMedia[];
|
||||
multiple?: RowValueMultiple[];
|
||||
range?: RowValueRange | undefined;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export type RowValueMultipleDto = {
|
||||
id?: string;
|
||||
order: number;
|
||||
value: string;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
export type RowValueRangeDto = {
|
||||
numberMin: Decimal | number | null;
|
||||
numberMax: Decimal | number | null;
|
||||
dateMin: Date | null;
|
||||
dateMax: Date | null;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export type GridLayoutDto = {
|
||||
columns?: number;
|
||||
sm?: number;
|
||||
md?: number;
|
||||
lg?: number;
|
||||
xl?: number;
|
||||
xl2?: number;
|
||||
gap: "xs" | "sm" | "md" | "lg" | "xl";
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface AnalyticsInfoDto {
|
||||
ip?: string;
|
||||
userAgent?: {
|
||||
type: string | undefined;
|
||||
os: {
|
||||
family: string | undefined;
|
||||
version: string | undefined;
|
||||
};
|
||||
browser: {
|
||||
name: string | undefined;
|
||||
version: string | undefined;
|
||||
};
|
||||
};
|
||||
referrer?: {
|
||||
via?: string;
|
||||
http?: string;
|
||||
source?: string;
|
||||
utm_medium?: string;
|
||||
utm_campaign?: string;
|
||||
utm_content?: string;
|
||||
utm_term?: string;
|
||||
};
|
||||
region?: {
|
||||
country?: string;
|
||||
city?: string;
|
||||
};
|
||||
userId: string | null;
|
||||
portalId?: string | null;
|
||||
portalUserId?: string | null;
|
||||
userAnalyticsId?: string;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
type ServerRuntimeMetaDescriptor =
|
||||
| {
|
||||
charSet: "utf-8";
|
||||
}
|
||||
| {
|
||||
title: string;
|
||||
}
|
||||
| {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
property: string;
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
httpEquiv: string;
|
||||
content: string;
|
||||
}
|
||||
| {
|
||||
"script:ld+json": LdJsonObject;
|
||||
}
|
||||
| {
|
||||
tagName: "meta" | "link";
|
||||
[name: string]: string;
|
||||
}
|
||||
| any;
|
||||
type LdJsonObject = {
|
||||
[Key in string]: LdJsonValue;
|
||||
} & {
|
||||
[Key in string]?: LdJsonValue | undefined;
|
||||
};
|
||||
type LdJsonArray = LdJsonValue[] | readonly LdJsonValue[];
|
||||
type LdJsonPrimitive = string | number | boolean | null;
|
||||
type LdJsonValue = LdJsonPrimitive | LdJsonObject | LdJsonArray;
|
||||
|
||||
export type MetaTagsDto = ServerRuntimeMetaDescriptor[];
|
||||
@@ -0,0 +1,6 @@
|
||||
import { UserWithoutPassword } from "~/utils/db/users.db.server";
|
||||
|
||||
export type ImpersonatingSessionDto = {
|
||||
fromUser: UserWithoutPassword;
|
||||
toUser: UserWithoutPassword;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type SetupItem = {
|
||||
title: string;
|
||||
description: string;
|
||||
completed: boolean;
|
||||
path: string | undefined;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export type CreatedByDto = {
|
||||
byUserId?: string | null;
|
||||
byApiKeyId?: string | null;
|
||||
byEmailId?: string | null;
|
||||
byEventWebhookAttemptId?: string | null;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export class CustomError {
|
||||
public message: string;
|
||||
public details?: Record<string, any>;
|
||||
public constructor(message: string, details?: Record<string, any>) {
|
||||
this.message = message;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum DefaultAdminRoles {
|
||||
SuperAdmin = "SuperAdmin",
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum DefaultAppRoles {
|
||||
SuperUser = "SuperUser",
|
||||
Admin = "Admin",
|
||||
BillingAdmin = "Billing Admin",
|
||||
User = "User",
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum DefaultEntityTypes {
|
||||
All = "all",
|
||||
AppOnly = "app",
|
||||
AdminOnly = "admin",
|
||||
System = "system",
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export enum DefaultFeatures {
|
||||
Users = "users",
|
||||
API = "api",
|
||||
RateLimitPerMinute = "rate-limit-per-minute",
|
||||
RateLimitPerSecond = "rate-limit-per-second",
|
||||
PrioritySupport = "priority-support",
|
||||
Credits = "ai-credits",
|
||||
PrivateGenerations = "private-generations",
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum DefaultLogActions {
|
||||
Login = "Login",
|
||||
Created = "Created",
|
||||
Updated = "Updated",
|
||||
Deleted = "Deleted",
|
||||
Commented = "Commented",
|
||||
Replied = "Replied",
|
||||
Reacted = "Reacted",
|
||||
RemovedReaction = "Removed Reaction",
|
||||
Logout = "Logout",
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
export const DefaultPermissions = [
|
||||
"admin.dashboard.view",
|
||||
"admin.accounts.view",
|
||||
"admin.accounts.create",
|
||||
"admin.account.view",
|
||||
"admin.account.settings.update",
|
||||
"admin.account.users",
|
||||
"admin.account.subscription",
|
||||
"admin.account.delete",
|
||||
"admin.accountTypes.view",
|
||||
"admin.accountTypes.create",
|
||||
"admin.accountTypes.update",
|
||||
"admin.accountTypes.delete",
|
||||
"admin.users.view",
|
||||
"admin.users.impersonate",
|
||||
"admin.users.changePassword",
|
||||
"admin.users.delete",
|
||||
"admin.relationships.view",
|
||||
"admin.relationships.create",
|
||||
"admin.relationships.update",
|
||||
"admin.relationships.delete",
|
||||
"admin.blacklist.view",
|
||||
"admin.tenantIpAddress.view",
|
||||
"admin.blacklist.manage",
|
||||
"admin.roles.view",
|
||||
"admin.roles.create",
|
||||
"admin.roles.update",
|
||||
"admin.roles.delete",
|
||||
"admin.roles.set",
|
||||
"admin.analytics.view",
|
||||
"admin.analytics.delete",
|
||||
"admin.entities.view",
|
||||
"admin.entities.create",
|
||||
"admin.entities.update",
|
||||
"admin.entities.delete",
|
||||
// "admin.workflows.view",
|
||||
// "admin.workflows.create",
|
||||
// "admin.workflows.update",
|
||||
// "admin.workflows.delete",
|
||||
"admin.apiKeys.view",
|
||||
"admin.apiKeys.create",
|
||||
"admin.apiKeys.update",
|
||||
"admin.apiKeys.delete",
|
||||
"admin.auditTrails.view",
|
||||
"admin.events.view",
|
||||
"admin.pricing.view",
|
||||
"admin.pricing.create",
|
||||
"admin.pricing.update",
|
||||
"admin.pricing.delete",
|
||||
"admin.emails.view",
|
||||
"admin.emails.create",
|
||||
"admin.emails.update",
|
||||
"admin.emails.delete",
|
||||
"admin.onboarding.view",
|
||||
"admin.onboarding.create",
|
||||
"admin.onboarding.update",
|
||||
"admin.onboarding.delete",
|
||||
"admin.notifications.view",
|
||||
"admin.notifications.manage",
|
||||
"admin.featureFlags.view",
|
||||
"admin.featureFlags.manage",
|
||||
"admin.marketing.leads.view",
|
||||
"admin.marketing.leads.manage",
|
||||
"admin.marketing.integrations.view",
|
||||
"admin.marketing.integrations.manage",
|
||||
"admin.marketing.funnels.view",
|
||||
"admin.prompts.view",
|
||||
"admin.prompts.create",
|
||||
"admin.prompts.update",
|
||||
"admin.prompts.delete",
|
||||
"admin.formulas.view",
|
||||
"admin.formulas.create",
|
||||
"admin.formulas.update",
|
||||
"admin.formulas.delete",
|
||||
"admin.metrics.view",
|
||||
"admin.metrics.update",
|
||||
"admin.metrics.delete",
|
||||
"admin.kb.view",
|
||||
"admin.kb.create",
|
||||
"admin.kb.update",
|
||||
"admin.kb.delete",
|
||||
"admin.settings.cookies.view",
|
||||
"admin.settings.cookies.create",
|
||||
"admin.settings.cookies.update",
|
||||
"admin.settings.general.view",
|
||||
"admin.settings.general.update",
|
||||
"admin.settings.authentication.view",
|
||||
"admin.settings.authentication.update",
|
||||
"admin.settings.analytics.view",
|
||||
"admin.settings.analytics.update",
|
||||
"admin.settings.seo.update",
|
||||
"admin.settings.internationalization.view",
|
||||
"admin.settings.internationalization.update",
|
||||
"admin.settings.danger.reset",
|
||||
"app.dashboard.view",
|
||||
"app.settings.members.view",
|
||||
"app.settings.members.create",
|
||||
"app.settings.members.update",
|
||||
"app.settings.members.delete",
|
||||
"app.settings.members.impersonate",
|
||||
"app.settings.roles.view",
|
||||
"app.settings.roles.set",
|
||||
"app.settings.groups.full",
|
||||
"app.settings.subscription.view",
|
||||
"app.settings.subscription.update",
|
||||
"app.settings.subscription.delete",
|
||||
"app.settings.subscription.invoices.view",
|
||||
"app.settings.account.view",
|
||||
"app.settings.account.update",
|
||||
"app.settings.account.delete",
|
||||
"app.settings.accounts.view",
|
||||
"app.settings.accounts.create",
|
||||
"app.settings.accounts.update",
|
||||
"app.settings.accounts.delete",
|
||||
"app.settings.apiKeys.view",
|
||||
"app.settings.apiKeys.create",
|
||||
"app.settings.apiKeys.update",
|
||||
"app.settings.apiKeys.delete",
|
||||
"app.settings.auditTrails.view",
|
||||
"app.plugins.use",
|
||||
"app.plugins.manage",
|
||||
// P4 Files (AK-P4a) — see packages/db permissions-seed (same list).
|
||||
"files.read_own",
|
||||
"files.read_all",
|
||||
"files.write",
|
||||
"files.delete",
|
||||
"files.admin",
|
||||
// P4 Tasks (AK-P4b) — see packages/db permissions-seed (same list).
|
||||
"tasks.read_own",
|
||||
"tasks.read_all",
|
||||
"tasks.write",
|
||||
"tasks.delete",
|
||||
"tasks.admin",
|
||||
// "entity.{name}.view",
|
||||
// "entity.{name}.read",
|
||||
// "entity.{name}.create",
|
||||
// "entity.{name}.update",
|
||||
// "entity.{name}.delete",
|
||||
] as const;
|
||||
export type DefaultPermission = (typeof DefaultPermissions)[number];
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum DefaultVisibility {
|
||||
Private = "private",
|
||||
Tenant = "tenant",
|
||||
Public = "public",
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface FileBase64 {
|
||||
file: File;
|
||||
base64: any;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { StatChange } from "./StatChange";
|
||||
|
||||
export type Stat = {
|
||||
name: string;
|
||||
hint: string;
|
||||
stat: string;
|
||||
previousStat?: string;
|
||||
change?: string;
|
||||
changeType?: StatChange;
|
||||
path?: string;
|
||||
entity?: {
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum StatChange {
|
||||
Decrease,
|
||||
Equal,
|
||||
Increase,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
|
||||
|
||||
export interface BillingPeriodDto {
|
||||
value: SubscriptionBillingPeriod;
|
||||
default?: boolean;
|
||||
disabled?: boolean;
|
||||
recurring: boolean;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface CurrencyDto {
|
||||
name: string;
|
||||
value: string;
|
||||
symbol: string;
|
||||
default?: boolean;
|
||||
disabled?: boolean;
|
||||
parities?: { from: string; parity: number }[];
|
||||
symbolRight?: boolean;
|
||||
thousandSeparator?: string;
|
||||
decimalSeparator?: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface FlatPriceDto {
|
||||
currency: string;
|
||||
monthlyPrice?: number;
|
||||
yearlyPrice?: number;
|
||||
oneTimePrice?: number;
|
||||
quarterlyPrice?: number;
|
||||
semiAnnualPrice?: number;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
|
||||
|
||||
export interface PlanFeatureUsageDto {
|
||||
order: number;
|
||||
title: string;
|
||||
name: string;
|
||||
type: SubscriptionFeatureLimitType;
|
||||
value: number;
|
||||
used: number;
|
||||
remaining: number | "unlimited";
|
||||
enabled: boolean;
|
||||
message: string;
|
||||
entity?: { id: string; title: string; titlePlural: string; slug: string };
|
||||
period?: {
|
||||
firstDay: Date | null;
|
||||
lastDay: Date | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
|
||||
|
||||
export interface SubscriptionFeatureDto {
|
||||
order: number;
|
||||
title: string;
|
||||
name: string;
|
||||
type: SubscriptionFeatureLimitType;
|
||||
value: number;
|
||||
href?: string | null;
|
||||
badge?: string | null;
|
||||
accumulate?: boolean;
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { SubscriptionFeatureLimitType } from "~/application/enums/subscriptions/SubscriptionFeatureLimitType";
|
||||
|
||||
export interface SubscriptionFeatureInPlansDto {
|
||||
id: string;
|
||||
order: number;
|
||||
name: string;
|
||||
href?: string | null;
|
||||
badge?: string | null;
|
||||
accumulate?: boolean;
|
||||
plans: {
|
||||
id: string;
|
||||
title: string;
|
||||
type: SubscriptionFeatureLimitType;
|
||||
value: number;
|
||||
}[];
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { SubscriptionProductDto } from "./SubscriptionProductDto";
|
||||
import { SubscriptionPriceType } from "~/application/enums/subscriptions/SubscriptionPriceType";
|
||||
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
|
||||
import { SubscriptionUsageBasedTier } from "@toolctrl/db/prisma-browser";
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
export interface SubscriptionPriceDto {
|
||||
id?: string;
|
||||
stripeId: string;
|
||||
type: SubscriptionPriceType;
|
||||
billingPeriod: SubscriptionBillingPeriod;
|
||||
price: number | Decimal;
|
||||
currency: string;
|
||||
trialDays: number;
|
||||
active: boolean;
|
||||
priceBefore?: number;
|
||||
subscriptionProductId: string;
|
||||
subscriptionProduct?: SubscriptionProductDto;
|
||||
usageBasedPrices?: SubscriptionUsageBasedTier[];
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SubscriptionPriceDto } from "./SubscriptionPriceDto";
|
||||
import { SubscriptionFeatureDto } from "./SubscriptionFeatureDto";
|
||||
import { PricingModel } from "~/application/enums/subscriptions/PricingModel";
|
||||
import { SubscriptionUsageBasedPriceDto } from "./SubscriptionUsageBasedPriceDto";
|
||||
import { TenantSubscriptionProduct, TenantType } from "@toolctrl/db/prisma-browser";
|
||||
|
||||
export interface SubscriptionProductDto {
|
||||
id?: string;
|
||||
stripeId: string;
|
||||
order: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
groupTitle?: string | null;
|
||||
groupDescription?: string | null;
|
||||
badge: string | null;
|
||||
active: boolean;
|
||||
model: PricingModel;
|
||||
public: boolean;
|
||||
prices: SubscriptionPriceDto[];
|
||||
features: SubscriptionFeatureDto[];
|
||||
translatedTitle?: string;
|
||||
usageBasedPrices?: SubscriptionUsageBasedPriceDto[];
|
||||
tenantProducts?: TenantSubscriptionProduct[];
|
||||
assignsTenantTypes?: TenantType[];
|
||||
billingAddressCollection?: string | null;
|
||||
hasQuantity?: boolean;
|
||||
canBuyAgain?: boolean;
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
|
||||
import { SubscriptionUsageBasedTierDto } from "./SubscriptionUsageBasedTierDto";
|
||||
|
||||
export interface SubscriptionUsageBasedPriceDto {
|
||||
id: string;
|
||||
subscriptionProductId: string;
|
||||
stripeId: string;
|
||||
billingPeriod: SubscriptionBillingPeriod;
|
||||
currency: string;
|
||||
unit: string;
|
||||
unitTitle: string;
|
||||
unitTitlePlural: string;
|
||||
usageType: string;
|
||||
aggregateUsage: string;
|
||||
tiersMode: string;
|
||||
billingScheme: string;
|
||||
tiers: SubscriptionUsageBasedTierDto[];
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import { Decimal } from "decimal.js";
|
||||
|
||||
export interface SubscriptionUsageBasedTierDto {
|
||||
id: string;
|
||||
subscriptionUsageBasedPriceId: string;
|
||||
from: number;
|
||||
to?: number | null;
|
||||
perUnitPrice?: number | Decimal | null;
|
||||
flatFeePrice?: number | Decimal | null;
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
export type SubscriptionUsageBasedUnitDto = {
|
||||
unit: string;
|
||||
unitTitle: string;
|
||||
unitTitlePlural: string;
|
||||
usageType: string;
|
||||
aggregateUsage: string;
|
||||
tiersMode: string;
|
||||
billingScheme: string;
|
||||
tiers: { from: number; to?: number }[];
|
||||
prices: {
|
||||
currency: string;
|
||||
from: number;
|
||||
to: number | undefined;
|
||||
perUnitPrice?: number | undefined;
|
||||
flatFeePrice?: number | undefined;
|
||||
}[];
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
export interface UsageBasedPriceDto {
|
||||
from: number;
|
||||
to?: number | undefined;
|
||||
perUnitPrice?: number | undefined;
|
||||
flatFeePrice?: number | undefined;
|
||||
currency: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export type UsageBasedUnitDto = {
|
||||
name: string;
|
||||
title: string;
|
||||
titlePlural: string;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export type TenantTypeDto = {
|
||||
id?: string;
|
||||
title: string;
|
||||
titlePlural: string;
|
||||
description: string | null;
|
||||
isDefault?: boolean;
|
||||
subscriptionProducts?: { id?: string; title: string }[];
|
||||
_count?: { tenants: number };
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, vi, expect } from 'vitest';
|
||||
import emailTemplates from "./emailTemplates.server";
|
||||
import { EmailTemplate } from "../dtos/email/EmailTemplate";
|
||||
import fs from "fs";
|
||||
|
||||
const mockEmailTemplates: EmailTemplate[] = [
|
||||
{
|
||||
type: "standard",
|
||||
name: "template-1",
|
||||
alias: "template-1-alias",
|
||||
subject: "template-1-subject",
|
||||
htmlBody: "<p>template-1-body</p>",
|
||||
active: false,
|
||||
associatedServerId: -1,
|
||||
templateId: -1,
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
// Mock fs
|
||||
fs.readdirSync = vi.fn().mockReturnValueOnce(["template-1.md", ]);
|
||||
fs.readFileSync = vi.fn().mockReturnValueOnce(`
|
||||
alias: \`template-1-alias\`
|
||||
|
||||
subject: \`template-1-subject\`
|
||||
|
||||
body: \`<p>template-1-body</p>\`
|
||||
`)
|
||||
|
||||
describe("emailTemplates", () => {
|
||||
it("should return an array of email templates", async () => {
|
||||
const result = await emailTemplates();
|
||||
expect(result).toEqual(mockEmailTemplates);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { EmailTemplate } from "../dtos/email/EmailTemplate";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const emailTemplatesDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "./templates");
|
||||
|
||||
export default async function emailTemplates(): Promise<EmailTemplate[]> {
|
||||
const items: EmailTemplate[] = [];
|
||||
|
||||
const dir = emailTemplatesDir;
|
||||
const fileNames = fs.readdirSync(dir);
|
||||
fileNames.forEach((file) => {
|
||||
if (path.parse(file).ext === ".md") {
|
||||
const fileName = path.parse(file).name;
|
||||
const content = fs.readFileSync(path.join(dir, file), "utf8").split("`");
|
||||
const alias = content[1].replace(/(\r\n|\n|\r)/gm, "");
|
||||
const subject = content[3].replace(/(\r\n|\n|\r)/gm, "");
|
||||
const body = content[5];
|
||||
items.push({
|
||||
type: alias.startsWith("layout-") ? "layout" : "standard",
|
||||
name: fileName,
|
||||
alias,
|
||||
subject,
|
||||
htmlBody: body,
|
||||
active: false,
|
||||
associatedServerId: -1,
|
||||
templateId: -1,
|
||||
});
|
||||
}
|
||||
});
|
||||
function cmp(a: any, b: any) {
|
||||
if (a > b) return +1;
|
||||
if (a < b) return -1;
|
||||
return 0;
|
||||
}
|
||||
return items.sort((x, y) => cmp(x.type, y.type) || cmp(Number(x.name.split("-")[0]), Number(y.name.split("-")[0])));
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
alias: `layout-basic`
|
||||
subject: ``
|
||||
body:
|
||||
`
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="supported-color-schemes" content="light dark" />
|
||||
<title></title>
|
||||
|
||||
<style type="text/css" rel="stylesheet" media="all">
|
||||
/* Base ------------------------------ */
|
||||
|
||||
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
||||
body {
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3869D4;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
td {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preheader {
|
||||
display: none !important;
|
||||
visibility: hidden;
|
||||
mso-hide: all;
|
||||
font-size: 1px;
|
||||
line-height: 1px;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Type ------------------------------ */
|
||||
|
||||
body,
|
||||
td,
|
||||
th {
|
||||
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
margin: .4em 0 1.1875em;
|
||||
font-size: 16px;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
p.sub {
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Utilities ------------------------------ */
|
||||
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
/* Buttons ------------------------------ */
|
||||
|
||||
.button {
|
||||
background-color: #3869D4;
|
||||
border-top: 10px solid #3869D4;
|
||||
border-right: 18px solid #3869D4;
|
||||
border-bottom: 10px solid #3869D4;
|
||||
border-left: 18px solid #3869D4;
|
||||
display: inline-block;
|
||||
color: #FFF;
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
||||
-webkit-text-size-adjust: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.button--green {
|
||||
background-color: #22BC66;
|
||||
border-top: 10px solid #22BC66;
|
||||
border-right: 18px solid #22BC66;
|
||||
border-bottom: 10px solid #22BC66;
|
||||
border-left: 18px solid #22BC66;
|
||||
}
|
||||
|
||||
.button--red {
|
||||
background-color: #FF6136;
|
||||
border-top: 10px solid #FF6136;
|
||||
border-right: 18px solid #FF6136;
|
||||
border-bottom: 10px solid #FF6136;
|
||||
border-left: 18px solid #FF6136;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.button {
|
||||
width: 100% !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
}
|
||||
/* Attribute list ------------------------------ */
|
||||
|
||||
.attributes {
|
||||
margin: 0 0 21px;
|
||||
}
|
||||
|
||||
.attributes_content {
|
||||
background-color: #F4F4F7;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.attributes_item {
|
||||
padding: 0;
|
||||
}
|
||||
/* Related Items ------------------------------ */
|
||||
|
||||
.related {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 25px 0 0 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.related_item {
|
||||
padding: 10px 0;
|
||||
color: #CBCCCF;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.related_item-title {
|
||||
display: block;
|
||||
margin: .5em 0 0;
|
||||
}
|
||||
|
||||
.related_item-thumb {
|
||||
display: block;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.related_heading {
|
||||
border-top: 1px solid #CBCCCF;
|
||||
text-align: center;
|
||||
padding: 25px 0 10px;
|
||||
}
|
||||
/* Discount Code ------------------------------ */
|
||||
|
||||
.discount {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #F4F4F7;
|
||||
border: 2px dashed #CBCCCF;
|
||||
}
|
||||
|
||||
.discount_heading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.discount_body {
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
/* Social Icons ------------------------------ */
|
||||
|
||||
.social {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.social td {
|
||||
padding: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.social_icon {
|
||||
height: 20px;
|
||||
margin: 0 8px 10px 8px;
|
||||
padding: 0;
|
||||
}
|
||||
/* Data table ------------------------------ */
|
||||
|
||||
.purchase {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 35px 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.purchase_content {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 25px 0 0 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.purchase_item {
|
||||
padding: 10px 0;
|
||||
color: #51545E;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.purchase_heading {
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #EAEAEC;
|
||||
}
|
||||
|
||||
.purchase_heading p {
|
||||
margin: 0;
|
||||
color: #85878E;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.purchase_footer {
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #EAEAEC;
|
||||
}
|
||||
|
||||
.purchase_total {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.purchase_total--label {
|
||||
padding: 0 15px 0 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #F2F4F6;
|
||||
color: #51545E;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #51545E;
|
||||
}
|
||||
|
||||
.email-wrapper {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #F2F4F6;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
/* Masthead ----------------------- */
|
||||
|
||||
.email-masthead {
|
||||
padding: 25px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-masthead_logo {
|
||||
width: 94px;
|
||||
}
|
||||
|
||||
.email-masthead_name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #A8AAAF;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 1px 0 white;
|
||||
}
|
||||
/* Body ------------------------------ */
|
||||
|
||||
.email-body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.email-body_inner {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-premailer-width: 570px;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.email-footer {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-premailer-width: 570px;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-footer p {
|
||||
color: #A8AAAF;
|
||||
}
|
||||
|
||||
.body-action {
|
||||
width: 100%;
|
||||
margin: 30px auto;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.body-sub {
|
||||
margin-top: 25px;
|
||||
padding-top: 25px;
|
||||
border-top: 1px solid #EAEAEC;
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
padding: 45px;
|
||||
}
|
||||
/*Media Queries ------------------------------ */
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-body_inner,
|
||||
.email-footer {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body,
|
||||
.email-body,
|
||||
.email-body_inner,
|
||||
.email-content,
|
||||
.email-wrapper,
|
||||
.email-masthead,
|
||||
.email-footer {
|
||||
background-color: #333333 !important;
|
||||
color: #FFF !important;
|
||||
}
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
span,
|
||||
.purchase_item {
|
||||
color: #FFF !important;
|
||||
}
|
||||
.attributes_content,
|
||||
.discount {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
.email-masthead_name {
|
||||
text-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
.f-fallback {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body>
|
||||
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="email-masthead">
|
||||
<a href="{{ product_url }}" class="f-fallback email-masthead_name">
|
||||
{{ product_name }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Email Body -->
|
||||
<tr>
|
||||
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
|
||||
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<!-- Body content -->
|
||||
<tr>
|
||||
<td class="content-cell">
|
||||
<div class="f-fallback">
|
||||
{{{ @content }}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table class="email-footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="content-cell" align="center">
|
||||
<p class="f-fallback sub align-center">© 2021 {{ product_name }}. All rights reserved.</p>
|
||||
<p class="f-fallback sub align-center">
|
||||
{{ company_name }}
|
||||
<br>{{ company_address }}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
@@ -0,0 +1,50 @@
|
||||
alias: `welcome`
|
||||
|
||||
subject: `Welcome to {{product_name}} {{name}}!`
|
||||
|
||||
body:
|
||||
`
|
||||
<h1>Welcome, {{name}}!</h1>
|
||||
<p>Thanks for trying {{ product_name }}. We’re thrilled to have you on board.</p>
|
||||
|
||||
<!-- Action -->
|
||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{action_url}}" class="button button--" target="_blank">Log in</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- End -->
|
||||
|
||||
<p>If you have any questions, feel free to <a href="mailto:{{support_email}}">email our customer success team</a>.
|
||||
|
||||
<p>Thanks,
|
||||
<br>{{ sender_name }}</p>
|
||||
<p><strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the {{ product_name }} support team is always ready to help!</p>
|
||||
|
||||
<!-- Sub copy -->
|
||||
|
||||
<table class="body-sub">
|
||||
<tr>
|
||||
<td>
|
||||
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
|
||||
<p class="sub">{{action_url}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
@@ -0,0 +1,51 @@
|
||||
alias: `password-reset`
|
||||
|
||||
subject: `Set up a new password for {{product_name}}`
|
||||
|
||||
body:
|
||||
`
|
||||
<h1>Hi {{name}},</h1>
|
||||
<p>You recently requested to reset your password for your {{ product_name }} account. Use the button below to reset it.</p>
|
||||
<!-- Action -->
|
||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{action_url}}" class="button button--" target="_blank">Reset your password</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p>If you did not request a password reset, please ignore this email or <a href="mailto:{{support_email}}">contact support</a> if you have questions.</p>
|
||||
|
||||
<!-- End -->
|
||||
|
||||
<p>If you have any questions, feel free to <a href="mailto:{{support_email}}">email our customer success team</a>.
|
||||
|
||||
<p>Thanks,
|
||||
<br>{{ sender_name }}</p>
|
||||
<p><strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the {{ product_name }} support team is always ready to help!</p>
|
||||
|
||||
<!-- Sub copy -->
|
||||
|
||||
<table class="body-sub">
|
||||
<tr>
|
||||
<td>
|
||||
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
|
||||
<p class="sub">{{action_url}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
@@ -0,0 +1,50 @@
|
||||
alias: `user-invitation`
|
||||
|
||||
subject: `{{ invite_sender_name }} invited you to {{ product_name }}`
|
||||
|
||||
body:
|
||||
`
|
||||
|
||||
<h1>Hi, {{name}}!</h1>
|
||||
<p>{{ invite_sender_name }} with {{invite_sender_organization}} has invited you to use {{ product_name }} to collaborate with them. Use the button below to set up your account and get started:</p>
|
||||
<!-- Action -->
|
||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{action_url}}" class="button button--" target="_blank">Set up account</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- End -->
|
||||
|
||||
<p>If you have any questions, feel free to <a href="mailto:{{support_email}}">email our customer success team</a>.
|
||||
|
||||
<p>Thanks,
|
||||
<br>{{ sender_name }}</p>
|
||||
<p><strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the {{ product_name }} support team is always ready to help!</p>
|
||||
|
||||
<!-- Sub copy -->
|
||||
|
||||
<table class="body-sub">
|
||||
<tr>
|
||||
<td>
|
||||
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
|
||||
<p class="sub">{{action_url}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
@@ -0,0 +1,25 @@
|
||||
alias: `comment-notification`
|
||||
|
||||
subject: `{{commenter_name}} made a comment on {{ folio }}`
|
||||
|
||||
body:
|
||||
`
|
||||
|
||||
<p>{{ body }}</p>
|
||||
<p>By {{commenter_name}} ({{commenter_email}}) at {{timestamp}}</p>
|
||||
|
||||
<p class="sub"><a href="{{action_url}}">View the comment</a></p>
|
||||
|
||||
<!-- End -->
|
||||
|
||||
<!-- Sub copy -->
|
||||
|
||||
<table class="body-sub">
|
||||
<tr>
|
||||
<td>
|
||||
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
|
||||
<p class="sub">{{action_url}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
@@ -0,0 +1,50 @@
|
||||
alias: `email-verification`
|
||||
|
||||
subject: `Verify your email address with {{product_name}}`
|
||||
|
||||
body:
|
||||
`
|
||||
|
||||
<p>Click the button below to verify your email address for {{product_name}}.</p>
|
||||
|
||||
<!-- Action -->
|
||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{action_url}}" class="button button--" target="_blank">Verify</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- End -->
|
||||
|
||||
<p>If you have any questions, feel free to <a href="mailto:{{support_email}}">email our customer success team</a>.
|
||||
|
||||
<p>Thanks,
|
||||
<br>{{ sender_name }}</p>
|
||||
<p><strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the {{ product_name }} support team is always ready to help!</p>
|
||||
|
||||
<!-- Sub copy -->
|
||||
|
||||
<table class="body-sub">
|
||||
<tr>
|
||||
<td>
|
||||
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
|
||||
<p class="sub">{{action_url}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
@@ -0,0 +1,50 @@
|
||||
alias: `account-setup`
|
||||
|
||||
subject: `Set up your account for {{product_name}}`
|
||||
|
||||
body:
|
||||
`
|
||||
|
||||
<p>Thank you for subscribing to <b>{{product_name}}</b>, click the button below to set up your account.</p>
|
||||
|
||||
<!-- Action -->
|
||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<!-- Border based button https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table border="0" cellspacing="0" cellpadding="0">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{action_url}}" class="button button--" target="_blank">Set up account</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<!-- End -->
|
||||
|
||||
<p>If you have any questions, feel free to <a href="mailto:{{support_email}}">email our customer success team</a>.
|
||||
|
||||
<p>Thanks,
|
||||
<br>{{ sender_name }}</p>
|
||||
<p><strong>P.S.</strong> Need immediate help getting started? Just reply to this email, the {{ product_name }} support team is always ready to help!</p>
|
||||
|
||||
<!-- Sub copy -->
|
||||
|
||||
<table class="body-sub">
|
||||
<tr>
|
||||
<td>
|
||||
<p class="sub">If you’re having trouble with the button above, copy and paste the URL below into your web browser.</p>
|
||||
<p class="sub">{{action_url}}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum EntityLimitType {
|
||||
MAX,
|
||||
MONTHLY,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export enum PropertyAttributeName {
|
||||
HintText = "HintText", // Common
|
||||
HelpText = "HelpText", // Common
|
||||
Icon = "Icon", // Common
|
||||
Pattern = "pattern", // Text
|
||||
Min = "Min",
|
||||
Max = "Max",
|
||||
Step = "Step",
|
||||
Rows = "Rows",
|
||||
DefaultValue = "DefaultValue",
|
||||
Uppercase = "Uppercase",
|
||||
Lowercase = "Lowercase",
|
||||
Placeholder = "Placeholder",
|
||||
AcceptFileTypes = "AcceptFileTypes",
|
||||
MaxSize = "MaxSize",
|
||||
Editor = "editor",
|
||||
EditorLanguage = "EditorLanguage",
|
||||
EditorSize = "EditorSize",
|
||||
Columns = "Columns",
|
||||
Group = "Group",
|
||||
FormatNumber = "FormatNumber",
|
||||
FormatDate = "FormatDate",
|
||||
FormatBoolean = "FormatBoolean",
|
||||
Separator = "Separator",
|
||||
SelectOptions = "SelectOptions",
|
||||
Password = "Password",
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export enum PropertyCondition {
|
||||
EQUAL, // =
|
||||
GREATER_THAN, // >
|
||||
EQUAL_OR_GREATER_THAN, // >=
|
||||
LESS_THAN, // <
|
||||
EQUAL_OR_LESS_THAN, // <=,
|
||||
// CONTAINS, // (),
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export enum PropertyType {
|
||||
NUMBER = 0,
|
||||
TEXT = 1,
|
||||
DATE = 2,
|
||||
// USER = 3,
|
||||
// ROLE = 4,
|
||||
// ENTITY = 5,
|
||||
MEDIA = 6,
|
||||
// ID = 7,
|
||||
SELECT = 8,
|
||||
// FORMULA = 9,
|
||||
BOOLEAN = 10,
|
||||
MULTI_SELECT = 11,
|
||||
MULTI_TEXT = 12,
|
||||
RANGE_NUMBER = 13,
|
||||
RANGE_DATE = 14,
|
||||
FORMULA = 15,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum PropertyValueType {
|
||||
ID,
|
||||
NUMBER,
|
||||
TEXT,
|
||||
DATE,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const RowAccessTypes = ["none", "view", "comment", "edit", "delete"] as const;
|
||||
export type RowAccess = (typeof RowAccessTypes)[number];
|
||||
@@ -0,0 +1,12 @@
|
||||
export enum ViewFilterCondition {
|
||||
equals = "equals",
|
||||
contains = "contains",
|
||||
lt = "lt",
|
||||
lte = "lte",
|
||||
gt = "gt",
|
||||
gte = "gte",
|
||||
startsWith = "startsWith",
|
||||
endsWith = "endsWith",
|
||||
in = "in",
|
||||
notIn = "notIn",
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum ApplicationLayout {
|
||||
SIDEBAR,
|
||||
STACKED,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export enum Colors {
|
||||
UNDEFINED,
|
||||
SLATE,
|
||||
GRAY,
|
||||
NEUTRAL,
|
||||
STONE,
|
||||
RED,
|
||||
ORANGE,
|
||||
AMBER,
|
||||
YELLOW,
|
||||
LIME,
|
||||
GREEN,
|
||||
EMERALD,
|
||||
TEAL,
|
||||
CYAN,
|
||||
SKY,
|
||||
BLUE,
|
||||
INDIGO,
|
||||
VIOLET,
|
||||
PURPLE,
|
||||
FUCHSIA,
|
||||
PINK,
|
||||
ROSE,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum InputType {
|
||||
TEXT,
|
||||
NUMBER,
|
||||
SELECT,
|
||||
DECIMAL,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export enum Periodicity {
|
||||
ONCE,
|
||||
MONTHLY,
|
||||
BIMONTHLY,
|
||||
QUARTERLY,
|
||||
YEARLY,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum Theme {
|
||||
LIGHT,
|
||||
DARK,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export enum PricingModel {
|
||||
FLAT_RATE,
|
||||
PER_SEAT,
|
||||
USAGE_BASED,
|
||||
FLAT_RATE_USAGE_BASED,
|
||||
ONCE,
|
||||
ALL,
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
export enum SubscriptionBillingPeriod {
|
||||
ONCE, // o
|
||||
DAILY, // d
|
||||
WEEKLY, // w
|
||||
MONTHLY, // m
|
||||
YEARLY, // y
|
||||
QUARTERLY, // q
|
||||
SEMI_ANNUAL, // s
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export enum SubscriptionFeatureLimitType {
|
||||
NOT_INCLUDED,
|
||||
INCLUDED,
|
||||
MONTHLY,
|
||||
MAX,
|
||||
UNLIMITED,
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum SubscriptionPriceType {
|
||||
ONE_TIME,
|
||||
RECURRING,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum TenantUserJoined {
|
||||
CREATOR,
|
||||
JOINED_BY_INVITATION,
|
||||
JOINED_BY_LINK,
|
||||
JOINED_BY_PUBLIC_URL,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum TenantUserStatus {
|
||||
PENDING_INVITATION,
|
||||
PENDING_ACCEPTANCE,
|
||||
ACTIVE,
|
||||
INACTIVE,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum TenantUserType {
|
||||
OWNER,
|
||||
ADMIN,
|
||||
MEMBER,
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getEnvVariableDefinition } from "@toolctrl/config";
|
||||
import { loadFeatureManifests } from "@toolctrl/plugins";
|
||||
import { queueNames } from "@toolctrl/queue";
|
||||
|
||||
const manifestLoad = await loadFeatureManifests();
|
||||
const manifests = manifestLoad.manifests;
|
||||
|
||||
const requiredPlaybooks = [
|
||||
"docs/agent-entrypoints.md",
|
||||
"docs/agent-playbooks/feature-integration.md",
|
||||
"docs/agent-playbooks/mvp-intake.md",
|
||||
"docs/agent-playbooks/admin-managed-features.md",
|
||||
"docs/agent-playbooks/worker-backed-features.md",
|
||||
];
|
||||
|
||||
function expectStringArray(value: unknown) {
|
||||
expect(Array.isArray(value)).toBe(true);
|
||||
expect((value as unknown[]).every((item) => typeof item === "string")).toBe(true);
|
||||
}
|
||||
|
||||
describe("agent feature planning docs", () => {
|
||||
it("provides the required agent entrypoint and playbooks", () => {
|
||||
requiredPlaybooks.forEach((file) => {
|
||||
expect(fs.existsSync(path.join(process.cwd(), file)), file).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("documents the key concepts agents need before implementation", () => {
|
||||
const entrypoint = fs.readFileSync(path.join(process.cwd(), "docs/agent-entrypoints.md"), "utf8");
|
||||
const featureIntegration = fs.readFileSync(path.join(process.cwd(), "docs/agent-playbooks/feature-integration.md"), "utf8");
|
||||
const adminManaged = fs.readFileSync(path.join(process.cwd(), "docs/agent-playbooks/admin-managed-features.md"), "utf8");
|
||||
const workerBacked = fs.readFileSync(path.join(process.cwd(), "docs/agent-playbooks/worker-backed-features.md"), "utf8");
|
||||
|
||||
expect(entrypoint).toContain("MVP import");
|
||||
expect(featureIntegration).toContain("Feature Integration Playbook");
|
||||
expect(featureIntegration).toContain("Do not expose provider secrets");
|
||||
expect(adminManaged).toContain("admin-managed");
|
||||
expect(adminManaged).toContain("Provider keys must not be sent to tenant loaders");
|
||||
expect(workerBacked).toContain("Browser-backed features require extra constraints");
|
||||
});
|
||||
});
|
||||
|
||||
describe("feature manifests", () => {
|
||||
it("keeps every manifest on the integration-aware schema", () => {
|
||||
manifests.forEach((manifest) => {
|
||||
expect(manifest.schemaVersion).toBe("1.0");
|
||||
expect(manifest.id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
|
||||
expect(manifest.title).toEqual(expect.any(String));
|
||||
expect(["core", "plugin-candidate", "remove-candidate", "mixed"]).toContain(manifest.classification);
|
||||
expect(["active", "proposed", "review-needed"]).toContain(manifest.status);
|
||||
expectStringArray(manifest.rationale);
|
||||
expectStringArray(manifest.codeAreas);
|
||||
expectStringArray(manifest.dependencies);
|
||||
expectStringArray(manifest.consumers);
|
||||
expectStringArray(manifest.boundaries.mustKeep);
|
||||
expectStringArray(manifest.boundaries.mayMoveToPlugin);
|
||||
expectStringArray(manifest.boundaries.mustNotTouch);
|
||||
expectStringArray(manifest.pruning.notes);
|
||||
expectStringArray(manifest.validation.source);
|
||||
expect(manifest.validation.lastReviewed).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
});
|
||||
|
||||
it("requires explicit ownership, surfaces, configuration, workers and UAT", () => {
|
||||
manifests.forEach((manifest) => {
|
||||
expect(["tenant-owned", "admin-managed", "hybrid", "system-only"]).toContain(manifest.integration.ownership);
|
||||
expectStringArray(manifest.integration.surfaces.adminRoutes);
|
||||
expectStringArray(manifest.integration.surfaces.tenantRoutes);
|
||||
expectStringArray(manifest.integration.surfaces.publicRoutes);
|
||||
expectStringArray(manifest.integration.surfaces.apiRoutes);
|
||||
expectStringArray(manifest.integration.configuration.admin);
|
||||
expectStringArray(manifest.integration.configuration.tenant);
|
||||
expectStringArray(manifest.integration.configuration.environment);
|
||||
expectStringArray(manifest.integration.configuration.secrets);
|
||||
expect(typeof manifest.integration.workers.required).toBe("boolean");
|
||||
expectStringArray(manifest.integration.workers.queues);
|
||||
expectStringArray(manifest.integration.workers.jobs);
|
||||
expectStringArray(manifest.integration.workers.notes);
|
||||
expectStringArray(manifest.integration.externalProviders);
|
||||
expectStringArray(manifest.integration.permissions);
|
||||
expectStringArray(manifest.integration.auditEvents);
|
||||
expectStringArray(manifest.integration.billingImpacts);
|
||||
expectStringArray(manifest.integration.uat);
|
||||
expect(manifest.integration.uat.length, manifest.id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("loads real docs manifests without loader diagnostics", () => {
|
||||
expect(manifestLoad.ok).toBe(true);
|
||||
expect(manifestLoad.diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it("guards the Feature Manifest manifest decision contract in docs", () => {
|
||||
const inventory = fs.readFileSync(path.join(process.cwd(), "docs/feature-inventory.md"), "utf8");
|
||||
const manifestReadme = fs.readFileSync(path.join(process.cwd(), "docs/feature-manifests/README.md"), "utf8");
|
||||
|
||||
["Manifest-Decision", "Pruned Core", "Full-Boilerplate", "plugin-candidate", "remove-candidate"].forEach((anchor) => {
|
||||
expect(inventory, `inventory missing ${anchor}`).toContain(anchor);
|
||||
expect(manifestReadme, `manifest README missing ${anchor}`).toContain(anchor);
|
||||
});
|
||||
|
||||
["core", "plugin-candidates", "remove-candidates"].forEach((manifestIdAnchor) => {
|
||||
expect(manifestReadme, `manifest README missing ${manifestIdAnchor}`).toContain(manifestIdAnchor);
|
||||
});
|
||||
});
|
||||
|
||||
it("guards admin-managed secret ownership and worker-backed declarations", () => {
|
||||
manifests
|
||||
.filter((manifest) => manifest.integration.ownership === "admin-managed")
|
||||
.forEach((manifest) => {
|
||||
expect(manifest.integration.configuration.admin.length, manifest.id).toBeGreaterThan(0);
|
||||
expect(manifest.integration.configuration.secrets.join(" ").toLowerCase(), manifest.id).toMatch(/admin|platform/);
|
||||
expect(manifest.integration.permissions.some((permission) => permission.startsWith("admin.")), manifest.id).toBe(true);
|
||||
expect(manifest.integration.auditEvents.length, manifest.id).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
manifests
|
||||
.filter((manifest) => manifest.integration.workers.required)
|
||||
.forEach((manifest) => {
|
||||
expect(manifest.integration.workers.queues.length, manifest.id).toBeGreaterThan(0);
|
||||
expect(manifest.integration.workers.jobs.length, manifest.id).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("includes the AI chatbot reference pattern for admin-managed worker-backed features", () => {
|
||||
const chatbot = manifests.find((manifest) => manifest.id === "ai-chatbot-admin-managed");
|
||||
|
||||
expect(chatbot).toBeDefined();
|
||||
expect(chatbot?.integration.ownership).toBe("admin-managed");
|
||||
expect(chatbot?.integration.externalProviders).toContain("deployment-region-ai-provider");
|
||||
expect(chatbot?.integration.workers.required).toBe(true);
|
||||
expect(chatbot?.integration.workers.queues).toContain(queueNames.aiChatbot);
|
||||
expect(chatbot?.integration.workers.queues).toContain(queueNames.browserAutomation);
|
||||
expect(chatbot?.integration.workers.jobs).toEqual(expect.arrayContaining(["chat-completion", "browser-session-run", "browser-session-cleanup", "usage-flush"]));
|
||||
expect(chatbot?.integration.configuration.environment).toEqual(
|
||||
expect.arrayContaining([
|
||||
"MISTRAL_API_KEY",
|
||||
"MISTRAL_MODEL",
|
||||
"SCALEWAY_API_KEY",
|
||||
"SCALEWAY_MODEL",
|
||||
"SCALEWAY_BASE_URL",
|
||||
"ALEPH_ALPHA_API_KEY",
|
||||
"ALEPH_ALPHA_MODEL",
|
||||
"ALEPH_ALPHA_BASE_URL",
|
||||
"BULLMQ_REDIS_URL",
|
||||
"BROWSER_RUNTIME_URL",
|
||||
])
|
||||
);
|
||||
expect(chatbot?.integration.configuration.secrets.join(" ")).toContain("never be exposed");
|
||||
});
|
||||
|
||||
it("keeps admin-managed AI/browser feature contracts backed by config and queue packages", () => {
|
||||
[
|
||||
"BULLMQ_REDIS_URL",
|
||||
"BROWSER_RUNTIME_URL",
|
||||
"BROWSER_RUNTIME_API_KEY",
|
||||
"BROWSER_SESSION_TIMEOUT_MS",
|
||||
].forEach((name) => {
|
||||
const definition = getEnvVariableDefinition(name);
|
||||
expect(definition?.classification, name).toBe("optional-provider");
|
||||
expect(definition?.targets.some((target) => target === "api" || target === "worker"), name).toBe(true);
|
||||
});
|
||||
|
||||
expect(queueNames.aiChatbot).toBe("toolctrl.ai-chatbot");
|
||||
expect(queueNames.browserAutomation).toBe("toolctrl.browser-automation");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BillingPeriodDto } from "../dtos/subscriptions/BillingPeriodDto";
|
||||
import { SubscriptionBillingPeriod } from "../enums/subscriptions/SubscriptionBillingPeriod";
|
||||
|
||||
const billingPeriods: BillingPeriodDto[] = [
|
||||
{
|
||||
value: SubscriptionBillingPeriod.ONCE,
|
||||
recurring: false,
|
||||
},
|
||||
{
|
||||
value: SubscriptionBillingPeriod.MONTHLY,
|
||||
default: true,
|
||||
recurring: true,
|
||||
},
|
||||
{
|
||||
value: SubscriptionBillingPeriod.QUARTERLY,
|
||||
disabled: false,
|
||||
recurring: true,
|
||||
},
|
||||
{
|
||||
value: SubscriptionBillingPeriod.SEMI_ANNUAL,
|
||||
disabled: false,
|
||||
recurring: true,
|
||||
},
|
||||
{
|
||||
value: SubscriptionBillingPeriod.YEARLY,
|
||||
recurring: true,
|
||||
},
|
||||
{
|
||||
value: SubscriptionBillingPeriod.DAILY,
|
||||
disabled: true,
|
||||
recurring: true,
|
||||
},
|
||||
{
|
||||
value: SubscriptionBillingPeriod.WEEKLY,
|
||||
disabled: true,
|
||||
recurring: true,
|
||||
},
|
||||
];
|
||||
export default billingPeriods;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import currencies from "./currencies";
|
||||
import { CurrencyDto } from "../dtos/subscriptions/CurrencyDto";
|
||||
|
||||
describe("currencies", () => {
|
||||
it("should contain the correct list of currencies", () => {
|
||||
const expectedCurrencies: CurrencyDto[] = [
|
||||
{
|
||||
name: "United States Dollar",
|
||||
value: "usd",
|
||||
symbol: "$",
|
||||
default: true,
|
||||
disabled: false,
|
||||
parities: [{ from: "usd", parity: 1 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Euro",
|
||||
value: "eur",
|
||||
symbol: "€",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 1 }],
|
||||
symbolRight: true,
|
||||
thousandSeparator: ".",
|
||||
decimalSeparator: ",",
|
||||
},
|
||||
{
|
||||
name: "Great Britain Pound",
|
||||
value: "gbp",
|
||||
symbol: "£",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 0.85 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Indian Rupee",
|
||||
value: "inr",
|
||||
symbol: "₹",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 79.96 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Mexican Peso",
|
||||
value: "mxn",
|
||||
symbol: "$",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 16.98 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Canadian Dollar",
|
||||
value: "cad",
|
||||
symbol: "C$",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 1.3 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Japanese Yen",
|
||||
value: "jpy",
|
||||
symbol: "¥",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 110 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Australian Dollar",
|
||||
value: "aud",
|
||||
symbol: "A$",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 1.4 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Swiss Franc",
|
||||
value: "chf",
|
||||
symbol: "CHF",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 0.92 }],
|
||||
symbolRight: true,
|
||||
thousandSeparator: "'",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Chinese Yuan",
|
||||
value: "cny",
|
||||
symbol: "¥",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 6.36 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "South Korean Won",
|
||||
value: "krw",
|
||||
symbol: "₩",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 1190 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Russian Ruble",
|
||||
value: "rub",
|
||||
symbol: "₽",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 76 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: " ",
|
||||
decimalSeparator: ",",
|
||||
},
|
||||
{
|
||||
name: "Brazilian Real",
|
||||
value: "brl",
|
||||
symbol: "R$",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 5.2 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ".",
|
||||
decimalSeparator: ",",
|
||||
},
|
||||
{
|
||||
name: "South African Rand",
|
||||
value: "zar",
|
||||
symbol: "R",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 15 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
];
|
||||
|
||||
expect(currencies).toHaveLength(expectedCurrencies.length);
|
||||
|
||||
currencies.forEach((currency, index) => {
|
||||
const expectedCurrency = expectedCurrencies[index];
|
||||
expect(currency).toMatchObject(expectedCurrency);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { CurrencyDto } from "../dtos/subscriptions/CurrencyDto";
|
||||
|
||||
const currencies: CurrencyDto[] = [
|
||||
{
|
||||
name: "United States Dollar",
|
||||
value: "usd",
|
||||
symbol: "$",
|
||||
default: true,
|
||||
disabled: false,
|
||||
parities: [{ from: "usd", parity: 1 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Euro",
|
||||
value: "eur",
|
||||
symbol: "€",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 1 }],
|
||||
symbolRight: true,
|
||||
thousandSeparator: ".",
|
||||
decimalSeparator: ",",
|
||||
},
|
||||
{
|
||||
name: "Great Britain Pound",
|
||||
value: "gbp",
|
||||
symbol: "£",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 0.85 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Indian Rupee",
|
||||
value: "inr",
|
||||
symbol: "₹",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 79.96 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Mexican Peso",
|
||||
value: "mxn",
|
||||
symbol: "$",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 16.98 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Canadian Dollar",
|
||||
value: "cad",
|
||||
symbol: "C$",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 1.3 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Japanese Yen",
|
||||
value: "jpy",
|
||||
symbol: "¥",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 110 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Australian Dollar",
|
||||
value: "aud",
|
||||
symbol: "A$",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 1.4 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Swiss Franc",
|
||||
value: "chf",
|
||||
symbol: "CHF",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 0.92 }],
|
||||
symbolRight: true,
|
||||
thousandSeparator: "'",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Chinese Yuan",
|
||||
value: "cny",
|
||||
symbol: "¥",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 6.36 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "South Korean Won",
|
||||
value: "krw",
|
||||
symbol: "₩",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 1190 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
{
|
||||
name: "Russian Ruble",
|
||||
value: "rub",
|
||||
symbol: "₽",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 76 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: " ",
|
||||
decimalSeparator: ",",
|
||||
},
|
||||
{
|
||||
name: "Brazilian Real",
|
||||
value: "brl",
|
||||
symbol: "R$",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 5.2 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ".",
|
||||
decimalSeparator: ",",
|
||||
},
|
||||
{
|
||||
name: "South African Rand",
|
||||
value: "zar",
|
||||
symbol: "R",
|
||||
disabled: true,
|
||||
parities: [{ from: "usd", parity: 15 }],
|
||||
symbolRight: false,
|
||||
thousandSeparator: ",",
|
||||
decimalSeparator: ".",
|
||||
},
|
||||
];
|
||||
export default currencies;
|
||||
@@ -0,0 +1,331 @@
|
||||
import { SubscriptionBillingPeriod } from "~/application/enums/subscriptions/SubscriptionBillingPeriod";
|
||||
import { SubscriptionPriceType } from "~/application/enums/subscriptions/SubscriptionPriceType";
|
||||
import { DefaultFeatures } from "../dtos/shared/DefaultFeatures";
|
||||
import { SubscriptionPriceDto } from "../dtos/subscriptions/SubscriptionPriceDto";
|
||||
import { SubscriptionProductDto } from "../dtos/subscriptions/SubscriptionProductDto";
|
||||
import { SubscriptionUsageBasedPriceDto } from "../dtos/subscriptions/SubscriptionUsageBasedPriceDto";
|
||||
import { SubscriptionUsageBasedTierDto } from "../dtos/subscriptions/SubscriptionUsageBasedTierDto";
|
||||
import { PricingModel } from "../enums/subscriptions/PricingModel";
|
||||
import { SubscriptionFeatureLimitType } from "../enums/subscriptions/SubscriptionFeatureLimitType";
|
||||
|
||||
const UNIT_API_CALL = {
|
||||
name: "api",
|
||||
title: "API Call",
|
||||
titlePlural: "API Calls",
|
||||
};
|
||||
|
||||
function generatePrice(currency: string, price: number, billingPeriod: SubscriptionBillingPeriod): SubscriptionPriceDto {
|
||||
return {
|
||||
stripeId: "",
|
||||
subscriptionProductId: "",
|
||||
type: billingPeriod === SubscriptionBillingPeriod.ONCE ? SubscriptionPriceType.ONE_TIME : SubscriptionPriceType.RECURRING,
|
||||
billingPeriod,
|
||||
price: Math.round(price * 100) / 100,
|
||||
currency,
|
||||
trialDays: 0,
|
||||
active: true,
|
||||
usageBasedPrices: [],
|
||||
};
|
||||
}
|
||||
|
||||
function generateUsageBasedPrice(
|
||||
currency: string,
|
||||
unit: {
|
||||
name: string;
|
||||
title: string;
|
||||
titlePlural: string;
|
||||
},
|
||||
usageType: "licensed" | "metered",
|
||||
aggregateUsage: "last_during_period" | "last_ever" | "max" | "sum",
|
||||
tiersMode: "graduated" | "volume",
|
||||
billingScheme: "per_unit" | "tiered",
|
||||
tiers: {
|
||||
from: number;
|
||||
to: undefined | number;
|
||||
perUnitPrice: number | undefined;
|
||||
flatFeePrice: number | undefined;
|
||||
}[]
|
||||
): SubscriptionUsageBasedPriceDto {
|
||||
const usageBasedPriceTiers: SubscriptionUsageBasedTierDto[] = [];
|
||||
tiers.forEach((tier) => {
|
||||
usageBasedPriceTiers.push({
|
||||
id: "",
|
||||
subscriptionUsageBasedPriceId: "",
|
||||
...tier,
|
||||
});
|
||||
});
|
||||
return {
|
||||
id: "",
|
||||
stripeId: "",
|
||||
subscriptionProductId: "",
|
||||
billingPeriod: SubscriptionBillingPeriod.MONTHLY,
|
||||
currency,
|
||||
unit: unit.name,
|
||||
unitTitle: unit.title,
|
||||
unitTitlePlural: unit.titlePlural,
|
||||
usageType,
|
||||
aggregateUsage,
|
||||
tiersMode,
|
||||
billingScheme,
|
||||
tiers: usageBasedPriceTiers,
|
||||
};
|
||||
}
|
||||
|
||||
function generateCommonPlan(order: number, model: PricingModel) {
|
||||
if (order === 1) {
|
||||
return {
|
||||
stripeId: "",
|
||||
order,
|
||||
title: "pricing.products.plan1.title",
|
||||
description: "pricing.products.plan1.description",
|
||||
model,
|
||||
public: true,
|
||||
features: [
|
||||
{
|
||||
order: 1,
|
||||
title: "pricing.features.users.max",
|
||||
name: DefaultFeatures.Users,
|
||||
value: 2,
|
||||
type: SubscriptionFeatureLimitType.MAX,
|
||||
accumulate: false,
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
title: "pricing.features.apiCalls.units",
|
||||
name: DefaultFeatures.API,
|
||||
value: 0,
|
||||
type: SubscriptionFeatureLimitType.NOT_INCLUDED,
|
||||
accumulate: false,
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
title: "pricing.features.prioritySupport.none",
|
||||
name: DefaultFeatures.PrioritySupport,
|
||||
value: 0,
|
||||
type: SubscriptionFeatureLimitType.NOT_INCLUDED,
|
||||
accumulate: false,
|
||||
},
|
||||
],
|
||||
badge: "",
|
||||
active: true,
|
||||
prices: [],
|
||||
};
|
||||
} else if (order === 2) {
|
||||
return {
|
||||
stripeId: "",
|
||||
order,
|
||||
title: "pricing.products.plan2.title",
|
||||
description: "pricing.products.plan2.description",
|
||||
public: true,
|
||||
model,
|
||||
features: [
|
||||
{
|
||||
order: 1,
|
||||
title: "pricing.features.users.max",
|
||||
name: DefaultFeatures.Users,
|
||||
value: 5,
|
||||
type: SubscriptionFeatureLimitType.MAX,
|
||||
accumulate: false,
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
title: "pricing.features.apiCalls.monthly",
|
||||
name: DefaultFeatures.API,
|
||||
value: 100,
|
||||
type: SubscriptionFeatureLimitType.MONTHLY,
|
||||
accumulate: false,
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
title: "pricing.features.prioritySupport.basic",
|
||||
name: DefaultFeatures.PrioritySupport,
|
||||
value: 0,
|
||||
type: SubscriptionFeatureLimitType.INCLUDED,
|
||||
accumulate: false,
|
||||
},
|
||||
],
|
||||
badge: "pricing.recommended",
|
||||
active: true,
|
||||
};
|
||||
} else if (order === 3) {
|
||||
return {
|
||||
stripeId: "",
|
||||
order,
|
||||
title: "pricing.products.plan3.title",
|
||||
description: "pricing.products.plan3.description",
|
||||
public: true,
|
||||
model,
|
||||
features: [
|
||||
{
|
||||
order: 1,
|
||||
title: "pricing.features.users.max",
|
||||
name: DefaultFeatures.Users,
|
||||
value: 12,
|
||||
type: SubscriptionFeatureLimitType.MAX,
|
||||
accumulate: false,
|
||||
},
|
||||
{
|
||||
order: 3,
|
||||
title: "pricing.features.apiCalls.unlimited",
|
||||
name: DefaultFeatures.API,
|
||||
value: 0,
|
||||
type: SubscriptionFeatureLimitType.UNLIMITED,
|
||||
accumulate: false,
|
||||
},
|
||||
{
|
||||
order: 4,
|
||||
title: "pricing.features.prioritySupport.priority",
|
||||
name: DefaultFeatures.PrioritySupport,
|
||||
value: 0,
|
||||
type: SubscriptionFeatureLimitType.INCLUDED,
|
||||
accumulate: false,
|
||||
},
|
||||
],
|
||||
badge: "",
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
stripeId: "",
|
||||
order,
|
||||
title: "pricing.products.plan1.title",
|
||||
description: "pricing.products.plan1.description",
|
||||
model,
|
||||
public: true,
|
||||
features: [
|
||||
{
|
||||
order: 1,
|
||||
title: "pricing.features.users.one",
|
||||
name: DefaultFeatures.Users,
|
||||
value: 1,
|
||||
type: SubscriptionFeatureLimitType.MAX,
|
||||
accumulate: false,
|
||||
},
|
||||
],
|
||||
badge: "",
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
|
||||
const FLAT_RATE_PRICES: SubscriptionProductDto[] = [
|
||||
{
|
||||
...generateCommonPlan(1, PricingModel.FLAT_RATE),
|
||||
prices: [
|
||||
generatePrice("usd", 9, SubscriptionBillingPeriod.MONTHLY),
|
||||
generatePrice("usd", 90, SubscriptionBillingPeriod.YEARLY),
|
||||
generatePrice("mxn", 199, SubscriptionBillingPeriod.MONTHLY),
|
||||
generatePrice("mxn", 1990, SubscriptionBillingPeriod.YEARLY),
|
||||
],
|
||||
usageBasedPrices: [],
|
||||
},
|
||||
{
|
||||
...generateCommonPlan(2, PricingModel.FLAT_RATE),
|
||||
prices: [
|
||||
generatePrice("usd", 199, SubscriptionBillingPeriod.MONTHLY),
|
||||
generatePrice("usd", 1990, SubscriptionBillingPeriod.YEARLY),
|
||||
generatePrice("mxn", 3999, SubscriptionBillingPeriod.MONTHLY),
|
||||
generatePrice("mxn", 39990, SubscriptionBillingPeriod.YEARLY),
|
||||
],
|
||||
usageBasedPrices: [],
|
||||
},
|
||||
{
|
||||
...generateCommonPlan(3, PricingModel.FLAT_RATE),
|
||||
prices: [
|
||||
generatePrice("usd", 399, SubscriptionBillingPeriod.MONTHLY),
|
||||
generatePrice("usd", 3990, SubscriptionBillingPeriod.YEARLY),
|
||||
generatePrice("mxn", 7999, SubscriptionBillingPeriod.MONTHLY),
|
||||
generatePrice("mxn", 79990, SubscriptionBillingPeriod.YEARLY),
|
||||
],
|
||||
usageBasedPrices: [],
|
||||
},
|
||||
];
|
||||
|
||||
const PER_SEAT_PRICES: SubscriptionProductDto[] = [
|
||||
...FLAT_RATE_PRICES.map((item) => {
|
||||
return { ...item, model: PricingModel.PER_SEAT };
|
||||
}),
|
||||
];
|
||||
|
||||
const USAGE_BASED_PRICES: SubscriptionProductDto[] = [
|
||||
{
|
||||
...generateCommonPlan(1, PricingModel.USAGE_BASED),
|
||||
prices: [],
|
||||
usageBasedPrices: [
|
||||
generateUsageBasedPrice("usd", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
|
||||
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
|
||||
{ from: 11, to: 20, perUnitPrice: 0.02, flatFeePrice: 5 },
|
||||
{ from: 21, to: undefined, perUnitPrice: 0.01, flatFeePrice: 10 },
|
||||
]),
|
||||
generateUsageBasedPrice("mxn", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
|
||||
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
|
||||
{ from: 11, to: 20, perUnitPrice: 0.4, flatFeePrice: 100 },
|
||||
{ from: 21, to: undefined, perUnitPrice: 0.2, flatFeePrice: 200 },
|
||||
]),
|
||||
],
|
||||
},
|
||||
{
|
||||
...generateCommonPlan(2, PricingModel.USAGE_BASED),
|
||||
prices: [],
|
||||
usageBasedPrices: [
|
||||
generateUsageBasedPrice("usd", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
|
||||
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
|
||||
{ from: 11, to: 20, perUnitPrice: 0.04, flatFeePrice: 10 },
|
||||
{ from: 21, to: undefined, perUnitPrice: 0.02, flatFeePrice: 20 },
|
||||
]),
|
||||
generateUsageBasedPrice("mxn", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
|
||||
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
|
||||
{ from: 11, to: 20, perUnitPrice: 0.8, flatFeePrice: 200 },
|
||||
{ from: 21, to: undefined, perUnitPrice: 0.4, flatFeePrice: 400 },
|
||||
]),
|
||||
],
|
||||
},
|
||||
{
|
||||
...generateCommonPlan(3, PricingModel.USAGE_BASED),
|
||||
prices: [],
|
||||
usageBasedPrices: [
|
||||
generateUsageBasedPrice("usd", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
|
||||
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
|
||||
{ from: 11, to: 20, perUnitPrice: 0.06, flatFeePrice: 15 },
|
||||
{ from: 21, to: undefined, perUnitPrice: 0.03, flatFeePrice: 30 },
|
||||
]),
|
||||
generateUsageBasedPrice("mxn", UNIT_API_CALL, "metered", "sum", "volume", "tiered", [
|
||||
{ from: 0, to: 10, perUnitPrice: 0.0, flatFeePrice: undefined },
|
||||
{ from: 11, to: 20, perUnitPrice: 1.2, flatFeePrice: 300 },
|
||||
{ from: 21, to: undefined, perUnitPrice: 0.6, flatFeePrice: 600 },
|
||||
]),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const FLAT_RATE_PLUS_USAGE_BASED_PRICES: SubscriptionProductDto[] = [
|
||||
...FLAT_RATE_PRICES.map((flatRate) => {
|
||||
const usageBasedPrices = USAGE_BASED_PRICES.find((f) => f.order === flatRate.order)?.usageBasedPrices ?? [];
|
||||
return {
|
||||
...flatRate,
|
||||
usageBasedPrices,
|
||||
model: PricingModel.FLAT_RATE_USAGE_BASED,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const ONE_TIME_PRICES: SubscriptionProductDto[] = [
|
||||
{
|
||||
...generateCommonPlan(1, PricingModel.ONCE),
|
||||
prices: [generatePrice("usd", 99 * 2, SubscriptionBillingPeriod.ONCE), generatePrice("mxn", 1999 * 2, SubscriptionBillingPeriod.ONCE)],
|
||||
usageBasedPrices: [],
|
||||
},
|
||||
{
|
||||
...generateCommonPlan(2, PricingModel.ONCE),
|
||||
prices: [generatePrice("usd", 499 * 2, SubscriptionBillingPeriod.ONCE), generatePrice("mxn", 9999 * 2, SubscriptionBillingPeriod.ONCE)],
|
||||
usageBasedPrices: [],
|
||||
},
|
||||
{
|
||||
...generateCommonPlan(3, PricingModel.ONCE),
|
||||
prices: [generatePrice("usd", 1999 * 2, SubscriptionBillingPeriod.ONCE), generatePrice("mxn", 4999 * 2, SubscriptionBillingPeriod.ONCE)],
|
||||
usageBasedPrices: [],
|
||||
},
|
||||
];
|
||||
|
||||
const plans = [...FLAT_RATE_PRICES, ...PER_SEAT_PRICES, ...USAGE_BASED_PRICES, ...FLAT_RATE_PLUS_USAGE_BASED_PRICES, ...ONE_TIME_PRICES];
|
||||
export default plans;
|
||||
@@ -0,0 +1,166 @@
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { AdminSidebar } from "./AdminSidebar";
|
||||
import type { SideBarItem } from "./SidebarItem";
|
||||
import { canonicalAdminRouteSlugs, disabledPluginRouteSlugs } from "~/utils/app/PublicRouteSegments";
|
||||
import { resolveRepoPath } from "~/utils/testing/repoPaths";
|
||||
|
||||
function flattenSidebarItems(items: SideBarItem[]): SideBarItem[] {
|
||||
return items.flatMap((item) => [item, ...flattenSidebarItems(item.items ?? [])]);
|
||||
}
|
||||
|
||||
function walkRouteFiles(directory: string): string[] {
|
||||
return readdirSync(directory).flatMap((entry) => {
|
||||
const absolutePath = path.join(directory, entry);
|
||||
const stats = statSync(absolutePath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
return walkRouteFiles(absolutePath);
|
||||
}
|
||||
|
||||
if (!/\.(tsx?|jsx?)$/.test(entry) || /\.(test|spec)\.(tsx?|jsx?)$/.test(entry) || entry.endsWith(".server.ts")) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [absolutePath];
|
||||
});
|
||||
}
|
||||
|
||||
function routePathFromAdminRouteFile(absolutePath: string): string | null {
|
||||
const routeRoot = resolveRepoPath("apps/web/app/routes/admin");
|
||||
const relativePath = path.relative(routeRoot, absolutePath).replace(/\\/g, "/");
|
||||
|
||||
if (relativePath === "404.tsx" || relativePath.startsWith("internal/")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withoutExtension = relativePath.replace(/\.(tsx?|jsx?)$/, "");
|
||||
const parts = withoutExtension
|
||||
.split("/")
|
||||
.flatMap((part) => part.split("."))
|
||||
.filter((part) => part && part !== "index" && !part.startsWith("__"));
|
||||
|
||||
return parts.join("/");
|
||||
}
|
||||
|
||||
function collectAdminRoutePaths(): string[] {
|
||||
return walkRouteFiles(resolveRepoPath("apps/web/app/routes/admin"))
|
||||
.map(routePathFromAdminRouteFile)
|
||||
.filter((routePath): routePath is string => routePath !== null);
|
||||
}
|
||||
|
||||
function targetMatchesRoute(target: string, routePath: string): boolean {
|
||||
const targetParts = target.split("/").filter(Boolean);
|
||||
const routeParts = routePath.split("/").filter(Boolean);
|
||||
|
||||
if (targetParts.length !== routeParts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return routeParts.every((routePart, index) => routePart.startsWith("$") || routePart === targetParts[index]);
|
||||
}
|
||||
|
||||
function normalizeSidebarPath(pathname: string): string | null {
|
||||
if (!pathname.startsWith("/") || pathname === "/") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return pathname.split(/[?#]/)[0]?.replace(/^\/+|\/+$/g, "") || null;
|
||||
}
|
||||
|
||||
describe("AdminSidebar", () => {
|
||||
const t = vi.fn().mockImplementation((v: string) => v) as any;
|
||||
|
||||
it("returns an array of sidebar items", () => {
|
||||
const sidebar = AdminSidebar({ t, appConfiguration: null });
|
||||
const serialized = JSON.stringify(sidebar);
|
||||
const allPaths = flattenSidebarItems(sidebar)
|
||||
.map((item) => item.path)
|
||||
.filter((path): path is string => Boolean(path) && path !== "/" && path.startsWith("/"));
|
||||
|
||||
expect(Array.isArray(sidebar)).toBe(true);
|
||||
expect(serialized).toContain("/dashboard");
|
||||
expect(serialized).toContain("/accounts");
|
||||
expect(serialized).toContain("/analytics/overview");
|
||||
expect(serialized).toContain("/audit-trails");
|
||||
expect(serialized).toContain("/notifications/messages");
|
||||
expect(serialized).toContain("/feature-flags/flags");
|
||||
expect(serialized).toContain("/onboarding/onboardings");
|
||||
expect(serialized).toContain("/metrics/summary");
|
||||
expect(serialized).toContain("/settings/profile");
|
||||
expect(sidebar).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "ui.nav.admin.dashboard", path: "/dashboard" }),
|
||||
]),
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(sidebar.at(-1)?.items).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "ui.nav.admin.switchToApp", path: "/", exact: true }),
|
||||
])
|
||||
);
|
||||
const emittedPaths = serialized.match(/"path":"[^"]*"/g) ?? [];
|
||||
expect(emittedPaths.filter((path) => path.includes("/admin/"))).toEqual([]);
|
||||
expect(serialized).not.toContain("/admin/pages");
|
||||
expect(serialized).not.toContain("/admin/blog");
|
||||
expect(serialized).not.toContain("/admin/crons");
|
||||
expect(serialized).not.toContain("/email-marketing");
|
||||
expect(serialized).not.toContain("/forms");
|
||||
expect(serialized).not.toContain("/help-desk");
|
||||
expect(serialized).not.toContain("/knowledge-base");
|
||||
expect(serialized).not.toContain("/portals");
|
||||
expect(serialized).not.toContain("/prompts");
|
||||
expect(serialized).not.toContain("/workflow-engine");
|
||||
expect(serialized).not.toContain("\"path\":\"/crm\"");
|
||||
expect(serialized).not.toContain("ui.nav.admin.crm");
|
||||
expect(allPaths.map((path) => path.split("/").filter(Boolean)[0])).not.toEqual(expect.arrayContaining([...disabledPluginRouteSlugs]));
|
||||
expect(allPaths.map((path) => path.split("/").filter(Boolean)[0])).toEqual(expect.arrayContaining(["dashboard", "accounts", "settings"]));
|
||||
allPaths.forEach((path) => {
|
||||
expect(canonicalAdminRouteSlugs).toContain(path.split("/").filter(Boolean)[0] as any);
|
||||
});
|
||||
|
||||
sidebar.forEach((item) => {
|
||||
expect(typeof item.title).toBe("string");
|
||||
expect(typeof item.path).toBe("string");
|
||||
|
||||
if (item.items) {
|
||||
expect(Array.isArray(item.items)).toBe(true);
|
||||
|
||||
item.items.forEach((subItem) => {
|
||||
expect(typeof subItem.title).toBe("string");
|
||||
expect(typeof subItem.path).toBe("string");
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("only emits canonical admin sidebar paths backed by route files", () => {
|
||||
const routePaths = collectAdminRoutePaths();
|
||||
const sidebar = AdminSidebar({
|
||||
t,
|
||||
appConfiguration: {
|
||||
app: {
|
||||
features: {
|
||||
tenantApiKeys: true,
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
const sidebarTargets = flattenSidebarItems(sidebar)
|
||||
.map((item) => item.path)
|
||||
.filter((pathname): pathname is string => Boolean(pathname))
|
||||
.map(normalizeSidebarPath)
|
||||
.filter((pathname): pathname is string => pathname !== null);
|
||||
|
||||
expect(sidebarTargets).not.toContain("admin");
|
||||
expect(sidebarTargets.map((pathname) => pathname.split("/")[0])).not.toEqual(expect.arrayContaining([...disabledPluginRouteSlugs]));
|
||||
|
||||
const missingTargets = sidebarTargets.filter((target) => !routePaths.some((routePath) => targetMatchesRoute(target, routePath)));
|
||||
expect(missingTargets).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,354 @@
|
||||
import { SideBarItem } from "./SidebarItem";
|
||||
import { TFunction } from "i18next";
|
||||
import IconDashboard from "~/components/layouts/icons/IconDashboard";
|
||||
import MembershipCardIcon from "~/components/ui/icons/MembershipCardIcon";
|
||||
import { AppConfiguration } from "~/utils/db/appConfiguration.db.server";
|
||||
import IconSettings from "~/components/layouts/icons/IconSettings";
|
||||
import IconApp from "~/components/layouts/icons/IconApp";
|
||||
import IconKeys from "~/components/layouts/icons/IconKeys";
|
||||
import IconEntities from "~/components/layouts/icons/IconEntities";
|
||||
import IconAnalytics from "~/components/layouts/icons/IconAnalytics";
|
||||
import IconDocs from "~/components/layouts/icons/IconDocs";
|
||||
import IconEmails from "~/components/layouts/icons/IconEmails";
|
||||
import IconEvents from "~/components/layouts/icons/IconEvents";
|
||||
import IconFeatureFlags from "~/components/layouts/icons/IconFeatureFlags";
|
||||
import IconHelpDesk from "~/components/layouts/icons/IconHelpDesk";
|
||||
import IconMetrics from "~/components/layouts/icons/IconMetrics";
|
||||
import IconNavigation from "~/components/layouts/icons/IconNavigation";
|
||||
import IconNotifications from "~/components/layouts/icons/IconNotifications";
|
||||
import IconOnboarding from "~/components/layouts/icons/IconOnboarding";
|
||||
import IconPortals from "~/components/layouts/icons/IconPortals";
|
||||
import IconWorkflows from "~/components/layouts/icons/IconWorkflows";
|
||||
import { UI_NAV_IDS } from "~/application/ui/uiRegistry";
|
||||
import { adminPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import { absoluteAppRootPath, type PublicDomainRoutingConfig } from "~/utils/app/CrossHostUrls";
|
||||
import { disabledPluginRouteSlugs } from "~/utils/app/PublicRouteSegments";
|
||||
|
||||
const adminSidebarPath = (path?: string) => adminPath(path, { mode: "host-aware" });
|
||||
const rootAppFallbackPath = "/";
|
||||
const disabledAdminRouteSlugs = new Set<string>(disabledPluginRouteSlugs);
|
||||
|
||||
function isAdminRouteEnabled(slug: string) {
|
||||
return !disabledAdminRouteSlugs.has(slug);
|
||||
}
|
||||
|
||||
function enabledAdminRouteItem(slug: string, item: SideBarItem): SideBarItem[] {
|
||||
return isAdminRouteEnabled(slug) ? [item] : [];
|
||||
}
|
||||
|
||||
type Props = {
|
||||
t: TFunction;
|
||||
appConfiguration: AppConfiguration | null;
|
||||
routing?: PublicDomainRoutingConfig;
|
||||
};
|
||||
|
||||
export const AdminSidebar = ({ t, appConfiguration, routing }: Props): SideBarItem[] => [
|
||||
{
|
||||
id: "ui.nav.admin.primary",
|
||||
title: "",
|
||||
path: "",
|
||||
items: [
|
||||
{
|
||||
id: UI_NAV_IDS.adminDashboard,
|
||||
title: t("app.sidebar.dashboard"),
|
||||
path: adminSidebarPath("dashboard"),
|
||||
icon: <IconDashboard />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ui.nav.admin.manage",
|
||||
title: t("segments.manage"),
|
||||
icon: <IconDashboard />,
|
||||
path: "",
|
||||
items: [
|
||||
{
|
||||
id: UI_NAV_IDS.adminAccounts,
|
||||
title: t("app.sidebar.accountsAndUsers"),
|
||||
path: adminSidebarPath("accounts"),
|
||||
icon: <MembershipCardIcon />,
|
||||
isCollapsible: true,
|
||||
isActive: false,
|
||||
items: [
|
||||
{
|
||||
id: "ui.nav.admin.accounts.tenants",
|
||||
title: t("admin.tenants.title"),
|
||||
path: adminSidebarPath("accounts"),
|
||||
// icon: SvgIcon.TENANTS,
|
||||
items: [],
|
||||
permission: "admin.accounts.view",
|
||||
},
|
||||
{
|
||||
id: "ui.nav.admin.accounts.users",
|
||||
title: t("models.user.plural"),
|
||||
path: adminSidebarPath("accounts/users"),
|
||||
items: [],
|
||||
permission: "admin.users.view",
|
||||
},
|
||||
{
|
||||
id: "ui.nav.admin.accounts.usageFeatures",
|
||||
title: t("admin.accounts.usageFeatures"),
|
||||
path: adminSidebarPath("accounts/subscriptions"),
|
||||
items: [],
|
||||
permission: "admin.accounts.view",
|
||||
},
|
||||
{
|
||||
id: "ui.nav.admin.accounts.rolesAndPermissions",
|
||||
title: t("app.sidebar.rolesAndPermissions"),
|
||||
path: adminSidebarPath("accounts/roles-and-permissions"),
|
||||
items: [],
|
||||
permission: "admin.roles.view",
|
||||
},
|
||||
{
|
||||
id: "ui.nav.admin.accounts.blacklist",
|
||||
title: t("models.blacklist.object"),
|
||||
path: adminSidebarPath("accounts/blacklist"),
|
||||
items: [],
|
||||
permission: "admin.blacklist.view",
|
||||
},
|
||||
{
|
||||
id: "ui.nav.admin.accounts.ipAddresses",
|
||||
title: t("models.tenantIpAddress.plural"),
|
||||
path: adminSidebarPath("accounts/ip-addresses"),
|
||||
items: [],
|
||||
permission: "admin.tenantIpAddress.view",
|
||||
},
|
||||
],
|
||||
permission: "admin.accounts.view",
|
||||
},
|
||||
{
|
||||
id: UI_NAV_IDS.adminAnalytics,
|
||||
title: t("app.sidebar.analytics"),
|
||||
path: adminSidebarPath("analytics"),
|
||||
icon: <IconAnalytics />,
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.analytics.overview", title: t("app.sidebar.overview"), path: adminSidebarPath("analytics/overview"), permission: "admin.analytics.view" },
|
||||
{ id: "ui.nav.admin.analytics.pageViews", title: t("app.sidebar.pageViews"), path: adminSidebarPath("analytics/page-views"), permission: "admin.analytics.view" },
|
||||
{ id: "ui.nav.admin.analytics.visitors", title: t("app.sidebar.visitors"), path: adminSidebarPath("analytics/visitors"), permission: "admin.analytics.view" },
|
||||
{ id: "ui.nav.admin.analytics.events", title: t("models.event.plural"), path: adminSidebarPath("analytics/events"), permission: "admin.analytics.view" },
|
||||
{ id: "ui.nav.admin.analytics.settings", title: t("app.sidebar.settings"), path: adminSidebarPath("analytics/settings"), permission: "admin.settings.analytics.view" },
|
||||
],
|
||||
permission: "admin.analytics.view",
|
||||
},
|
||||
{
|
||||
id: UI_NAV_IDS.adminEvents,
|
||||
title: t("models.event.plural"),
|
||||
path: adminSidebarPath("events"),
|
||||
icon: <IconEvents />,
|
||||
permission: "admin.events.view",
|
||||
},
|
||||
{
|
||||
id: UI_NAV_IDS.adminAuditTrails,
|
||||
title: t("app.sidebar.auditTrails"),
|
||||
path: adminSidebarPath("audit-trails"),
|
||||
icon: <IconEvents />,
|
||||
permission: "admin.auditTrails.view",
|
||||
},
|
||||
{
|
||||
id: UI_NAV_IDS.adminNotifications,
|
||||
title: t("app.sidebar.notifications"),
|
||||
path: adminSidebarPath("notifications"),
|
||||
icon: <IconNotifications />,
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.notifications.messages", title: t("app.sidebar.messages"), path: adminSidebarPath("notifications/messages"), permission: "admin.notifications.view" },
|
||||
{ id: "ui.nav.admin.notifications.channels", title: t("app.sidebar.channels"), path: adminSidebarPath("notifications/channels"), permission: "admin.notifications.view" },
|
||||
{ id: "ui.nav.admin.notifications.subscribers", title: t("app.sidebar.subscribers"), path: adminSidebarPath("notifications/subscribers"), permission: "admin.notifications.view" },
|
||||
],
|
||||
permission: "admin.notifications.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ui.nav.admin.build",
|
||||
title: t("segments.build"),
|
||||
path: "",
|
||||
items: [
|
||||
{
|
||||
id: UI_NAV_IDS.adminEntities,
|
||||
title: t("models.entity.plural"),
|
||||
path: adminSidebarPath("entities"),
|
||||
icon: <IconEntities />,
|
||||
permission: "admin.entities.view",
|
||||
},
|
||||
...enabledAdminRouteItem("workflow-engine", {
|
||||
id: UI_NAV_IDS.adminWorkflows,
|
||||
title: t("models.workflow.plural"),
|
||||
path: adminSidebarPath("workflow-engine"),
|
||||
icon: <IconWorkflows />,
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.workflows.workflows", title: t("models.workflow.plural"), path: adminSidebarPath("workflow-engine/workflows") },
|
||||
{ id: "ui.nav.admin.workflows.executions", title: t("app.sidebar.executions"), path: adminSidebarPath("workflow-engine/executions") },
|
||||
{ id: "ui.nav.admin.workflows.templates", title: t("models.entity.templates"), path: adminSidebarPath("workflow-engine/templates") },
|
||||
{ id: "ui.nav.admin.workflows.credentials", title: t("app.sidebar.credentials"), path: adminSidebarPath("workflow-engine/credentials") },
|
||||
{ id: "ui.nav.admin.workflows.variables", title: t("app.sidebar.variables"), path: adminSidebarPath("workflow-engine/variables") },
|
||||
],
|
||||
}),
|
||||
{
|
||||
id: UI_NAV_IDS.adminApi,
|
||||
title: t("settings.api.title"),
|
||||
path: adminSidebarPath("api"),
|
||||
icon: <IconKeys />,
|
||||
permission: "admin.apiKeys.view",
|
||||
hidden: !appConfiguration?.app.features.tenantApiKeys,
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.api.keys", title: t("app.sidebar.apiKeys"), path: adminSidebarPath("api/keys"), permission: "admin.apiKeys.view" },
|
||||
{ id: "ui.nav.admin.api.logs", title: t("app.sidebar.logs"), path: adminSidebarPath("api/logs"), permission: "admin.apiKeys.view" },
|
||||
{ id: "ui.nav.admin.api.docs", title: t("app.sidebar.docs"), path: adminSidebarPath("api/docs"), permission: "admin.apiKeys.view" },
|
||||
{ id: "ui.nav.admin.api.credits", title: t("app.sidebar.credits"), path: adminSidebarPath("api/credits"), permission: "admin.apiKeys.view" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: UI_NAV_IDS.adminFeatureFlags,
|
||||
title: t("app.sidebar.featureFlags"),
|
||||
path: adminSidebarPath("feature-flags"),
|
||||
icon: <IconFeatureFlags />,
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.featureFlags.flags", title: t("app.sidebar.flags"), path: adminSidebarPath("feature-flags/flags"), permission: "admin.featureFlags.view" },
|
||||
{ id: "ui.nav.admin.featureFlags.settings", title: t("app.sidebar.settings"), path: adminSidebarPath("feature-flags/settings"), permission: "admin.featureFlags.view" },
|
||||
],
|
||||
permission: "admin.featureFlags.view",
|
||||
},
|
||||
{
|
||||
id: UI_NAV_IDS.adminNavigation,
|
||||
title: t("admin.navigation.title"),
|
||||
path: adminSidebarPath("navigation"),
|
||||
icon: <IconNavigation className="size-4" />,
|
||||
},
|
||||
...enabledAdminRouteItem("portals", {
|
||||
id: UI_NAV_IDS.adminPortals,
|
||||
title: t("app.sidebar.portals"),
|
||||
path: adminSidebarPath("portals"),
|
||||
icon: <IconPortals />,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ui.nav.admin.market",
|
||||
title: t("segments.market"),
|
||||
path: "",
|
||||
items: [
|
||||
...enabledAdminRouteItem("email-marketing", {
|
||||
id: UI_NAV_IDS.adminEmailMarketing,
|
||||
title: t("app.sidebar.emailMarketing"),
|
||||
path: adminSidebarPath("email-marketing"),
|
||||
icon: <IconEmails />,
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.emailMarketing.campaigns", title: t("app.sidebar.campaigns"), path: adminSidebarPath("email-marketing/campaigns"), permission: "admin.emails.view" },
|
||||
{ id: "ui.nav.admin.emailMarketing.senders", title: t("app.sidebar.senders"), path: adminSidebarPath("email-marketing/senders"), permission: "admin.emails.view" },
|
||||
{ id: "ui.nav.admin.emailMarketing.activity", title: t("app.sidebar.activity"), path: adminSidebarPath("email-marketing/activity"), permission: "admin.emails.view" },
|
||||
],
|
||||
permission: "admin.emails.view",
|
||||
}),
|
||||
{
|
||||
id: UI_NAV_IDS.adminMarketing,
|
||||
title: t("app.sidebar.marketing"),
|
||||
path: adminSidebarPath("marketing-leads"),
|
||||
icon: <IconAnalytics />,
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.marketing.leads", title: t("app.sidebar.leads"), path: adminSidebarPath("marketing-leads"), permission: "admin.marketing.leads.view" },
|
||||
{ id: "ui.nav.admin.marketing.funnels", title: t("app.sidebar.funnels"), path: adminSidebarPath("marketing-funnels"), permission: "admin.marketing.funnels.view" },
|
||||
{ id: "ui.nav.admin.marketing.integrations", title: t("app.sidebar.integrations"), path: adminSidebarPath("marketing-integrations"), permission: "admin.marketing.integrations.view" },
|
||||
{ id: "ui.nav.admin.marketing.emailSequences", title: t("app.sidebar.emailSequences"), path: adminSidebarPath("email-sequences"), permission: "admin.marketing.leads.view" },
|
||||
],
|
||||
permission: "admin.marketing.leads.view",
|
||||
},
|
||||
...enabledAdminRouteItem("knowledge-base", {
|
||||
id: UI_NAV_IDS.adminKnowledgeBase,
|
||||
title: t("app.sidebar.knowledgeBase"),
|
||||
path: adminSidebarPath("knowledge-base"),
|
||||
icon: <IconDocs className="size-4" />,
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.knowledgeBase.bases", title: t("app.sidebar.bases"), path: adminSidebarPath("knowledge-base/bases"), permission: "admin.kb.view" },
|
||||
{ id: "ui.nav.admin.knowledgeBase.articles", title: t("app.sidebar.articles"), path: adminSidebarPath("knowledge-base/articles"), permission: "admin.kb.view" },
|
||||
],
|
||||
permission: "admin.kb.view",
|
||||
}),
|
||||
...enabledAdminRouteItem("help-desk", {
|
||||
id: UI_NAV_IDS.adminHelpDesk,
|
||||
title: t("app.sidebar.helpDesk"),
|
||||
path: adminSidebarPath("help-desk"),
|
||||
icon: <IconHelpDesk className="size-4" />,
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.helpDesk.feedback", title: t("app.sidebar.feedback"), path: adminSidebarPath("help-desk/feedback") },
|
||||
],
|
||||
}),
|
||||
{
|
||||
id: UI_NAV_IDS.adminOnboarding,
|
||||
title: t("app.sidebar.onboarding"),
|
||||
path: adminSidebarPath("onboarding"),
|
||||
icon: <IconOnboarding />,
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.onboarding.onboardings", title: t("app.sidebar.onboardings"), path: adminSidebarPath("onboarding/onboardings"), permission: "admin.onboarding.view" },
|
||||
{ id: "ui.nav.admin.onboarding.sessions", title: t("app.sidebar.sessions"), path: adminSidebarPath("onboarding/sessions"), permission: "admin.onboarding.view" },
|
||||
],
|
||||
permission: "admin.onboarding.view",
|
||||
},
|
||||
{
|
||||
id: UI_NAV_IDS.adminMetrics,
|
||||
title: t("app.sidebar.metrics"),
|
||||
path: adminSidebarPath("metrics"),
|
||||
icon: <IconMetrics />,
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.metrics.summary", title: t("app.sidebar.summary"), path: adminSidebarPath("metrics/summary"), permission: "admin.metrics.view" },
|
||||
{ id: "ui.nav.admin.metrics.logs", title: t("app.sidebar.logs"), path: adminSidebarPath("metrics/logs"), permission: "admin.metrics.view" },
|
||||
{ id: "ui.nav.admin.metrics.settings", title: t("app.sidebar.settings"), path: adminSidebarPath("metrics/settings"), permission: "admin.metrics.view" },
|
||||
],
|
||||
permission: "admin.metrics.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ui.nav.admin.secondary",
|
||||
title: "",
|
||||
path: "",
|
||||
// isSecondary: true,
|
||||
items: [
|
||||
{
|
||||
id: UI_NAV_IDS.adminSettings,
|
||||
title: t("app.sidebar.settings"),
|
||||
icon: <IconSettings />,
|
||||
path: adminSidebarPath("settings"),
|
||||
isCollapsible: true,
|
||||
items: [
|
||||
{ id: "ui.nav.admin.settings.profile", title: t("settings.admin.profile.title"), path: adminSidebarPath("settings/profile") },
|
||||
{ id: "ui.nav.admin.settings.general", title: t("settings.admin.general.title"), path: adminSidebarPath("settings/general"), permission: "admin.settings.general.view" },
|
||||
{ id: "ui.nav.admin.settings.pricing", title: t("settings.admin.pricing.title"), path: adminSidebarPath("settings/pricing"), permission: "admin.pricing.view" },
|
||||
{
|
||||
id: "ui.nav.admin.settings.authentication",
|
||||
title: t("settings.admin.authentication.title"),
|
||||
path: adminSidebarPath("settings/authentication"),
|
||||
permission: "admin.settings.authentication.view",
|
||||
},
|
||||
{ id: "ui.nav.admin.settings.analytics", title: t("settings.admin.analytics.title"), path: adminSidebarPath("settings/analytics"), permission: "admin.settings.analytics.view" },
|
||||
{
|
||||
id: "ui.nav.admin.settings.transactionalEmails",
|
||||
title: t("settings.admin.transactionalEmails.title"),
|
||||
path: adminSidebarPath("settings/transactional-emails"),
|
||||
permission: "admin.emails.view",
|
||||
},
|
||||
{ id: "ui.nav.admin.settings.accountTypes", title: t("settings.admin.tenants.types.title"), path: adminSidebarPath("settings/accounts/types"), permission: "admin.accountTypes.view" },
|
||||
{ id: "ui.nav.admin.settings.cookies", title: t("settings.admin.cookies.title"), path: adminSidebarPath("settings/cookies"), permission: "admin.settings.cookies.view" },
|
||||
{ id: "ui.nav.admin.settings.cache", title: t("settings.admin.cache.title"), path: adminSidebarPath("settings/cache"), permission: "admin.settings.general.update" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: UI_NAV_IDS.adminSwitchToApp,
|
||||
title: t("admin.switchToApp"),
|
||||
path: routing ? absoluteAppRootPath(routing) : rootAppFallbackPath,
|
||||
icon: <IconApp />,
|
||||
exact: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,231 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { vi, describe, it, expect } from "vitest";
|
||||
import { AppSidebar } from "./AppSidebar";
|
||||
import { UI_NAV_IDS } from "~/application/ui/uiRegistry";
|
||||
import { EntitySimple } from "~/utils/db/entities/entities.db.server";
|
||||
import { resolveRepoPath } from "~/utils/testing/repoPaths";
|
||||
|
||||
describe("AppSidebar", () => {
|
||||
const starterProductAccess = {
|
||||
"crm-basic": { allowed: true },
|
||||
"email-marketing": { allowed: false },
|
||||
api: { allowed: false },
|
||||
"workflow-engine": { allowed: false },
|
||||
"ai-credits": { allowed: true },
|
||||
};
|
||||
const proProductAccess = {
|
||||
"crm-basic": { allowed: true },
|
||||
"email-marketing": { allowed: true },
|
||||
api: { allowed: true },
|
||||
"workflow-engine": { allowed: false },
|
||||
"ai-credits": { allowed: true },
|
||||
};
|
||||
const businessProductAccess = {
|
||||
"crm-basic": { allowed: true },
|
||||
"email-marketing": { allowed: true },
|
||||
api: { allowed: true },
|
||||
"workflow-engine": { allowed: true },
|
||||
"ai-credits": { allowed: true },
|
||||
};
|
||||
|
||||
it("returns the expected sidebar items", () => {
|
||||
const t = vi.fn().mockImplementation((v: string) => v) as any;
|
||||
const i18n = { exists: vi.fn().mockReturnValue(false) } as any;
|
||||
const entities = [
|
||||
{ name: "entity1", slug: "entity1", titlePlural: "Entity 1", showInSidebar: true, icon: "" },
|
||||
{ name: "entity2", slug: "entity2", titlePlural: "Entity 2", showInSidebar: false, icon: "" },
|
||||
{ name: "entity3", slug: "entity3", titlePlural: "Entity 3", showInSidebar: true, icon: "" },
|
||||
] as EntitySimple[];
|
||||
|
||||
const result = AppSidebar({ t, i18n, tenantId: "test-user", entities, entityGroups: [], appConfiguration: null });
|
||||
const serialized = JSON.stringify(result);
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
title: "",
|
||||
path: "",
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
id: "ui.nav.app.dashboard",
|
||||
title: "app.sidebar.dashboard",
|
||||
path: "/test-user/dashboard",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: UI_NAV_IDS.appNotifications,
|
||||
title: "app.sidebar.notifications",
|
||||
path: "/test-user/notifications",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "ui.nav.app.entity.entity1",
|
||||
title: "Entity 1",
|
||||
path: "/test-user/entity1",
|
||||
permission: "entity.entity1.view",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "ui.nav.app.entity.entity3",
|
||||
title: "Entity 3",
|
||||
path: "/test-user/entity3",
|
||||
permission: "entity.entity3.view",
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(result[1]).toMatchObject({
|
||||
title: "",
|
||||
path: "",
|
||||
isSecondary: true,
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
id: "ui.nav.app.settings",
|
||||
title: "app.sidebar.settings",
|
||||
path: "/test-user/settings",
|
||||
redirectTo: "/test-user/settings/profile",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "ui.nav.app.switchToAdmin",
|
||||
title: "admin.switchToAdmin",
|
||||
path: "/dashboard",
|
||||
adminOnly: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(serialized).not.toContain("/app/");
|
||||
expect(serialized).not.toContain("/admin/");
|
||||
expect(serialized).not.toContain("/blog");
|
||||
expect(serialized).not.toContain("/pricing");
|
||||
expect(serialized).not.toContain("/email-marketing");
|
||||
expect(serialized).not.toContain("/workflow-engine");
|
||||
expect(serialized).not.toContain("/widgets");
|
||||
expect(serialized).not.toContain("/portals");
|
||||
expect(serialized).not.toContain("/sites/");
|
||||
});
|
||||
|
||||
it("renders plan-backed core feature links from product access decisions", () => {
|
||||
const t = vi.fn().mockImplementation((v: string) => v) as any;
|
||||
const i18n = { exists: vi.fn().mockReturnValue(false) } as any;
|
||||
|
||||
const starter = JSON.stringify(
|
||||
AppSidebar({ t, i18n, tenantId: "starter", entities: [], entityGroups: [], appConfiguration: null, productAccess: starterProductAccess })
|
||||
);
|
||||
const pro = JSON.stringify(
|
||||
AppSidebar({ t, i18n, tenantId: "pro", entities: [], entityGroups: [], appConfiguration: null, productAccess: proProductAccess })
|
||||
);
|
||||
const business = JSON.stringify(
|
||||
AppSidebar({ t, i18n, tenantId: "business", entities: [], entityGroups: [], appConfiguration: null, productAccess: businessProductAccess })
|
||||
);
|
||||
|
||||
expect(starter).toContain("/starter/crm");
|
||||
expect(starter).toContain("/starter/settings/credits");
|
||||
expect(starter).not.toContain("/starter/email-marketing");
|
||||
expect(starter).not.toContain("/starter/settings/api");
|
||||
expect(starter).not.toContain("/starter/workflow-engine");
|
||||
|
||||
expect(pro).toContain("/pro/crm");
|
||||
expect(pro).toContain("/pro/email-marketing");
|
||||
expect(pro).toContain("/pro/settings/api");
|
||||
expect(pro).not.toContain("/pro/workflow-engine");
|
||||
|
||||
expect(business).toContain("/business/crm");
|
||||
expect(business).toContain("/business/email-marketing");
|
||||
expect(business).toContain("/business/settings/api");
|
||||
expect(business).toContain("/business/workflow-engine");
|
||||
});
|
||||
|
||||
it("marks collapsible entity groups as expand-only parents", () => {
|
||||
const t = vi.fn().mockImplementation((v: string) => v) as any;
|
||||
const i18n = { exists: vi.fn().mockReturnValue(false) } as any;
|
||||
const entities = [
|
||||
{ name: "company", slug: "companies", titlePlural: "Companies", showInSidebar: true, icon: "" },
|
||||
] as EntitySimple[];
|
||||
|
||||
const result = AppSidebar({
|
||||
t,
|
||||
i18n,
|
||||
tenantId: "acme",
|
||||
entities: [],
|
||||
entityGroups: [
|
||||
{
|
||||
slug: "sales",
|
||||
title: "Sales",
|
||||
icon: "",
|
||||
collapsible: true,
|
||||
section: null,
|
||||
entities: [{ entity: entities[0] }],
|
||||
},
|
||||
] as any,
|
||||
appConfiguration: null,
|
||||
});
|
||||
const group = result[0].items?.find((item) => item.id === "ui.nav.app.entityGroup.sales");
|
||||
|
||||
expect(group).toMatchObject({
|
||||
isCollapsible: true,
|
||||
path: "/acme/g/sales",
|
||||
});
|
||||
expect(group?.items?.[0]).toMatchObject({ title: "shared.overview", path: "/acme/g/sales" });
|
||||
});
|
||||
|
||||
it("translates DB-backed labels only when locale keys exist", () => {
|
||||
const t = vi.fn().mockImplementation((v: string) => `translated:${v}`) as any;
|
||||
const i18n = { exists: vi.fn().mockImplementation((value: string) => value === "models.contact.plural" || value === "groups.sales") } as any;
|
||||
const entities = [
|
||||
{ name: "contact", slug: "contacts", titlePlural: "models.contact.plural", showInSidebar: true, icon: "" },
|
||||
{ name: "custom", slug: "custom", titlePlural: "Custom records", showInSidebar: true, icon: "" },
|
||||
] as EntitySimple[];
|
||||
|
||||
const result = AppSidebar({
|
||||
t,
|
||||
i18n,
|
||||
tenantId: "acme",
|
||||
entities,
|
||||
entityGroups: [
|
||||
{
|
||||
slug: "sales",
|
||||
title: "groups.sales",
|
||||
icon: "",
|
||||
collapsible: true,
|
||||
section: null,
|
||||
entities: [{ entity: entities[0] }],
|
||||
},
|
||||
] as any,
|
||||
appConfiguration: null,
|
||||
});
|
||||
const serialized = JSON.stringify(result);
|
||||
|
||||
expect(serialized).toContain("translated:models.contact.plural");
|
||||
expect(serialized).toContain("translated:groups.sales");
|
||||
expect(serialized).toContain("Custom records");
|
||||
expect(serialized).not.toContain('"models.contact.plural"');
|
||||
expect(serialized).not.toContain('"groups.sales"');
|
||||
});
|
||||
|
||||
it("keeps static tenant sidebar targets backed by registered route files", () => {
|
||||
const t = vi.fn().mockImplementation((v: string) => v) as any;
|
||||
const i18n = { exists: vi.fn().mockReturnValue(false) } as any;
|
||||
const result = AppSidebar({ t, i18n, tenantId: "acme", entities: [], entityGroups: [], appConfiguration: null, productAccess: businessProductAccess });
|
||||
const serialized = JSON.stringify(result);
|
||||
|
||||
expect(serialized).toContain("/acme/dashboard");
|
||||
expect(serialized).toContain("/acme/notifications");
|
||||
expect(serialized).toContain("/acme/crm");
|
||||
expect(serialized).toContain("/acme/email-marketing");
|
||||
expect(serialized).toContain("/acme/workflow-engine");
|
||||
expect(serialized).toContain("/acme/settings/api");
|
||||
expect(serialized).toContain("/acme/settings/credits");
|
||||
expect(serialized).toContain("/acme/settings");
|
||||
expect(serialized).toContain("/acme/settings/profile");
|
||||
expect(serialized).not.toContain("/app/");
|
||||
expect(serialized).not.toContain("/admin/");
|
||||
|
||||
[
|
||||
"apps/web/app/routes/app.$tenant/dashboard.tsx",
|
||||
"apps/web/app/routes/app.$tenant/notifications.tsx",
|
||||
"apps/web/app/routes/app.$tenant/crm.tsx",
|
||||
"apps/web/app/routes/app.$tenant/email-marketing.tsx",
|
||||
"apps/web/app/routes/app.$tenant/__workflows/workflow-engine.tsx",
|
||||
"apps/web/app/routes/app.$tenant/settings/api/index.tsx",
|
||||
"apps/web/app/routes/app.$tenant/settings/credits.tsx",
|
||||
"apps/web/app/routes/app.$tenant/settings.tsx",
|
||||
"apps/web/app/routes/app.$tenant/settings/profile.tsx",
|
||||
].forEach((routeFile) => {
|
||||
expect(existsSync(resolveRepoPath(routeFile))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,269 @@
|
||||
import { SideBarItem } from "./SidebarItem";
|
||||
import { EntitySimple } from "~/utils/db/entities/entities.db.server";
|
||||
import { TFunction, type i18n as I18n } from "i18next";
|
||||
import { EntityGroupWithDetails } from "~/utils/db/entities/entityGroups.db.server";
|
||||
import { AppConfiguration } from "~/utils/db/appConfiguration.db.server";
|
||||
import IconSettings from "~/components/layouts/icons/IconSettings";
|
||||
import IconDashboard from "~/components/layouts/icons/IconDashboard";
|
||||
import IconAdmin from "~/components/layouts/icons/IconAdmin";
|
||||
import IconNotifications from "~/components/layouts/icons/IconNotifications";
|
||||
import IconEmails from "~/components/layouts/icons/IconEmails";
|
||||
import IconKeys from "~/components/layouts/icons/IconKeys";
|
||||
import IconWorkflows from "~/components/layouts/icons/IconWorkflows";
|
||||
import CreditsIcon from "~/components/ui/icons/CreditsIcon";
|
||||
import AddressBookIcon from "~/components/ui/icons/crm/AddressBookIcon";
|
||||
import IconDocs from "~/components/layouts/icons/IconDocs";
|
||||
import ViewListIcon from "~/components/ui/icons/ViewListIcon";
|
||||
import { UI_ID_PATTERNS, UI_NAV_IDS, uiIdSegment } from "~/application/ui/uiRegistry";
|
||||
import { appPath } from "~/utils/app/CanonicalUrlUtils";
|
||||
import { absoluteAdminPath, type PublicDomainRoutingConfig } from "~/utils/app/CrossHostUrls";
|
||||
import { getEntityPermission } from "~/utils/helpers/PermissionsHelper";
|
||||
import { translateLabel } from "~/utils/i18n/translateLabel";
|
||||
import { resolveConnectorNavState } from "~/utils/connector/connectorNavState";
|
||||
import { WALKING_SKELETON_MODULE_KEY } from "~/modules/walkingSkeleton/constants";
|
||||
|
||||
type Props = {
|
||||
t: TFunction;
|
||||
i18n: I18n;
|
||||
tenantId: string;
|
||||
entities: EntitySimple[];
|
||||
entityGroups: EntityGroupWithDetails[];
|
||||
appConfiguration: AppConfiguration | null;
|
||||
routing?: PublicDomainRoutingConfig;
|
||||
productAccess?: Record<string, { allowed: boolean; reason?: string }>;
|
||||
};
|
||||
|
||||
export const AppSidebar = ({ t, i18n, tenantId, entities, entityGroups, routing, productAccess }: Props): SideBarItem[] => {
|
||||
const tenantPath = (path?: string) => appPath({ tenantSlug: tenantId, path, mode: "host-aware" });
|
||||
const hasFeatureAccess = (featureKey: string) => productAccess?.[featureKey]?.allowed === true;
|
||||
// R-P1c Konnektor-Demo: Walking-Skeleton-Eintrag zeigt die drei Gate-Zustaende (aktiv/gesperrt/ausgeblendet).
|
||||
const walkingSkeletonDecision = productAccess?.[WALKING_SKELETON_MODULE_KEY];
|
||||
const walkingSkeletonState = walkingSkeletonDecision ? resolveConnectorNavState(walkingSkeletonDecision) : "hidden";
|
||||
|
||||
const sectionItems: SideBarItem[] = [];
|
||||
const entitiesItems: SideBarItem[] = [];
|
||||
const productCoreItems: SideBarItem[] = [
|
||||
...(hasFeatureAccess("crm-basic")
|
||||
? [
|
||||
{
|
||||
id: UI_NAV_IDS.appCrm,
|
||||
title: t("crm:title"),
|
||||
path: tenantPath("crm"),
|
||||
icon: <AddressBookIcon />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasFeatureAccess("files")
|
||||
? [
|
||||
{
|
||||
id: "ui.nav.app.files",
|
||||
title: t("files.nav.title"),
|
||||
path: tenantPath("files"),
|
||||
icon: <IconDocs />,
|
||||
permission: "files.read_own" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasFeatureAccess("tasks")
|
||||
? [
|
||||
{
|
||||
id: "ui.nav.app.tasks",
|
||||
title: t("tasks.nav.title"),
|
||||
path: tenantPath("tasks"),
|
||||
icon: <ViewListIcon />,
|
||||
permission: "tasks.read_own" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasFeatureAccess("email-marketing")
|
||||
? [
|
||||
{
|
||||
id: UI_NAV_IDS.appEmailMarketing,
|
||||
title: t("emailMarketing:title"),
|
||||
path: tenantPath("email-marketing"),
|
||||
icon: <IconEmails />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasFeatureAccess("workflow-engine")
|
||||
? [
|
||||
{
|
||||
id: UI_NAV_IDS.appWorkflowEngine,
|
||||
title: t("workflows.title"),
|
||||
path: tenantPath("workflow-engine"),
|
||||
icon: <IconWorkflows />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasFeatureAccess("api")
|
||||
? [
|
||||
{
|
||||
id: UI_NAV_IDS.appApi,
|
||||
title: t("models.apiKey.plural"),
|
||||
path: tenantPath("settings/api"),
|
||||
icon: <IconKeys />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(hasFeatureAccess("ai-credits")
|
||||
? [
|
||||
{
|
||||
id: UI_NAV_IDS.appCredits,
|
||||
title: t("models.credit.plural"),
|
||||
path: tenantPath("settings/credits"),
|
||||
icon: <CreditsIcon />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(walkingSkeletonState !== "hidden"
|
||||
? [
|
||||
{
|
||||
id: "ui.nav.app.walkingSkeleton",
|
||||
title: t("walkingSkeleton.nav"),
|
||||
path: tenantPath("walking-skeleton"),
|
||||
icon: <IconDashboard />,
|
||||
disabled: walkingSkeletonState === "locked",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
entityGroups.forEach((group) => {
|
||||
const item: SideBarItem = {
|
||||
id: `ui.nav.app.entityGroup.${uiIdSegment(group.slug)}`,
|
||||
uiIdPattern: UI_ID_PATTERNS.appEntityGroup,
|
||||
uiIdParams: { groupSlug: group.slug },
|
||||
title: translateLabel(t, i18n, group.title),
|
||||
// icon: group.icon,
|
||||
path: tenantPath(`g/${group.slug}`),
|
||||
icon: group.icon,
|
||||
// isActive: true,
|
||||
};
|
||||
if (group.collapsible) {
|
||||
item.isCollapsible = true;
|
||||
item.items = [
|
||||
{
|
||||
id: `ui.nav.app.entityGroup.${uiIdSegment(group.slug)}.overview`,
|
||||
title: t("shared.overview"),
|
||||
path: tenantPath(`g/${group.slug}`),
|
||||
},
|
||||
...group.entities.map(({ entity }) => {
|
||||
return {
|
||||
id: `ui.nav.app.entityGroup.${uiIdSegment(group.slug)}.entity.${uiIdSegment(entity.slug)}`,
|
||||
uiIdPattern: UI_ID_PATTERNS.appEntityGroupEntity,
|
||||
uiIdParams: { groupSlug: group.slug, entityName: entity.name ?? entity.slug },
|
||||
title: translateLabel(t, i18n, entity.titlePlural),
|
||||
// icon: entity.icon,
|
||||
path: tenantPath(`g/${group.slug}/${entity.slug}`),
|
||||
permission: getEntityPermission(entity, "view"),
|
||||
// entityIcon: entity.icon,
|
||||
};
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (group.section) {
|
||||
const sectionTitle = translateLabel(t, i18n, group.section);
|
||||
const section = sectionItems.find((f) => f.title === sectionTitle);
|
||||
if (section) {
|
||||
section.items = [...(section.items ?? []), item];
|
||||
} else {
|
||||
sectionItems.push({
|
||||
id: `ui.nav.app.section.${uiIdSegment(group.section)}`,
|
||||
uiIdPattern: UI_ID_PATTERNS.appSection,
|
||||
uiIdParams: { sectionName: group.section },
|
||||
title: sectionTitle,
|
||||
path: ``,
|
||||
items: [item],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
entitiesItems.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
entities
|
||||
.filter((f) => f.showInSidebar)
|
||||
.forEach((entity) => {
|
||||
entitiesItems.push({
|
||||
id: `ui.nav.app.entity.${uiIdSegment(entity.slug)}`,
|
||||
uiIdPattern: UI_ID_PATTERNS.appEntity,
|
||||
uiIdParams: { entityName: entity.name ?? entity.slug, entitySlug: entity.slug },
|
||||
title: translateLabel(t, i18n, entity.titlePlural),
|
||||
icon: entity.icon,
|
||||
// tenantUserTypes: [TenantUserType.OWNER, TenantUserType.ADMIN],
|
||||
path: tenantPath(entity.slug),
|
||||
permission: getEntityPermission(entity, "view"),
|
||||
// side: isDebugMode && SideText({ text: modelProperties.length > 0 ? "DB Model" : "Custom" }),
|
||||
});
|
||||
});
|
||||
|
||||
const appItem: SideBarItem = {
|
||||
id: "ui.nav.app.primary",
|
||||
title: "",
|
||||
path: "",
|
||||
items: [
|
||||
{
|
||||
id: UI_NAV_IDS.appDashboard,
|
||||
title: t("app.sidebar.dashboard"),
|
||||
path: tenantPath("dashboard"),
|
||||
icon: <IconDashboard />,
|
||||
// tenantUserTypes: [TenantUserType.OWNER, TenantUserType.ADMIN, TenantUserType.MEMBER],
|
||||
},
|
||||
{
|
||||
id: UI_NAV_IDS.appNotifications,
|
||||
title: t("app.sidebar.notifications"),
|
||||
path: tenantPath("notifications"),
|
||||
icon: <IconNotifications />,
|
||||
},
|
||||
...productCoreItems,
|
||||
...entitiesItems,
|
||||
// {
|
||||
// title: t("models.email.plural"),
|
||||
// path: `${currentTenantUrl}/emails`,
|
||||
// icon: SvgIcon.EMAILS,
|
||||
// },
|
||||
],
|
||||
};
|
||||
|
||||
return [
|
||||
appItem,
|
||||
...sectionItems,
|
||||
{
|
||||
id: "ui.nav.app.secondary",
|
||||
title: "",
|
||||
path: "",
|
||||
isSecondary: true,
|
||||
items: [
|
||||
{
|
||||
id: UI_NAV_IDS.appSettings,
|
||||
title: t("app.sidebar.settings"),
|
||||
icon: <IconSettings />,
|
||||
// tenantUserTypes: [TenantUserType.OWNER, TenantUserType.ADMIN],
|
||||
path: tenantPath("settings"),
|
||||
redirectTo: tenantPath("settings/profile"),
|
||||
},
|
||||
// {
|
||||
// title: t("app.sidebar.logs"),
|
||||
// icon: SvgIcon.LOGS,
|
||||
// tenantUserTypes: [TenantUserType.OWNER, TenantUserType.ADMIN],
|
||||
// path: `${currentTenantUrl}/logs`,
|
||||
// },
|
||||
{
|
||||
id: UI_NAV_IDS.appSwitchToAdmin,
|
||||
title: t("admin.switchToAdmin"),
|
||||
path: absoluteAdminPath(routing, "dashboard"),
|
||||
icon: <IconAdmin />,
|
||||
adminOnly: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
// function SideText({ text }: { text: string }) {
|
||||
// return (
|
||||
// <div className="inline-flex justify-center space-x-1 items-center px-1.5 py-0.5 rounded-sm text-xs font-medium bg-yellow-100 text-yellow-900 italic truncate w-20">
|
||||
// <div className="truncate">{text}</div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
@@ -0,0 +1,57 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DocsSidebar } from "./DocsSidebar";
|
||||
import { SideBarItem } from "./SidebarItem";
|
||||
|
||||
describe("DocsSidebar", () => {
|
||||
it("should return an array of SideBarItem objects", () => {
|
||||
const docsSidebar: SideBarItem[] = DocsSidebar();
|
||||
expect(Array.isArray(docsSidebar)).toBe(true);
|
||||
docsSidebar.forEach((item) => {
|
||||
expect(typeof item.title).toBe("string");
|
||||
expect(typeof item.path).toBe("string");
|
||||
if (item.items) {
|
||||
expect(Array.isArray(item.items)).toBe(true);
|
||||
item.items.forEach((subItem) => {
|
||||
expect(typeof subItem.title).toBe("string");
|
||||
expect(typeof subItem.path).toBe("string");
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should return feature items with tab titles starting with capital letters", () => {
|
||||
const docsSidebar: SideBarItem[] = DocsSidebar();
|
||||
const pageBlockItem = docsSidebar[2];
|
||||
const pageBlockTab = pageBlockItem.items?.find((item) => item.path === "/docs/features");
|
||||
|
||||
expect(pageBlockTab).toBeDefined();
|
||||
expect(pageBlockTab?.title).toMatch(/^\w/);
|
||||
expect(pageBlockTab?.title.charAt(0)).toMatch(/[A-Z]/);
|
||||
});
|
||||
|
||||
it("does not expose removed marketing docs in the default navigation", () => {
|
||||
const serialized = JSON.stringify(DocsSidebar());
|
||||
|
||||
expect(serialized).not.toContain(["/docs/page", "-blocks"].join(""));
|
||||
expect(serialized).not.toContain("/docs/features/marketing");
|
||||
expect(serialized).not.toContain("/docs/features/email-marketing");
|
||||
expect(serialized).not.toContain("/docs/features/help-desk");
|
||||
expect(serialized).not.toContain("/docs/features/knowledge-base");
|
||||
expect(serialized).not.toContain("/docs/features/analytics");
|
||||
expect(serialized).not.toContain("/docs/features/onboarding");
|
||||
expect(serialized).not.toContain("/docs/learning-center/guides/branding/landing");
|
||||
expect(serialized).not.toContain("🚀");
|
||||
expect(serialized).not.toContain("🚧");
|
||||
});
|
||||
|
||||
it("uses semantic docs sidebar translation keys", () => {
|
||||
const source = readFileSync(join(process.cwd(), "apps/web/app/application/sidebar/DocsSidebar.ts"), "utf8");
|
||||
|
||||
expect(source).toContain("docs.sidebar.introduction");
|
||||
expect(source).toContain("docs.sidebar.learningCenter");
|
||||
expect(source).toContain("docs.sidebar.coreModels");
|
||||
expect(source.match(/^\s*title:\s*"(?!")/gmu)).toBeNull();
|
||||
expect(source).not.toContain("ui.autogenerated");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import { SideBarItem } from "./SidebarItem";
|
||||
import { t as i18nT } from "i18next";
|
||||
import { UI_ID_PATTERNS, UI_NAV_IDS, uiIdSegment } from "~/application/ui/uiRegistry";
|
||||
|
||||
function withDocsSidebarIds(items: SideBarItem[], seenIds = new Map<string, number>()): SideBarItem[] {
|
||||
return items.map((item) => {
|
||||
const baseId = item.id ?? (item.path ? `ui.nav.docs.${uiIdSegment(item.path.replace(/^\/docs\/?/, "") || "index")}` : `ui.nav.docs.group.${uiIdSegment(item.title)}`);
|
||||
const seenCount = seenIds.get(baseId) ?? 0;
|
||||
seenIds.set(baseId, seenCount + 1);
|
||||
const id = seenCount === 0 ? baseId : `${baseId}.${seenCount + 1}`;
|
||||
|
||||
return {
|
||||
...item,
|
||||
id,
|
||||
uiIdPattern: item.uiIdPattern ?? (item.path ? UI_ID_PATTERNS.docsRoute : undefined),
|
||||
uiIdParams: item.uiIdParams ?? (item.path ? { routeSlug: item.path.replace(/^\/docs\/?/, "") || "index" } : undefined),
|
||||
items: item.items ? withDocsSidebarIds(item.items, seenIds) : item.items,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const DocsSidebar = (): SideBarItem[] => {
|
||||
return withDocsSidebarIds([
|
||||
{
|
||||
id: "ui.nav.docs.primary",
|
||||
title: "",
|
||||
path: "/docs",
|
||||
exact: true,
|
||||
items: [
|
||||
{
|
||||
id: UI_NAV_IDS.docsIntroduction,
|
||||
title: i18nT("docs.sidebar.introduction"),
|
||||
path: "/docs",
|
||||
|
||||
exact: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ui.nav.docs.gettingStarted",
|
||||
title: i18nT("docs.sidebar.gettingStarted"),
|
||||
path: "",
|
||||
|
||||
items: [
|
||||
{
|
||||
title: i18nT("docs.sidebar.stack"),
|
||||
path: "/docs/stack",
|
||||
},
|
||||
// {
|
||||
// title: "Code structure",
|
||||
// path: "/docs/code-structure",
|
||||
//
|
||||
// },
|
||||
{
|
||||
title: i18nT("docs.sidebar.roadmap"),
|
||||
path: "/docs/roadmap",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.license"),
|
||||
path: "/docs/license",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.contributing"),
|
||||
path: "/docs/contributing",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.community"),
|
||||
path: "/docs/community",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ui.nav.docs.product",
|
||||
title: i18nT("docs.sidebar.product"),
|
||||
path: "",
|
||||
items: [
|
||||
{
|
||||
title: i18nT("docs.sidebar.features"),
|
||||
path: "/docs/features",
|
||||
|
||||
items: [
|
||||
{
|
||||
title: i18nT("docs.sidebar.allFeatures"),
|
||||
path: "/docs/features",
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.adminPortal"),
|
||||
path: "/docs/features/admin-portal",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.appPortal"),
|
||||
path: "/docs/features/app-portal",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.authentication"),
|
||||
path: "/docs/features/authentication",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.subscriptions"),
|
||||
path: "/docs/features/subscriptions",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.entityBuilder"),
|
||||
path: "/docs/features/entity-builder",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.api"),
|
||||
path: "/docs/features/api",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.webhooks"),
|
||||
path: "/docs/features/webhooks",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.auditTrails"),
|
||||
path: "/docs/features/audit-trails",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.rolesPermissions"),
|
||||
path: "/docs/features/roles-and-permissions",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.eventsAndWebhooks"),
|
||||
path: "/docs/features/events-and-webhooks",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.notifications"),
|
||||
path: "/docs/features/notifications",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.components"),
|
||||
path: "/docs/components",
|
||||
|
||||
items: [
|
||||
{
|
||||
title: i18nT("docs.sidebar.buttons"),
|
||||
path: "/docs/components/buttons",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.badges"),
|
||||
path: "/docs/components/badges",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.banners"),
|
||||
path: "/docs/components/banners",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.breadcrumbs"),
|
||||
path: "/docs/components/breadcrumbs",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.commandPalette"),
|
||||
path: "/docs/components/command-palette",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.dropdowns"),
|
||||
path: "/docs/components/dropdowns",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.emptyStates"),
|
||||
path: "/docs/components/empty-states",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.forms"),
|
||||
path: "/docs/components/forms",
|
||||
},
|
||||
// {
|
||||
// title: "Layouts",
|
||||
// path: "/docs/components/layouts",
|
||||
//
|
||||
// },
|
||||
{
|
||||
title: i18nT("docs.sidebar.loaders"),
|
||||
path: "/docs/components/loaders",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.modals"),
|
||||
path: "/docs/components/modals",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.pdfViewer"),
|
||||
path: "/docs/components/pdf-viewer",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.tables"),
|
||||
path: "/docs/components/tables",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.tabs"),
|
||||
path: "/docs/components/tabs",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.tooltips"),
|
||||
path: "/docs/components/tooltips",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.uploaders"),
|
||||
path: "/docs/components/uploaders",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.logoAndIcon"),
|
||||
path: "/docs/components/logo-and-icon",
|
||||
},
|
||||
// {
|
||||
// title: "Inputs",
|
||||
// path: "/docs/components/inputs",
|
||||
//
|
||||
// items: [
|
||||
{
|
||||
title: i18nT("docs.sidebar.inputText"),
|
||||
path: "/docs/components/inputs/text",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.inputNumber"),
|
||||
path: "/docs/components/inputs/number",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.inputDate"),
|
||||
path: "/docs/components/inputs/date",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.inputSelector"),
|
||||
path: "/docs/components/inputs/selector",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.inputCheckbox"),
|
||||
path: "/docs/components/inputs/checkbox",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.inputRadioGroup"),
|
||||
path: "/docs/components/inputs/radio-group",
|
||||
},
|
||||
// ],
|
||||
// },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ui.nav.docs.learningCenter",
|
||||
title: i18nT("docs.sidebar.learningCenter"),
|
||||
path: "/docs/learning-center",
|
||||
items: [
|
||||
{ title: i18nT("docs.sidebar.learningCenter"), path: "/docs/learning-center", exact: true },
|
||||
{
|
||||
title: i18nT("docs.sidebar.quickStart"),
|
||||
path: "/docs/learning-center/tutorials/quick-start",
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.quickGuides"),
|
||||
path: "",
|
||||
items: [
|
||||
{
|
||||
title: i18nT("docs.sidebar.productReleases"),
|
||||
path: "/docs/learning-center/guides/upgrade/product-releases",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.customizeTheme"),
|
||||
path: "/docs/learning-center/guides/branding/theme",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.customizeLogoIcon"),
|
||||
path: "/docs/learning-center/guides/branding/logo-and-icon",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.generateFavicon"),
|
||||
path: "/docs/learning-center/guides/branding/favicon",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.supportLanguageI18n"),
|
||||
path: "/docs/learning-center/guides/localization/support-a-language",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.createCustomEntity"),
|
||||
path: "/docs/learning-center/guides/entities/create-a-custom-entity",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.useCustomEntityApi"),
|
||||
path: "/docs/learning-center/guides/entities/use-the-custom-entity-api",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.sharedEntityRows"),
|
||||
path: "/docs/learning-center/guides/entities/shared-entity-rows",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.planEntityLimits"),
|
||||
path: "/docs/learning-center/guides/entities/plan-entity-limits",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.extendExistingModels"),
|
||||
path: "/docs/learning-center/guides/entities/extend-existing-models",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.singleSignOn"),
|
||||
path: "/docs/learning-center/guides/sso/",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.vercel"),
|
||||
path: "/docs/learning-center/guides/hosting/vercel",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.supabase"),
|
||||
path: "/docs/learning-center/guides/database/supabase",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "ui.nav.docs.coreModels",
|
||||
title: i18nT("docs.sidebar.coreModels"),
|
||||
path: "",
|
||||
items: [
|
||||
{
|
||||
title: i18nT("docs.sidebar.prismaSchema"),
|
||||
path: "/docs/core-models",
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.account"),
|
||||
path: "/docs/core-models/account",
|
||||
items: [
|
||||
// {
|
||||
// title: "Diagram",
|
||||
// path: "/docs/core-models/account/diagram",
|
||||
//
|
||||
// },
|
||||
{
|
||||
title: i18nT("docs.sidebar.user"),
|
||||
path: "/docs/core-models/account/user",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.tenant"),
|
||||
path: "/docs/core-models/account/tenant",
|
||||
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.tenantUser"),
|
||||
path: "/docs/core-models/account/tenant-user",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.apiKey"),
|
||||
path: "/docs/core-models/account/api-key",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.subscription"),
|
||||
path: "/docs/core-models/subscription",
|
||||
|
||||
items: [
|
||||
{
|
||||
title: i18nT("docs.sidebar.product"),
|
||||
path: "/docs/core-models/subscription/product",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.price"),
|
||||
path: "/docs/core-models/subscription/price",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.feature"),
|
||||
path: "/docs/core-models/subscription/feature",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.tenantSubscription"),
|
||||
path: "/docs/core-models/subscription/tenant-subscription",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.customEntity"),
|
||||
path: "/docs/core-models/custom-entity",
|
||||
|
||||
items: [
|
||||
{
|
||||
title: i18nT("docs.sidebar.entity"),
|
||||
path: "/docs/core-models/custom-entity/entity",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.property"),
|
||||
path: "/docs/core-models/custom-entity/property",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.row"),
|
||||
path: "/docs/core-models/custom-entity/row",
|
||||
|
||||
exact: true,
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.rowValue"),
|
||||
path: "/docs/core-models/custom-entity/row-value",
|
||||
},
|
||||
{
|
||||
title: i18nT("docs.sidebar.webhook"),
|
||||
path: "/docs/core-models/custom-entity/webhook",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { SideBarItem } from "./SidebarItem";
|
||||
|
||||
export type SidebarGroupDto = {
|
||||
id?: string;
|
||||
uiIdPattern?: string;
|
||||
uiIdParams?: Record<string, string | number | boolean | null | undefined>;
|
||||
items: SideBarItem[];
|
||||
title?: string;
|
||||
type?: "main" | "secondary" | "quick-link";
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { AdminSidebar } from "./AdminSidebar";
|
||||
import { AppSidebar } from "./AppSidebar";
|
||||
import { DocsSidebar } from "./DocsSidebar";
|
||||
import type { SideBarItem } from "./SidebarItem";
|
||||
import type { EntitySimple } from "~/utils/db/entities/entities.db.server";
|
||||
import { UI_ID_PATTERNS, UI_NAV_IDS, UI_SURFACE_IDS } from "~/application/ui/uiRegistry";
|
||||
|
||||
function flattenSidebarItems(items: SideBarItem[]): SideBarItem[] {
|
||||
return items.flatMap((item) => [item, ...flattenSidebarItems(item.items ?? [])]);
|
||||
}
|
||||
|
||||
function expectCompleteUniqueIds(items: SideBarItem[]) {
|
||||
const flattened = flattenSidebarItems(items);
|
||||
const ids = flattened.map((item) => item.id);
|
||||
|
||||
expect(ids).not.toContain(undefined);
|
||||
expect(ids.every((id) => id?.startsWith("ui."))).toBe(true);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
}
|
||||
|
||||
function expectStaticItemsHaveIds(items: SideBarItem[]) {
|
||||
const staticItems = flattenSidebarItems(items).filter((item) => !item.uiIdPattern);
|
||||
expect(staticItems.length).toBeGreaterThan(0);
|
||||
expect(staticItems.every((item) => typeof item.id === "string" && item.id.length > 0)).toBe(true);
|
||||
}
|
||||
|
||||
function expectDynamicItemsHavePatternMetadata(items: SideBarItem[]) {
|
||||
const dynamicItems = flattenSidebarItems(items).filter((item) => item.uiIdPattern);
|
||||
expect(dynamicItems.every((item) => item.id && item.uiIdPattern && item.uiIdParams && Object.keys(item.uiIdParams).length > 0)).toBe(true);
|
||||
}
|
||||
|
||||
describe("sidebar UI IDs", () => {
|
||||
const t = vi.fn().mockImplementation((v: string) => v) as any;
|
||||
const i18n = { exists: vi.fn().mockReturnValue(false) } as any;
|
||||
|
||||
it("defines the primary sidebar surface IDs", () => {
|
||||
expect(UI_SURFACE_IDS.appSidebar).toBe("ui.surface.app.sidebar");
|
||||
expect(UI_SURFACE_IDS.adminSidebar).toBe("ui.surface.admin.sidebar");
|
||||
expect(UI_SURFACE_IDS.docsSidebar).toBe("ui.surface.docs.sidebar");
|
||||
});
|
||||
|
||||
it("assigns unique IDs to admin sidebar items", () => {
|
||||
const sidebar = AdminSidebar({ t, appConfiguration: null });
|
||||
const ids = flattenSidebarItems(sidebar).map((item) => item.id);
|
||||
|
||||
expectCompleteUniqueIds(sidebar);
|
||||
expectStaticItemsHaveIds(sidebar);
|
||||
expect(ids).toContain(UI_NAV_IDS.adminDashboard);
|
||||
expect(ids).toContain(UI_NAV_IDS.adminSettings);
|
||||
expect(ids).toContain(UI_NAV_IDS.adminEntities);
|
||||
});
|
||||
|
||||
it("assigns unique IDs to app sidebar static and dynamic items", () => {
|
||||
const entities = [
|
||||
{ id: "1", order: 1, name: "contacts", slug: "contacts", title: "Contact", titlePlural: "Contacts", active: true, type: "custom", hasApi: true, showInSidebar: true, icon: "", onEdit: null },
|
||||
{ id: "2", order: 2, name: "hidden", slug: "hidden", title: "Hidden", titlePlural: "Hidden", active: true, type: "custom", hasApi: true, showInSidebar: false, icon: "", onEdit: null },
|
||||
] as EntitySimple[];
|
||||
const sidebar = AppSidebar({
|
||||
t,
|
||||
i18n,
|
||||
tenantId: "acme",
|
||||
entities,
|
||||
entityGroups: [
|
||||
{
|
||||
slug: "sales",
|
||||
title: "Sales",
|
||||
icon: "",
|
||||
collapsible: true,
|
||||
section: "Operations",
|
||||
entities: [{ entity: entities[0] }],
|
||||
},
|
||||
] as any,
|
||||
appConfiguration: null,
|
||||
});
|
||||
const ids = flattenSidebarItems(sidebar).map((item) => item.id);
|
||||
const dynamicEntity = flattenSidebarItems(sidebar).find((item) => item.id === "ui.nav.app.entity.contacts");
|
||||
|
||||
expectCompleteUniqueIds(sidebar);
|
||||
expectStaticItemsHaveIds(sidebar);
|
||||
expectDynamicItemsHavePatternMetadata(sidebar);
|
||||
expect(ids).toContain(UI_NAV_IDS.appDashboard);
|
||||
expect(ids).toContain(UI_NAV_IDS.appSettings);
|
||||
expect(ids).toContain("ui.nav.app.entity.contacts");
|
||||
expect(ids).toContain("ui.nav.app.entityGroup.sales");
|
||||
expect(ids).toContain("ui.nav.app.section.operations");
|
||||
expect(dynamicEntity?.uiIdPattern).toBe(UI_ID_PATTERNS.appEntity);
|
||||
expect(dynamicEntity?.uiIdParams).toMatchObject({ entityName: "contacts", entitySlug: "contacts" });
|
||||
});
|
||||
|
||||
it("assigns unique IDs to docs sidebar items", () => {
|
||||
const sidebar = DocsSidebar();
|
||||
const ids = flattenSidebarItems(sidebar).map((item) => item.id);
|
||||
|
||||
expectCompleteUniqueIds(sidebar);
|
||||
expectStaticItemsHaveIds(sidebar);
|
||||
expectDynamicItemsHavePatternMetadata(sidebar);
|
||||
expect(ids).toContain(UI_NAV_IDS.docsIntroduction);
|
||||
expect(ids).toContain("ui.nav.docs.componentsButtons");
|
||||
const removedDocsPrefix = ["ui.nav.docs.page", "Blocks."].join("");
|
||||
expect(ids.some((id) => id?.startsWith(removedDocsPrefix))).toBe(false);
|
||||
});
|
||||
});
|
||||
Loaded 100 of 5784 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user