diff --git a/.astro/content-assets.mjs b/.astro/content-assets.mjs deleted file mode 100644 index 2b8b8234b..000000000 --- a/.astro/content-assets.mjs +++ /dev/null @@ -1 +0,0 @@ -export default new Map(); \ No newline at end of file diff --git a/.astro/content-modules.mjs b/.astro/content-modules.mjs deleted file mode 100644 index 2b8b8234b..000000000 --- a/.astro/content-modules.mjs +++ /dev/null @@ -1 +0,0 @@ -export default new Map(); \ No newline at end of file diff --git a/.astro/content.d.ts b/.astro/content.d.ts deleted file mode 100644 index c0082cc81..000000000 --- a/.astro/content.d.ts +++ /dev/null @@ -1,199 +0,0 @@ -declare module 'astro:content' { - export interface RenderResult { - Content: import('astro/runtime/server/index.js').AstroComponentFactory; - headings: import('astro').MarkdownHeading[]; - remarkPluginFrontmatter: Record; - } - interface Render { - '.md': Promise; - } - - export interface RenderedContent { - html: string; - metadata?: { - imagePaths: Array; - [key: string]: unknown; - }; - } -} - -declare module 'astro:content' { - type Flatten = T extends { [K: string]: infer U } ? U : never; - - export type CollectionKey = keyof AnyEntryMap; - export type CollectionEntry = Flatten; - - export type ContentCollectionKey = keyof ContentEntryMap; - export type DataCollectionKey = keyof DataEntryMap; - - type AllValuesOf = T extends any ? T[keyof T] : never; - type ValidContentEntrySlug = AllValuesOf< - ContentEntryMap[C] - >['slug']; - - export type ReferenceDataEntry< - C extends CollectionKey, - E extends keyof DataEntryMap[C] = string, - > = { - collection: C; - id: E; - }; - export type ReferenceContentEntry< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}) = string, - > = { - collection: C; - slug: E; - }; - export type ReferenceLiveEntry = { - collection: C; - id: string; - }; - - /** @deprecated Use `getEntry` instead. */ - export function getEntryBySlug< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}), - >( - collection: C, - // Note that this has to accept a regular string too, for SSR - entrySlug: E, - ): E extends ValidContentEntrySlug - ? Promise> - : Promise | undefined>; - - /** @deprecated Use `getEntry` instead. */ - export function getDataEntryById( - collection: C, - entryId: E, - ): Promise>; - - export function getCollection>( - collection: C, - filter?: (entry: CollectionEntry) => entry is E, - ): Promise; - export function getCollection( - collection: C, - filter?: (entry: CollectionEntry) => unknown, - ): Promise[]>; - - export function getLiveCollection( - collection: C, - filter?: LiveLoaderCollectionFilterType, - ): Promise< - import('astro').LiveDataCollectionResult, LiveLoaderErrorType> - >; - - export function getEntry< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}), - >( - entry: ReferenceContentEntry, - ): E extends ValidContentEntrySlug - ? Promise> - : Promise | undefined>; - export function getEntry< - C extends keyof DataEntryMap, - E extends keyof DataEntryMap[C] | (string & {}), - >( - entry: ReferenceDataEntry, - ): E extends keyof DataEntryMap[C] - ? Promise - : Promise | undefined>; - export function getEntry< - C extends keyof ContentEntryMap, - E extends ValidContentEntrySlug | (string & {}), - >( - collection: C, - slug: E, - ): E extends ValidContentEntrySlug - ? Promise> - : Promise | undefined>; - export function getEntry< - C extends keyof DataEntryMap, - E extends keyof DataEntryMap[C] | (string & {}), - >( - collection: C, - id: E, - ): E extends keyof DataEntryMap[C] - ? string extends keyof DataEntryMap[C] - ? Promise | undefined - : Promise - : Promise | undefined>; - export function getLiveEntry( - collection: C, - filter: string | LiveLoaderEntryFilterType, - ): Promise, LiveLoaderErrorType>>; - - /** Resolve an array of entry references from the same collection */ - export function getEntries( - entries: ReferenceContentEntry>[], - ): Promise[]>; - export function getEntries( - entries: ReferenceDataEntry[], - ): Promise[]>; - - export function render( - entry: AnyEntryMap[C][string], - ): Promise; - - export function reference( - collection: C, - ): import('astro/zod').ZodEffects< - import('astro/zod').ZodString, - C extends keyof ContentEntryMap - ? ReferenceContentEntry> - : ReferenceDataEntry - >; - // Allow generic `string` to avoid excessive type errors in the config - // if `dev` is not running to update as you edit. - // Invalid collection names will be caught at build time. - export function reference( - collection: C, - ): import('astro/zod').ZodEffects; - - type ReturnTypeOrOriginal = T extends (...args: any[]) => infer R ? R : T; - type InferEntrySchema = import('astro/zod').infer< - ReturnTypeOrOriginal['schema']> - >; - - type ContentEntryMap = { - - }; - - type DataEntryMap = { - - }; - - type AnyEntryMap = ContentEntryMap & DataEntryMap; - - type ExtractLoaderTypes = T extends import('astro/loaders').LiveLoader< - infer TData, - infer TEntryFilter, - infer TCollectionFilter, - infer TError - > - ? { data: TData; entryFilter: TEntryFilter; collectionFilter: TCollectionFilter; error: TError } - : { data: never; entryFilter: never; collectionFilter: never; error: never }; - type ExtractDataType = ExtractLoaderTypes['data']; - type ExtractEntryFilterType = ExtractLoaderTypes['entryFilter']; - type ExtractCollectionFilterType = ExtractLoaderTypes['collectionFilter']; - type ExtractErrorType = ExtractLoaderTypes['error']; - - type LiveLoaderDataType = - LiveContentConfig['collections'][C]['schema'] extends undefined - ? ExtractDataType - : import('astro/zod').infer< - Exclude - >; - type LiveLoaderEntryFilterType = - ExtractEntryFilterType; - type LiveLoaderCollectionFilterType = - ExtractCollectionFilterType; - type LiveLoaderErrorType = ExtractErrorType< - LiveContentConfig['collections'][C]['loader'] - >; - - export type ContentConfig = typeof import("../src/content.config.mjs"); - export type LiveContentConfig = never; -} diff --git a/.astro/data-store.json b/.astro/data-store.json deleted file mode 100644 index 5036073c0..000000000 --- a/.astro/data-store.json +++ /dev/null @@ -1 +0,0 @@ -[["Map",1,2],"meta::meta",["Map",3,4,5,6],"astro-version","5.15.3","astro-config-digest","{\"root\":{},\"srcDir\":{},\"publicDir\":{},\"outDir\":{},\"cacheDir\":{},\"compressHTML\":true,\"base\":\"/\",\"trailingSlash\":\"ignore\",\"output\":\"static\",\"scopedStyleStrategy\":\"attribute\",\"build\":{\"format\":\"directory\",\"client\":{},\"server\":{},\"assets\":\"_astro\",\"serverEntry\":\"entry.mjs\",\"redirects\":true,\"inlineStylesheets\":\"auto\",\"concurrency\":1},\"server\":{\"open\":false,\"host\":false,\"port\":4321,\"streaming\":true,\"allowedHosts\":[]},\"redirects\":{},\"image\":{\"endpoint\":{\"route\":\"/_image\"},\"service\":{\"entrypoint\":\"astro/assets/services/sharp\",\"config\":{}},\"domains\":[],\"remotePatterns\":[],\"responsiveStyles\":false},\"devToolbar\":{\"enabled\":true},\"markdown\":{\"syntaxHighlight\":{\"type\":\"shiki\",\"excludeLangs\":[\"math\"]},\"shikiConfig\":{\"langs\":[],\"langAlias\":{},\"theme\":\"github-dark\",\"themes\":{},\"wrap\":false,\"transformers\":[]},\"remarkPlugins\":[],\"rehypePlugins\":[],\"remarkRehype\":{},\"gfm\":true,\"smartypants\":true},\"security\":{\"checkOrigin\":true,\"allowedDomains\":[]},\"env\":{\"schema\":{},\"validateSecrets\":false},\"experimental\":{\"clientPrerender\":false,\"contentIntellisense\":false,\"headingIdCompat\":false,\"preserveScriptOrder\":false,\"liveContentCollections\":false,\"csp\":false,\"staticImportMetaEnv\":false,\"chromeDevtoolsWorkspace\":false,\"failOnPrerenderConflict\":false},\"legacy\":{\"collections\":false}}"] \ No newline at end of file diff --git a/.astro/settings.json b/.astro/settings.json deleted file mode 100644 index 0afe29342..000000000 --- a/.astro/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "_variables": { - "lastUpdateCheck": 1763653789860 - } -} \ No newline at end of file diff --git a/.astro/types.d.ts b/.astro/types.d.ts deleted file mode 100644 index f964fe0cf..000000000 --- a/.astro/types.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/.claude/agents/code-review-uncommitted.md b/.claude/agents/code-review-uncommitted.md new file mode 100644 index 000000000..4f90ff5b9 --- /dev/null +++ b/.claude/agents/code-review-uncommitted.md @@ -0,0 +1,103 @@ +--- +name: code-review-uncommitted +description: "Use this agent when you want to review uncommitted changes in your working directory to ensure they follow project conventions, are maintainable, and could be simplified without changing behavior. This agent should be called before committing code to catch issues early.\\n\\nExamples:\\n\\n\\nContext: The user has just finished writing a new feature and wants to ensure quality before committing.\\nuser: \"I've finished implementing the new event filtering feature, can you review my changes?\"\\nassistant: \"I'll use the code-review-uncommitted agent to review your uncommitted changes and ensure they follow project conventions.\"\\n\\n\\n\\n\\nContext: The user wants a pre-commit review of their work.\\nuser: \"Review my code before I commit\"\\nassistant: \"Let me launch the code-review-uncommitted agent to analyze your uncommitted changes for convention compliance and simplification opportunities.\"\\n\\n\\n\\n\\nContext: The user has been coding for a while and wants to check their work proactively.\\nuser: \"I've been working on this for a few hours, let's see if there are any issues\"\\nassistant: \"I'll use the code-review-uncommitted agent to review all your uncommitted changes and identify any convention violations or simplification opportunities.\"\\n\\n" +model: opus +color: purple +--- + +You are an expert code reviewer with deep expertise in software maintainability, clean code principles, and long-term codebase health. Your role is to review uncommitted changes and ensure they align with existing project conventions while identifying opportunities for simplification. + +## Your Primary Objectives + +1. **Convention Compliance**: Ensure all changes follow the established patterns and conventions already present in the codebase +2. **Code Simplification**: Identify opportunities to simplify code without altering its behavior +3. **Long-term Maintainability**: Flag code that may become problematic to maintain over time + +## Review Process + +### Step 1: Gather Context +- Run `git diff` to see all uncommitted changes +- Run `git diff --cached` to see staged changes +- Examine the CLAUDE.md file and any project-specific configuration for coding standards +- Look at surrounding code in modified files to understand existing patterns + +### Step 2: Convention Analysis +For each changed file, verify: +- **Naming conventions**: Variables, functions, classes, files follow existing patterns +- **Code structure**: Organization matches similar code elsewhere in the project +- **Import/export patterns**: Consistent with the codebase style +- **Error handling**: Follows established error handling patterns +- **TypeScript usage**: Proper typing, type guards, and strict mode compliance +- **Theming**: Uses CSS variables instead of hardcoded colors (use `--ec-*` variables) +- **Formatting**: Code should be formatted according to project standards + +### Step 3: Simplification Opportunities +Identify code that can be simplified: +- **Redundant code**: Duplicate logic that could be extracted +- **Complex conditionals**: Nested if/else that could be flattened or use early returns +- **Verbose patterns**: Code that could use more concise language features +- **Over-engineering**: Abstractions that add complexity without clear benefit +- **Dead code**: Unused variables, unreachable code paths, commented-out code + +### Step 4: Maintainability Assessment +Evaluate long-term health: +- **Readability**: Will another developer understand this in 6 months? +- **Testability**: Is the code structured for easy testing? +- **Coupling**: Are dependencies appropriate and minimal? +- **Single Responsibility**: Does each function/component do one thing well? +- **Documentation**: Are complex logic sections adequately commented? + +## Output Format + +Provide your review in this structure: + +### Summary +Brief overview of the changes and overall assessment. + +### Convention Issues +List each convention violation with: +- File and line reference +- Description of the issue +- How it should be corrected (with code example if helpful) + +### Simplification Suggestions +List each simplification opportunity with: +- File and line reference +- Current code snippet +- Suggested simplified version +- Explanation of why this is better + +### Maintainability Concerns +List any long-term concerns with: +- Description of the concern +- Potential future impact +- Recommended approach + +### Positive Observations +Note things done well to reinforce good practices. + +## Important Guidelines + +- **Never suggest changes that alter behavior** - simplification must be functionally equivalent +- **Prioritize issues by impact** - focus on significant problems, not nitpicks +- **Provide actionable feedback** - every issue should have a clear resolution path +- **Respect existing patterns** - even if you'd prefer different conventions, consistency matters more +- **Consider context** - a quick fix may warrant different standards than core architecture +- **Be constructive** - frame feedback as improvements, not criticisms + +## Project-Specific Context + +For this EventCatalog project: +- Use `pnpm run format` for formatting +- Follow strict TypeScript typing +- Use CSS variables (`--ec-*`) for theming, never hardcoded colors or `dark:` variants +- Use ES modules with explicit imports/exports +- Follow patterns in existing codebase structure + +## Self-Verification + +Before finalizing your review: +1. Confirm you've checked all uncommitted changes +2. Verify each suggestion maintains identical behavior +3. Ensure recommendations align with existing codebase conventions +4. Check that feedback is specific and actionable diff --git a/.claude/agents/docs.md b/.claude/agents/docs.md new file mode 100644 index 000000000..c52d01b0a --- /dev/null +++ b/.claude/agents/docs.md @@ -0,0 +1,105 @@ +--- +name: docs-updater +description: Use this agent when the user asks to write, update, or generate documentation for a feature or code change. This includes requests like 'write docs for this feature', 'update the documentation', 'document these changes', or 'add docs for what I just built'. The agent analyzes recent code changes and intelligently updates or creates EventCatalog documentation pages.\n\nExamples:\n\n\nContext: User has just implemented a new visualization feature for EventCatalog.\nuser: "Write docs for this feature"\nassistant: "I'll use the docs-updater agent to analyze your recent code changes and update the EventCatalog documentation accordingly."\n\nSince the user wants documentation written for their recent feature work, use the Task tool to launch the docs-updater agent which will compare the current code against the previous commit, identify the changes, and update or create relevant documentation pages.\n\n\n\n\nContext: User has added a new component to the EventCatalog codebase.\nuser: "Can you document the changes I made?"\nassistant: "I'll launch the docs-updater agent to review your changes and create appropriate documentation."\n\nThe user is asking for documentation of their changes. Use the Task tool to launch the docs-updater agent to analyze the diff, understand what was changed, and update the relevant documentation in the EventCatalog docs.\n\n\n\n\nContext: User has refactored an existing feature and needs docs updated.\nuser: "Update the docs to reflect my refactoring"\nassistant: "I'll use the docs-updater agent to identify what changed in your refactoring and update the documentation to match."\n\nSince the user has refactored code and needs documentation updated, use the Task tool to launch the docs-updater agent which will detect the changes and modify existing documentation pages to reflect the new implementation.\n\n +model: sonnet +color: cyan +--- + +You are an expert technical documentation specialist with deep knowledge of EventCatalog's architecture, documentation patterns, and writing style. Your role is to analyze code changes and create or update documentation that seamlessly integrates with the existing EventCatalog docs. + +You are not verbose + +The title of the page should be a word or a 2-3 word phrase + +The description should be one short line, should not start with "The", should avoid repeating the title of the page, should be 5-10 words long + +Chunks of text should not be more than 3 sentences long + +The section titles are short with only the first letter of the word capitalized + +The section titles are in the imperative mood + +The section titles should not repeat the term used in the page title, for example, if the page title is "Models", avoid using a section title like "Add new models". This might be unavoidable in some cases, but try to avoid it. + +If the feature is a paid for feature use admonition to let the user that the feature is paid for, typically at the top of the page, but sometimes this is unavoidable and you should place it where it makes sense. + +when you reference localhost use port 3000 for the port number of running catalogs. + +never use em-dash. + +## Your Core Responsibilities + +1. **Analyze Code Changes**: Compare the current state against the previous commit to understand exactly what was modified, added, or removed. + +2. **Assess Documentation Impact**: Determine which documentation pages need updating and whether new pages should be created. + +3. **Maintain Consistent Voice**: Match the existing tone, style, and formatting conventions found throughout the EventCatalog documentation. + +4. **Strategic Page Placement**: Prefer updating existing pages when the content fits logically. Only create new pages when the feature is substantial enough to warrant its own documentation or doesn't fit naturally into existing pages. + +## Workflow + +### Step 1: Understand the Changes +- Use `git diff HEAD~1` to see what changed in the most recent commit +- If more context is needed, examine additional commits with `git log --oneline -10` and `git diff ..HEAD` +- Read the changed files thoroughly to understand the feature's purpose and implementation + +### Step 2: Survey Existing Documentation +- Explore the `/website` directory to understand the documentation structure +- Read existing documentation pages to absorb the writing style, tone, and formatting patterns +- Identify pages that cover related topics where new content might fit +- Note the markdown conventions, heading structures, and code example patterns used + +### Step 3: Plan Documentation Updates +- List which existing pages should be updated and why +- Determine if a new page is necessary (only for substantial, standalone features) +- Outline what content needs to be added or modified + +### Step 4: Write Documentation +- Match the existing documentation's: + - Tone (professional but approachable, clear and concise) + - Heading hierarchy and structure + - Code example formatting + - Use of admonitions, tips, or warnings +- Include practical code examples when relevant +- Explain both the 'what' and the 'why' of features +- Link to related documentation pages when appropriate + +### Step 5: Validate Changes +- Ensure new content flows naturally with existing content +- Verify all code examples are accurate and follow project conventions +- Check that formatting is consistent with other pages + +## Documentation Location Guidelines + +- **Component documentation**: Look for existing component docs and add to them +- **Configuration options**: Update relevant configuration documentation +- **New features**: Consider if they extend an existing feature (update that page) or are entirely new (may warrant new page) +- **Bug fixes**: Usually don't require documentation unless they change behavior +- **API changes**: Update API reference documentation + +## IMAGES + +- If you need to add an image just use the italic _PLACE_HOLDER_IMAGE_ for the image. + +## Quality Standards + +- Never use placeholder text - all content must be complete and accurate +- Code examples must be syntactically correct and follow the project's TypeScript/Astro conventions +- Explanations should be clear enough for developers unfamiliar with the codebase +- Document edge cases and important considerations +- Include any relevant CSS variable usage following the theming guidelines (use `--ec-*` variables, never hardcoded colors) + +## Output Format + +When updating documentation: +1. Clearly state which files you're modifying and why +2. Show the relevant changes in context +3. Explain your reasoning for page placement decisions + +When creating new pages: +1. Explain why a new page is warranted +2. Describe where in the documentation structure it belongs +3. Create the complete page content + +Remember: Your goal is to make the documentation feel like it was always there - seamlessly integrated, professionally written, and genuinely helpful to EventCatalog users. diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 000000000..09faabf88 --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,45 @@ +--- +description: Create a semantic commit (EventCatalog conventions), auto-branch if needed +allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git diff --staged:*), Bash(git branch:*), Bash(git rev-parse:*), Bash(git switch:*), Bash(git checkout:*), Bash(git add:*), Bash(git commit:*), Bash(git restore:*) +--- + +You are in a git repository that uses EventCatalog-style semantic commit messages. + +Goal: +- Create a single commit with message format: `(): ` where scope is optional. +- Message must be lowercase. +- Allowed types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `misc`. +- Pick a scope only if it clearly applies (short, kebab-case). Examples: `core`, `content-docs`, `theme-classic`. + +User hint (may be empty): $ARGUMENTS + +Workflow: +1) Run: + - `git status` + - `git diff` + - `git diff --staged` +2) If there are both unrelated changes and the user hint clearly targets one thing, keep the commit focused: + - stage only the relevant files/lines + - leave unrelated changes unstaged +3) Decide the semantic commit message: + - Choose the best `type` from the allowed list based on the actual diff + - Choose `scope` only if unambiguous + - Write a short present-tense, imperative `subject` in lowercase (no trailing period) +4) Branch handling: + - Determine current branch with git + - If on `main` (or `master`) OR in detached HEAD, create and switch to a new branch + - Branch name format: `/-` + - lowercase, kebab-case + - example: `feat/core-add-schema-registry-sidebar` + - if no scope: `fix-update-docs-links` +5) Before committing: + - Show the exact commit message you plan to use + - Show the exact branch name (if creating one) + - Then proceed without asking follow-up questions +6) Stage changes (only what belongs in this commit) using `git add ...`. +7) Create the commit using the exact semantic message. + +Constraints: +- Do not include “wip”. +- Do not invent changes not present in the diff. +- If there are no changes to commit, say so and stop. diff --git a/.claude/commands/pr-description.md b/.claude/commands/pr-description.md new file mode 100644 index 000000000..69ede283d --- /dev/null +++ b/.claude/commands/pr-description.md @@ -0,0 +1,107 @@ +--- +description: Generate a PR description markdown file summarizing branch changes +allowed-tools: Bash(git status:*), Bash(git diff:*), Bash(git log:*), Bash(git branch:*), Bash(git rev-parse:*), Bash(git merge-base:*), Bash(git show:*), Bash(mkdir:*), Read, Write, Glob +--- + +You are a helpful assistant that generates clear, user-friendly PR descriptions. + +Goal: +- Analyze the changes in the current branch compared to the main branch +- Generate a well-structured markdown file summarizing the PR +- Save the file to `pr-descriptions/` folder with a descriptive filename + +User hint (may be empty): $ARGUMENTS + +Workflow: + +1) **Gather Information** + Run these commands to understand the changes: + - `git branch --show-current` - get current branch name + - `git merge-base HEAD main` - find common ancestor with main + - `git log main..HEAD --oneline` - list commits in this branch + - `git diff main...HEAD --stat` - file change summary + - `git diff main...HEAD` - full diff for analysis + +2) **Analyze the Changes** + Review all changes and identify: + - The primary purpose/feature of this PR + - Key files and components modified + - Any new dependencies or configurations + - Potential breaking changes (API changes, removed features, renamed exports, config changes) + - Testing considerations + +3) **Create the pr-descriptions Directory** + - Run `mkdir -p pr-descriptions` to ensure the folder exists + +4) **Generate the PR Description File** + Create a markdown file with this structure: + + ```markdown + # PR: [Brief Title Based on Changes] + + **Branch:** `[branch-name]` + **Date:** [YYYY-MM-DD] + **Commits:** [number of commits] + + ## What This PR Does + + [2-4 sentences describing the purpose and goals of this PR. Focus on the "why" - what problem does this solve or what feature does it add?] + + ## Changes Overview + + ### Files Changed + - [List key files/directories modified with brief notes] + + ### Key Changes + - [Bullet points of the main changes, written for humans to understand] + - [Focus on what changed, not line-by-line diffs] + + ## How It Works + + [Explain the implementation approach. How does the new code work? What patterns or approaches were used? Include relevant technical details that reviewers should understand.] + + ## Breaking Changes + + [List any breaking changes, or state "None" if there are no breaking changes] + + - **[Change]**: [Description of what broke and how to migrate] + + ## Testing + + - [ ] Unit tests added/updated + - [ ] Manual testing performed + - [ ] [Other relevant testing notes] + + ## Screenshots/Examples + + [If applicable, note where screenshots could be added or include code examples] + + ## Checklist + + - [ ] Code follows project conventions + - [ ] Documentation updated (if needed) + - [ ] No console errors or warnings introduced + - [ ] Reviewed for security implications + + ## Additional Notes + + [Any other context, related issues, follow-up work needed, or notes for reviewers] + ``` + +5) **Filename Convention** + Save the file as: `pr-descriptions/[branch-name]-[YYYY-MM-DD].md` + - Replace `/` in branch names with `-` + - Example: `pr-descriptions/feat-core-add-mcp-server-2025-01-12.md` + +6) **Output** + After creating the file: + - Show the full path to the generated file + - Display a brief summary of what was documented + +Constraints: +- Write in clear, simple language that non-technical stakeholders can understand +- Be honest about breaking changes - don't hide them +- If there are no changes compared to main, say so and stop +- Focus on substance over formatting - the content matters more than looking pretty +- Use present tense ("Adds feature" not "Added feature") +- Keep bullet points concise but informative diff --git a/.github/workflows/notify-discord.yml b/.github/workflows/notify-discord.yml new file mode 100644 index 000000000..5b6c125cd --- /dev/null +++ b/.github/workflows/notify-discord.yml @@ -0,0 +1,14 @@ +name: discord + +on: + release: + types: [released] # fires when a draft release is published + +jobs: + notify: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Send nicely-formatted embed to Discord + uses: SethCohen/github-releases-to-discord@v1 + with: + webhook_url: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} \ No newline at end of file diff --git a/.github/workflows/redeploy-eventcatalog-examples.yml b/.github/workflows/redeploy-eventcatalog-examples.yml new file mode 100644 index 000000000..ba613af80 --- /dev/null +++ b/.github/workflows/redeploy-eventcatalog-examples.yml @@ -0,0 +1,47 @@ +name: Redeploy EventCatalog Examples on core release +on: + workflow_run: + workflows: ["Release"] + types: + - completed +jobs: + redeploy-finance: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_commit.message, 'Version Packages') + steps: + - name: Redeploy EventCatalog Finance Example Catalog + run: curl -f -X POST "$VERCEL_DEPLOY_HOOK_URL" + env: + VERCEL_DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_FINANCE_EXAMPLE_CATALOG_URL }} + redeploy-healthcare: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_commit.message, 'Version Packages') + steps: + - name: Redeploy EventCatalog Healthcare Example Catalog + run: curl -f -X POST "$VERCEL_DEPLOY_HOOK_URL" + env: + VERCEL_DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_HEALTHCARE_EXAMPLE_CATALOG_URL }} + redeploy-demo: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_commit.message, 'Version Packages') + steps: + - name: Redeploy EventCatalog Demo (FlowMart) Example Catalog + run: curl -f -X POST "$VERCEL_DEPLOY_HOOK_URL" + env: + VERCEL_DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_DEMO_FLOWMART_CATALOG_URL }} + redeploy-saas: + runs-on: ubuntu-latest + if: > + github.event.workflow_run.conclusion == 'success' && + startsWith(github.event.workflow_run.head_commit.message, 'Version Packages') + steps: + - name: Redeploy EventCatalog Demo (SaaS) Example Catalog + run: curl -f -X POST "$VERCEL_DEPLOY_HOOK_URL" + env: + VERCEL_DEPLOY_HOOK_URL: ${{ secrets.VERCEL_DEPLOY_HOOK_SAAS_EXAMPLE_CATALOG_URL }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 326a6a93e..50115f36e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ pnpm-debug.log* /examples/large-catalog/* eventcatalog/pnpm-lock.yaml eventcatalog/public/pagefind +/eventcatalog/src/pages/api/[...auth].ts .vscode/* @@ -37,10 +38,18 @@ git-push.sh src/__tests__/example-catalog-dependencies/dependencies +**/__tests__/catalog/ + eventcatalog/public/ai examples/default/public/ai +examples/default/eventcatalog.chat.js **/[...auth].ts +.astro/ + dev-scripts/ -examples/e-commerce \ No newline at end of file +examples/e-commerce + +.claude/agents/eventcatalog-blog-writer.md +pr-descriptions/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a2da8a41..42d6cbcf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,283 @@ # @eventcatalog/core +## 3.4.0 + +### Minor Changes + +- 5162696: feat(core): add mcp server and shared catalog tools + +## 3.3.1 + +### Patch Changes + +- 1bd23c3: fix(core): fixed schema explorer in srr mode on start + +## 3.3.0 + +### Minor Changes + +- a4645bf: feat(core): added new resource type diagrams + +## 3.2.2 + +### Patch Changes + +- d1b2172: fix(core): changelog theme styles + +## 3.2.1 + +### Patch Changes + +- c8e2c5c: feat(core): added support for icepanel diagrams + +## 3.2.0 + +### Minor Changes + +- 31c0bfe: feat(core): added ability to add custom tools to AI assistant + +## 3.1.0 + +### Minor Changes + +- c518cf3: core(feat): added support for dark mode and custom themes + +## 3.0.0 + +### Major Changes + +- 1d1111d: feat(core): eventcatalog-v3 release +- bf6fe18: chore(core): release of v3 + +### Patch Changes + +- 786db2d: chore(core): added icons to spec files in sidebar +- f49aade: feat(core): updated architecture overview pages +- 80399f5: chore(core): updated ubi lang styles +- 6c33b1f: feat(core): visualizer now has presentation mode +- d1e0979: feat(core): updated chat features +- c5592f1: fix(core): fixed issue embedding pages on build +- 39fbd2f: feat(core): added support for titles on admonitions +- 5525414: chore(core): removed md to mdx checks +- 8db71c9: fix(core): fixed issue embedding pages on build +- 507d14d: fix(core): fixed deployment of example catalogs on releases +- 341279e: fix(core): fixed issue embedding pages on build +- 713c535: chore(core): updated logger for the ecstudio watcher +- de19451: feat(core): simplified eventcatalog chat +- c43115d: feat(core): added channel support in nav and search +- 8ed1960: chore(core): removed unused icons on domain grid +- 17c1abc: chore(core): updated tables styles +- 0bc73d3: chore(core): auth is now more explict opt in +- f7ef380: feat(fix): fixed search throughout the application +- 35f760b: chore(core): updated styles for v3 +- 1a0bc7d: chore(core): removed some redundant files +- 9ec4525: chore(core): refactored features into astro custom integrations +- 2a32d7c: chore(core): added empty state to nested sidebar +- fcd3e9c: chore(core): removed some redundant files +- ddc8af5: fix(core): fixed issues with nested sidebar state +- dfce0b7: chore(core): llms-txt is now enabled by default +- 4a17954: chore(core): updated sidebar styles +- 1cc63fa: chore(core): added v3 beta message update +- 7b1311b: chore(core): updated packages +- 8ca5436: chore(core): fixing circular dep in JS +- 57d1496: chore(core): auth is now more explict opt in +- b8730a9: fix(core): mdx pages are added to teams and users +- 5a7f45b: chore(core): updated the schema explorer UI +- 08f0c81: chore(core): updated cli logger +- dac4dc5: feat(core): updated homepage styles +- aff2f92: fix(core): removed duplicated edge labels +- cd713b8: feat(core): updated default homepage +- 5b4095e: fix(core): fixed accessibility issues +- c0372e5: feat(core): embedding visualizer can be embedded with animations +- 525c809: chore(core): updated react-syntax-highlighter +- c270a98: fix(core): problems with asyncapi loading in the DOM + +## 3.0.0-beta.28 + +### Patch Changes + +- dfce0b7: chore(core): llms-txt is now enabled by default + +## 3.0.0-beta.27 + +### Patch Changes + +- f49aade: feat(core): updated architecture overview pages + +## 3.0.0-beta.26 + +### Patch Changes + +- 80399f5: chore(core): updated ubi lang styles +- 5525414: chore(core): removed md to mdx checks +- 17c1abc: chore(core): updated tables styles +- 4a17954: chore(core): updated sidebar styles +- 5a7f45b: chore(core): updated the schema explorer UI +- cd713b8: feat(core): updated default homepage + +## 3.0.0-beta.25 + +### Patch Changes + +- f7ef380: feat(fix): fixed search throughout the application + +## 3.0.0-beta.24 + +### Patch Changes + +- d1e0979: feat(core): updated chat features + +## 3.0.0-beta.23 + +### Patch Changes + +- aff2f92: fix(core): removed duplicated edge labels + +## 3.0.0-beta.22 + +### Patch Changes + +- fcd3e9c: chore(core): removed some redundant files + +## 3.0.0-beta.21 + +### Patch Changes + +- 1a0bc7d: chore(core): removed some redundant files + +## 3.0.0-beta.20 + +### Patch Changes + +- 9ec4525: chore(core): refactored features into astro custom integrations + +## 3.0.0-beta.19 + +### Patch Changes + +- de19451: feat(core): simplified eventcatalog chat +- 7b1311b: chore(core): updated packages + +## 3.0.0-beta.18 + +### Patch Changes + +- 1cc63fa: chore(core): added v3 beta message update + +## 3.0.0-beta.17 + +### Patch Changes + +- c0372e5: feat(core): embedding visualizer can be embedded with animations + +## 3.0.0-beta.16 + +### Patch Changes + +- 786db2d: chore(core): added icons to spec files in sidebar + +## 3.0.0-beta.15 + +### Patch Changes + +- 5b4095e: fix(core): fixed accessibility issues + +## 3.0.0-beta.14 + +### Patch Changes + +- 57d1496: chore(core): auth is now more explict opt in +- c270a98: fix(core): problems with asyncapi loading in the DOM + +## 3.0.0-beta.13 + +### Patch Changes + +- 0bc73d3: chore(core): auth is now more explict opt in +- b8730a9: fix(core): mdx pages are added to teams and users + +## 3.0.0-beta.12 + +### Patch Changes + +- 8ed1960: chore(core): removed unused icons on domain grid + +## 3.0.0-beta.11 + +### Patch Changes + +- 39fbd2f: feat(core): added support for titles on admonitions +- dac4dc5: feat(core): updated homepage styles + +## 3.0.0-beta.10 + +### Patch Changes + +- c5592f1: fix(core): fixed issue embedding pages on build + +## 3.0.0-beta.9 + +### Patch Changes + +- 8db71c9: fix(core): fixed issue embedding pages on build + +## 3.0.0-beta.8 + +### Patch Changes + +- 507d14d: fix(core): fixed deployment of example catalogs on releases + +## 3.0.0-beta.7 + +### Patch Changes + +- 341279e: fix(core): fixed issue embedding pages on build + +## 3.0.0-beta.6 + +### Patch Changes + +- 713c535: chore(core): updated logger for the ecstudio watcher +- 2a32d7c: chore(core): added empty state to nested sidebar + +## 3.0.0-beta.5 + +### Patch Changes + +- 525c809: chore(core): updated react-syntax-highlighter + +## 3.0.0-beta.4 + +### Patch Changes + +- c43115d: feat(core): added channel support in nav and search + +## 3.0.0-beta.3 + +### Patch Changes + +- 08f0c81: chore(core): updated cli logger + +## 3.0.0-beta.2 + +### Patch Changes + +- 6c33b1f: feat(core): visualizer now has presentation mode + +## 3.0.0-beta.1 + +### Patch Changes + +- 35f760b: chore(core): updated styles for v3 +- ddc8af5: fix(core): fixed issues with nested sidebar state +- 8ca5436: chore(core): fixing circular dep in JS + +## 3.0.0-beta.0 + +### Major Changes + +- 1d1111d: feat(core): eventcatalog-v3 release + ## 2.65.1 ### Patch Changes diff --git a/CLAUDE.md b/CLAUDE.md index 32e2adcfb..736d1d397 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,25 +2,224 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Build, Lint, and Test Commands +## Project Overview -- **Build**: `pnpm run verify-build:catalog` -- **Test**: `pnpm run test` -- **Format and lint code**: `pnpm run format` -- **Start the catalog**: `pnpm run start:catalog` +EventCatalog is an open-source documentation tool for Event-Driven Architectures. It helps teams document events, commands, queries, services, domains, and flows in a discoverable catalog. Built with Astro, React, and TypeScript. -## Code Style Guidelines +## Quick Reference Commands + +| Task | Command | +|------|---------| +| Start dev server | `pnpm run start:catalog` | +| Start SSR server | `pnpm run start:catalog:server` | +| Build | `pnpm run verify-build:catalog` | +| Test (all) | `pnpm run test` | +| Test (single file) | `pnpm run test path/to/file.test.ts` | +| Test (watch mode) | `pnpm run test --watch` | +| Format code | `pnpm run format` | +| E2E tests | `pnpm run test:e2e` | + +## Project Structure + +``` +/eventcatalog # Main application source + /src + /components # React and Astro components + /pages # Astro pages and API routes + /enterprise # Scale plan features (AI Chat, MCP Server) + /utils # Shared utilities + /collections # Astro content collection helpers + /layouts # Page layouts + /styles # CSS and theme files + /types # TypeScript type definitions + /stores # Nanostores state management + /content # Content collection definitions + /__tests__ # Unit tests (colocated with source) +/examples + /default # Default example catalog (used by start:catalog) + /e-commerce # E-commerce example +/scripts # Build and development scripts +``` + +## Tech Stack + +- **Framework**: Astro 5 with React islands +- **Styling**: Tailwind CSS with CSS variables for theming +- **State**: Nanostores +- **Testing**: Vitest (unit), Playwright (E2E) +- **Content**: Astro Content Collections with Zod schemas +- **AI Features**: Vercel AI SDK, MCP Protocol + +## Code Conventions + +### Imports + +```typescript +// Use node: prefix for Node.js built-ins +import fs from 'node:fs'; +import path from 'node:path'; + +// Use path aliases +import { getCollection } from 'astro:content'; +import { myUtil } from '@utils/my-util'; +import { MyComponent } from '@components/MyComponent'; +``` ### TypeScript -- Strict typing with TypeScript -- ES modules with explicit imports/exports -- Error handling with proper type guards +- Strict typing enabled +- Use `as const` for literal types +- Prefer type guards over type assertions +- Use Zod for runtime validation (especially in API routes) -## Project Structure +### Astro Content Collections + +Resources are stored in content collections. Key collections: +- `events`, `commands`, `queries` (messages) +- `services`, `domains`, `flows`, `channels`, `entities` +- `teams`, `users` (non-versioned) + +```typescript +// Getting collections +import { getCollection, getEntry } from 'astro:content'; + +const events = await getCollection('events'); +const event = await getEntry('events', 'OrderCreated-1.0.0'); +``` + +### Versioning + +Most resources are versioned. Entry IDs follow the pattern: `{id}-{version}` (e.g., `OrderCreated-1.0.0`). + +```typescript +// Use existing utilities for version handling +import { getItemsFromCollectionByIdAndSemverOrLatest } from '@utils/collections/util'; +``` + +### Error Handling + +For API routes and tools, return error objects instead of throwing: + +```typescript +if (!resource) { + return { error: `Resource not found: ${id}` }; +} +``` + +### Pagination + +Use cursor-based pagination with the `paginate()` helper from `@enterprise/tools/catalog-tools`: + +```typescript +import { paginate } from '@enterprise/tools/catalog-tools'; + +const result = paginate(items, cursor, pageSize); +if ('error' in result) return result; +``` + +## Feature Flags + +Check feature availability before using enterprise features: + +```typescript +import { isEventCatalogScaleEnabled, isSSR } from '@utils/feature'; + +if (!isEventCatalogScaleEnabled()) { + return { error: 'Feature requires Scale plan' }; +} +``` + +## Testing + +- Tests are colocated in `__tests__` directories +- Test files use `.test.ts` or `.test.tsx` extension +- Example catalogs for testing: `src/__tests__/example-catalog/` + +```bash +# Run specific test file +pnpm run test eventcatalog/src/utils/__tests__/my-util.test.ts + +# Run tests matching pattern +pnpm run test -t "getResources" +``` + +## Theming Guidelines + +EventCatalog uses CSS variables for theming to support light/dark mode and custom themes. + +### Use CSS Variables Instead of Hardcoded Colors + +```astro + +
+ + +
+``` + +### Common CSS Variables + +| Variable | Usage | +|----------|-------| +| `--ec-page-bg` | Page/content background | +| `--ec-page-text` | Primary text color | +| `--ec-page-text-muted` | Secondary/muted text | +| `--ec-page-border` | Borders and dividers | +| `--ec-card-bg` | Card/elevated surface background | +| `--ec-accent` | Accent/brand color | +| `--ec-accent-subtle` | Light accent background | +| `--ec-accent-text` | Text on accent backgrounds | +| `--ec-button-bg` | Button background | +| `--ec-button-text` | Button text | +| `--ec-icon-color` | Icon default color | +| `--ec-icon-hover` | Icon hover color | +| `--ec-input-bg` | Input field background | +| `--ec-input-border` | Input field border | +| `--ec-input-text` | Input field text | + +### Theme Files + +- Base theme: `eventcatalog/src/styles/theme.css` +- Additional themes: `eventcatalog/src/styles/themes/*.css` + +### Key Points + +1. Variables use RGB values without `rgb()` wrapper for Tailwind opacity support +2. Use syntax `[rgb(var(--ec-variable))]` in Tailwind classes +3. For opacity: `[rgb(var(--ec-variable)/0.5)]` +4. Dark mode handled via `data-theme="dark"` attribute +5. Never use `dark:` Tailwind variants for theme colors + +## Common Patterns + +### API Routes with Hono + +For complex API routes, use Hono inside Astro API routes: + +```typescript +import type { APIRoute } from 'astro'; +import { Hono } from 'hono'; + +const app = new Hono().basePath('/api/my-route'); + +app.get('/', async (c) => { + return c.json({ message: 'Hello' }); +}); + +export const ALL: APIRoute = async ({ request }) => { + return app.fetch(request); +}; + +export const prerender = false; +``` + +### Shared Tool Implementations + +When building features used by both AI Chat and MCP Server, add shared logic to `@enterprise/tools/catalog-tools.ts`. -- `/eventcatalog` - The EventCatalog codebase -- `/examples/default` - An example of how users use EventCatalog. This is the default example that is used when you run `pnpm run start:catalog` -- `/scripts` - Scripts to help with the development of the EventCatalog +## Development Tips -Run linting and formatting before submitting changes. Follow existing patterns when adding new code. +- The example catalog at `/examples/default` is used when running `pnpm run start:catalog` +- SSR mode is required for AI Chat and MCP Server features +- Use `DISABLE_EVENTCATALOG_CACHE=true` env var to disable caching during development +- Run `pnpm run format` before committing changes diff --git a/Dockerfile.server b/Dockerfile.server index f4bab595d..4a7da649c 100644 --- a/Dockerfile.server +++ b/Dockerfile.server @@ -5,7 +5,7 @@ # FOR DEVELOPMENT ONLY, DO NOT USE THIS FOR PRODUCTION -FROM node:20-slim AS runtime +FROM node:20.19.6-trixie-slim AS runtime WORKDIR /app # Install pnpm diff --git a/README.md b/README.md index ee9dd5531..3304a359b 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,7 @@
-

📖 EventCatalog

-

The open source tool to help you discover and document your event-driven architectures

- -[![MIT License][license-badge]][license] -[![PRs Welcome][prs-badge]][prs] - -[![](https://dcbadge.limes.pink/api/server/https://discord.gg/3rjaZMmrAm?style=flat)](https://discord.gg/3rjaZMmrAm) [](https://www.linkedin.com/in/david-boyne/) [![blog](https://img.shields.io/badge/blog-EDA--Visuals-brightgreen)](https://eda-visuals.boyney.io/?utm_source=event-catalog-gihub) + + @@ -25,134 +20,82 @@ - -EventCatalog - +EventCatalog -

Features: Documentation for Event Driven Architectures, Integration with any broker, Generator from your OpenAPI and AsyncAPI documents, Docs and Code, Markdown driven, Document Domains/Services/Messages/Schemas and more, Content versioning, Assign Owners, Schemas, OpenAPI, MDX Components and more...

+

+
+ EventCatalog is a documentation tool for software architectures — +
+ bring discoverability to complex systems. +

+

- -[![All Contributors](https://img.shields.io/badge/all_contributors-67-orange.svg?style=flat-square)](#contributors-) - +
-[Read the Docs](https://www.eventcatalog.dev/docs/development/getting-started/introduction) | [View Demo](https://demo.eventcatalog.dev) +[![main](https://github.com/event-catalog/eventcatalog/actions/workflows/verify-build.yml/badge.svg)](https://github.com/event-catalog/eventcatalog/actions/workflows/verify-build.yml) +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/event-catalog/eventcatalog/blob/main/LICENSE) +[![npm version](https://badge.fury.io/js/@eventcatalog%2Fcore.svg)](https://badge.fury.io/js/@eventcatalog/core)
-
- -# Core Features - -- 📃 Document domains, services and messages ([demo](https://demo.eventcatalog.dev/docs)) -- 📊 Visualise your architecture ([demo](https://demo.eventcatalog.dev/visualiser/domains/Orders)) -- ⭐ Supports any Schema format (e.g Avro, JSON) ([demo](https://demo.eventcatalog.dev/docs/events/OrderConfirmed/0.0.1)) -- 🗂️ Document any code examples (Any code snippet) -- 💅 Custom MDX components ([read more](https://eventcatalog.dev/docs/development/components/using-components)) -- 🗄️ Version domains, services and messages -- ⭐ Discoverability feature (search, filter and more) ([demo](https://demo.eventcatalog.dev/discover/events)) -- ⭐ Document teams and users ([demo](https://demo.eventcatalog.dev/docs/teams/full-stack)) -- 🤖 Automate your catalogs with [generators](https://www.eventcatalog.dev/docs/development/plugins/plugin-overview) (e.g generate your catalogs from your [AsyncAPI](https://www.eventcatalog.dev/docs/asyncapi)/[OpenAPI](https://www.eventcatalog.dev/docs/openapi) documents) -- 👨🏼‍💻 Follows [Docs as code](https://www.writethedocs.org/guide/docs-as-code/) principles -- ⭐ And much more... - - -# The problem - -Event-driven architectures are becoming more popular, giving us the ability to write decoupled architectures and use messages as away to communicate between domains/teams. - -When starting with event-driven architectures you may have a handful of services and messages. As this scales with your team and organization it becomes very hard to manage and govern this. -Over a period of time more events are added to our domain, requirements change, and our architecture scales. + -Think of EventCatalog as a website generator that allows you to document your event architectures powered by markdown. -EventCatalog is focused on discovery and documentation and allows you to: -- Document Domains/Services/Messages/Schemas/Code Examples and more... -- Visually shows relationships between upstream/downstream services using your Events -- Allows you to version your documentation and supports changelogs -- Add owners to domains,services and messages so your teams know who owns which parts of your domain -- And much more... -EventCatalog is technology agnostic, which means you can integrate your Catalog with any EDA technology of your choice and any schema formats. -EventCatalog supports a [Plugin Architecture](https://github.com/event-catalog/generators) which lets you generate documentation from your systems including OpenAPI, AsyncAPI, Event Brokers and more. + -# Getting Started + + + -You should be able to get setup within minutes if you head over to our documentation to get started 👇 + -➡️ [Get Started](https://www.eventcatalog.dev/docs/development/getting-started/installation) +
-Or run this command to build a new catalog +## Install +The **recommended** way to install the latest version of EventCatalog is by running the command below: ``` npx @eventcatalog/create-eventcatalog@latest my-catalog ``` -# Demo - -Here is an example of a Retail system using domains, services and messages. +Looking for help? Start with our [Getting Started](https://www.eventcatalog.dev/docs/development/starting-a-new-project/installation) guide -[demo.eventcatalog.dev](https://demo.eventcatalog.dev) +## Documentation +Visit our [official documentation](https://www.eventcatalog.dev/docs/development/getting-started). -You can see the markdown files that generated the website in the GitHub repo under [examples](/examples). +## Support +Having trouble? Get help in the official [EventCatalog Discord](https://discord.gg/3rjaZMmrAm). -# Enterprise support +## Demos -Interested in collaborating with us? Our offerings include dedicated support, priority assistance, feature development, custom integrations, and more. +Here are some examples of EventCatalog in action: -Find more details on our [services page](https://eventcatalog.dev/services). +- [Finance System](https://eventcatalog-examples-finance.vercel.app/) +- [Healthcare System](https://eventcatalog-examples-healthcare.vercel.app/) +- [E-Commerce System](https://demo.eventcatalog.dev/) +- [SaaS System](https://eventcatalog-examples-saas.vercel.app/) -# Looking for v1? -- Documentation: https://v1.eventcatalog.dev -- Code: https://github.com/event-catalog/eventcatalog/tree/v1 - -_Still using v1 of EventCatalog? We recommnded upgrading to the latest version. [Read more in the migration guide](https://eventcatalog.dev/docs/development/guides/upgrading-from-version-1)._ - - -# Contributing +## Contributing If you have any questions, features or issues please raise any issue or pull requests you like. We will try my best to get back to you. You can find the [contributing guidelines here](https://eventcatalog.dev/docs/contributing/overview). -## Running the project locally - -1. Clone the repo -1. Install required dependencies `pnpm install` -1. Run the command `pnpm run start:catalog` - - This will start the catalog found in `/examples` repo, locally on your machine - -[license-badge]: https://img.shields.io/github/license/event-catalog/eventcatalog.svg?color=yellow -[license]: https://github.com/event-catalog/eventcatalog/blob/main/LICENSE -[prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square -[prs]: http://makeapullrequest.com -[github-watch-badge]: https://img.shields.io/github/watchers/event-catalog/eventcatalog.svg?style=social -[github-watch]: https://github.com/event-catalog/eventcatalog/watchers -[github-star-badge]: https://img.shields.io/github/stars/event-catalog/eventcatalog.svg?style=social -[github-star]: https://github.com/event-catalog/eventcatalog/stargazers - ## Contributors ✨ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): diff --git a/default-files-for-collections/changelogs.md b/default-files-for-collections/changelogs.md deleted file mode 100644 index c97be0077..000000000 --- a/default-files-for-collections/changelogs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -id: empty ---- - - diff --git a/default-files-for-collections/channels.md b/default-files-for-collections/channels.md deleted file mode 100644 index 089fe4913..000000000 --- a/default-files-for-collections/channels.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: empty -name: empty -version: 0.0.1 -hidden: true ---- - - diff --git a/default-files-for-collections/commands.md b/default-files-for-collections/commands.md deleted file mode 100644 index 089fe4913..000000000 --- a/default-files-for-collections/commands.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: empty -name: empty -version: 0.0.1 -hidden: true ---- - - diff --git a/default-files-for-collections/domains.md b/default-files-for-collections/domains.md deleted file mode 100644 index 089fe4913..000000000 --- a/default-files-for-collections/domains.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: empty -name: empty -version: 0.0.1 -hidden: true ---- - - diff --git a/default-files-for-collections/events.md b/default-files-for-collections/events.md deleted file mode 100644 index 089fe4913..000000000 --- a/default-files-for-collections/events.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: empty -name: empty -version: 0.0.1 -hidden: true ---- - - diff --git a/default-files-for-collections/flows.md b/default-files-for-collections/flows.md deleted file mode 100644 index effd29cc4..000000000 --- a/default-files-for-collections/flows.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -id: empty -steps: - - id: "empty" - title: "empty" -name: empty -version: 0.0.1 -hidden: true ---- - - diff --git a/default-files-for-collections/queries.md b/default-files-for-collections/queries.md deleted file mode 100644 index 089fe4913..000000000 --- a/default-files-for-collections/queries.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: empty -name: empty -version: 0.0.1 -hidden: true ---- - - diff --git a/default-files-for-collections/services.md b/default-files-for-collections/services.md deleted file mode 100644 index 089fe4913..000000000 --- a/default-files-for-collections/services.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -id: empty -name: empty -version: 0.0.1 -hidden: true ---- - - diff --git a/default-files-for-collections/ubiquitousLanguages.md b/default-files-for-collections/ubiquitousLanguages.md deleted file mode 100644 index aaba1d03b..000000000 --- a/default-files-for-collections/ubiquitousLanguages.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -id: ubiquitous-language -name: Ubiquitous Language -summary: A shared language used by all team members to communicate about the system. -description: A shared language used by all team members to communicate about the system. ---- - \ No newline at end of file diff --git a/eventcatalog/astro.config.mjs b/eventcatalog/astro.config.mjs index 9035b1045..d011c92f6 100644 --- a/eventcatalog/astro.config.mjs +++ b/eventcatalog/astro.config.mjs @@ -20,12 +20,14 @@ import rehypeExpressiveCode from 'rehype-expressive-code' import config from './eventcatalog.config'; import expressiveCode from 'astro-expressive-code'; import ecstudioWatcher from './integrations/ecstudio-watcher.mjs'; +import eventCatalogIntegration from './integrations/eventcatalog-features.ts'; const projectDirectory = process.env.PROJECT_DIR || process.cwd(); const base = config.base || '/'; const host = config.host || false; const compress = config.compress ?? false; + const expressiveCodeConfig = { themes: ['andromeeda'], defaultProps: { @@ -89,6 +91,7 @@ export default defineConfig({ CSS: false, }), ecstudioWatcher(), + eventCatalogIntegration(), ].filter(Boolean), vite: { define: { @@ -119,7 +122,7 @@ export default defineConfig({ } }, ssr: { - external: ['eventcatalog.auth.js'], + external: ['eventcatalog.auth.js', 'eventcatalog.chat.js'], } } }); diff --git a/eventcatalog/integrations/ecstudio-watcher.mjs b/eventcatalog/integrations/ecstudio-watcher.mjs index 94aa28f0f..6c17cca42 100644 --- a/eventcatalog/integrations/ecstudio-watcher.mjs +++ b/eventcatalog/integrations/ecstudio-watcher.mjs @@ -55,7 +55,7 @@ export default function ecstudioWatcher() { // Also add the root directory to watch for new files server.watcher.add(rootDir); - console.log('Set up dynamic .ecstudio file watcher with content refresh'); + // console.log('Set up dynamic .ecstudio file watcher with content refresh'); }, }, }; diff --git a/eventcatalog/integrations/eventcatalog-features.ts b/eventcatalog/integrations/eventcatalog-features.ts new file mode 100644 index 000000000..dd17fded7 --- /dev/null +++ b/eventcatalog/integrations/eventcatalog-features.ts @@ -0,0 +1,78 @@ +import type { AstroIntegration } from 'astro'; +import path from 'path'; +import { + isEventCatalogChatEnabled, + isAuthEnabled, + isEventCatalogScaleEnabled, + isEventCatalogStarterEnabled, + isEventCatalogMCPEnabled, +} from '../src/utils/feature'; + +const catalogDirectory = process.env.CATALOG_DIR || process.cwd(); + +const configureAuthentication = (params: { + injectRoute: (route: { pattern: string; entrypoint: string }) => void; + addMiddleware: (middleware: { entrypoint: string; order: 'pre' }) => void; +}) => { + params.injectRoute({ + pattern: '/api/[...auth]', + entrypoint: path.join(catalogDirectory, 'src/enterprise/auth/[...auth].ts'), + }); + params.injectRoute({ + pattern: '/auth/login', + entrypoint: path.join(catalogDirectory, 'src/enterprise/auth/login.astro'), + }); + params.injectRoute({ + pattern: '/auth/error', + entrypoint: path.join(catalogDirectory, 'src/enterprise/auth/error.astro'), + }); + + params.injectRoute({ + pattern: '/unauthorized', + entrypoint: path.join(catalogDirectory, 'src/enterprise/auth/unauthorized.astro'), + }); + + // Add the authentication middleware + params.addMiddleware({ + entrypoint: path.join(catalogDirectory, 'src/enterprise/auth/middleware/middleware.ts'), + order: 'pre', + }); +}; + +export default function eventCatalogIntegration(): AstroIntegration { + return { + name: 'eventcatalog', + hooks: { + 'astro:config:setup': (params) => { + // Handle routes for AI features + if (isEventCatalogChatEnabled()) { + params.injectRoute({ + pattern: '/api/chat', + entrypoint: path.join(catalogDirectory, 'src/enterprise/ai/chat-api.ts'), + }); + } + + // Handle routes for MCP Server (requires SSR + Scale) + if (isEventCatalogMCPEnabled()) { + params.injectRoute({ + pattern: '/docs/mcp/[...path]', + entrypoint: path.join(catalogDirectory, 'src/enterprise/mcp/mcp-server.ts'), + }); + } + + // Handle routes for authentication + if (isAuthEnabled()) { + configureAuthentication(params); + } + + // If non paying user, add the plans route into the project + if (!isEventCatalogStarterEnabled() && !isEventCatalogScaleEnabled()) { + params.injectRoute({ + pattern: '/plans', + entrypoint: path.join(catalogDirectory, 'src/enterprise/plans/index.astro'), + }); + } + }, + }, + }; +} diff --git a/eventcatalog/public/icons/asyncapi-black.svg b/eventcatalog/public/icons/asyncapi-black.svg new file mode 100644 index 000000000..8217adbc8 --- /dev/null +++ b/eventcatalog/public/icons/asyncapi-black.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/eventcatalog/public/icons/graphql-black.svg b/eventcatalog/public/icons/graphql-black.svg new file mode 100644 index 000000000..88c722d51 --- /dev/null +++ b/eventcatalog/public/icons/graphql-black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/eventcatalog/public/icons/openapi-black.svg b/eventcatalog/public/icons/openapi-black.svg new file mode 100644 index 000000000..c8b9aaa78 --- /dev/null +++ b/eventcatalog/public/icons/openapi-black.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/eventcatalog/public/logo_old.png b/eventcatalog/public/logo_old.png deleted file mode 100644 index 9df4b958f..000000000 Binary files a/eventcatalog/public/logo_old.png and /dev/null differ diff --git a/eventcatalog/src/__tests__/middleware-auth.spec.ts b/eventcatalog/src/__tests__/middleware-auth.spec.ts index d7849ce4e..c076d8ff0 100644 --- a/eventcatalog/src/__tests__/middleware-auth.spec.ts +++ b/eventcatalog/src/__tests__/middleware-auth.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { findMatchingRule, matchesPattern } from '../middleware-auth'; +import { findMatchingRule, matchesPattern } from '../enterprise/auth/middleware/middleware-auth'; describe('middleware-auth', () => { describe('matchesPattern', () => { diff --git a/eventcatalog/src/components/ChatPanel/ChatPanel.tsx b/eventcatalog/src/components/ChatPanel/ChatPanel.tsx new file mode 100644 index 000000000..f96465ae3 --- /dev/null +++ b/eventcatalog/src/components/ChatPanel/ChatPanel.tsx @@ -0,0 +1,1276 @@ +import { useEffect, useRef, useCallback, useState, useMemo, memo } from 'react'; +import { X, Square, Trash2, BookOpen, Copy, Check, Maximize2, Minimize2, Wrench, ChevronDown, MessageSquare } from 'lucide-react'; +import { useChat } from '@ai-sdk/react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; +import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism'; +import * as Dialog from '@radix-ui/react-dialog'; +import * as Popover from '@radix-ui/react-popover'; + +interface ToolMetadata { + name: string; + description: string; + isCustom?: boolean; +} + +// CSS keyframes - defined once outside component to avoid re-injection +const CHAT_PANEL_STYLES = ` + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + @keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } + } + @keyframes focusIn { + from { + box-shadow: 0 0 0 0 rgba(168, 85, 247, 0); + border-color: #e5e7eb; + } + to { + box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.15); + border-color: #c084fc; + } + } + @keyframes pulse-glow { + 0%, 100% { box-shadow: 0 0 8px rgba(147, 51, 234, 0.3); } + 50% { box-shadow: 0 0 16px rgba(147, 51, 234, 0.5); } + } +`; + +// Stable style object for syntax highlighter +const CODE_BLOCK_STYLE = { + margin: 0, + borderRadius: '0.375rem', + fontSize: '12px', + padding: '1rem', +}; + +// Code block component with copy functionality - memoized +const CodeBlock = memo(({ language, children }: { language: string; children: string }) => { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(children); + setCopied(true); + } catch { + // Clipboard API can fail in some contexts + } + }, [children]); + + // Clear copied state after 2 seconds with proper cleanup + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 2000); + return () => clearTimeout(timer); + }, [copied]); + + return ( +
+ + + {children} + +
+ ); +}); +CodeBlock.displayName = 'CodeBlock'; + +// Get time-based greeting +const getGreeting = () => { + const hour = new Date().getHours(); + if (hour < 12) return 'Good morning'; + if (hour < 18) return 'Good afternoon'; + return 'Good evening'; +}; + +// ============================================ +// SUGGESTED QUESTIONS CONFIGURATION +// ============================================ +// Each config has a pattern (regex) to match the URL path +// and an array of questions to show. Questions are checked +// in order - first matching pattern wins. +// ============================================ + +interface SuggestedQuestion { + label: string; + prompt: string; +} + +interface QuestionConfig { + pattern: RegExp; + questions: SuggestedQuestion[]; +} + +const suggestedQuestionsConfig: QuestionConfig[] = [ + // Message pages (events, commands, queries) - most specific first + { + pattern: /^\/docs\/(events|commands|queries)\/.+/, + questions: [ + { label: 'Which services publish this?', prompt: 'Who produces this message?' }, + { label: 'Which services subscribe to this?', prompt: 'Who consumes this message?' }, + { label: 'View the message schema', prompt: 'Show me the schema for this message' }, + { label: 'What breaks if this changes?', prompt: 'What services would be affected if this message changes?' }, + ], + }, + // AsyncAPI specification page + { + pattern: /^\/docs\/services\/.+\/asyncapi\/.+/, + questions: [ + { label: 'Summarize this API', prompt: 'Help me understand this AsyncAPI specification' }, + { label: 'Show all channels', prompt: 'What channels are defined in this AsyncAPI spec?' }, + { label: 'How do I authenticate?', prompt: 'What authentication is required for this service?' }, + { label: 'What message formats are used?', prompt: 'What are the message formats and schemas?' }, + ], + }, + // OpenAPI specification page + { + pattern: /^\/docs\/services\/.+\/spec\/.+/, + questions: [ + { label: 'Summarize this API', prompt: 'Help me understand this OpenAPI specification' }, + { label: 'Show all endpoints', prompt: 'What endpoints are available in this API?' }, + { label: 'How do I authenticate?', prompt: 'What authentication is required for this API?' }, + { label: 'What are the request & response formats?', prompt: 'What are the request and response formats?' }, + ], + }, + // Services page + { + pattern: /^\/docs\/services\/.+/, + questions: [ + { label: 'Who owns this service?', prompt: 'Who owns this service and how do I contact them?' }, + { + label: 'What does this service depend on?', + prompt: 'What are the upstream and downstream dependencies of this service?', + }, + { label: 'How do I integrate with this service?', prompt: 'How do I integrate with this service?' }, + { label: 'What messages are published and consumed?', prompt: 'What messages does this service produce and consume?' }, + ], + }, + // Domains page + { + pattern: /^\/docs\/domains\/.+/, + questions: [ + { label: 'What services are in this domain?', prompt: 'What services belong to this domain?' }, + { label: 'What business capability is this?', prompt: 'What business capability does this domain represent?' }, + { label: 'What events come from this domain?', prompt: 'What events are published by this domain?' }, + { label: 'Who owns this domain?', prompt: 'Who owns this domain and how do I contact them?' }, + ], + }, + // Designs page + { + pattern: /^\/diagrams\/.+/, + questions: [ + { label: 'Tell me more about this diagram?', prompt: 'Tell me more about this diagram?' }, + { label: 'Help me understand this diagram', prompt: 'Help me understand this diagram' }, + { label: 'What is the context of this diagram?', prompt: 'What is the context of this diagram, what is it related to?' }, + ], + }, + // Match /schemas with specType=graphql as query parameter + { + pattern: /^\/schemas.*[?&]specType=graphql/, + questions: [ + { label: 'Tell me more about this GraphQL schema', prompt: 'Tell me more about this GraphQL schema' }, + { label: 'What queries are available?', prompt: 'What queries are available in this GraphQL schema?' }, + { label: 'What mutations are available?', prompt: 'What mutations are available in this GraphQL schema?' }, + { label: 'Show me the types defined', prompt: 'Show me the types defined in this GraphQL schema' }, + ], + }, + { + pattern: /^\/schemas.*[?&]specType=openapi/, + questions: [ + { label: 'Tell me more about this OpenAPI schema', prompt: 'Tell me more about this OpenAPI schema' }, + { label: 'Show me the endpoints available', prompt: 'Show me the endpoints available in this OpenAPI schema' }, + { + label: 'Show me the request and response formats', + prompt: 'Show me the request and response formats in this OpenAPI schema', + }, + ], + }, + { + pattern: /^\/schemas.*[?&]specType=asyncapi/, + questions: [ + { label: 'Tell me more about this AsyncAPI schema', prompt: 'Tell me more about this AsyncAPI schema' }, + { label: 'Show me the channels available', prompt: 'Show me the channels available in this AsyncAPI schema' }, + { label: 'Show me the messages available', prompt: 'Show me the messages available in this AsyncAPI schema' }, + { + label: 'Show me the request and response formats', + prompt: 'Show me the request and response formats in this AsyncAPI schema', + }, + ], + }, + { + pattern: /^\/schemas/, + questions: [ + { label: 'Tell me more about this schema?', prompt: 'Tell me more about this schema' }, + { label: 'Who producers or consumes this schema?', prompt: 'Who producers or consumes this schema?' }, + { label: 'What fields are required?', prompt: 'What fields are required for this schema?' }, + { + label: 'Generate code using this schema', + prompt: 'Create a code example of using this schema, ask me for the programming language I want the example in.', + }, + ], + }, + + // Any other docs page + { + pattern: /^\/docs\/.+/, + questions: [ + { label: 'Tell me about this', prompt: 'Tell me more about this page' }, + { label: 'Who is responsible for this?', prompt: 'Who owns this resource?' }, + { label: 'What else is related to this?', prompt: 'What other resources are related to this?' }, + ], + }, + // Default questions (fallback) + { + pattern: /.*/, + questions: [ + { label: 'What domains do we have?', prompt: 'What domains are in my catalog?' }, + { label: 'Show me all services', prompt: 'What services do I have?' }, + { label: 'What changed recently?', prompt: 'What are the most recent changes in the catalog?' }, + { label: 'How does data flow between services?', prompt: 'Show me how data flows between services' }, + ], + }, +]; + +// Get suggested questions based on current URL path +const getSuggestedQuestions = (pathname: string): SuggestedQuestion[] => { + for (const config of suggestedQuestionsConfig) { + if (config.pattern.test(pathname)) { + return config.questions; + } + } + // Fallback to last config (default) + return suggestedQuestionsConfig[suggestedQuestionsConfig.length - 1].questions; +}; + +interface ChatPanelProps { + isOpen: boolean; + onClose: () => void; +} + +const PANEL_WIDTH = 400; + +// Staggered fade-in animation styles (delays account for 800ms panel slide) +const fadeInStyles = { + header: { + animation: 'fadeIn 0.5s ease-out 0.3s both', + }, + welcome: { + animation: 'fadeIn 0.6s ease-out 0.4s both', + }, + // Label and questions will be staggered individually in the component + questionsLabel: { + animation: 'fadeIn 0.5s ease-out 0.7s both', + }, + getQuestionStyle: (index: number) => ({ + animation: `fadeIn 0.5s ease-out ${0.85 + index * 0.12}s both`, + }), + inputFocus: { + animation: 'focusIn 0.6s ease-out 1.4s both', + }, + // Follow-up suggestions animate with staggered fade-in-up + getFollowUpStyle: (index: number) => ({ + animation: `fadeInUp 0.4s ease-out ${index * 0.1}s both`, + }), +}; + +// Preprocess markdown to fix common formatting issues +const preprocessMarkdown = (text: string): string => { + // Add newlines before headings if they're directly after text (no newline) + // This fixes cases like "some text.## Heading" → "some text.\n\n## Heading" + return text.replace(/([^\n])(#{1,6}\s)/g, '$1\n\n$2'); +}; + +// Helper to extract text content from message parts +const getMessageContent = (message: { parts?: Array<{ type: string; text?: string }> }): string => { + if (!message.parts) return ''; + const rawContent = message.parts + .filter((part): part is { type: 'text'; text: string } => part.type === 'text' && typeof part.text === 'string') + .map((part) => part.text) + .join(''); + return preprocessMarkdown(rawContent); +}; + +// Helper to extract follow-up suggestions from message parts +const getFollowUpSuggestions = (message: { parts?: Array }): string[] => { + if (!message.parts) return []; + + for (const part of message.parts) { + // AI SDK format: type is "tool-{toolName}" and result is in "output" + if (part.type === 'tool-suggestFollowUpQuestions' && part.state === 'output-available') { + const suggestions = part.output?.suggestions; + if (suggestions && Array.isArray(suggestions)) { + return suggestions; + } + } + } + return []; +}; + +// Helper to extract currently running tools from message parts +const getRunningTools = (message: { parts?: Array }): string[] => { + if (!message.parts) return []; + + const runningTools: string[] = []; + for (const part of message.parts) { + // Tool parts have type like "tool-{toolName}" and state indicates progress + if (part.type?.startsWith('tool-') && part.state !== 'output-available') { + // Extract tool name from type (e.g., "tool-getServiceHealth" -> "getServiceHealth") + const toolName = part.type.replace('tool-', ''); + // Skip the follow-up suggestions tool as it's internal + if (toolName !== 'suggestFollowUpQuestions') { + runningTools.push(toolName); + } + } + } + return runningTools; +}; + +// Helper to extract completed tools from message parts (for showing after completion) +const getCompletedTools = (message: { parts?: Array }): string[] => { + if (!message.parts) return []; + + const completedTools: string[] = []; + for (const part of message.parts) { + // Tool parts have type like "tool-{toolName}" and state 'output-available' when done + if (part.type?.startsWith('tool-') && part.state === 'output-available') { + const toolName = part.type.replace('tool-', ''); + // Skip internal tools + if (toolName !== 'suggestFollowUpQuestions') { + completedTools.push(toolName); + } + } + } + return completedTools; +}; + +// Skeleton loading component - memoized since it never changes +const SkeletonLoader = memo(() => ( +
+
+
+
+
+
+)); +SkeletonLoader.displayName = 'SkeletonLoader'; + +// Memoized markdown components to prevent re-renders +const markdownComponents = { + a: ({ ...props }: any) => ( + + ), + code: ({ children, className, ...props }: any) => { + const isInline = !className; + const match = /language-(\w+)/.exec(className || ''); + const language = match ? match[1] : 'text'; + const codeString = String(children).replace(/\n$/, ''); + + return isInline ? ( + + {children} + + ) : ( + {codeString} + ); + }, + table: ({ ...props }: any) => ( +
+ + + ), + thead: ({ ...props }: any) => , + th: ({ ...props }: any) => ( +
+ ), + td: ({ ...props }: any) => ( + + ), +}; + +// Modal version with slightly different code styling +const modalMarkdownComponents = { + ...markdownComponents, + code: ({ children, className, ...props }: any) => { + const isInline = !className; + const match = /language-(\w+)/.exec(className || ''); + const language = match ? match[1] : 'text'; + const codeString = String(children).replace(/\n$/, ''); + + return isInline ? ( + + {children} + + ) : ( + {codeString} + ); + }, + table: ({ ...props }: any) => ( +
+ + + ), +}; + +const ChatPanel = ({ isOpen, onClose }: ChatPanelProps) => { + const inputRef = useRef(null); + const modalInputRef = useRef(null); + const messagesEndRef = useRef(null); + const modalMessagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); + const [inputValue, setInputValue] = useState(''); + const [isWaitingForResponse, setIsWaitingForResponse] = useState(false); + const [pathname, setPathname] = useState(''); + const [isFullscreen, setIsFullscreen] = useState(false); + const [tools, setTools] = useState([]); + const [showScrollButton, setShowScrollButton] = useState(false); + + // Sort tools with custom ones first + const sortedTools = useMemo(() => { + return [...tools].sort((a, b) => { + if (a.isCustom && !b.isCustom) return -1; + if (!a.isCustom && b.isCustom) return 1; + return 0; + }); + }, [tools]); + + // Fetch available tools when panel opens + useEffect(() => { + if (isOpen && tools.length === 0) { + fetch('/api/chat') + .then((res) => res.json()) + .then((data) => { + if (data.tools) { + setTools(data.tools); + } + }) + .catch(() => { + // Silently fail - tools info is optional + }); + } + }, [isOpen, tools.length]); + + // Get current URL (pathname + search) on mount and when panel opens + useEffect(() => { + setPathname(window.location.pathname + window.location.search); + }, [isOpen]); + + // Memoize suggested questions to avoid recalculating on every render + const suggestedQuestions = useMemo(() => getSuggestedQuestions(pathname), [pathname]); + + // Memoize page context to avoid recalculating on every render + const pageContext = useMemo(() => { + const match = pathname.match( + /^\/(docs|visualiser|architecture)\/(events|services|commands|queries|flows|domains|channels|entities|containers)\/([^/]+)(?:\/([^/]+))?/ + ); + if (match) { + const [, , collection, id, version] = match; + const collectionNames: Record = { + events: 'Event', + services: 'Service', + commands: 'Command', + queries: 'Query', + flows: 'Flow', + domains: 'Domain', + channels: 'Channel', + entities: 'Entity', + containers: 'Container', + }; + return { + type: collectionNames[collection] || collection, + name: id, + version: version || 'latest', + }; + } + return null; + }, [pathname]); + + // Handle scroll to detect if user scrolled up + const handleScroll = useCallback((e: React.UIEvent) => { + const target = e.target as HTMLDivElement; + const isNearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 100; + setShowScrollButton(!isNearBottom); + }, []); + + const { messages, sendMessage, stop, status, setMessages, error } = useChat(); + + // Extract user-friendly error message + const errorMessage = error?.message || 'Something went wrong. Please try again.'; + + // Memoize last assistant message to avoid array operations on every render + const lastAssistantMessage = useMemo(() => messages.findLast((m) => m.role === 'assistant'), [messages]); + + // Check if the assistant has started outputting content + const assistantHasContent = useMemo( + () => lastAssistantMessage?.parts?.some((p) => p.type === 'text' && (p as { type: 'text'; text: string }).text.length > 0), + [lastAssistantMessage] + ); + + // Clear waiting state once assistant starts outputting or on error + useEffect(() => { + if (assistantHasContent || status === 'error') { + setIsWaitingForResponse(false); + } + }, [assistantHasContent, status]); + + const isStreaming = status === 'streaming' && assistantHasContent; + const isThinking = + isWaitingForResponse || (messages.length > 0 && (status === 'submitted' || status === 'streaming') && !assistantHasContent); + const isLoading = isThinking || isStreaming; + + // Get currently running tools from the last assistant message + const runningTools = useMemo(() => (lastAssistantMessage ? getRunningTools(lastAssistantMessage) : []), [lastAssistantMessage]); + + // Scroll to bottom when new messages arrive + const scrollToBottom = useCallback(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, []); + + useEffect(() => { + scrollToBottom(); + // Also scroll modal messages + modalMessagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages, scrollToBottom]); + + // Focus modal input when fullscreen opens + useEffect(() => { + if (isFullscreen && !isLoading) { + setTimeout(() => { + modalInputRef.current?.focus(); + }, 100); + } + }, [isFullscreen, isLoading]); + + // Handle escape key to close + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onClose(); + } + // Focus input on CMD+I or CTRL+I + if ((e.metaKey || e.ctrlKey) && e.key === 'i' && isOpen) { + e.preventDefault(); + inputRef.current?.focus(); + } + }; + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen, onClose]); + + // Focus input when opened and not loading + useEffect(() => { + if (isOpen && !isLoading) { + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + } + }, [isOpen, isLoading]); + + // Add/remove padding to main application and header when sidebar panel is open + useEffect(() => { + const appEl = document.getElementById('eventcatalog-application'); + const headerEl = document.getElementById('eventcatalog-header'); + const docsSidebarEl = document.getElementById('eventcatalog-docs-sidebar'); + + const elements = [appEl, headerEl].filter(Boolean) as HTMLElement[]; + + elements.forEach((el) => { + // Add transition if not already present + if (!el.style.transition) { + el.style.transition = 'padding-right 800ms cubic-bezier(0.16, 1, 0.3, 1)'; + } + + // Only add padding when panel is open AND not in fullscreen mode + if (isOpen && !isFullscreen) { + el.style.paddingRight = `${PANEL_WIDTH}px`; + } else { + el.style.paddingRight = '0'; + } + }); + + // Hide docs sidebar when chat panel is open + if (docsSidebarEl) { + if (isOpen && !isFullscreen) { + docsSidebarEl.style.display = 'none'; + } else { + docsSidebarEl.style.display = ''; + } + } + + // Cleanup on unmount + return () => { + elements.forEach((el) => { + el.style.paddingRight = '0'; + }); + if (docsSidebarEl) { + docsSidebarEl.style.display = ''; + } + }; + }, [isOpen, isFullscreen]); + + // Submit message handler + const submitMessage = useCallback( + (text: string) => { + if (!text.trim() || isLoading) return; + setInputValue(''); + setIsWaitingForResponse(true); + sendMessage({ text }); + }, + [isLoading, sendMessage] + ); + + // Handle suggested action clicks + const handleSuggestedAction = useCallback( + (prompt: string) => { + submitMessage(prompt); + }, + [submitMessage] + ); + + // Handle input enter key + const handleInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submitMessage(inputValue); + } + }, + [inputValue, submitMessage] + ); + + // Handle form submit + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + submitMessage(inputValue); + }, + [inputValue, submitMessage] + ); + + const hasMessages = messages.length > 0; + + // Memoize greeting - only changes when hour changes (effectively stable during session) + const greeting = useMemo(() => getGreeting(), []); + + return ( + <> + {/* Keyframes for fade-in animation - using constant to avoid re-injection */} + + + {/* Panel - hidden when fullscreen modal is open */} + {!isFullscreen && ( +
+ {/* Header */} +
+
+
+ + EventCatalog Assistant +
+
+ {tools.length > 0 && ( + + + + + + +
+ Available Tools ({sortedTools.length}) +
+
+ {sortedTools.map((tool) => ( +
+
+ {tool.name} + {tool.isCustom && ( + + Custom + + )} +
+
{tool.description}
+
+ ))} +
+ +
+
+
+ )} + + {hasMessages && ( + + )} + +
+
+ {/* Thinking indicator */} + {isThinking && ( +
+
+ + {runningTools.length > 0 ? ( + <> + Using {runningTools[0]}... + + ) : ( + 'Thinking...' + )} + +
+ )} +
+ + {/* Content */} +
+ {/* Messages or Welcome area */} +
+ {!hasMessages ? ( + /* Welcome area - Clean GitBook-inspired design */ +
+ {/* Greeting section - centered */} +
+ {/* Icon with circular background */} +
+
+ +
+
+

{greeting}

+

+ I'm here to help with your architecture +

+
+ + {/* Suggested questions - pill style */} +
+ {suggestedQuestions.map((question, index) => ( + + ))} +
+
+ ) : ( + /* Messages area */ +
+ {messages.map((message, messageIndex) => { + const content = getMessageContent(message); + const followUpSuggestions = message.role === 'assistant' ? getFollowUpSuggestions(message) : []; + const completedTools = message.role === 'assistant' ? getCompletedTools(message) : []; + const isLastMessage = messageIndex === messages.length - 1; + return ( +
+ {message.role === 'user' ? ( +
+

{content}

+
+ ) : ( + <> + {/* Tools used indicator */} + {completedTools.length > 0 && ( +
+ + + Used{' '} + {completedTools.slice(0, 2).map((tool, i) => ( + + {tool} + {i < Math.min(completedTools.length, 2) - 1 && ', '} + + ))} + {completedTools.length > 2 && +{completedTools.length - 2} more} + +
+ )} +
+
+ + {content} + +
+
+ {/* Follow-up suggestions - only show for last assistant message when not loading */} + {isLastMessage && followUpSuggestions.length > 0 && !isLoading && ( +
+ {followUpSuggestions.map((suggestion, index) => ( + + ))} +
+ )} + + )} +
+ ); + })} + + {/* Skeleton loading indicator */} + {isThinking && ( +
+ +
+ )} + + {/* Error message as chat bubble */} + {status === 'error' && ( +
+
+
+ ⚠️ + {errorMessage} +
+
+
+ )} + +
+
+ )} +
+ + {/* Scroll to bottom button */} + {hasMessages && showScrollButton && ( + + )} + + {/* Input area (Fixed at bottom) */} +
+
+
+ setInputValue(e.target.value)} + onKeyDown={handleInputKeyDown} + placeholder="Ask, search, or explain..." + disabled={isLoading} + className="w-full px-4 py-3 pr-16 bg-transparent text-[rgb(var(--ec-input-text))] placeholder-[rgb(var(--ec-input-placeholder))] focus:outline-none text-sm disabled:opacity-50 rounded-xl" + /> +
+ {isStreaming ? ( + + ) : ( + + )} +
+
+ + {/* Context indicator */} +
+ {pageContext ? ( + + + Based on {pageContext.type}: {pageContext.name} + + ) : ( + + AI can make mistakes. Verify important info. + + )} +
+
+
+
+ )} + + {/* Fullscreen Modal */} + { + setIsFullscreen(open); + // If modal is being closed (clicking outside, etc.), close the chat entirely + if (!open) { + onClose(); + } + }} + > + + + + {/* Modal Header */} +
+
+
+ +
+ Ask AI +
+
+ {tools.length > 0 && ( + + + + + + +
+ Available Tools ({sortedTools.length}) +
+
+ {sortedTools.map((tool) => ( +
+
+ {tool.name} + {tool.isCustom && ( + + Custom + + )} +
+
{tool.description}
+
+ ))} +
+ +
+
+
+ )} + {hasMessages && ( + + )} + + +
+
+ + {/* Thinking indicator */} + {isThinking && ( +
+
+ + {runningTools.length > 0 ? ( + <> + Using {runningTools[0]}... + + ) : ( + 'Thinking...' + )} + +
+ )} + + {/* Modal Content */} +
+ {!hasMessages ? ( + /* Welcome area - Clean design */ +
+ {/* Greeting section - centered */} +
+ {/* Icon with circular background */} +
+
+ +
+
+

{greeting}

+

+ I'm here to help with your architecture +

+
+ + {/* Suggested questions - pill style */} +
+ {suggestedQuestions.map((question, index) => ( + + ))} +
+
+ ) : ( + /* Messages area */ +
+ {messages.map((message, messageIndex) => { + const content = getMessageContent(message); + const followUpSuggestions = message.role === 'assistant' ? getFollowUpSuggestions(message) : []; + const completedTools = message.role === 'assistant' ? getCompletedTools(message) : []; + const isLastMessage = messageIndex === messages.length - 1; + return ( +
+ {message.role === 'user' ? ( +
+

{content}

+
+ ) : ( + <> + {/* Tools used indicator */} + {completedTools.length > 0 && ( +
+ + + Used{' '} + {completedTools.slice(0, 3).map((tool, i) => ( + + {tool} + {i < Math.min(completedTools.length, 3) - 1 && ', '} + + ))} + {completedTools.length > 3 && +{completedTools.length - 3} more} + +
+ )} +
+
+ + {content} + +
+
+ {/* Follow-up suggestions - only show for last assistant message when not loading */} + {isLastMessage && followUpSuggestions.length > 0 && !isLoading && ( +
+ {followUpSuggestions.map((suggestion, index) => ( + + ))} +
+ )} + + )} +
+ ); + })} + + {isThinking && ( +
+ +
+ )} + + {/* Error message as chat bubble */} + {status === 'error' && ( +
+
+
+ ⚠️ + {errorMessage} +
+
+
+ )} + +
+
+ )} +
+ + {/* Modal Input area */} +
+
+
+ setInputValue(e.target.value)} + onKeyDown={handleInputKeyDown} + placeholder="Ask, search, or explain..." + disabled={isLoading} + className="w-full px-4 py-3.5 pr-20 bg-transparent text-[rgb(var(--ec-input-text))] placeholder-[rgb(var(--ec-input-placeholder))] focus:outline-none text-sm disabled:opacity-50 rounded-xl" + /> +
+ {isStreaming ? ( + + ) : ( + + )} +
+
+ + {/* Context indicator */} +
+ {pageContext ? ( + + + Based on {pageContext.type}: {pageContext.name} + + ) : ( + AI can make mistakes. Verify important info. + )} +
+
+ + + + + ); +}; + +export default ChatPanel; diff --git a/eventcatalog/src/components/ChatPanel/ChatPanelButton.tsx b/eventcatalog/src/components/ChatPanel/ChatPanelButton.tsx new file mode 100644 index 000000000..4cd710617 --- /dev/null +++ b/eventcatalog/src/components/ChatPanel/ChatPanelButton.tsx @@ -0,0 +1,34 @@ +import { useState, useEffect } from 'react'; +import { BookOpen } from 'lucide-react'; +import ChatPanel from './ChatPanel'; + +const ChatPanelButton = () => { + const [isOpen, setIsOpen] = useState(false); + + // Listen for custom event to open chat panel from other components + useEffect(() => { + const handleOpenChat = () => { + setIsOpen(true); + }; + + window.addEventListener('eventcatalog:open-chat', handleOpenChat); + return () => window.removeEventListener('eventcatalog:open-chat', handleOpenChat); + }, []); + + return ( + <> + + + setIsOpen(false)} /> + + ); +}; + +export default ChatPanelButton; diff --git a/eventcatalog/src/components/Checkbox.astro b/eventcatalog/src/components/Checkbox.astro index fbcfa85f8..a69c469fd 100644 --- a/eventcatalog/src/components/Checkbox.astro +++ b/eventcatalog/src/components/Checkbox.astro @@ -12,21 +12,24 @@ const { id, name, label, class: className, ...attrs } = Astro.props; + + + diff --git a/eventcatalog/src/components/MDX/Tiles/Tile.astro b/eventcatalog/src/components/MDX/Tiles/Tile.astro index a9a02f333..4dba26044 100644 --- a/eventcatalog/src/components/MDX/Tiles/Tile.astro +++ b/eventcatalog/src/components/MDX/Tiles/Tile.astro @@ -28,31 +28,43 @@ function startsWithProtocol(str: string) {
-
-
- - {IconComponent && } +
+ + {IconComponent && }
-

+

{title}

-

+

{description}

+ + diff --git a/eventcatalog/src/components/MDX/Tiles/Tiles.astro b/eventcatalog/src/components/MDX/Tiles/Tiles.astro index 31ab25801..51204152f 100644 --- a/eventcatalog/src/components/MDX/Tiles/Tiles.astro +++ b/eventcatalog/src/components/MDX/Tiles/Tiles.astro @@ -3,7 +3,7 @@ const { title, columns = 2 } = Astro.props; ---
- {title &&

{title}

} + {title &&

{title}

}
diff --git a/eventcatalog/src/components/MDX/components.tsx b/eventcatalog/src/components/MDX/components.tsx index 81d850edc..14175a1c7 100644 --- a/eventcatalog/src/components/MDX/components.tsx +++ b/eventcatalog/src/components/MDX/components.tsx @@ -25,6 +25,7 @@ import Miro from '@components/MDX/Miro/Miro.astro'; import Lucid from '@components/MDX/Lucid/Lucid.astro'; import DrawIO from '@components/MDX/DrawIO/DrawIO.astro'; import FigJam from '@components/MDX/FigJam/FigJam.astro'; +import IcePanel from '@components/MDX/IcePanel/IcePanel.astro'; import Design from '@components/MDX/Design/Design.astro'; import MermaidFileLoader from '@components/MDX/MermaidFileLoader/MermaidFileLoader.astro'; // Portals: required for server/client components @@ -65,6 +66,7 @@ const components = (props: any) => { Lucid: (mdxProp: any) => jsx(Lucid, { ...props, ...mdxProp }), DrawIO: (mdxProp: any) => jsx(DrawIO, { ...props, ...mdxProp }), FigJam: (mdxProp: any) => jsx(FigJam, { ...props, ...mdxProp }), + IcePanel: (mdxProp: any) => jsx(IcePanel, { ...props, ...mdxProp }), MermaidFileLoader: (mdxProp: any) => jsx(MermaidFileLoader, { ...props, ...mdxProp }), }; }; diff --git a/eventcatalog/src/components/SchemaExplorer/ApiAccessSection.tsx b/eventcatalog/src/components/SchemaExplorer/ApiAccessSection.tsx index 29feb0e35..433c5c3e9 100644 --- a/eventcatalog/src/components/SchemaExplorer/ApiAccessSection.tsx +++ b/eventcatalog/src/components/SchemaExplorer/ApiAccessSection.tsx @@ -1,4 +1,5 @@ -import { ChevronUpIcon, ChevronDownIcon, ClipboardDocumentIcon } from '@heroicons/react/24/outline'; +import { ChevronUpIcon, ChevronDownIcon, ClipboardDocumentIcon, CheckIcon } from '@heroicons/react/24/outline'; +import { CommandLineIcon, LockClosedIcon } from '@heroicons/react/24/solid'; import type { SchemaItem } from './types'; interface ApiAccessSectionProps { @@ -27,109 +28,113 @@ export default function ApiAccessSection({ apiPath = `/api/schemas/${message.collection}/${message.data.id}/${message.data.version}`; } - const curlCommand = typeof window !== 'undefined' ? `curl -X GET "${window.location.origin}${apiPath}"` : ''; + const fullUrl = typeof window !== 'undefined' ? `${window.location.origin}${apiPath}` : apiPath; + const curlCommand = `curl ${fullUrl}`; + const isCopied = copiedId === `${message.data.id}-api`; return ( -
+
{isExpanded && ( -
+
{apiAccessEnabled ? ( - <> -

Access this schema programmatically via API

-
-
- GET - -
- {apiPath} -
-

Example:

- {curlCommand} -
+
+ {/* Endpoint */} +
+ GET + {apiPath} + +
+ + {/* Quick copy buttons */} +
+ +
- +
) : ( -
-
-
- - - -
-
-

Upgrade to Scale

-

- Access your schemas programmatically via API. Perfect for CI/CD pipelines, automation, and integrations. -

- - Start 14-day free trial - - - - -
+
+
+

Access schemas via API

+

CI/CD, automation & integrations

+ + Try Scale + + + +
)}
diff --git a/eventcatalog/src/components/SchemaExplorer/ApiContentViewer.tsx b/eventcatalog/src/components/SchemaExplorer/ApiContentViewer.tsx new file mode 100644 index 000000000..0c2ed5dde --- /dev/null +++ b/eventcatalog/src/components/SchemaExplorer/ApiContentViewer.tsx @@ -0,0 +1,144 @@ +import { ClipboardDocumentIcon, CheckIcon } from '@heroicons/react/24/outline'; +import { CommandLineIcon, LockClosedIcon } from '@heroicons/react/24/solid'; +import type { SchemaItem } from './types'; + +interface ApiContentViewerProps { + message: SchemaItem; + onCopy: (content: string, id: string) => void; + copiedId: string | null; + apiAccessEnabled?: boolean; +} + +export default function ApiContentViewer({ message, onCopy, copiedId, apiAccessEnabled = false }: ApiContentViewerProps) { + // Generate API path based on collection type + let apiPath = ''; + if (message.collection === 'services') { + const specType = message.specType || 'openapi'; + apiPath = `/api/schemas/services/${message.data.id}/${message.data.version}/${specType}`; + } else { + apiPath = `/api/schemas/${message.collection}/${message.data.id}/${message.data.version}`; + } + + const fullUrl = typeof window !== 'undefined' ? `${window.location.origin}${apiPath}` : apiPath; + const curlCommand = `curl ${fullUrl}`; + + if (!apiAccessEnabled) { + return ( +
+
+
+ +
+

API Access

+

+ Access your schemas programmatically via REST API. Perfect for CI/CD pipelines, automation, and integrations with your + development workflow. +

+ + Upgrade to Scale + + + + +

Start your 14-day free trial

+
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+
+ +
+
+

API Access

+

Access this schema programmatically

+
+
+ + {/* Endpoint Card */} +
+
+ Endpoint + +
+
+
+ GET + {apiPath} +
+
+
+ + {/* cURL Example */} +
+
+ cURL Example + +
+
+ {curlCommand} +
+
+ + {/* Response Info */} +
+

Response

+
+
+ Content-Type + application/json +
+
+ Returns + Raw schema content +
+
+
+
+
+ ); +} diff --git a/eventcatalog/src/components/SchemaExplorer/AvroSchemaViewer.tsx b/eventcatalog/src/components/SchemaExplorer/AvroSchemaViewer.tsx index 54db2a354..d186f34f0 100644 --- a/eventcatalog/src/components/SchemaExplorer/AvroSchemaViewer.tsx +++ b/eventcatalog/src/components/SchemaExplorer/AvroSchemaViewer.tsx @@ -114,14 +114,14 @@ const AvroField = ({ field, level, expand, showRequired }: AvroFieldProps) => { }, [expand]); return ( -
+
{/* Collapse/Expand button */}
{hasNested ? (
{searchQuery && ( -
+
{currentMatches.length > 0 ? `${currentMatchIndex + 1} of ${currentMatches.length} ${currentMatches.length === 1 ? 'match' : 'matches'}` : 'No fields found'} @@ -420,11 +422,13 @@ export default function AvroSchemaViewer({ {/* Schema info */}
- Record: - {schema.name} - {schema.namespace && ({schema.namespace})} + Record: + {schema.name} + {schema.namespace && ( + ({schema.namespace}) + )}
- {schema.doc &&

{schema.doc}

} + {schema.doc &&

{schema.doc}

}
{/* Fields */} @@ -434,14 +438,14 @@ export default function AvroSchemaViewer({ )) ) : ( -
+

No fields defined

)} {searchQuery && currentMatches.length === 0 && (
-
+
) : ( -
+
Start 14-day free trial diff --git a/eventcatalog/src/components/SchemaExplorer/JSONSchemaViewer.tsx b/eventcatalog/src/components/SchemaExplorer/JSONSchemaViewer.tsx index 833e65838..ceaebfbb8 100644 --- a/eventcatalog/src/components/SchemaExplorer/JSONSchemaViewer.tsx +++ b/eventcatalog/src/components/SchemaExplorer/JSONSchemaViewer.tsx @@ -225,7 +225,7 @@ const SchemaProperty = ({ name, details, isRequired, level, isListItem = false, const indentationClass = `pl-${level * 3}`; return ( -
+
{isCollapsible ? (
{searchQuery && ( -
+
{currentMatches.length > 0 ? `${currentMatchIndex + 1} of ${currentMatches.length} ${currentMatches.length === 1 ? 'match' : 'matches'}` : 'No properties found'} @@ -636,26 +655,26 @@ export default function JSONSchemaViewer({ {/* Content */}
{isRootArray && ( -
+
- Array Schema - array[object] + Array Schema + array[object]
-

+

This schema defines an array of objects. Each item in the array has the properties shown below.

)} - {description &&

{description}

} + {description &&

{description}

} {variants && (
- (one of) + (one of) onVersionChange(e.target.value)} - className="text-xs text-gray-700 bg-white border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" +
+ {/* Main Header */} +
+
+
+ {/* Title Row */} +
+
- {availableVersions.map((v) => ( - - ))} - - ) : ( - v{message.data.version} - )} -
-
- - {message.collection} - - - {(() => { - const ext = message.schemaExtension?.toLowerCase(); - if ( - ext === 'openapi' || - ext === 'asyncapi' || - ext === 'graphql' || - ext === 'avro' || - ext === 'json' || - ext === 'proto' - ) { - // Map json extension to json-schema icon - const iconName = ext === 'json' ? 'json-schema' : ext; - const iconPath = buildUrl(`/icons/${iconName}.svg`, true); - return ( - <> - {`${ext} - {getSchemaTypeLabel(message.schemaExtension)} - - ); - } - return getSchemaTypeLabel(message.schemaExtension); - })()} - + +
+
+
+

{message.data.name}

+ {hasMultipleVersions ? ( +
+ + +
+ ) : ( + + v{message.data.version} + + )} +
+ {/* Tags */} +
+ + {message.collection} + + + {hasSchemaIcon && ( + {`${ext} + )} + {getSchemaTypeLabel(message.schemaExtension)} + +
+ {/* Summary */} + {message.data.summary && ( +

{message.data.summary}

+ )} +
+
- {message.data.summary &&

{message.data.summary}

} + + {/* View Docs Button */} + + View docs + +
- {/* Action Buttons */} -
+ {/* Action Bar */} +
{/* View Mode Toggle */} -
+
)} +
- - - - View Docs → - + {/* Action Buttons */} +
+ + +
); diff --git a/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx b/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx index 6f120afd0..bd67a308e 100644 --- a/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx +++ b/eventcatalog/src/components/SchemaExplorer/SchemaDetailsPanel.tsx @@ -3,11 +3,11 @@ import * as Diff from 'diff'; import { html } from 'diff2html'; import 'diff2html/bundles/css/diff2html.min.css'; import SchemaDetailsHeader from './SchemaDetailsHeader'; -import ApiAccessSection from './ApiAccessSection'; import OwnersSection from './OwnersSection'; import ProducersConsumersSection from './ProducersConsumersSection'; import SchemaContentViewer from './SchemaContentViewer'; import DiffViewer from './DiffViewer'; +import ApiContentViewer from './ApiContentViewer'; import VersionHistoryModal from './VersionHistoryModal'; import SchemaCodeModal from './SchemaCodeModal'; import SchemaViewerModal from './SchemaViewerModal'; @@ -20,6 +20,8 @@ interface SchemaDetailsPanelProps { selectedVersion: string | null; onVersionChange: (version: string) => void; apiAccessEnabled?: boolean; + showOwners?: boolean; + showProducersConsumers?: boolean; } export default function SchemaDetailsPanel({ @@ -28,10 +30,11 @@ export default function SchemaDetailsPanel({ selectedVersion, onVersionChange, apiAccessEnabled = false, + showOwners = true, + showProducersConsumers = true, }: SchemaDetailsPanelProps) { const [copiedId, setCopiedId] = useState(null); - const [schemaViewMode, setSchemaViewMode] = useState<'code' | 'schema' | 'diff'>('code'); - const [apiAccessExpanded, setApiAccessExpanded] = useState(false); + const [schemaViewMode, setSchemaViewMode] = useState<'code' | 'schema' | 'diff' | 'api'>('code'); const [ownersExpanded, setOwnersExpanded] = useState(false); const [producersConsumersExpanded, setProducersConsumersExpanded] = useState(false); const [isDiffModalOpen, setIsDiffModalOpen] = useState(false); @@ -139,7 +142,7 @@ export default function SchemaDetailsPanel({ const isCopied = copiedId === message.data.id; return ( -
+
{/* Header */} - {/* API Access Section - Always show, but content changes based on Scale access */} - setApiAccessExpanded(!apiAccessExpanded)} - onCopy={handleCopyCustom} - copiedId={copiedId} - apiAccessEnabled={apiAccessEnabled} - /> - {/* Producers and Consumers Section - Only show for messages (not services) */} - {message.collection !== 'services' && ( + {message.collection !== 'services' && showProducersConsumers && ( setOwnersExpanded(!ownersExpanded)} /> + {showOwners && ( + setOwnersExpanded(!ownersExpanded)} /> + )} {/* Schema Content - Takes full remaining height */}
- {schemaViewMode === 'diff' && allDiffs.length > 0 ? ( + {schemaViewMode === 'api' ? ( + + ) : schemaViewMode === 'diff' && allDiffs.length > 0 ? ( setIsDiffModalOpen(true)} apiAccessEnabled={apiAccessEnabled} /> ) : ( (() => { + const [selectedTypes, setSelectedTypes] = useState>(() => { // Load from localStorage if (typeof window !== 'undefined') { - const stored = localStorage.getItem('schemaRegistrySelectedType'); - return stored !== null ? (stored as 'all' | CollectionMessageTypes | 'services') : 'all'; + const stored = localStorage.getItem('schemaRegistrySelectedTypes'); + if (stored) { + try { + const parsed = JSON.parse(stored); + return new Set(parsed); + } catch { + return new Set(); + } + } } - return 'all'; + return new Set(); }); const [selectedSchemaType, setSelectedSchemaType] = useState<'all' | string>(() => { // Load from localStorage @@ -41,23 +56,10 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc const [selectedMessage, setSelectedMessage] = useState(null); const [selectedVersion, setSelectedVersion] = useState(null); const [currentPage, setCurrentPage] = useState(1); - const [filtersExpanded, setFiltersExpanded] = useState(() => { - if (typeof window !== 'undefined') { - const stored = localStorage.getItem('schemaRegistryFiltersExpanded'); - return stored !== null ? stored === 'true' : true; - } - return true; - }); - const [isMounted, setIsMounted] = useState(false); const searchInputRef = useRef(null); const selectedItemRef = useRef(null); const ITEMS_PER_PAGE = 50; - // Set mounted state after hydration to prevent FOUC - useEffect(() => { - setIsMounted(true); - }, []); - // Function to update URL with query params const updateUrlParams = (message: SchemaItem) => { if (typeof window === 'undefined') return; @@ -138,9 +140,19 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc const filteredMessages = useMemo(() => { let result = [...latestMessages]; - // Filter by message type - if (selectedType !== 'all') { - result = result.filter((msg) => msg.collection === selectedType); + // Filter by message types (multi-select) + if (selectedTypes.size > 0) { + result = result.filter((msg) => { + // Check if message matches any selected collection type + if (selectedTypes.has(msg.collection as CollectionMessageTypes)) { + return true; + } + // Check if 'specifications' is selected and this is a spec file + if (selectedTypes.has('specifications') && SPEC_TYPES.includes(msg.schemaExtension?.toLowerCase() || '')) { + return true; + } + return false; + }); } // Filter by schema type @@ -167,7 +179,7 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc }); return result; - }, [latestMessages, searchQuery, selectedType, selectedSchemaType]); + }, [latestMessages, searchQuery, selectedTypes, selectedSchemaType]); // Pagination const totalPages = Math.ceil(filteredMessages.length / ITEMS_PER_PAGE); @@ -178,7 +190,7 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc useEffect(() => { setCurrentPage(1); - }, [searchQuery, selectedType, selectedSchemaType]); + }, [searchQuery, selectedTypes, selectedSchemaType]); // Load from query string on mount useEffect(() => { @@ -255,13 +267,6 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc return versionedMessage || versions[0]; }, [selectedMessage, selectedVersion, messagesByIdAndVersions]); - // Save filter expanded state to localStorage - useEffect(() => { - if (typeof window !== 'undefined') { - localStorage.setItem('schemaRegistryFiltersExpanded', filtersExpanded.toString()); - } - }, [filtersExpanded]); - // Save filter states to localStorage useEffect(() => { if (typeof window !== 'undefined') { @@ -271,9 +276,9 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc useEffect(() => { if (typeof window !== 'undefined') { - localStorage.setItem('schemaRegistrySelectedType', selectedType); + localStorage.setItem('schemaRegistrySelectedTypes', JSON.stringify(Array.from(selectedTypes))); } - }, [selectedType]); + }, [selectedTypes]); useEffect(() => { if (typeof window !== 'undefined') { @@ -312,42 +317,202 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc } }; + // Calculate stats + const stats = useMemo(() => { + return { + total: latestMessages.length, + events: latestMessages.filter((m) => m.collection === 'events').length, + commands: latestMessages.filter((m) => m.collection === 'commands').length, + queries: latestMessages.filter((m) => m.collection === 'queries').length, + specifications: latestMessages.filter((m) => SPEC_TYPES.includes(m.schemaExtension?.toLowerCase() || '')).length, + }; + }, [latestMessages]); + + // Toggle type selection (multi-select) + const toggleType = (type: CollectionMessageTypes | 'specifications') => { + setSelectedTypes((prev) => { + const next = new Set(prev); + if (next.has(type)) { + next.delete(type); + } else { + next.add(type); + } + return next; + }); + }; + + // Clear all type filters + const clearTypeFilters = () => { + setSelectedTypes(new Set()); + }; + return (
- {/* Compact Header */} -
-
-

Schema Explorer

-

- {filteredMessages.length} schema{filteredMessages.length !== 1 ? 's' : ''} available -

-
-
- - {/* Split View */} + {/* Split View - Full Height */}
- {/* Left: Filters + Schema List */} -
- {/* Filters */} - setFiltersExpanded(!filtersExpanded)} - searchInputRef={searchInputRef} - isMounted={isMounted} - /> + {/* Left: Schema List */} +
+ {/* Search Header */} +
+ {/* Search + Format Filter Row */} +
+
+
+ +
+ setSearchQuery(e.target.value)} + className="w-full rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-input-bg))] py-2 pl-9 pr-8 text-sm text-[rgb(var(--ec-page-text))] placeholder:text-[rgb(var(--ec-icon-color))] focus:border-[rgb(var(--ec-accent))] focus:outline-none focus:ring-1 focus:ring-[rgb(var(--ec-accent)/0.3)] transition-all" + /> + {searchQuery && ( + + )} +
+ {/* Format Dropdown */} + {schemaTypes.length > 1 && ( + + )} +
+ + {/* Type Filter - Multi-select chips */} +
+ {stats.events > 0 && ( + + )} + {stats.commands > 0 && ( + + )} + {stats.queries > 0 && ( + + )} + {stats.specifications > 0 && ( + + )} +
+
+ + {/* Results Count Bar */} +
+ + {filteredMessages.length === stats.total + ? `${stats.total} schemas` + : `${filteredMessages.length} of ${stats.total} schemas`} + + {(searchQuery || selectedTypes.size > 0 || selectedSchemaType !== 'all') && ( + + )} +
{/* Schema List - Independently Scrollable */}
{paginatedMessages.length > 0 ? ( -
+
{paginatedMessages.map((message) => { // For services, also check spec type to determine if selected const isSelected = @@ -378,10 +543,26 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc })}
) : ( -
- -

No schemas found

-

Try adjusting your filters

+
+
+ +
+

No schemas found

+

+ {searchQuery ? `No results for "${searchQuery}"` : 'Try adjusting your filters'} +

+ {(searchQuery || selectedTypes.size > 0 || selectedSchemaType !== 'all') && ( + + )}
)}
@@ -391,7 +572,7 @@ export default function SchemaExplorer({ schemas, apiAccessEnabled = false }: Sc
{/* Right: Schema Details */} -
+
{displayMessage ? ( ) : ( -
-
- -

Select a schema to view details

+
+
+
+ +
+

Select a schema

+

+ Choose a schema from the list to view details, compare versions, and access raw code +

)} diff --git a/eventcatalog/src/components/SchemaExplorer/SchemaFilters.tsx b/eventcatalog/src/components/SchemaExplorer/SchemaFilters.tsx index 323674b1c..987bef94e 100644 --- a/eventcatalog/src/components/SchemaExplorer/SchemaFilters.tsx +++ b/eventcatalog/src/components/SchemaExplorer/SchemaFilters.tsx @@ -1,171 +1,109 @@ -import { MagnifyingGlassIcon, XMarkIcon, FunnelIcon, ChevronUpIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; -import type { CollectionMessageTypes } from '@types'; +import { AdjustmentsHorizontalIcon, ChevronUpIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; +import { buildUrl } from '@utils/url-builder'; import { getSchemaTypeLabel } from './utils'; import type { SchemaItem } from './types'; interface SchemaFiltersProps { - searchQuery: string; - onSearchChange: (query: string) => void; - selectedType: 'all' | CollectionMessageTypes | 'services'; - onTypeChange: (type: 'all' | CollectionMessageTypes | 'services') => void; selectedSchemaType: string; onSchemaTypeChange: (type: string) => void; schemaTypes: string[]; latestMessages: SchemaItem[]; filtersExpanded: boolean; onToggleExpanded: () => void; - searchInputRef: React.RefObject; isMounted: boolean; } export default function SchemaFilters({ - searchQuery, - onSearchChange, - selectedType, - onTypeChange, selectedSchemaType, onSchemaTypeChange, schemaTypes, latestMessages, filtersExpanded, onToggleExpanded, - searchInputRef, isMounted, }: SchemaFiltersProps) { - const activeFilterCount = [searchQuery, selectedType !== 'all', selectedSchemaType !== 'all'].filter(Boolean).length; + const hasActiveFilters = selectedSchemaType !== 'all'; + + // Only show this component if there are schema types to filter + if (schemaTypes.length <= 1) { + return null; + } return ( -
+
{/* Filter Header */} - {/* Collapsible Filter Content - Only render after mount to prevent FOUC */} + {/* Collapsible Filter Content */} {isMounted && filtersExpanded && ( -
- {/* Search */} -
- -
-
- -
- onSearchChange(e.target.value)} - className="w-full rounded-md border-gray-300 shadow-sm focus:border-primary focus:ring-primary text-xs pl-8 pr-8 py-1.5 border" - /> - {searchQuery && ( - - )} -
-
- - {/* Message Type Filter */} -
- - -
- - {/* Schema Type Filter */} -
- - -
+ All + + {schemaTypes.map((type) => { + const count = latestMessages.filter((m) => m.schemaExtension?.toLowerCase() === type).length; + const ext = type.toLowerCase(); + const hasIcon = ['openapi', 'asyncapi', 'graphql', 'avro', 'json', 'proto'].includes(ext); + const iconName = ext === 'json' ? 'json-schema' : ext; - {/* Active filters */} - {activeFilterCount > 0 && ( -
-
- {searchQuery && ( - - {searchQuery.substring(0, 15)} - {searchQuery.length > 15 ? '...' : ''} - - - )} - {selectedType !== 'all' && ( - - {selectedType} - - - )} - {selectedSchemaType !== 'all' && ( - - {getSchemaTypeLabel(selectedSchemaType)} - - - )} + return ( -
-
+ ); + })} +
+ + {/* Clear filter */} + {hasActiveFilters && ( + )}
)} diff --git a/eventcatalog/src/components/SchemaExplorer/SchemaListItem.tsx b/eventcatalog/src/components/SchemaExplorer/SchemaListItem.tsx index e2e7c1283..8887fd3eb 100644 --- a/eventcatalog/src/components/SchemaExplorer/SchemaListItem.tsx +++ b/eventcatalog/src/components/SchemaExplorer/SchemaListItem.tsx @@ -8,64 +8,74 @@ interface SchemaListItemProps { isSelected: boolean; versions: SchemaItem[]; onClick: () => void; - itemRef?: React.RefObject; + itemRef?: React.Ref; } export default function SchemaListItem({ message, isSelected, versions, onClick, itemRef }: SchemaListItemProps) { const { color, Icon } = getCollectionStyles(message.collection); const hasMultipleVersions = versions.length > 1; + // Get the schema icon + const ext = message.schemaExtension?.toLowerCase(); + const hasSchemaIcon = ['openapi', 'asyncapi', 'graphql', 'avro', 'json', 'proto'].includes(ext || ''); + const iconName = ext === 'json' ? 'json-schema' : ext; + return ( diff --git a/eventcatalog/src/components/SchemaExplorer/SchemaPageViewer.tsx b/eventcatalog/src/components/SchemaExplorer/SchemaPageViewer.tsx new file mode 100644 index 000000000..fd6cddce8 --- /dev/null +++ b/eventcatalog/src/components/SchemaExplorer/SchemaPageViewer.tsx @@ -0,0 +1,37 @@ +import SchemaDetailsPanel from './SchemaDetailsPanel'; +import type { SchemaItem } from './types'; + +interface SchemaPageViewerProps { + message: SchemaItem; + availableVersions: SchemaItem[]; + apiAccessEnabled?: boolean; + showOwners?: boolean; + showProducersConsumers?: boolean; +} + +export default function SchemaPageViewer({ + message, + availableVersions, + apiAccessEnabled = false, + showOwners = true, + showProducersConsumers = true, +}: SchemaPageViewerProps) { + const handleVersionChange = (version: string) => { + // Construct new URL + // URL: /schemas/[collection]/[id]/[version] + const url = `/schemas/${message.collection}/${message.data.id}/${version}`; + window.location.href = url; + }; + + return ( + + ); +} diff --git a/eventcatalog/src/components/SchemaExplorer/SchemaViewerModal.tsx b/eventcatalog/src/components/SchemaExplorer/SchemaViewerModal.tsx index d696e3a57..69db078a6 100644 --- a/eventcatalog/src/components/SchemaExplorer/SchemaViewerModal.tsx +++ b/eventcatalog/src/components/SchemaExplorer/SchemaViewerModal.tsx @@ -27,14 +27,14 @@ export default function SchemaViewerModal({ - + {/* Header */} -
+
- +
- {message.data.name} - + {message.data.name} + v{message.data.version} · {isAvro ? 'Avro' : 'JSON'} Schema
@@ -42,7 +42,7 @@ export default function SchemaViewerModal({
{/* Footer */} -
+
diff --git a/eventcatalog/src/components/Search/Search.astro b/eventcatalog/src/components/Search/Search.astro index b7f59bde1..5b1846a24 100644 --- a/eventcatalog/src/components/Search/Search.astro +++ b/eventcatalog/src/components/Search/Search.astro @@ -1,53 +1,51 @@ --- import MagnifyingGlassIcon from '@heroicons/react/24/outline/MagnifyingGlassIcon'; import SearchModal from './SearchModal.tsx'; -import { buildUrl } from '@utils/url-builder.ts'; - -const pagefindJsUrl = buildUrl('/pagefind/pagefind.js', true); -const pagefindCssUrl = buildUrl('/pagefind/pagefind-ui.css', true); --- - - -
-
+
- -
- ⌘K + +
+ ⌘K
- + -
-
-
-
e.stopPropagation()} + { + setQuery(''); + setActiveFilter('all'); + }} + appear + > + + +
+ + +
+ - {pagefindLoadError ? ( - // Show indexing required message when Pagefind fails to load - full modal content -
-
-
-

Search Index Not Found

-

- Your EventCatalog needs to be built to generate the search index. This enables fast searching across all - your domains, services, events, and documentation. -

-
- -
-

- - - - Build Your Catalog -

-
- npm run build -
-

This will generate your catalog and create the search index

-
- -
- - - -
-

Need to update search results?

-

- Run npm run build again after - making changes to your catalog content. -

-
-
-
-
- ) : ( - <> - {/* Search Input */} -
- - + { + if (item?.url) { + window.location.href = item.url; + closeModal(); + } + }} + > +
+
- {/* Main Content Area */} -
- {/* Left Filters */} -
- {/* All Resources */} -
-
- -
-
- - {/* Resources Section */} -
-

Resources

-
- {Object.entries({ - domains: 'Domains', - services: 'Services', - entities: 'Entities', - language: 'Ubiquitous Language', - }).map(([key, label]) => { - const config = typeConfig[key as keyof typeof typeConfig]; - const IconComponent = config?.icon; - - return ( - - ); - })} + {/* Filter Tabs */} +
+ {filters.map((tab) => ( + + ))} +
+ + {filteredItems.length > 0 && ( + <> + {query === '' && favorites.length > 0 && ( +
+

Favourites

-
- - {/* Messages Section */} -
-

Messages

-
- {Object.entries({ - events: 'Events', - commands: 'Commands', - queries: 'Queries', - channels: 'Channels', - }).map(([key, label]) => { - const config = typeConfig[key as keyof typeof typeConfig]; - const IconComponent = config?.icon; - - return ( - - ); - })} -
-
- - {/* Organization Section */} -
-

Organization

-
- {Object.entries({ - teams: 'Teams', - users: 'Users', - }).map(([key, label]) => { - const config = typeConfig[key as keyof typeof typeConfig]; - const IconComponent = config?.icon; - - return ( - - ); - })} -
-
- - {/* Specifications Section */} -
-

Specifications

-
- {Object.entries({ - openapi: 'OpenAPI Specification', - asyncapi: 'AsyncAPI Specification', - }).map(([key, label]) => { - const config = typeConfig[key as keyof typeof typeConfig]; - const IconComponent = config.icon; - - return ( - + {active && ( +
- {counts[key as keyof typeof counts]} -
- - ); - })} -
-
+ + )} + + ); + })} + + + )} + + {query !== '' && filteredItems.length === 0 && ( +
+ +

No results found

+

+ No components found for this search term. Please try again. +

+ )} + + {query === '' && filteredItems.length === 0 && ( +
+ +

Search for anything

+

+ Search for domains, services, events, commands, queries and more. +

+
+ )} - {/* Right Results */} -
- {/* Show stats and exact match toggle - only when there are results or search term */} - {currentSearch.trim() && (filteredResults.length > 0 || isLoading) && ( -
-
- {filteredResults.length} results for "{currentSearch}" - {isLoading && Loading...} -
- - {/* Exact Match Checkbox */} -
- setExactMatch(e.target.checked)} - className="h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded" - /> - -
-
- )} - - {/* Main content area */} -
- {!currentSearch.trim() ? ( - // Show when no search term is entered - centered -
-
- -

Discover your EventCatalog

-

- Start typing to search for domains, services, events, and more. -

-
-
- ) : filteredResults.length === 0 && !isLoading ? ( - // Show when search term exists but no results and not loading - centered -
-
- -

No results found

-

- No results found for "{currentSearch}". -

-
-
- ) : isLoading ? ( - // Show loading state - centered -
-
-
-

Searching...

-

- Finding results for "{currentSearch}" -

-
-
- ) : ( - // Show results in a grid with padding -
-
- -
-
- )} -
+ {/* Footer */} +
+
+ + to select +
+
+ + + to navigate +
+
+ esc + to close
- - )} -
+ + +
-
-
+
+
); -}; - -export default SearchModal; +} diff --git a/eventcatalog/src/components/SideBars/ChannelSideBar.astro b/eventcatalog/src/components/SideBars/ChannelSideBar.astro deleted file mode 100644 index e99685d3e..000000000 --- a/eventcatalog/src/components/SideBars/ChannelSideBar.astro +++ /dev/null @@ -1,204 +0,0 @@ ---- -import type { CollectionEntry } from 'astro:content'; -import PillListFlat from '@components/Lists/PillListFlat'; -import ProtocolList from '@components/Lists/ProtocolList'; -import OwnersList from '@components/Lists/OwnersList'; -import VersionList from '@components/Lists/VersionList.astro'; -import { buildUrl } from '@utils/url-builder'; -import { ScrollText } from 'lucide-react'; -import RepositoryList from '@components/Lists/RepositoryList.astro'; -import { getOwner } from '@utils/collections/owners'; -import { getProducersAndConsumersForChannel } from '@utils/collections/services'; -import { isChangelogEnabled } from '@utils/feature'; -interface Props { - channel: CollectionEntry<'channels'>; -} - -const { channel } = Astro.props; - -const ownersRaw = channel.data?.owners || []; -const owners = await Promise.all>(ownersRaw.map(getOwner)); -const filteredOwners = owners.filter((o) => o !== undefined); -const { producers, consumers } = await getProducersAndConsumersForChannel(channel); - -const channelParameters: Record = channel.data.parameters || {}; -const parameters = Object.keys(channelParameters).map((key) => ({ - key, - ...channelParameters[key], -})); - -const attachments = channel.data.attachments || []; - -const attachmentsList = attachments.map((a) => { - const attachmentIsURL = typeof a === 'string'; - return { - label: attachmentIsURL ? a : (a.title ?? a.url), - href: attachmentIsURL ? a : a.url, - icon: attachmentIsURL ? 'ExternalLinkIcon' : (a.icon ?? 'ExternalLinkIcon'), - target: '_blank' as const, - subgroup: attachmentIsURL ? undefined : (a.type ?? ''), - }; -}); - -const paramsList = parameters.map((param) => ({ - label: param.key, - description: param?.description, - collection: 'channels-parameter', -})); - -const protocols = channel.data.protocols || []; -const protocolList = protocols.map((p) => ({ - label: p, - collection: 'channels-protocol', - // icon: p, - icon: p, -})); - -const ownersList = filteredOwners.map((o) => ({ - label: o.data.name, - type: o.collection, - badge: o.collection === 'users' ? o.data.role : 'Team', - avatarUrl: o.collection === 'users' ? o.data.avatarUrl : '', - href: buildUrl(`/docs/${o.collection}/${o.data.id}`), -})); - -const messageList = (channel.data.messages ?? []).map((p) => ({ - label: p.name, - badge: p.collection, - color: p.collection === 'events' ? 'orange' : 'blue', - collection: p.collection, - tag: `v${p.version}`, - href: buildUrl(`/docs/${p.collection}/${p.id}/${p.version}`), -})); - -const producersList = producers.map((p) => ({ - label: p.data.name, - badge: p.collection, - color: 'pink', - collection: p.collection, - tag: `v${p.data.version}`, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), -})); - -const consumersList = consumers.map((c) => ({ - label: c.data.name, - badge: c.collection, - color: 'blue', - collection: c.collection, - tag: `v${c.data.version}`, - href: buildUrl(`/docs/${c.collection}/${c.data.id}/${c.data.version}`), -})); - -const shouldRenderSideBarSection = (section: string) => { - if (!channel.data.detailsPanel) { - return true; - } - // @ts-ignore - return channel.data.detailsPanel[section]?.visible ?? true; -}; ---- - - diff --git a/eventcatalog/src/components/SideBars/ContainerSideBar.astro b/eventcatalog/src/components/SideBars/ContainerSideBar.astro deleted file mode 100644 index 0ece9ed9e..000000000 --- a/eventcatalog/src/components/SideBars/ContainerSideBar.astro +++ /dev/null @@ -1,183 +0,0 @@ ---- -import type { CollectionEntry } from 'astro:content'; -import PillListFlat from '@components/Lists/PillListFlat'; -import OwnersList from '@components/Lists/OwnersList'; -import VersionList from '@components/Lists/VersionList.astro'; -import { buildUrl } from '@utils/url-builder'; -import { ScrollText, Workflow, RssIcon } from 'lucide-react'; -import RepositoryList from '@components/Lists/RepositoryList.astro'; -import { getOwner } from '@utils/collections/owners'; -import CustomSideBarSectionList from '@components/Lists/CustomSideBarSectionList.astro'; -import { isChangelogEnabled, isVisualiserEnabled } from '@utils/feature'; -import config from '@config'; - -interface Props { - container: CollectionEntry<'containers'>; -} - -const { container } = Astro.props; - -const servicesThatWriteToContainer = (container.data.servicesThatWriteToContainer as CollectionEntry<'services'>[]) || []; -const servicesThatReadFromContainer = (container.data.servicesThatReadFromContainer as CollectionEntry<'services'>[]) || []; -const ownersRaw = container.data?.owners || []; - -const owners = await Promise.all>(ownersRaw.map(getOwner)); -const filteredOwners = owners.filter((o) => o !== undefined); -const resourceGroups = container.data?.resourceGroups || []; -const attachments = container.data.attachments || []; - -const attachmentsList = attachments.map((a) => { - const attachmentIsURL = typeof a === 'string'; - return { - label: attachmentIsURL ? a : (a.title ?? a.url), - href: attachmentIsURL ? a : a.url, - icon: attachmentIsURL ? 'ExternalLinkIcon' : (a.icon ?? 'ExternalLinkIcon'), - target: '_blank' as const, - subgroup: attachmentIsURL ? undefined : (a.type ?? ''), - }; -}); - -const writesToList = servicesThatWriteToContainer?.map((p) => ({ - label: p.data.name, - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/services/${p.data.id}/${p.data.version}`), -})); - -const readsFromList = servicesThatReadFromContainer?.map((p) => ({ - label: p.data.name, - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/services/${p.data.id}/${p.data.version}`), -})); - -const ownersList = filteredOwners.map((o) => ({ - label: o.data.name, - type: o.collection, - badge: o.collection === 'users' ? o.data.role : 'Team', - avatarUrl: o.collection === 'users' ? o.data.avatarUrl : '', - href: buildUrl(`/docs/${o.collection}/${o.data.id}`), -})); - -const shouldRenderSideBarSection = (section: string) => { - if (!container.data.detailsPanel) { - return true; - } - // @ts-ignore - return container.data.detailsPanel[section]?.visible ?? true; -}; - -const isRSSEnabled = config.rss?.enabled; ---- - - diff --git a/eventcatalog/src/components/SideBars/DomainSideBar.astro b/eventcatalog/src/components/SideBars/DomainSideBar.astro deleted file mode 100644 index f43b16359..000000000 --- a/eventcatalog/src/components/SideBars/DomainSideBar.astro +++ /dev/null @@ -1,277 +0,0 @@ ---- -import OwnersList from '@components/Lists/OwnersList'; -import PillListFlat from '@components/Lists/PillListFlat'; -import RepositoryList from '@components/Lists/RepositoryList.astro'; -import VersionList from '@components/Lists/VersionList.astro'; -import { getUbiquitousLanguage, getMessagesForDomain, getParentDomains } from '@utils/collections/domains'; -import CustomSideBarSectionList from '@components/Lists/CustomSideBarSectionList.astro'; -import { getOwner } from '@utils/collections/owners'; -import { buildUrl } from '@utils/url-builder'; -import type { CollectionEntry } from 'astro:content'; -import { ScrollText, Workflow } from 'lucide-react'; -import { isChangelogEnabled, isVisualiserEnabled } from '@utils/feature'; - -interface Props { - domain: CollectionEntry<'domains'>; -} - -const { domain } = Astro.props; - -// @ts-ignore -const services = (domain.data.services as CollectionEntry<'services'>[]) || []; -// @ts-ignore -const subDomains = (domain.data.domains as CollectionEntry<'domains'>[]) || []; - -// @ts-ignore -const entities = (domain.data.entities as CollectionEntry<'entities'>[]) || []; - -const attachments = domain.data.attachments || []; - -const ubiquitousLanguage = await getUbiquitousLanguage(domain); -const hasUbiquitousLanguage = ubiquitousLanguage.length > 0; -const ubiquitousLanguageDictionary = hasUbiquitousLanguage ? ubiquitousLanguage[0].data.dictionary : []; - -const ownersRaw = domain.data?.owners || []; -const owners = await Promise.all>(ownersRaw.map(getOwner)); -const filteredOwners = owners.filter((o) => o !== undefined); - -const messagesForDomain = await getMessagesForDomain(domain); - -const resourceGroups = domain.data?.resourceGroups || []; - -const parentDomains = await getParentDomains(domain); - -const serviceList = services.map((p) => ({ - label: p.data.name, - badge: p.collection, - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), -})); - -const subDomainList = subDomains.map((p) => ({ - label: p.data.name, - badge: p.collection, - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), -})); - -const parentDomainList = parentDomains.map((p) => ({ - label: p.data.name, - badge: p.collection, - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), -})); - -const sendsList = messagesForDomain.sends - .sort((a, b) => a.collection.localeCompare(b.collection)) - .map((p) => ({ - label: p.data.name, - badge: p.collection, - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), - })); - -const receivesList = messagesForDomain.receives - .sort((a, b) => a.collection.localeCompare(b.collection)) - .map((p) => ({ - label: p.data.name, - badge: p.collection, - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), - })); - -const ubiquitousLanguageList = ubiquitousLanguageDictionary?.map((l) => ({ - label: l.name, - badge: 'Ubiquitous Language', - collection: 'ubiquitousLanguages', - href: buildUrl(`/docs/${domain.collection}/${domain.data.id}/language?id=${l.id}`), -})); - -const entityList = entities.map((p) => ({ - label: p.data.name, - badge: p.collection, - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), -})); - -const ownersList = filteredOwners.map((o) => ({ - label: o.data.name, - type: o.collection, - badge: o.collection === 'users' ? o.data.role : 'Team', - avatarUrl: o.collection === 'users' ? o.data.avatarUrl : '', - href: buildUrl(`/docs/${o.collection}/${o.data.id}`), -})); - -const attachmentsList = attachments.map((a) => { - const attachmentIsURL = typeof a === 'string'; - - return { - label: attachmentIsURL ? a : (a.title ?? a.url), - href: attachmentIsURL ? a : a.url, - icon: attachmentIsURL ? 'ExternalLinkIcon' : (a.icon ?? 'ExternalLinkIcon'), - target: '_blank' as const, - subgroup: attachmentIsURL ? undefined : (a.type ?? ''), - }; -}); - -const shouldRenderSideBarSection = (section: string) => { - if (!domain.data.detailsPanel) { - return true; - } - // @ts-ignore - return domain.data.detailsPanel[section]?.visible ?? true; -}; ---- - - diff --git a/eventcatalog/src/components/SideBars/EntitySideBar.astro b/eventcatalog/src/components/SideBars/EntitySideBar.astro deleted file mode 100644 index ceba00f68..000000000 --- a/eventcatalog/src/components/SideBars/EntitySideBar.astro +++ /dev/null @@ -1,139 +0,0 @@ ---- -import type { CollectionEntry } from 'astro:content'; -import PillListFlat from '@components/Lists/PillListFlat'; -import OwnersList from '@components/Lists/OwnersList'; -import VersionList from '@components/Lists/VersionList.astro'; -import { buildUrl } from '@utils/url-builder'; -import { ScrollText } from 'lucide-react'; -import { getOwner } from '@utils/collections/owners'; -import { isChangelogEnabled } from '@utils/feature'; - -interface Props { - entity: CollectionEntry<'entities'>; -} - -const { entity } = Astro.props; - -const ownersRaw = entity.data?.owners || []; -const owners = await Promise.all>(ownersRaw.map(getOwner)); -const filteredOwners = owners.filter((o) => o !== undefined); - -// @ts-ignore -const services = (entity.data.services as CollectionEntry<'services'>[]) || []; -// @ts-ignore -const domains = (entity.data.domains as CollectionEntry<'domains'>[]) || []; - -const attachments = entity.data.attachments || []; - -const attachmentsList = attachments.map((a) => { - const attachmentIsURL = typeof a === 'string'; - return { - label: attachmentIsURL ? a : (a.title ?? a.url), - href: attachmentIsURL ? a : a.url, - icon: attachmentIsURL ? 'ExternalLinkIcon' : (a.icon ?? 'ExternalLinkIcon'), - target: '_blank' as const, - subgroup: attachmentIsURL ? undefined : (a.type ?? ''), - }; -}); - -const ownersList = filteredOwners.map((o) => ({ - label: o.data.name, - type: o.collection, - badge: o.collection === 'users' ? o.data.role : 'Team', - avatarUrl: o.collection === 'users' ? o.data.avatarUrl : '', - href: buildUrl(`/docs/${o.collection}/${o.data.id}`), -})); - -const servicesList = services.map((p) => ({ - label: p.data.name, - badge: p.collection, - color: 'pink', - collection: p.collection, - tag: `v${p.data.version}`, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), -})); - -const domainsList = domains.map((p) => ({ - label: p.data.name, - badge: p.collection, - color: 'blue', - collection: p.collection, - tag: `v${p.data.version}`, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), -})); - -const shouldRenderSideBarSection = (section: string) => { - if (!entity.data.detailsPanel) { - return true; - } - // @ts-ignore - return entity.data.detailsPanel[section]?.visible ?? true; -}; ---- - - diff --git a/eventcatalog/src/components/SideBars/FlowSideBar.astro b/eventcatalog/src/components/SideBars/FlowSideBar.astro deleted file mode 100644 index 0bcaf45d4..000000000 --- a/eventcatalog/src/components/SideBars/FlowSideBar.astro +++ /dev/null @@ -1,132 +0,0 @@ ---- -import OwnersList from '@components/Lists/OwnersList'; -import VersionList from '@components/Lists/VersionList.astro'; -import PillListFlat from '@components/Lists/PillListFlat'; -import { buildUrl } from '@utils/url-builder'; -import { getOwner } from '@utils/collections/owners'; -import type { CollectionEntry } from 'astro:content'; -import { ScrollText, Workflow, RssIcon } from 'lucide-react'; -import config from '@config'; -import { isChangelogEnabled, isVisualiserEnabled } from '@utils/feature'; -import CustomSideBarSectionList from '@components/Lists/CustomSideBarSectionList.astro'; -interface Props { - flow: CollectionEntry<'flows'>; -} - -const { flow } = Astro.props; - -const ownersRaw = flow.data?.owners || []; -const owners = await Promise.all>(ownersRaw.map(getOwner)); -const filteredOwners = owners.filter((o) => o !== undefined); - -const resourceGroups = flow.data?.resourceGroups || []; - -const ownersList = filteredOwners.map((o) => ({ - label: o.data.name, - type: o.collection, - badge: o.collection === 'users' ? o.data.role : 'Team', - avatarUrl: o.collection === 'users' ? o.data.avatarUrl : '', - href: buildUrl(`/docs/${o.collection}/${o.data.id}`), -})); - -const isRSSEnabled = config.rss?.enabled; - -const attachments = flow.data.attachments || []; - -const attachmentsList = attachments.map((a) => { - const attachmentIsURL = typeof a === 'string'; - return { - label: attachmentIsURL ? a : (a.title ?? a.url), - href: attachmentIsURL ? a : a.url, - icon: attachmentIsURL ? 'ExternalLinkIcon' : (a.icon ?? 'ExternalLinkIcon'), - target: '_blank' as const, - subgroup: attachmentIsURL ? undefined : (a.type ?? ''), - }; -}); - -const shouldRenderSideBarSection = (section: string) => { - if (!flow.data.detailsPanel) { - return true; - } - // @ts-ignore - return flow.data.detailsPanel[section]?.visible ?? true; -}; ---- - - diff --git a/eventcatalog/src/components/SideBars/MessageSideBar.astro b/eventcatalog/src/components/SideBars/MessageSideBar.astro deleted file mode 100644 index a75ac0297..000000000 --- a/eventcatalog/src/components/SideBars/MessageSideBar.astro +++ /dev/null @@ -1,251 +0,0 @@ ---- -import type { CollectionEntry } from 'astro:content'; -import PillListFlat from '@components/Lists/PillListFlat'; -import OwnersList from '@components/Lists/OwnersList'; -import type { CollectionMessageTypes } from '@types'; -import * as path from 'path'; -import VersionList from '@components/Lists/VersionList.astro'; -import { buildUrl } from '@utils/url-builder'; -import { FileDownIcon, ScrollText, Workflow, RssIcon } from 'lucide-react'; -import RepositoryList from '@components/Lists/RepositoryList.astro'; -import { getOwner } from '@utils/collections/owners'; -import CustomSideBarSectionList from '@components/Lists/CustomSideBarSectionList.astro'; -import config from '@config'; -import { getSchemaFormatFromURL } from '@utils/collections/schemas'; -import { isChangelogEnabled, isVisualiserEnabled } from '@utils/feature'; -interface Props { - message: CollectionEntry; -} - -const { message } = Astro.props; - -const producers = (message.data.producers as CollectionEntry<'services'>[]) || []; -const consumers = (message.data.consumers as CollectionEntry<'services'>[]) || []; -const channels = (message.data.messageChannels as CollectionEntry<'channels'>[]) || []; - -const ownersRaw = message.data?.owners || []; -const owners = await Promise.all>(ownersRaw.map(getOwner)); -const filteredOwners = owners.filter((o) => o !== undefined); - -const resourceGroups = message.data?.resourceGroups || []; - -const attachments = message.data.attachments || []; - -const attachmentsList = attachments.map((a) => { - const attachmentIsURL = typeof a === 'string'; - return { - label: attachmentIsURL ? a : (a.title ?? a.url), - href: attachmentIsURL ? a : a.url, - icon: attachmentIsURL ? 'ExternalLinkIcon' : (a.icon ?? 'ExternalLinkIcon'), - target: '_blank' as const, - subgroup: attachmentIsURL ? undefined : (a.type ?? ''), - }; -}); - -const producerList = producers.map((p) => ({ - label: `${p.data.name}`, - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/services/${p.data.id}/${p.data.version}`), -})); - -const consumerList = consumers.map((p) => ({ - label: `${p.data.name}`, - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/services/${p.data.id}/${p.data.version}`), -})); - -const ownersList = filteredOwners.map((o) => ({ - label: o.data.name, - type: o.collection, - badge: o.collection === 'users' ? o.data.role : 'Team', - avatarUrl: o.collection === 'users' ? o.data.avatarUrl : '', - href: buildUrl(`/docs/${o.collection}/${o.data.id}`), -})); - -const channelList = channels.map((c) => ({ - label: `${c.data.name}`, - tag: `v${c.data.version}`, - collection: c.collection, - href: buildUrl(`/docs/channels/${c.data.id}/${c.data.version}`), -})); - -const getType = (type: string) => { - switch (type) { - case 'queries': - return 'Query'; - default: - return message.collection.slice(0, -1); - } -}; - -const getProducerEmptyMessage = (type: string) => { - const value = type.toLowerCase(); - switch (value) { - case 'query': - case 'command': - return `This ${value} does not get invoked by any services.`; - default: - return `This ${value} does not get produced by any services.`; - } -}; - -const getConsumerEmptyMessage = (type: string) => { - const value = type.toLowerCase(); - switch (value) { - case 'query': - case 'command': - return `This ${value} does not invoke any service.`; - default: - return `This ${value} does not get consumed by any services.`; - } -}; - -const type = getType(message.collection); - -const isRSSEnabled = config.rss?.enabled; - -// @ts-ignore -const publicPath = message?.catalog?.publicPath; - -const schemaFilePath = message?.data?.schemaPath; -const schemaURL = path.join(publicPath, schemaFilePath || ''); - -const shouldRenderSideBarSection = (section: string) => { - if (!message.data.detailsPanel) { - return true; - } - // @ts-ignore - return message.data.detailsPanel[section]?.visible ?? true; -}; ---- - - diff --git a/eventcatalog/src/components/SideBars/ServiceSideBar.astro b/eventcatalog/src/components/SideBars/ServiceSideBar.astro deleted file mode 100644 index 9f9231522..000000000 --- a/eventcatalog/src/components/SideBars/ServiceSideBar.astro +++ /dev/null @@ -1,298 +0,0 @@ ---- -import OwnersList from '@components/Lists/OwnersList'; -import PillListFlat from '@components/Lists/PillListFlat'; -import RepositoryList from '@components/Lists/RepositoryList.astro'; -import SpecificationsList from '@components/Lists/SpecificationsList.astro'; -import CustomSideBarSectionList from '@components/Lists/CustomSideBarSectionList.astro'; -import VersionList from '@components/Lists/VersionList.astro'; -import { buildUrl } from '@utils/url-builder'; -import { getOwner } from '@utils/collections/owners'; -import type { CollectionEntry } from 'astro:content'; -import { ScrollText, Workflow, FileDownIcon, Code, Link, RssIcon } from 'lucide-react'; -import { join } from 'node:path'; -import config from '@config'; -import { getDomainsForService } from '@utils/collections/domains'; -import { isChangelogEnabled, isVisualiserEnabled } from '@utils/feature'; -interface Props { - service: CollectionEntry<'services'>; -} - -const { service } = Astro.props; - -// @ts-ignore -const sends = (service.data.sends as CollectionEntry<'events'>[]) || []; -// @ts-ignore -const receives = (service.data.receives as CollectionEntry<'events'>[]) || []; - -// @ts-ignore -const writesTo = (service.data.writesTo as CollectionEntry<'containers'>[]) || []; -// @ts-ignore -const readsFrom = (service.data.readsFrom as CollectionEntry<'containers'>[]) || []; - -// @ts-ignore -const entities = (service.data.entities as CollectionEntry<'entities'>[]) || []; - -const ownersRaw = service.data?.owners || []; -const owners = await Promise.all>(ownersRaw.map(getOwner)); -const filteredOwners = owners.filter((o) => o !== undefined); - -const resourceGroups = service.data?.resourceGroups || []; - -const domainsServiceBelongsTo = await getDomainsForService(service); - -const attachments = service.data.attachments || []; - -const attachmentsList = attachments.map((a) => { - const attachmentIsURL = typeof a === 'string'; - return { - label: attachmentIsURL ? a : (a.title ?? a.url), - href: attachmentIsURL ? a : a.url, - icon: attachmentIsURL ? 'ExternalLinkIcon' : (a.icon ?? 'ExternalLinkIcon'), - target: '_blank' as const, - subgroup: attachmentIsURL ? undefined : (a.type ?? ''), - }; -}); - -const sendsList = sends - .sort((a, b) => a.collection.localeCompare(b.collection)) - .map((p) => ({ - label: p.data.name, - badge: p.collection, - color: p.collection === 'events' ? 'orange' : 'blue', - collection: p.collection, - tag: `v${p.data.version}`, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), - })); - -const receivesList = receives - .sort((a, b) => a.collection.localeCompare(b.collection)) - .map((p) => ({ - label: p.data.name, - badge: p.collection, - color: p.collection === 'events' ? 'orange' : 'blue', - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), - })); - -const writesToList = writesTo.map((p) => ({ - label: p.data.name, - badge: p.collection, - color: p.collection === 'containers' ? 'green' : 'blue', - collection: p.collection, - tag: `v${p.data.version}`, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), -})); - -const readsFromList = readsFrom.map((p) => ({ - label: p.data.name, - badge: p.collection, - color: p.collection === 'containers' ? 'green' : 'blue', - collection: p.collection, - tag: `v${p.data.version}`, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), -})); - -const ownersList = filteredOwners.map((o) => ({ - label: o.data.name, - type: o.collection, - badge: o.collection === 'users' ? o.data.role : 'Team', - avatarUrl: o.collection === 'users' ? o.data.avatarUrl : '', - href: buildUrl(`/docs/${o.collection}/${o.data.id}`), -})); - -const domainList = domainsServiceBelongsTo.map((d) => ({ - label: d.data.name, - badge: d.collection, - tag: `v${d.data.version}`, - collection: d.collection, - href: buildUrl(`/docs/${d.collection}/${d.data.id}/${d.data.version}`), -})); - -const entityList = entities.map((p) => ({ - label: p.data.name, - badge: p.collection, - tag: `v${p.data.version}`, - collection: p.collection, - href: buildUrl(`/docs/${p.collection}/${p.data.id}/${p.data.version}`), -})); - -const isRSSEnabled = config.rss?.enabled; - -// @ts-ignore -const publicPath = service?.catalog?.publicPath; -const schemaFilePath = service?.data?.schemaPath; -const schemaURL = join(publicPath, schemaFilePath || ''); - -const shouldRenderSideBarSection = (section: string) => { - if (!service.data.detailsPanel) { - return true; - } - // @ts-ignore - return service.data.detailsPanel[section]?.visible ?? true; -}; ---- - - diff --git a/eventcatalog/src/components/SideNav/ListViewSideBar/components/CollapsibleGroup.tsx b/eventcatalog/src/components/SideNav/ListViewSideBar/components/CollapsibleGroup.tsx deleted file mode 100644 index a661bb9a2..000000000 --- a/eventcatalog/src/components/SideNav/ListViewSideBar/components/CollapsibleGroup.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import { ChevronDownIcon } from '@heroicons/react/24/outline'; - -interface CollapsibleGroupProps { - isCollapsed: boolean; - onToggle: () => void; - title: React.ReactNode; - children: React.ReactNode; - className?: string; -} - -const CollapsibleGroup: React.FC = ({ isCollapsed, onToggle, title, children, className = '' }) => ( -
-
- - {typeof title === 'string' ? ( - - ) : ( - title - )} -
-
- {children} -
-
-); - -export default CollapsibleGroup; diff --git a/eventcatalog/src/components/SideNav/ListViewSideBar/components/MessageList.tsx b/eventcatalog/src/components/SideNav/ListViewSideBar/components/MessageList.tsx deleted file mode 100644 index 3c955b29e..000000000 --- a/eventcatalog/src/components/SideNav/ListViewSideBar/components/MessageList.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React from 'react'; -import { getMessageColorByCollection, getMessageCollectionName } from '../index'; - -interface MessageListProps { - messages: any[]; - decodedCurrentPath: string; - searchTerm?: string; -} - -const HighlightedText = React.memo(({ text, searchTerm }: { text: string; searchTerm?: string }) => { - if (!searchTerm) return <>{text}; - - const regex = new RegExp(`(${searchTerm})`, 'gi'); - const parts = text.split(regex); - - return ( - <> - {parts.map((part, index) => - regex.test(part) ? ( - - {part} - - ) : ( - {part} - ) - )} - - ); -}); - -const getMessageColorByLabelOrCollection = (collection: string, badge?: string) => { - if (!badge) { - return getMessageColorByCollection(collection); - } - - // Will try and match the label against HTTP verbs - const httpVerbs = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT']; - if (badge && httpVerbs.includes(badge.toUpperCase())) { - if (badge.toUpperCase() === 'GET') return 'bg-blue-50 text-blue-600'; - if (badge.toUpperCase() === 'POST') return 'bg-green-50 text-green-600'; - if (badge.toUpperCase() === 'PUT') return 'bg-yellow-50 text-yellow-600'; - if (badge.toUpperCase() === 'DELETE') return 'bg-red-50 text-red-600'; - if (badge.toUpperCase() === 'PATCH') return 'bg-purple-50 text-purple-600'; - if (badge.toUpperCase() === 'HEAD') return 'bg-gray-50 text-gray-600'; - if (badge.toUpperCase() === 'OPTIONS') return 'bg-orange-50 text-orange-600'; - } - - return getMessageColorByCollection(collection); -}; - -const MessageList: React.FC = ({ messages, decodedCurrentPath, searchTerm }) => ( - -); - -export default MessageList; diff --git a/eventcatalog/src/components/SideNav/ListViewSideBar/components/SpecificationList.tsx b/eventcatalog/src/components/SideNav/ListViewSideBar/components/SpecificationList.tsx deleted file mode 100644 index fd51c0908..000000000 --- a/eventcatalog/src/components/SideNav/ListViewSideBar/components/SpecificationList.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import { buildUrl } from '@utils/url-builder'; -import type { ServiceItem } from '../types'; - -interface SpecificationListProps { - specifications: { - type: string; - path: string; - name?: string; - filename?: string; - filenameWithoutExtension?: string; - }[]; - id: string; - version: string; -} - -const SpecificationList: React.FC = ({ specifications, id, version }) => { - const asyncAPISpecifications = specifications.filter((spec) => spec.type === 'asyncapi'); - const openAPISpecifications = specifications.filter((spec) => spec.type === 'openapi'); - const graphQLSpecifications = specifications.filter((spec) => spec.type === 'graphql'); - - return ( -
    - {asyncAPISpecifications && - asyncAPISpecifications.length > 0 && - asyncAPISpecifications.map((spec) => ( - - {spec.name} - - AsyncAPI - - - ))} - {openAPISpecifications && - openAPISpecifications.length > 0 && - openAPISpecifications.map((spec) => ( - - {spec.name} - - OpenAPI - - - ))} - {graphQLSpecifications && - graphQLSpecifications.length > 0 && - graphQLSpecifications.map((spec) => ( - - {spec.name} - - GraphQL - - - ))} -
- ); -}; - -export default SpecificationList; diff --git a/eventcatalog/src/components/SideNav/ListViewSideBar/index.tsx b/eventcatalog/src/components/SideNav/ListViewSideBar/index.tsx deleted file mode 100644 index 797860f42..000000000 --- a/eventcatalog/src/components/SideNav/ListViewSideBar/index.tsx +++ /dev/null @@ -1,1250 +0,0 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; -import { ChevronDownIcon, ChevronDoubleDownIcon, ChevronDoubleUpIcon, XMarkIcon } from '@heroicons/react/24/outline'; -import { buildUrl, buildUrlWithParams } from '@utils/url-builder'; -import CollapsibleGroup from './components/CollapsibleGroup'; -import MessageList from './components/MessageList'; -import SpecificationsList from './components/SpecificationList'; -import type { MessageItem, ServiceItem, ListViewSideBarProps, DomainItem, FlowItem, Resources } from './types'; -import { PanelLeft } from 'lucide-react'; -const STORAGE_KEY = 'EventCatalog:catalogSidebarCollapsedGroups'; -const DEBOUNCE_DELAY = 300; // 300ms debounce delay - -const HighlightedText = React.memo(({ text, searchTerm }: { text: string; searchTerm: string }) => { - if (!searchTerm) return <>{text}; - - const regex = new RegExp(`(${searchTerm})`, 'gi'); - const parts = text.split(regex); - - return ( - <> - {parts.map((part, index) => - regex.test(part) ? ( - - {part} - - ) : ( - {part} - ) - )} - - ); -}); - -export const getMessageColorByCollection = (collection: string) => { - if (collection === 'commands') return 'bg-blue-50 text-blue-600'; - if (collection === 'queries') return 'bg-green-50 text-green-600'; - if (collection === 'events') return 'bg-orange-50 text-orange-600'; - if (collection === 'entities') return 'bg-purple-50 text-purple-600'; - return 'text-gray-600'; -}; - -export const getMessageCollectionName = (collection: string, item: any) => { - if (collection === 'commands') return 'Command'; - if (collection === 'queries') return 'Query'; - if (collection === 'events') return 'Event'; - if (collection === 'entities' && item.data.aggregateRoot) return 'Entity (Root)'; - if (collection === 'entities') return 'Entity'; - return collection.slice(0, collection.length - 1).toUpperCase(); -}; - -const NoResultsFound = React.memo(({ searchTerm }: { searchTerm: string }) => ( -
-
No results found for "{searchTerm}"
-
- Try: -
    -
  • Checking for typos
  • -
  • Using fewer keywords
  • -
  • Using more general terms
  • -
-
-
-)); - -const ServiceItem = React.memo( - ({ - item, - decodedCurrentPath, - collapsedGroups, - toggleGroupCollapse, - isVisualizer, - searchTerm, - }: { - item: ServiceItem; - decodedCurrentPath: string; - collapsedGroups: { [key: string]: boolean }; - toggleGroupCollapse: (group: string) => void; - isVisualizer: boolean; - searchTerm: string; - }) => { - const readsAndWritesTo = item.writesTo.filter((writeTo) => item.readsFrom.some((readFrom) => readFrom.id === writeTo.id)); - const resourceReads = item.readsFrom.filter((readFrom) => !readsAndWritesTo.some((writeTo) => writeTo.id === readFrom.id)); - const resourceWrites = item.writesTo.filter((writeTo) => !readsAndWritesTo.some((readFrom) => readFrom.id === writeTo.id)); - const hasData = item.writesTo.length > 0 || item.readsFrom.length > 0; - - const sendsMessages = item.sends && item.sends.length > 0; - const receivesMessages = item.receives && item.receives.length > 0; - - return ( - toggleGroupCollapse(item.href)} - title={ - - } - > -
- - Overview - - {isVisualizer && hasData && ( - - Data Diagram - - )} - {!isVisualizer && ( - - Architecture - - )} - - {!isVisualizer && item.specifications && item.specifications.length > 0 && ( - toggleGroupCollapse(`${item.href}-specifications`)} - title={ - - } - > - - - )} - - {receivesMessages && ( - toggleGroupCollapse(`${item.href}-receives`)} - title={ - - } - > - - - )} - {sendsMessages && ( - toggleGroupCollapse(`${item.href}-sends`)} - title={ - - } - > - - - )} - {!isVisualizer && hasData && ( - toggleGroupCollapse(`${item.href}-data`)} - title={ - - } - > - {readsAndWritesTo.length > 0 && ( - toggleGroupCollapse(`${item.href}-writesTo-data`)} - title={ - - } - > - - - )} - {resourceWrites.length > 0 && ( - toggleGroupCollapse(`${item.href}-writesTo-data`)} - title={ - - } - > - - - )} - {resourceReads.length > 0 && ( - toggleGroupCollapse(`${item.href}-readsFrom-data`)} - title={ - - } - > - - - )} - - )} - {!isVisualizer && item.entities.length > 0 && ( - toggleGroupCollapse(`${item.href}-entities`)} - title={ - - } - > - - - )} -
-
- ); - } -); - -const ListViewSideBar: React.FC = ({ resources, currentPath, showOrphanedMessages }) => { - const navRef = useRef(null); - const [data] = useState(resources); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - const [isInitialized, setIsInitialized] = useState(false); - const [isExpanded, setIsExpanded] = useState(true); - const [isSearchPinned, setIsSearchPinned] = useState(false); - const [lastScrollTop, setLastScrollTop] = useState(0); - const [collapsedGroups, setCollapsedGroups] = useState<{ [key: string]: boolean }>(() => { - if (typeof window !== 'undefined') { - const saved = window.localStorage.getItem(STORAGE_KEY); - const savedState = saved ? JSON.parse(saved) : {}; - const currentPath = window.location.pathname; - - // Default all sections to collapsed - const defaultCollapsedState: { [key: string]: boolean } = { - 'all-services-group': true, - 'flows-group': true, - 'data-group': true, - 'designs-group': true, - 'messagesNotInService-group': true, - }; - - // Default all domains, services, and their subsections to collapsed - resources.domains?.forEach((domain: any) => { - const isDomainActive = currentPath.includes(domain.href); - defaultCollapsedState[domain.href] = !isDomainActive; - defaultCollapsedState[`${domain.href}-entities`] = true; - defaultCollapsedState[`${domain.href}-subdomains`] = true; - defaultCollapsedState[`${domain.href}-services`] = true; - }); - - resources.services?.forEach((service: any) => { - const isServiceActive = currentPath.includes(service.href); - defaultCollapsedState[service.href] = !isServiceActive; - defaultCollapsedState[`${service.href}-specifications`] = true; - defaultCollapsedState[`${service.href}-receives`] = true; - defaultCollapsedState[`${service.href}-sends`] = true; - defaultCollapsedState[`${service.href}-entities`] = true; - defaultCollapsedState[`${service.href}-data`] = true; - defaultCollapsedState[`${service.href}-writesTo-data`] = true; - defaultCollapsedState[`${service.href}-readsFrom-data`] = true; - }); - - setIsInitialized(true); - return { ...defaultCollapsedState, ...savedState }; - } - return {}; - }); - - const decodedCurrentPath = window.location.pathname; - const isVisualizer = window.location.pathname.includes('/visualiser/'); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm.toLowerCase()); - }, DEBOUNCE_DELAY); - - return () => clearTimeout(timer); - }, [searchTerm]); - - // Filter data based on search term - const filteredData: Resources = useMemo(() => { - if (!debouncedSearchTerm) return data; - - const filterItem = (item: { label: string; id?: string }) => { - return ( - item.label.toLowerCase().includes(debouncedSearchTerm) || (item.id && item.id.toLowerCase().includes(debouncedSearchTerm)) - ); - }; - - const filterMessages = (messages: MessageItem[]) => { - return messages.filter( - (message) => - message.data.name.toLowerCase().includes(debouncedSearchTerm) || message.id.toLowerCase().includes(debouncedSearchTerm) - ); - }; - - // Enhanced domain filtering that considers parent-subdomain relationships - const filterDomains = (domains: any[]) => { - const filteredDomains: any[] = []; - - domains.forEach((domain: any) => { - const domainMatches = filterItem(domain); - - // Check if this domain is a subdomain of another domain - const isSubdomain = domains.some((parentDomain: any) => { - const subdomains = parentDomain.domains || []; - return subdomains.some((subdomain: any) => subdomain.data.id === domain.id); - }); - - // If this is a parent domain, check if any of its subdomains match - let hasMatchingSubdomains = false; - if (!isSubdomain) { - const subdomains = domain.domains || []; - hasMatchingSubdomains = domains.some((potentialSubdomain: any) => - subdomains.some((subdomain: any) => subdomain.data.id === potentialSubdomain.id && filterItem(potentialSubdomain)) - ); - } - - // Include domain if: - // 1. The domain itself matches the search - // 2. It's a parent domain and has matching subdomains - // 3. It's a subdomain and matches the search - if (domainMatches || hasMatchingSubdomains || (isSubdomain && domainMatches)) { - filteredDomains.push(domain); - } - - // If this is a subdomain that matches, also include its parent domain - if (isSubdomain && domainMatches) { - const parentDomain = domains.find((parentDomain: any) => { - const subdomains = parentDomain.domains || []; - return subdomains.some((subdomain: any) => subdomain.data.id === domain.id); - }); - - if (parentDomain && !filteredDomains.some((d: any) => d.id === parentDomain.id)) { - filteredDomains.push(parentDomain); - } - } - }); - - return filteredDomains; - }; - - return { - 'context-map': data['context-map']?.filter(filterItem) || [], - domains: data.domains ? filterDomains(data.domains) : [], - services: - data.services - ?.map((service: ServiceItem) => ({ - ...service, - sends: filterMessages(service.sends), - receives: filterMessages(service.receives), - isVisible: - filterItem(service) || - service.sends.some( - (msg: MessageItem) => - msg.data.name.toLowerCase().includes(debouncedSearchTerm) || msg.id.toLowerCase().includes(debouncedSearchTerm) - ) || - service.receives.some( - (msg: MessageItem) => - msg.data.name.toLowerCase().includes(debouncedSearchTerm) || msg.id.toLowerCase().includes(debouncedSearchTerm) - ), - })) - .filter((service: ServiceItem & { isVisible: boolean }) => service.isVisible) || [], - flows: data.flows?.filter(filterItem) || [], - designs: data.designs?.filter(filterItem) || [], - messagesNotInService: - data.messagesNotInService?.filter( - (msg: MessageItem) => - msg.label.toLowerCase().includes(debouncedSearchTerm) || msg.id.toLowerCase().includes(debouncedSearchTerm) - ) || [], - }; - }, [data, debouncedSearchTerm]); - - // Auto-expand groups when searching - useEffect(() => { - if (debouncedSearchTerm) { - // Expand all groups when searching - const newCollapsedState = { ...collapsedGroups }; - Object.keys(newCollapsedState).forEach((key) => { - newCollapsedState[key] = false; - }); - setCollapsedGroups(newCollapsedState); - } - }, [debouncedSearchTerm]); - - // Handle scroll for sticky search bar - useEffect(() => { - const nav = navRef.current; - if (!nav) return; - - const handleScroll = () => { - const scrollTop = nav.scrollTop; - const scrollThreshold = 50; // Pin after scrolling 50px - - // Scrolling down past threshold - if (scrollTop > scrollThreshold && scrollTop > lastScrollTop) { - setIsSearchPinned(true); - } - // Scrolling up near the top - else if (scrollTop <= scrollThreshold) { - setIsSearchPinned(false); - } - - setLastScrollTop(scrollTop); - }; - - nav.addEventListener('scroll', handleScroll); - return () => nav.removeEventListener('scroll', handleScroll); - }, [lastScrollTop]); - - // Store collapsed groups in local storage - useEffect(() => { - if (typeof window !== 'undefined') { - window.localStorage.setItem(STORAGE_KEY, JSON.stringify(collapsedGroups)); - } - }, [collapsedGroups]); - - // If we find a data-active element, scroll to it on mount and open its section - useEffect(() => { - const activeElement = document.querySelector('[data-active="true"]'); - if (activeElement) { - // Add y offset to the scroll position - activeElement.scrollIntoView({ behavior: 'instant', block: 'center' }); - - // Check which section the active element belongs to and open it - const newCollapsedState = { ...collapsedGroups }; - - // Check if active page is in a domain - data.domains?.forEach((domain: any) => { - if (decodedCurrentPath.includes(domain.href)) { - newCollapsedState[domain.href] = false; - - // Check if it's in domain entities - const isInDomainEntities = domain.entities?.some((entity: any) => decodedCurrentPath.includes(entity.href)); - if (isInDomainEntities) { - newCollapsedState[`${domain.href}-entities`] = false; - } - - // Check if it's in domain services - const isInDomainServices = domain.services?.some((service: any) => decodedCurrentPath.includes(service.data.id)); - if (isInDomainServices) { - newCollapsedState[`${domain.href}-services`] = false; - } - - // Check if it's in a subdomain - const subdomains = domain.domains || []; - subdomains.forEach((subdomain: any) => { - const actualSubdomain = data.domains?.find((d: any) => d.id === subdomain.data.id); - if (actualSubdomain && decodedCurrentPath.includes(actualSubdomain.href)) { - newCollapsedState[`${domain.href}-subdomains`] = false; - newCollapsedState[actualSubdomain.href] = false; - - // Check subdomain entities - const isInSubdomainEntities = actualSubdomain.entities?.some((entity: any) => - decodedCurrentPath.includes(entity.href) - ); - if (isInSubdomainEntities) { - newCollapsedState[`${actualSubdomain.href}-entities`] = false; - } - - // Check subdomain services - const isInSubdomainServices = actualSubdomain.services?.some((service: any) => - decodedCurrentPath.includes(service.data.id) - ); - if (isInSubdomainServices) { - newCollapsedState[`${actualSubdomain.href}-services`] = false; - } - } - }); - } - }); - - // Check if active page is a service - data.services?.forEach((service: ServiceItem) => { - if (decodedCurrentPath.includes(service.href)) { - newCollapsedState['all-services-group'] = false; - newCollapsedState[service.href] = false; - - // Open specific service sections if active - if (decodedCurrentPath.includes('/data')) { - newCollapsedState[`${service.href}-data`] = false; - } - } - }); - - // Check if active page is a flow - const isFlow = data.flows?.some((flow: FlowItem) => decodedCurrentPath === flow.href); - if (isFlow) { - newCollapsedState['flows-group'] = false; - } - - // Check if active page is a container/data store - const isContainer = data.containers?.some((container: any) => decodedCurrentPath === container.href); - if (isContainer) { - newCollapsedState['data-group'] = false; - } - - // Check if active page is a design - const isDesign = data.designs?.some((design: any) => decodedCurrentPath === design.href); - if (isDesign) { - newCollapsedState['designs-group'] = false; - } - - // Check if active page is an orphaned message - const isOrphanedMessage = data.messagesNotInService?.some((msg: MessageItem) => decodedCurrentPath === msg.href); - if (isOrphanedMessage) { - newCollapsedState['messagesNotInService-group'] = false; - } - - setCollapsedGroups(newCollapsedState); - } - }, []); - - const toggleGroupCollapse = useCallback((group: string) => { - setCollapsedGroups((prev) => ({ - ...prev, - [group]: !prev[group], - })); - }, []); - - const handleSearchChange = useCallback((e: React.ChangeEvent) => { - setSearchTerm(e.target.value); - }, []); - - const collapseAll = useCallback(() => { - const newCollapsedState: { [key: string]: boolean } = {}; - - // Collapse all domains - filteredData.domains?.forEach((domain: any) => { - newCollapsedState[domain.href] = true; - newCollapsedState[`${domain.href}-entities`] = true; - newCollapsedState[`${domain.href}-subdomains`] = true; - newCollapsedState[`${domain.href}-services`] = true; - }); - - // Collapse all services - filteredData.services?.forEach((service: any) => { - newCollapsedState[service.href] = true; - newCollapsedState[`${service.href}-specifications`] = true; - newCollapsedState[`${service.href}-receives`] = true; - newCollapsedState[`${service.href}-sends`] = true; - newCollapsedState[`${service.href}-entities`] = true; - }); - - setCollapsedGroups(newCollapsedState); - setIsExpanded(false); - }, [filteredData]); - - const expandAll = useCallback(() => { - const newCollapsedState: { [key: string]: boolean } = {}; - - // Expand all domains - filteredData.domains?.forEach((domain: any) => { - newCollapsedState[domain.href] = false; - newCollapsedState[`${domain.href}-entities`] = false; - newCollapsedState[`${domain.href}-subdomains`] = false; - newCollapsedState[`${domain.href}-services`] = false; - }); - - // Expand all services - filteredData.services?.forEach((service: any) => { - newCollapsedState[service.href] = false; - newCollapsedState[`${service.href}-specifications`] = false; - newCollapsedState[`${service.href}-receives`] = false; - newCollapsedState[`${service.href}-sends`] = false; - newCollapsedState[`${service.href}-entities`] = false; - }); - - setCollapsedGroups(newCollapsedState); - setIsExpanded(true); - }, [filteredData]); - - const toggleExpandCollapse = useCallback(() => { - if (isExpanded) { - collapseAll(); - } else { - expandAll(); - } - }, [isExpanded, collapseAll, expandAll]); - - const hideSidebar = useCallback(() => { - // Dispatch custom event that the Astro layout will listen for - window.dispatchEvent(new CustomEvent('sidebarToggle', { detail: { action: 'hide' } })); - }, []); - - const isDomainSubDomain = useMemo(() => { - return (domain: any) => { - const domains = data.domains || []; - return domains.some((d: any) => { - const subdomains = d.domains || []; - return subdomains.some((subdomain: any) => subdomain.data.id === domain.id); - }); - }; - }, [data.domains]); - - // Helper function to get parent domains (domains that are not subdomains) - const getParentDomains = useMemo(() => { - return (domains: any[]) => { - return domains.filter((domain: any) => !isDomainSubDomain(domain)); - }; - }, [isDomainSubDomain]); - - // Helper function to get subdomains for a specific parent domain - const getSubdomainsForParent = useMemo(() => { - return (parentDomain: any, allDomains: any[]) => { - const subdomains = parentDomain.domains || []; - return allDomains.filter((domain: any) => subdomains.some((subdomain: any) => subdomain.data.id === domain.id)); - }; - }, []); - - // Helper function to get services for a specific domain (only direct services, not from subdomains) - const getServicesForDomain = useMemo(() => { - return (domain: any, allServices: ServiceItem[], allDomains: any[]) => { - const domainServices = domain.services || []; - const subdomains = getSubdomainsForParent(domain, allDomains); - - // Get all service IDs from subdomains - const subdomainServiceIds = subdomains.flatMap((subdomain: any) => (subdomain.services || []).map((s: any) => s.data.id)); - - // Filter services that belong to this domain but NOT to any of its subdomains - return allServices.filter( - (service: ServiceItem) => - domainServices.some((domainService: any) => domainService.data.id === service.id) && - !subdomainServiceIds.includes(service.id) - ); - }; - }, [getSubdomainsForParent]); - - // Component to render a single domain item - const DomainItem = React.memo( - ({ item, isSubdomain = false, nestingLevel = 0 }: { item: any; isSubdomain?: boolean; nestingLevel?: number }) => { - const marginLeft = nestingLevel > 0 ? `ml-${nestingLevel * 4}` : ''; - - return ( -
- - -
- ); - } - ); - - // Component to render domain content (Overview, Architecture, etc.) - const DomainContent = React.memo( - ({ - item, - nestingLevel = 0, - className = '', - isSubdomain = false, - }: { - item: any; - nestingLevel?: number; - className?: string; - isSubdomain?: boolean; - }) => { - const marginLeft = nestingLevel > 0 ? `ml-${nestingLevel * 4}` : ''; - const hasEntities = item.entities && item.entities.length > 0; - - // Get services for this domain - const domainServices = getServicesForDomain(item, filteredData['services'] || [], filteredData['domains'] || []); - - return ( -
-
- - Overview - - - {isVisualizer && hasEntities && ( - - Entity Map - - )} - {!isVisualizer && ( - service.data.id).join(','), - domainId: item.id, - domainName: item.name, - })} - data-active={window.location.href.includes(`domainId=${item.id}`)} - className={`flex items-center px-2 py-1.5 text-xs text-gray-600 hover:bg-purple-100 rounded-md ${ - window.location.href.includes(`domainId=${item.id}`) ? 'bg-purple-100 ' : 'hover:bg-purple-100' - }`} - > - Architecture - - )} - {!isVisualizer && ( - - Ubiquitous Language - - )} - - {/* Render services before entities */} - {domainServices.length > 0 && ( - toggleGroupCollapse(`${item.href}-services`)} - title={ - - } - > -
- {domainServices.map((service: ServiceItem) => ( - - ))} -
-
- )} - - {item.entities.length > 0 && !isVisualizer && ( - toggleGroupCollapse(`${item.href}-entities`)} - title={ - - } - > - - - )} -
-
- ); - } - ); - - if (!isInitialized) return null; - - const hasNoResults = - debouncedSearchTerm && - !filteredData['context-map']?.length && - !filteredData.domains?.length && - !filteredData.services?.length && - !filteredData.flows?.length && - !filteredData.messagesNotInService?.length; - - return ( - - ); -}; - -export default React.memo(ListViewSideBar); diff --git a/eventcatalog/src/components/SideNav/ListViewSideBar/types.ts b/eventcatalog/src/components/SideNav/ListViewSideBar/types.ts deleted file mode 100644 index f38199905..000000000 --- a/eventcatalog/src/components/SideNav/ListViewSideBar/types.ts +++ /dev/null @@ -1,91 +0,0 @@ -export interface MessageItem { - href: string; - label: string; - service: string; - id: string; - direction: 'sends' | 'receives'; - type: 'command' | 'query' | 'event'; - collection: string; - data: { - name: string; - }; -} - -export interface EntityItem { - href: string; - label: string; - id: string; - name: string; -} - -export interface ServiceItem { - href: string; - label: string; - name: string; - id: string; - version: string; - sidebar?: { - badge?: string; - color?: string; - backgroundColor?: string; - }; - draft: boolean | { title?: string; message: string }; - sends: MessageItem[]; - receives: MessageItem[]; - entities: EntityItem[]; - writesTo: MessageItem[]; - readsFrom: MessageItem[]; - specifications?: { - type: string; - path: string; - name?: string; - filename?: string; - filenameWithoutExtension?: string; - }[]; -} - -export interface DomainItem { - href: string; - label: string; - id: string; - name: string; - services: any[]; - domains: any[]; - entities: EntityItem[]; -} - -export interface FlowItem { - href: string; - label: string; -} - -export interface DesignItem { - href: string; - label: string; - id: string; - name: string; -} - -export interface Resources { - 'context-map'?: Array<{ - href: string; - label: string; - id: string; - name: string; - }>; - domains?: DomainItem[]; - services?: ServiceItem[]; - flows?: FlowItem[]; - designs?: DesignItem[]; - messagesNotInService?: MessageItem[]; - commands?: MessageItem[]; - queries?: MessageItem[]; - events?: MessageItem[]; - containers?: MessageItem[]; -} - -export interface ListViewSideBarProps { - resources: Resources; - currentPath: string; - showOrphanedMessages: boolean; -} diff --git a/eventcatalog/src/components/SideNav/ListViewSideBar/utils.ts b/eventcatalog/src/components/SideNav/ListViewSideBar/utils.ts deleted file mode 100644 index 693e57aaa..000000000 --- a/eventcatalog/src/components/SideNav/ListViewSideBar/utils.ts +++ /dev/null @@ -1,201 +0,0 @@ -import { isCollectionVisibleInCatalog } from '@eventcatalog'; -import { buildUrl } from '@utils/url-builder'; -import { getChannels } from '@utils/channels'; -import { getDomains } from '@utils/collections/domains'; -import { getFlows } from '@utils/collections/flows'; -import { getServices, getSpecificationsForService } from '@utils/collections/services'; -import { getCommands } from '@utils/commands'; -import { getEvents } from '@utils/events'; -import { getQueries } from '@utils/queries'; -import { getDesigns } from '@utils/collections/designs'; -import { getContainers } from '@utils/collections/containers'; - -const stripCollection = (collection: any) => { - return collection.map((item: any) => ({ - data: { - id: item.data.id, - version: item.data.version, - }, - })); -}; - -export async function getResourcesForNavigation({ currentPath }: { currentPath: string }) { - const events = await getEvents({ getAllVersions: false }); - const commands = await getCommands({ getAllVersions: false }); - const queries = await getQueries({ getAllVersions: false }); - const services = await getServices({ getAllVersions: false }); - const domains = await getDomains({ getAllVersions: false }); - const channels = await getChannels({ getAllVersions: false }); - const flows = await getFlows({ getAllVersions: false }); - const containers = await getContainers({ getAllVersions: false }); - const designs = await getDesigns({ getAllVersions: false }); - - const messages = [...events, ...commands, ...queries]; - - // messages that are not in a service (sends or receives) - const messagesNotInService = messages.filter( - (message) => - !services.some( - (service) => - service.data?.sends?.some((send: any) => send.data.id === message.data.id) || - service.data?.receives?.some((receive: any) => receive.data.id === message.data.id) - ) - ); - - const route = currentPath.includes('visualiser') ? 'visualiser' : 'docs'; - - // Just the domains for now. - const allDataAsSideNav = [...domains, ...services, ...flows, ...channels, ...containers].reduce((acc, item) => { - const title = item.collection; - const group = acc[title] || []; - - const isCollectionDomain = item.collection === 'domains'; - const isCollectionService = item.collection === 'services'; - - const servicesCount = isCollectionDomain ? item.data.services?.length || 0 : 0; - const sends = isCollectionService ? item.data.sends || null : null; - const receives = isCollectionService ? item.data.receives || null : null; - const entities = isCollectionDomain || isCollectionService ? item.data.entities || null : null; - - const writesTo = isCollectionService ? item.data.writesTo || null : null; - const readsFrom = isCollectionService ? item.data.readsFrom || null : null; - - // Add href to the sends and receives - const sendsWithHref = sends?.map((send: any) => ({ - id: send.data.id, - data: { - name: send.data.name, - sidebar: send.data.sidebar, - aggregateRoot: send?.data?.aggregateRoot, - draft: send.data.draft, - }, - collection: send.collection, - href: buildUrl(`/${route}/${send.collection}/${send.data.id}/${send.data.version}`), - })); - const receivesWithHref = receives?.map((receive: any) => ({ - id: receive.data.id, - data: { - name: receive.data.name, - sidebar: receive.data.sidebar, - aggregateRoot: receive?.data?.aggregateRoot, - draft: receive.data.draft, - }, - collection: receive.collection, - href: buildUrl(`/${route}/${receive.collection}/${receive.data.id}/${receive.data.version}`), - })); - const entitiesWithHref = entities?.map((entity: any) => ({ - id: entity.data.id, - data: { - name: entity.data.name, - sidebar: entity.data.sidebar, - aggregateRoot: entity?.data?.aggregateRoot, - draft: entity.data.draft, - }, - collection: entity.collection, - href: buildUrl(`/${route}/${entity.collection}/${entity.data.id}/${entity.data.version}`), - })); - - const writesToWithHref = writesTo?.map((writeTo: any) => ({ - id: writeTo.data.id, - data: { - name: writeTo.data.name, - sidebar: { - badge: writeTo.data.container_type || writeTo.collection, - backgroundColor: 'bg-blue-100 text-blue-600', - }, - }, - collection: writeTo.collection, - href: buildUrl(`/${route}/${writeTo.collection}/${writeTo.data.id}/${writeTo.data.version}`), - })); - - const readsFromWithHref = readsFrom?.map((readFrom: any) => ({ - id: readFrom.data.id, - data: { - name: readFrom.data.name, - sidebar: { - badge: readFrom.data.container_type || readFrom.collection, - backgroundColor: 'bg-blue-100 text-indigo-600', - }, - }, - collection: readFrom.collection, - href: buildUrl(`/${route}/${readFrom.collection}/${readFrom.data.id}/${readFrom.data.version}`), - })); - - // don't render items if we are in the visualiser and the item has visualiser set to false - if (currentPath.includes('visualiser') && item.data.visualiser === false) { - return acc; - } - - const navigationItem = { - label: item.data.name, - version: item.data.version, - // items: item.collection === 'users' ? [] : item.headings, - visible: isCollectionVisibleInCatalog(item.collection), - // @ts-ignore - href: item.data.version - ? // @ts-ignore - buildUrl(`/${route}/${item.collection}/${item.data.id}/${item.data.version}`) - : buildUrl(`/${route}/${item.collection}/${item.data.id}`), - collection: item.collection, - servicesCount, - id: item.data.id, - name: item.data.name, - draft: item.data.draft, - services: isCollectionDomain ? stripCollection(item.data.services) : null, - domains: isCollectionDomain ? stripCollection(item.data.domains) : null, - sends: sendsWithHref, - receives: receivesWithHref, - entities: entitiesWithHref, - specifications: isCollectionService ? getSpecificationsForService(item) : null, - writesTo: writesToWithHref, - readsFrom: readsFromWithHref, - sidebar: item.data?.sidebar, - renderInVisualiser: item.data?.visualiser ?? true, - }; - - group.push(navigationItem); - - return { - ...acc, - [title]: group, - }; - }, {} as any); - - // Add messagesNotInService - const messagesNotInServiceAsSideNav = messagesNotInService.map((item) => ({ - label: item.data.name, - version: item.data.version, - id: item.data.id, - name: item.data.name, - draft: item.data.draft, - href: buildUrl(`/${route}/${item.collection}/${item.data.id}/${item.data.version}`), - collection: item.collection, - })); - - const sideNav = { - ...(currentPath.includes('visualiser') - ? { - 'context-map': [ - { - label: 'Domain Integration Map', - href: buildUrl('/visualiser/domain-integrations'), - collection: 'domain-integrations', - }, - ], - } - : {}), - ...allDataAsSideNav, - messagesNotInService: messagesNotInServiceAsSideNav, - }; - - // Add designs? - if (designs.length > 0) { - sideNav['designs'] = designs.map((design) => ({ - label: design.data.name, - href: buildUrl(`/visualiser/designs/${design.data.id}`), - collection: 'designs', - })); - } - - return sideNav; -} diff --git a/eventcatalog/src/components/SideNav/NestedSideBar/SearchBar.tsx b/eventcatalog/src/components/SideNav/NestedSideBar/SearchBar.tsx new file mode 100644 index 000000000..bc70dc3a1 --- /dev/null +++ b/eventcatalog/src/components/SideNav/NestedSideBar/SearchBar.tsx @@ -0,0 +1,315 @@ +'use client'; + +import { useState, useCallback, useMemo } from 'react'; +import { + Search, + X, + SlidersHorizontal, + ChevronRight, + Boxes, + Server, + Zap, + MessageSquare, + Search as SearchIcon, + Database, + Waypoints, + SquareMousePointer, + ListOrdered, + ArrowLeftRight, +} from 'lucide-react'; +import type { NavNode } from '@stores/sidebar-store/state'; + +const cn = (...classes: (string | false | undefined)[]) => classes.filter(Boolean).join(' '); + +const getBadgeClasses = (badge: string): string => { + const badgeColors: Record = { + domain: 'bg-blue-100 text-blue-700', + service: 'bg-green-100 text-green-700', + event: 'bg-amber-100 text-amber-700', + command: 'bg-pink-100 text-pink-700', + query: 'bg-green-100 text-green-700', + message: 'bg-indigo-100 text-indigo-700', + design: 'bg-teal-100 text-teal-700', + channel: 'bg-indigo-100 text-indigo-700', + }; + return badgeColors[badge.toLowerCase()] || 'bg-gray-100 text-gray-600'; +}; + +type SearchResult = { + nodeKey: string; + node: NavNode; + matchType: 'name' | 'id'; +}; + +type Props = { + nodes: Record; + onSelectResult: (nodeKey: string, node: NavNode) => void; + onSearchChange?: (isSearching: boolean) => void; +}; + +export default function SearchBar({ nodes, onSelectResult, onSearchChange }: Props) { + const [searchQuery, setSearchQuery] = useState(''); + const [searchFilters, setSearchFilters] = useState>(new Set()); + const [showFilterDropdown, setShowFilterDropdown] = useState(false); + + // Pre-process searchable nodes to avoid iterating object on every render + const searchableNodes = useMemo(() => { + return Object.entries(nodes).filter(([_, node]) => node.type !== 'group'); + }, [nodes]); + + // Get available badges from nodes + const availableBadges = useMemo(() => { + const badges = new Set(); + + for (const [_, node] of searchableNodes) { + if (node.badge) { + badges.add(node.badge); + } + } + return badges; + }, [searchableNodes]); + + const handleSearchChange = (value: string) => { + setSearchQuery(value); + onSearchChange?.(value.trim().length > 0); + }; + + const clearSearch = () => { + setSearchQuery(''); + onSearchChange?.(false); + }; + + const filterTypes = [ + { key: 'channel', label: 'Channels', badge: 'Channel', icon: ArrowLeftRight }, + { key: 'command', label: 'Commands', badge: 'Command', icon: MessageSquare }, + { key: 'container', label: 'Data Stores', badge: 'Container', icon: Database }, + { key: 'design', label: 'Designs', badge: 'Design', icon: SquareMousePointer }, + { key: 'domain', label: 'Domains', badge: 'Domain', icon: Boxes }, + { key: 'event', label: 'Events', badge: 'Event', icon: Zap }, + { key: 'flow', label: 'Flows', badge: 'Flow', icon: Waypoints }, + { key: 'query', label: 'Queries', badge: 'Query', icon: SearchIcon }, + { key: 'service', label: 'Services', badge: 'Service', icon: Server }, + ].filter((filter) => availableBadges.has(filter.badge)); + + const toggleSearchFilter = (filterKey: string) => { + setSearchFilters((prev) => { + const next = new Set(prev); + if (next.has(filterKey)) { + next.delete(filterKey); + } else { + next.add(filterKey); + } + return next; + }); + }; + + const searchResults = useCallback((): SearchResult[] => { + if (!searchQuery.trim()) return []; + + const query = searchQuery.toLowerCase(); + const results: SearchResult[] = []; + + const badgeToFilterKey: Record = { + Domain: 'domain', + Service: 'service', + Event: 'event', + Command: 'command', + Query: 'query', + Container: 'container', + Flow: 'flow', + Design: 'design', + }; + + // Use the memoized array instead of Object.entries(nodes) + for (const [key, node] of searchableNodes) { + if (searchFilters.size > 0) { + const filterKey = node.badge ? badgeToFilterKey[node.badge] : null; + if (!filterKey || !searchFilters.has(filterKey)) continue; + } + + if (node.title.toLowerCase().includes(query)) { + results.push({ nodeKey: key, node, matchType: 'name' }); + continue; + } + + const keyParts = key.split(':'); + if (keyParts.length >= 3) { + const id = keyParts[2].toLowerCase(); + if (id.includes(query)) { + results.push({ nodeKey: key, node, matchType: 'id' }); + } + } + } + + return results + .sort((a, b) => { + const aExact = a.node.title.toLowerCase() === query; + const bExact = b.node.title.toLowerCase() === query; + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + return a.node.title.localeCompare(b.node.title); + }) + .slice(0, 20); + }, [searchQuery, searchableNodes, searchFilters]); + + const handleSelectResult = (nodeKey: string, node: NavNode) => { + onSelectResult(nodeKey, node); + clearSearch(); + }; + + const results = searchResults(); + const showSearchResults = searchQuery.trim().length > 0; + + return ( + <> + {/* Search Input */} +
+
+
+ + handleSearchChange(e.target.value)} + className="w-full pl-9 pr-8 py-2 text-sm bg-[rgb(var(--ec-input-bg))] border border-[rgb(var(--ec-input-border))] rounded-lg focus:outline-none focus:ring-2 focus:ring-[rgb(var(--ec-accent))] focus:border-transparent text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-input-placeholder))]" + /> + {searchQuery && ( + + )} +
+ {/* Filter Button */} +
+ + + {/* Filter Dropdown */} + {showFilterDropdown && ( + <> +
setShowFilterDropdown(false)} /> +
+
+
+ Filter by type +
+
+ {filterTypes.map((filter) => { + const isActive = searchFilters.has(filter.key); + const Icon = filter.icon; + return ( + + ); + })} +
+ {searchFilters.size > 0 && ( + <> +
+ + + )} +
+
+ + )} +
+
+
+ + {/* Search Results */} + {showSearchResults && ( +
+
+
+ {results.length > 0 ? `${results.length} result${results.length > 1 ? 's' : ''}` : 'No results'} +
+ {results.length > 0 && ( +
+ {results.map(({ nodeKey, node, matchType }) => ( + + ))} +
+ )} + {results.length === 0 && searchQuery.trim() && ( +
+ No resources found for "{searchQuery}" +
+ )} +
+
+ )} + + ); +} diff --git a/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx b/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx new file mode 100644 index 000000000..a0bdbec4c --- /dev/null +++ b/eventcatalog/src/components/SideNav/NestedSideBar/index.tsx @@ -0,0 +1,1162 @@ +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import * as LucideIcons from 'lucide-react'; +import { ChevronRight, ChevronLeft, ChevronDown, Home, Star, FileQuestion } from 'lucide-react'; +import type { NavNode, ChildRef } from '@stores/sidebar-store/state'; +import SearchBar from './SearchBar'; +import { saveState, loadState, saveCollapsedSections, loadCollapsedSections } from './storage'; +import { useStore } from '@nanostores/react'; +import { sidebarStore } from '@stores/sidebar-store'; +import { favoritesStore, toggleFavorite as toggleFavoriteAction, type FavoriteItem } from '@stores/favorites-store'; + +const cn = (...classes: (string | false | undefined)[]) => classes.filter(Boolean).join(' '); + +// ============================================ +// Badge color mapping (uses CSS variables from theme.css) +// ============================================ + +const getBadgeClasses = (badge: string): string => { + const badgeColors: Record = { + domain: 'bg-[rgb(var(--ec-badge-domain-bg))] text-[rgb(var(--ec-badge-domain-text))]', + service: 'bg-[rgb(var(--ec-badge-service-bg))] text-[rgb(var(--ec-badge-service-text))]', + event: 'bg-[rgb(var(--ec-badge-event-bg))] text-[rgb(var(--ec-badge-event-text))]', + command: 'bg-[rgb(var(--ec-badge-command-bg))] text-[rgb(var(--ec-badge-command-text))]', + query: 'bg-[rgb(var(--ec-badge-query-bg))] text-[rgb(var(--ec-badge-query-text))]', + message: 'bg-[rgb(var(--ec-badge-message-bg))] text-[rgb(var(--ec-badge-message-text))]', + design: 'bg-[rgb(var(--ec-badge-design-bg))] text-[rgb(var(--ec-badge-design-text))]', + channel: 'bg-[rgb(var(--ec-badge-channel-bg))] text-[rgb(var(--ec-badge-channel-text))]', + }; + return badgeColors[badge.toLowerCase()] || 'bg-[rgb(var(--ec-badge-default-bg))] text-[rgb(var(--ec-badge-default-text))]'; +}; + +// ============================================ +// Component +// ============================================ + +type NavigationLevel = { + key: string | null; // The key of the node that was drilled into (null for root) + entries: ChildRef[]; + title: string; + badge?: string; // Category badge (e.g., "Domain", "Service") +}; + +export default function NestedSideBar() { + const data = useStore(sidebarStore); + const favorites = useStore(favoritesStore); + + // Guard against undefined data (e.g., during hydration) + // Use useMemo to ensure stable references for roots and nodes + const roots = useMemo(() => data?.roots ?? [], [data?.roots]); + const nodes = useMemo(() => data?.nodes ?? {}, [data?.nodes]); + + const [navigationStack, setNavigationStack] = useState(() => [ + { key: null, entries: [], title: 'Documentation' }, + ]); + const [animationKey, setAnimationKey] = useState(0); + const [slideDirection, setSlideDirection] = useState<'forward' | 'backward' | null>(null); + const [isInitialized, setIsInitialized] = useState(false); + const [currentPath, setCurrentPath] = useState(''); + const [collapsedSections, setCollapsedSections] = useState>(new Set()); + const [showPathPreview, setShowPathPreview] = useState(false); + const [showFullPath, setShowFullPath] = useState(false); + const [isSearching, setIsSearching] = useState(false); + + // Build a lookup map for faster URL navigation + // Map format: "type:id" -> "nodeKey" + const nodeLookup = useMemo(() => { + const lookup = new Map(); + + Object.keys(nodes).forEach((key) => { + // Key formats: + // - "type:id:version" (e.g., "service:OrdersService:0.0.3") + // - "type:id" (e.g., "service:OrdersService", "user:john", "team:backend") + // - "list:name" (e.g., "list:domains") - skip these + const parts = key.split(':'); + + // Skip list items + if (parts[0] === 'list') return; + + if (parts.length >= 2) { + // Store as "type:id" + const type = parts[0]; + const id = parts[1]; + lookup.set(`${type}:${id}`, key); + } + }); + + return lookup; + }, [nodes]); + + /** + * Toggle section collapse state + */ + const toggleSectionCollapse = (sectionId: string) => { + setCollapsedSections((prev) => { + const next = new Set(prev); + if (next.has(sectionId)) { + next.delete(sectionId); + } else { + next.add(sectionId); + } + // Save to localStorage + saveCollapsedSections(next); + return next; + }); + }; + + /** + * Load collapsed sections from localStorage on mount + */ + useEffect(() => { + const saved = loadCollapsedSections(); + if (saved.size > 0) { + setCollapsedSections(saved); + } + }, []); + + /** + * Update navigation stack when roots become available + */ + useEffect(() => { + if (roots.length > 0) { + setNavigationStack((prevStack) => { + // Only update if the current stack has no entries (initial state) + if (prevStack.length === 1 && prevStack[0].entries.length === 0) { + return [{ key: null, entries: roots, title: 'Documentation' }]; + } + return prevStack; + }); + } + }, [roots]); + + /** + * Populate the store with the data when the component mounts or data changes + */ + // useEffect(() => { + // if (data) { + // setSidebarData(data); + // } + // }, [data]); + + /** + * Resolve a child reference to a NavNode + */ + const resolveRef = useCallback( + (ref: ChildRef): NavNode | null => { + if (typeof ref === 'string') { + return nodes[ref] ?? null; + } + return ref; + }, + [nodes] + ); + + /** + * Check if a node is visible (default: true) + */ + const isVisible = useCallback((node: NavNode | null): boolean => { + if (!node) return false; + return node.visible !== false; + }, []); + + /** + * Build navigation stack from a path of keys + */ + const buildStackFromPath = useCallback( + (path: string[]): NavigationLevel[] => { + const stack: NavigationLevel[] = [{ key: null, entries: roots, title: 'Documentation' }]; + + for (const key of path) { + const node = nodes[key]; + if (node && node.pages) { + stack.push({ + key, + entries: node.pages, + title: node.title, + badge: node.badge, + }); + } else { + // Path is invalid (node doesn't exist or has no children), stop here + break; + } + } + + return stack; + }, + [roots, nodes] + ); + + /** + * Get current path from navigation stack + */ + const getCurrentPath = useCallback((): string[] => { + return navigationStack.filter((level) => level.key !== null).map((level) => level.key as string); + }, [navigationStack]); + + /** + * Find a node key by matching URL patterns + */ + const findNodeKeyByUrl = useCallback( + (url: string): string | null => { + // URL patterns to match resources with version + const urlPatternsWithVersion = [ + // Domains + { pattern: /^\/docs\/domains\/([^/]+)\/([^/]+)/, type: 'domain' }, + { pattern: /^\/visualiser\/domains\/([^/]+)\/([^/]+)/, type: 'domain' }, + { pattern: /^\/architecture\/domains\/([^/]+)\/([^/]+)/, type: 'domain' }, + // Services + { pattern: /^\/docs\/services\/([^/]+)\/([^/]+)/, type: 'service' }, + { pattern: /^\/architecture\/services\/([^/]+)\/([^/]+)/, type: 'service' }, + { pattern: /^\/visualiser\/services\/([^/]+)\/([^/]+)/, type: 'service' }, + // Messages (events, commands, queries) - note: keys use singular form + { pattern: /^\/docs\/events\/([^/]+)\/([^/]+)/, type: 'event' }, + { pattern: /^\/docs\/commands\/([^/]+)\/([^/]+)/, type: 'command' }, + { pattern: /^\/docs\/queries\/([^/]+)\/([^/]+)/, type: 'query' }, + { pattern: /^\/visualiser\/messages\/([^/]+)\/([^/]+)/, type: 'message' }, + { pattern: /^\/visualiser\/events\/([^/]+)\/([^/]+)/, type: 'event' }, + { pattern: /^\/visualiser\/commands\/([^/]+)\/([^/]+)/, type: 'command' }, + { pattern: /^\/visualiser\/queries\/([^/]+)\/([^/]+)/, type: 'query' }, + // Containers + { pattern: /^\/docs\/containers\/([^/]+)\/([^/]+)/, type: 'container' }, + { pattern: /^\/visualiser\/containers\/([^/]+)\/([^/]+)/, type: 'container' }, + // Flows + { pattern: /^\/docs\/flows\/([^/]+)\/([^/]+)/, type: 'flow' }, + { pattern: /^\/visualiser\/flows\/([^/]+)\/([^/]+)/, type: 'flow' }, + ]; + + // URL patterns without version (language pages, etc) + const urlPatternsWithoutVersion = [{ pattern: /^\/docs\/domains\/([^/]+)\/language/, type: 'domain' }]; + + // First try to match patterns with version + for (const { pattern, type } of urlPatternsWithVersion) { + const match = url.match(pattern); + if (match) { + const id = match[1]; + const version = match[2]; + + // First try with version + const keyWithVersion = `${type}:${id}:${version}`; + if (nodes[keyWithVersion]) { + return keyWithVersion; + } + + // Fallback to lookup without version (for latest) + const foundNodeKey = nodeLookup.get(`${type}:${id}`); + if (foundNodeKey) return foundNodeKey; + } + } + + // Then try patterns without version + for (const { pattern, type } of urlPatternsWithoutVersion) { + const match = url.match(pattern); + if (match) { + const id = match[1]; + const foundNodeKey = nodeLookup.get(`${type}:${id}`); + if (foundNodeKey) return foundNodeKey; + } + } + + return null; + }, + [nodeLookup, nodes] + ); + + /** + * Try to connect a target node to the current stack (drill down, move up, or validate leaf) + */ + const tryConnectStack = useCallback( + (targetKey: string, currentStack: NavigationLevel[]): NavigationLevel[] | null => { + const targetNode = nodes[targetKey]; + if (!targetNode) return null; + + // 1. Check if we are already at this level (or above) + const existingLevelIndex = currentStack.findIndex((level) => level.key === targetKey); + if (existingLevelIndex !== -1) { + // Truncate stack to this level + return currentStack.slice(0, existingLevelIndex + 1); + } + + // 2. Check if it's a child of the current last level + const lastLevel = currentStack[currentStack.length - 1]; + const lastNode = lastLevel.key ? nodes[lastLevel.key] : null; + + // If root level (key=null), we check against roots + const parentChildren = lastLevel.key === null ? roots : lastNode?.pages; + + if (parentChildren) { + const isChild = parentChildren.some((ref) => { + if (typeof ref === 'string') return ref === targetKey; + // Inline nodes don't have global keys usually + return false; + }); + + if (isChild) { + // If it has children, we drill down + if (targetNode.pages && targetNode.pages.length > 0) { + return [ + ...currentStack, + { key: targetKey, entries: targetNode.pages, title: targetNode.title, badge: targetNode.badge }, + ]; + } + // If it's a leaf, the stack is valid as is + return currentStack; + } + } + + return null; + }, + [nodes, roots] + ); + + /** + * Find a node by matching URL patterns and navigate to it + */ + const findAndNavigateToUrl = useCallback( + (url: string) => { + const foundNodeKey = findNodeKeyByUrl(url); + + if (foundNodeKey) { + setNavigationStack((currentStack) => { + // Try to connect to current stack first + const connectedStack = tryConnectStack(foundNodeKey, currentStack); + + if (connectedStack) { + return connectedStack; + } + + const foundNode = nodes[foundNodeKey]; + if (foundNode && foundNode.pages && foundNode.pages.length > 0) { + // Fallback: Flattened navigation + return [ + { key: null, entries: roots, title: 'Documentation' }, + { key: foundNodeKey, entries: foundNode.pages, title: foundNode.title, badge: foundNode.badge }, + ]; + } + + return currentStack; + }); + return true; + } else if (url === '/' || url === '') { + // Reset to root if we are on homepage + setNavigationStack((currentStack) => { + if (currentStack.length > 1) { + setSlideDirection('backward'); + setAnimationKey((prev) => prev + 1); + } + return [{ key: null, entries: roots, title: 'Documentation' }]; + }); + return true; + } + return false; + }, + [findNodeKeyByUrl, tryConnectStack, nodes, roots] + ); + + /** + * Restore state from localStorage on mount, or navigate to URL + */ + useEffect(() => { + if (!data || roots.length === 0 || isInitialized) return; + + const currentUrl = window.location.pathname; + + // Force root navigation on homepage + if (currentUrl === '/' || currentUrl === '') { + setNavigationStack([{ key: null, entries: roots, title: 'Documentation' }]); + setIsInitialized(true); + return; + } + + const savedState = loadState(); + const targetKey = findNodeKeyByUrl(currentUrl); + + let finalStack: NavigationLevel[] | null = null; + + // 1. Try to restore saved state + connect to target + if (savedState && savedState.path.length > 0) { + const restoredStack = buildStackFromPath(savedState.path); + + if (targetKey) { + // Try to connect restored stack to target + const connectedStack = tryConnectStack(targetKey, restoredStack); + if (connectedStack) { + finalStack = connectedStack; + } + } else { + // No target from URL, just restore saved state + finalStack = restoredStack; + } + } + + // 2. If no valid stack from step 1, try just the target (flattened) + if (!finalStack && targetKey) { + const targetNode = nodes[targetKey]; + if (targetNode && targetNode.pages && targetNode.pages.length > 0) { + finalStack = [ + { key: null, entries: roots, title: 'Documentation' }, + { key: targetKey, entries: targetNode.pages, title: targetNode.title, badge: targetNode.badge }, + ]; + } + } + + // 3. Fallback to root + if (!finalStack) { + setNavigationStack([{ key: null, entries: roots, title: 'Documentation' }]); + } else { + setNavigationStack(finalStack); + } + + setIsInitialized(true); + }, [data, roots, nodes, isInitialized, buildStackFromPath, findNodeKeyByUrl, tryConnectStack]); + + /** + * Save state whenever navigation changes + */ + useEffect(() => { + if (!isInitialized) return; + + const path = getCurrentPath(); + saveState({ + path, + currentUrl: window.location.pathname, + }); + }, [navigationStack, isInitialized, getCurrentPath]); + + /** + * Track current URL for highlighting active item and auto-navigation + */ + useEffect(() => { + // Set initial path + setCurrentPath(window.location.pathname); + + // Listen for URL changes (for client-side navigation) + const handleUrlChange = () => { + const newPath = window.location.pathname; + setCurrentPath(newPath); + + // Try to auto-navigate to the new URL's resource + if (isInitialized) { + findAndNavigateToUrl(newPath); + } + }; + + window.addEventListener('popstate', handleUrlChange); + + // Also listen for click events on links to catch client-side navigation + const handleClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + const anchor = target.closest('a'); + if (anchor && anchor.href && anchor.href.startsWith(window.location.origin)) { + // Delay to let the navigation happen first + setTimeout(() => { + const newPath = window.location.pathname; + if (newPath !== currentPath) { + setCurrentPath(newPath); + if (isInitialized) { + findAndNavigateToUrl(newPath); + } + } + }, 100); + } + }; + + document.addEventListener('click', handleClick); + + return () => { + window.removeEventListener('popstate', handleUrlChange); + document.removeEventListener('click', handleClick); + }; + }, [isInitialized, findAndNavigateToUrl, currentPath]); + + // Show loading state if no data yet + if (!data || roots.length === 0) { + return ( + + ); + } + + const currentLevel = navigationStack[navigationStack.length - 1]; + + /** + * Check if a node is a group + */ + const isGroup = (node: NavNode): boolean => node.type === 'group'; + + /** + * Check if a node has children + */ + const hasChildren = (node: NavNode): boolean => { + return (node.pages?.length ?? 0) > 0; + }; + + /** + * Check if a section has any visible children + */ + const hasVisibleChildren = (node: NavNode): boolean => { + if (!node.pages) return false; + return node.pages.some((childRef) => { + const child = resolveRef(childRef); + return isVisible(child); + }); + }; + + /** + * Handle drilling down into an item with children + */ + const handleDrillDown = (node: NavNode, nodeKey: string | null) => { + if (node.pages && node.pages.length > 0) { + setSlideDirection('forward'); + setAnimationKey((prev) => prev + 1); + const newStack = [...navigationStack, { key: nodeKey, entries: node.pages, title: node.title, badge: node.badge }]; + setNavigationStack(newStack); + // Reset hover states to prevent showing path preview immediately after navigation + setShowPathPreview(false); + setShowFullPath(false); + } + }; + + /** + * Navigate back one level + */ + const navigateBack = () => { + if (navigationStack.length > 1) { + setSlideDirection('backward'); + setAnimationKey((prev) => prev + 1); + setNavigationStack(navigationStack.slice(0, -1)); + // Reset hover states + setShowPathPreview(false); + setShowFullPath(false); + } + }; + + /** + * Navigate to a specific level in the stack + */ + const navigateToLevel = (levelIndex: number) => { + if (levelIndex < navigationStack.length - 1) { + setSlideDirection('backward'); + setAnimationKey((prev) => prev + 1); + setNavigationStack(navigationStack.slice(0, levelIndex + 1)); + setShowPathPreview(false); + setShowFullPath(false); + } + }; + + /** + * Check if a node is favorited + */ + const isFavorited = useCallback( + (nodeKey: string | null): boolean => { + if (!nodeKey) return false; + return favorites.some((fav) => fav.nodeKey === nodeKey); + }, + [favorites] + ); + + /** + * Toggle favorite status for a node + */ + const toggleFavorite = (nodeKey: string | null, node: NavNode) => { + if (!nodeKey) return; + + const favoriteItem: FavoriteItem = { + nodeKey, + path: getCurrentPath(), + title: node.title, + badge: node.badge, + href: node.href, + }; + + toggleFavoriteAction(favoriteItem); + }; + + /** + * Navigate to a favorited item + */ + const navigateToFavorite = (favorite: FavoriteItem) => { + // If it has an href and no children, just navigate to the URL + const node = nodes[favorite.nodeKey]; + if (favorite.href && (!node?.pages || node.pages.length === 0)) { + window.location.href = favorite.href; + return; + } + + // Build the stack to this favorite + const stack = buildStackFromPath(favorite.path); + + // If the node has children, add it to the stack + if (node && node.pages && node.pages.length > 0) { + stack.push({ + key: favorite.nodeKey, + entries: node.pages, + title: node.title, + badge: node.badge, + }); + } + + setSlideDirection('forward'); + setAnimationKey((prev) => prev + 1); + setNavigationStack(stack); + // Reset hover states + setShowPathPreview(false); + setShowFullPath(false); + }; + + const isTopLevel = navigationStack.length === 1; + + /** + * Navigate to a search result + */ + const navigateToSearchResult = (nodeKey: string, node: NavNode) => { + // If it's a leaf node with href, navigate directly + if (node.href && (!node.pages || node.pages.length === 0)) { + window.location.href = node.href; + return; + } + + // If it has children, drill down to it + if (node.pages && node.pages.length > 0) { + setSlideDirection('forward'); + setAnimationKey((prev) => prev + 1); + setNavigationStack([ + { key: null, entries: roots, title: 'Documentation' }, + { key: nodeKey, entries: node.pages, title: node.title, badge: node.badge }, + ]); + } + + setIsSearching(false); + // Reset hover states + setShowPathPreview(false); + setShowFullPath(false); + }; + + /** + * Render a list of child refs (resolving keys as needed) + */ + const renderEntries = (refs: ChildRef[]) => { + const result: React.ReactNode[] = []; + let currentItemGroup: { node: NavNode; key: string | null }[] = []; + + const flushItemGroup = () => { + if (currentItemGroup.length > 0) { + result.push( +
+ {currentItemGroup.map((item, idx) => renderItem(item.node, item.key, idx))} +
+ ); + currentItemGroup = []; + } + }; + + refs.forEach((ref, index) => { + const node = resolveRef(ref); + if (!node) return; + + // Skip invisible nodes + if (!isVisible(node)) return; + + // Track the key if this is a reference + const nodeKey = typeof ref === 'string' ? ref : null; + + if (isGroup(node)) { + // Skip groups with no visible children + if (!hasVisibleChildren(node)) return; + + flushItemGroup(); + result.push(renderGroup(node, nodeKey, index)); + } else { + currentItemGroup.push({ node, key: nodeKey }); + } + }); + + flushItemGroup(); + return result; + }; + + /** + * Render a group with its children + */ + const renderGroup = (group: NavNode, groupKey: string | null, index: number) => { + // Get optional icon for group + const GroupIcon = group.icon ? (LucideIcons as unknown as Record)[group.icon] : null; + + // Get visible children + const visibleChildren = + group.pages?.filter((childRef) => { + const child = resolveRef(childRef); + return child && isVisible(child); + }) ?? []; + + const groupId = groupKey || `group-${index}`; + const isCollapsed = collapsedSections.has(groupId); + const canCollapse = visibleChildren.length > 3; + + const headerContent = ( + <> +
+ {GroupIcon && ( + + + + )} + {group.title} +
+ {canCollapse && ( + + )} + + ); + + return ( +
+ {canCollapse ? ( + + ) : ( +
{headerContent}
+ )} + {!isCollapsed && ( +
+ {visibleChildren.map((childRef, childIndex) => { + const child = resolveRef(childRef); + if (!child) return null; + + const childKey = typeof childRef === 'string' ? childRef : null; + + if (isGroup(child)) { + // Skip nested groups with no visible children + if (!hasVisibleChildren(child)) return null; + + return ( +
+ {renderGroup(child, childKey, childIndex)} +
+ ); + } + return renderItem(child, childKey, childIndex); + })} +
+ )} +
+ ); + }; + + /** + * Render a single item + */ + const renderItem = (item: NavNode, itemKey: string | null, index: number) => { + const itemHasChildren = hasChildren(item); + const isActive = item.href && currentPath === item.href; + const isFav = isFavorited(itemKey); + const canFavorite = itemKey !== null; // Only items with keys can be favorited + + // Get icon component from lucide-react + const IconComponent = item.icon ? (LucideIcons as unknown as Record)[item.icon] : null; + + const handleStarClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + toggleFavorite(itemKey, item); + }; + + const content = ( + <> +
+ {IconComponent && ( + + + + )} + {item.leftIcon && } + + {item.title} + +
+
+ {canFavorite && ( +
+ +
+ )} + {itemHasChildren && ( + + + + )} +
+ + ); + + const baseClasses = + 'group flex items-center justify-between w-full px-3 py-1.5 rounded-lg cursor-pointer text-left transition-colors hover:bg-[rgb(var(--ec-content-hover))] active:bg-[rgb(var(--ec-content-hover))]'; + const parentClasses = itemHasChildren ? 'font-medium' : ''; + const activeClasses = isActive + ? 'bg-[rgb(var(--ec-content-active))] hover:bg-[rgb(var(--ec-content-active))] border-l-2 border-[rgb(var(--ec-accent))] rounded-l-none' + : ''; + + // Leaf item with href → render as link + if (item.href && !itemHasChildren) { + return ( + + {content} + + ); + } + + // Item with children → render as button for drill-down + return ( + + ); + }; + + // Animation classes + const getAnimationClass = () => { + if (slideDirection === 'forward') return 'animate-slide-in-right'; + if (slideDirection === 'backward') return 'animate-slide-in-left'; + return ''; + }; + + return ( + + ); +} diff --git a/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts b/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts new file mode 100644 index 000000000..9972a41e4 --- /dev/null +++ b/eventcatalog/src/components/SideNav/NestedSideBar/storage.ts @@ -0,0 +1,90 @@ +// ============================================ +// Local Storage Persistence +// ============================================ + +const STORAGE_KEY = 'eventcatalog-sidebar-nav'; +const COLLAPSED_SECTIONS_KEY = 'eventcatalog-sidebar-collapsed'; +const FAVORITES_KEY = 'eventcatalog-sidebar-favorites'; + +// ============================================ +// Types +// ============================================ + +export type PersistedState = { + path: string[]; // Array of node keys representing drill-down path + currentUrl: string; // The URL when this state was saved +}; + +export type FavoriteItem = { + nodeKey: string; // The key of the favorited node + path: string[]; // Path of keys to reach this node + title: string; // Display title + badge?: string; // Type badge (Domain, Service, etc.) + href?: string; // Direct link if it's a leaf item +}; + +// ============================================ +// Navigation State +// ============================================ + +export const saveState = (state: PersistedState): void => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + } catch (e) { + console.warn('Failed to save sidebar state:', e); + } +}; + +export const loadState = (): PersistedState | null => { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : null; + } catch (e) { + console.warn('Failed to load sidebar state:', e); + return null; + } +}; + +// ============================================ +// Collapsed Sections +// ============================================ + +export const saveCollapsedSections = (sections: Set): void => { + try { + localStorage.setItem(COLLAPSED_SECTIONS_KEY, JSON.stringify([...sections])); + } catch (e) { + console.warn('Failed to save collapsed sections:', e); + } +}; + +export const loadCollapsedSections = (): Set => { + try { + const stored = localStorage.getItem(COLLAPSED_SECTIONS_KEY); + return stored ? new Set(JSON.parse(stored)) : new Set(); + } catch (e) { + console.warn('Failed to load collapsed sections:', e); + return new Set(); + } +}; + +// ============================================ +// Favorites +// ============================================ + +export const saveFavorites = (favorites: FavoriteItem[]): void => { + try { + localStorage.setItem(FAVORITES_KEY, JSON.stringify(favorites)); + } catch (e) { + console.warn('Failed to save favorites:', e); + } +}; + +export const loadFavorites = (): FavoriteItem[] => { + try { + const stored = localStorage.getItem(FAVORITES_KEY); + return stored ? JSON.parse(stored) : []; + } catch (e) { + console.warn('Failed to load favorites:', e); + return []; + } +}; diff --git a/eventcatalog/src/components/SideNav/SideNav.astro b/eventcatalog/src/components/SideNav/SideNav.astro index 427f8c361..7fa25a0cc 100644 --- a/eventcatalog/src/components/SideNav/SideNav.astro +++ b/eventcatalog/src/components/SideNav/SideNav.astro @@ -1,37 +1,12 @@ --- import type { HTMLAttributes } from 'astro/types'; -import config from '@config'; - -// FlatView -import { getResourcesForNavigation as getListViewResources } from './ListViewSideBar/utils'; - -// TreeView -import { SideNavTreeView } from './TreeView'; -import { getTreeView } from './TreeView/getTreeView'; - -import ListViewSideBar from './ListViewSideBar'; interface Props extends Omit, 'children'> {} - -const currentPath = Astro.url.pathname; - -let props; - -const SIDENAV_TYPE = config?.docs?.sidebar?.type ?? 'LIST_VIEW'; -const SHOW_ORPHANED_MESSAGES = config?.docs?.sidebar?.showOrphanedMessages ?? true; - -if (SIDENAV_TYPE === 'LIST_VIEW') { - props = await getListViewResources({ currentPath }); -} else if (SIDENAV_TYPE === 'TREE_VIEW') { - props = getTreeView({ projectDir: process.env.PROJECT_DIR!, currentPath }); -} +import NestedSideBar from './NestedSideBar'; +import { ClientRouter } from 'astro:transitions'; ---
- { - SIDENAV_TYPE === 'LIST_VIEW' && ( - - ) - } - {SIDENAV_TYPE === 'TREE_VIEW' && } + +
diff --git a/eventcatalog/src/components/SideNav/TreeView/getTreeView.ts b/eventcatalog/src/components/SideNav/TreeView/getTreeView.ts deleted file mode 100644 index b82868382..000000000 --- a/eventcatalog/src/components/SideNav/TreeView/getTreeView.ts +++ /dev/null @@ -1,190 +0,0 @@ -import fs from 'fs'; -import path from 'path'; -import os from 'node:os'; -import gm from 'gray-matter'; -import { globSync } from 'glob'; -import type { CollectionKey } from 'astro:content'; -import { buildUrl } from '@utils/url-builder'; - -export type TreeNode = { - id: string; - name: string; - version: string; - href?: string; - type: CollectionKey | null; - children: TreeNode[]; -}; - -/** - * Resource types that should be in the sidenav - */ -const RESOURCE_TYPES = ['domains', 'entities', 'services', 'events', 'commands', 'queries', 'flows', 'channels', 'containers']; -// const RESOURCE_TYPES = ['domains', 'services', 'events', 'commands', 'queries', 'flows', 'channels']; - -/** - * Check if the path has a RESOURCE_TYPE on path - */ -function canBeResource(dirPath: string) { - const parts = dirPath.split(path.sep); - for (let i = parts.length - 1; i >= 0; i--) { - if (RESOURCE_TYPES.includes(parts[i])) return true; - } - return false; -} - -function isNotVersioned(dirPath: string) { - const parts = dirPath.split(path.sep); - return parts.every((p) => p !== 'versioned'); -} - -function getResourceType(filePath: string): CollectionKey | null { - const parts = filePath.split(path.sep); - for (let i = parts.length - 1; i >= 0; i--) { - if (RESOURCE_TYPES.includes(parts[i])) return parts[i] as CollectionKey; - } - return null; -} - -function buildTreeOfDir(directory: string, parentNode: TreeNode, options: { ignore?: CollectionKey[] }) { - let node: TreeNode | null = null; - - const resourceType = getResourceType(directory); - - const markdownFiles = globSync(path.join(directory, '/*.mdx'), { windowsPathsNoEscape: os.platform() === 'win32' }); - const isResourceIgnored = options?.ignore && resourceType && options.ignore.includes(resourceType); - - if (markdownFiles.length > 0 && !isResourceIgnored) { - const resourceFilePath = markdownFiles.find((md) => md.endsWith('index.mdx')); - if (resourceFilePath) { - const resourceDef = gm.read(resourceFilePath); - node = { - id: resourceDef.data.id, - name: resourceDef.data.name, - type: resourceType, - version: resourceDef.data.version, - children: [], - }; - parentNode.children.push(node); - } - } - - const directories = fs.readdirSync(directory).filter((name) => { - const dirPath = path.join(directory, name); - return fs.statSync(dirPath).isDirectory() && isNotVersioned(dirPath) && canBeResource(dirPath); - }); - for (const dir of directories) { - buildTreeOfDir(path.join(directory, dir), node || parentNode, options); - } -} - -function forEachTreeNodeOf(node: TreeNode, ...callbacks: Array<(node: TreeNode) => void>) { - const next = node.children; - - callbacks.forEach((cb) => cb(node)); - - // Go to next level - next.forEach((n) => { - forEachTreeNodeOf(n, ...callbacks); - }); -} - -function addHrefToNode(basePathname: 'docs' | 'visualiser') { - return (node: TreeNode) => { - node.href = encodeURI( - buildUrl( - `/${basePathname}/${node.type}/${node.id}${node.type === 'teams' || node.type === 'users' ? '' : `/${node.version}`}` - ) - ); - }; -} - -function orderChildrenByName(parentNode: TreeNode) { - parentNode.children.sort((a, b) => a.name.localeCompare(b.name)); -} - -function groupChildrenByType(parentNode: TreeNode) { - if (parentNode.children.length === 0) return; // Only group if there are children - - const acc: Record = {}; - - // Flows and messages are collapsed by default - - parentNode.children.forEach((n) => { - if (n.type === null) return; // TODO: Just ignore or remove the type null??? - if (!(n.type in acc)) acc[n.type] = []; - acc[n.type].push(n); - }); - - // Collapse everything except domains - const AUTO_EXPANDED_TYPES = ['domains']; - - parentNode.children = Object.entries(acc) - // Order label nodes by RESOURCE_TYPES - .sort(([aType], [bType]) => RESOURCE_TYPES.indexOf(aType) - RESOURCE_TYPES.indexOf(bType)) - // Construct the label nodes - .map(([type, nodes]) => { - return { - id: `${parentNode.id}/${type}`, - name: type === 'containers' ? 'Data' : type, - type: type as CollectionKey, - version: '0', - children: nodes, - isExpanded: AUTO_EXPANDED_TYPES.includes(type), - isLabel: true, - }; - }); -} - -const treeViewCache = new Map(); - -export function getTreeView({ projectDir, currentPath }: { projectDir: string; currentPath: string }): TreeNode { - const basePathname = currentPath.split('/').find((p) => p === 'docs' || p === 'visualiser') || 'docs'; - - const cacheKey = `${projectDir}:${basePathname}`; - if (treeViewCache.has(cacheKey)) return treeViewCache.get(cacheKey)!; - - const rootNode: TreeNode = { - id: '/', - name: 'root', - type: null, - version: '0', - children: [], - }; - - buildTreeOfDir(projectDir, rootNode, { - ignore: basePathname === 'visualiser' ? ['teams', 'users', 'channels'] : undefined, - }); - - // prettier-ignore - forEachTreeNodeOf( - rootNode, - addHrefToNode(basePathname), - orderChildrenByName, - groupChildrenByType, - ); - - if (basePathname === 'visualiser') { - rootNode.children.unshift({ - id: '/bounded-context-map', - name: 'bounded context map', - type: 'bounded-context-map' as any, - version: '0', - isLabel: true, - children: [ - { - id: '/domain-map', - name: 'Domain map', - href: buildUrl('/visualiser/context-map'), - type: 'bounded-context-map' as any, - version: '', - children: [], - }, - ], - } as TreeNode); - } - - // Store in cache before returning - treeViewCache.set(cacheKey, rootNode); - - return rootNode; -} diff --git a/eventcatalog/src/components/SideNav/TreeView/index.tsx b/eventcatalog/src/components/SideNav/TreeView/index.tsx deleted file mode 100644 index fbe6a7f08..000000000 --- a/eventcatalog/src/components/SideNav/TreeView/index.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { gray } from 'tailwindcss/colors'; -import { TreeView } from '@components/TreeView'; -import { navigate } from 'astro:transitions/client'; -import type { TreeNode as RawTreeNode } from './getTreeView'; -import { getIconForCollection } from '@utils/collections/icons'; -import { useEffect, useState } from 'react'; - -type TreeNode = RawTreeNode & { isLabel?: true; isDefaultExpanded?: boolean; isExpanded?: boolean }; - -function isCurrentNode(node: TreeNode, currentPathname: string) { - return currentPathname === node.href; -} - -function TreeNode({ node }: { node: TreeNode }) { - const Icon = getIconForCollection(node.type ?? ''); - const [isCurrent, setIsCurrent] = useState(document.location.pathname === node.href); - - useEffect(() => { - const abortCtrl = new AbortController(); - // prettier-ignore - document.addEventListener( - 'astro:page-load', - () => setIsCurrent(document.location.pathname === node.href), - { signal: abortCtrl.signal }, - ); - return () => abortCtrl.abort(); - }, [document, node]); - - return ( - navigate(node.href!)} - > - {!node?.isLabel && ( - - - - )} - - {node.name} {node.isLabel ? `(${node.children.length})` : ''} - - {(node.children || []).length > 0 && ( - - {node.children!.map((childNode) => ( - - ))} - - )} - - ); -} - -export function SideNavTreeView({ tree }: { tree: TreeNode }) { - function bubbleUpExpanded(parentNode: TreeNode) { - if (isCurrentNode(parentNode, document.location.pathname)) return true; - return (parentNode.isDefaultExpanded = parentNode.children.some(bubbleUpExpanded)); - } - bubbleUpExpanded(tree); - - return ( - - ); -} diff --git a/eventcatalog/src/components/Tables/Table.tsx b/eventcatalog/src/components/Tables/Table.tsx index e1a905ace..1c6d921e0 100644 --- a/eventcatalog/src/components/Tables/Table.tsx +++ b/eventcatalog/src/components/Tables/Table.tsx @@ -11,6 +11,7 @@ import { type ColumnFiltersState, } from '@tanstack/react-table'; import DebouncedInput from './DebouncedInput'; +import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, SearchX } from 'lucide-react'; import { getColumnsByCollection } from './columns'; import { useEffect, useMemo, useState } from 'react'; @@ -240,23 +241,26 @@ export const Table = ({ }, }); + const totalResults = table.getPrePaginationRowModel().rows.length; + const hasResults = table.getRowModel().rows.length > 0; + return (
- {/*
{table.getPrePaginationRowModel().rows.length} results
*/} -
-
- +
+
+ {table.getHeaderGroups().map((headerGroup, index) => ( - + {headerGroup.headers.map((header) => ( - @@ -265,80 +269,95 @@ export const Table = ({ ))} - - {table.getRowModel().rows.map((row, index) => ( - - {row.getVisibleCells().map((cell) => ( - - ))} + + {hasResults ? ( + table.getRowModel().rows.map((row, index) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + ) : ( + + - ))} + )}
-
-
- {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} -
-
+
+
+
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
+
{header.column.columnDef.meta?.showFilter !== false && } - {header.column.columnDef.meta?.showFilter == false &&
} + {header.column.columnDef.meta?.showFilter == false &&
}
- {flexRender(cell.column.columnDef.cell, cell.getContext())} -
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ +

No results found

+

Try adjusting your search or filters

+
+
-
-
-
- - - - - -
Page
- - {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} - -
- - | Go to page: - { - const page = e.target.value ? Number(e.target.value) - 1 : 0; - table.setPageIndex(page); - }} - className="border border-gray-300 p-1 rounded w-16" - /> - + + {/* Pagination */} +
+
+ {totalResults > 0 && ( + + Showing {table.getRowModel().rows.length} of{' '} + {totalResults} results + + )} +
+
+
+ + + + Page{' '} + {table.getState().pagination.pageIndex + 1} of{' '} + {table.getPageCount() || 1} + + + +
@@ -394,6 +413,8 @@ function Filter({ column }: { column: Column {/* Autocomplete suggestions from faceted values feature */} @@ -406,11 +427,10 @@ function Filter({ column }: { column: Column column.setFilterValue(value)} - placeholder={`Search... ${!column?.columnDef?.meta?.filterVariant ? `(${column.getFacetedUniqueValues().size})` : ''}`} - className="w-full p-2 border shadow rounded" + placeholder={!column?.columnDef?.meta?.filterVariant ? `Search (${uniqueCount})...` : 'Search...'} + className="w-full px-3 py-2 text-sm bg-[rgb(var(--ec-input-bg))] text-[rgb(var(--ec-input-text))] border border-[rgb(var(--ec-input-border))] rounded-lg placeholder:text-[rgb(var(--ec-input-placeholder))] focus:outline-none focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.2)] focus:border-[rgb(var(--ec-accent))] transition-colors" list={column.id + 'list'} /> -
); } diff --git a/eventcatalog/src/components/Tables/columns/ContainersTableColumns.tsx b/eventcatalog/src/components/Tables/columns/ContainersTableColumns.tsx index b3a810866..2b2d56b53 100644 --- a/eventcatalog/src/components/Tables/columns/ContainersTableColumns.tsx +++ b/eventcatalog/src/components/Tables/columns/ContainersTableColumns.tsx @@ -1,4 +1,5 @@ import { createColumnHelper } from '@tanstack/react-table'; +import { useState } from 'react'; import { filterByName } from '../filters/custom-filters'; import { buildUrl } from '@utils/url-builder'; import { ServerIcon } from '@heroicons/react/24/solid'; @@ -15,25 +16,21 @@ export const columns = (tableConfiguration: TableConfiguration) => [ header: () => {tableConfiguration.columns?.name?.label || 'Storage'}, cell: (info) => { const containerRaw = info.row.original; - const color = 'blue'; return ( - + + + + + + + {containerRaw.data.name} + v{containerRaw.data.version} + + + ); }, footer: (info) => info.column.id, @@ -45,7 +42,14 @@ export const columns = (tableConfiguration: TableConfiguration) => [ columnHelper.accessor('data.summary', { id: 'summary', header: () => {tableConfiguration.columns?.summary?.label || 'Summary'}, - cell: (info) => {info.renderValue()}, + cell: (info) => { + const summary = info.renderValue() as string; + return ( + + {summary} + + ); + }, footer: (info) => info.column.id, meta: { showFilter: false, @@ -61,32 +65,46 @@ export const columns = (tableConfiguration: TableConfiguration) => [ }, cell: (info) => { const services = info.getValue(); + const [isExpanded, setIsExpanded] = useState(false); + if (services?.length === 0 || !services) - return
No services documented
; + return ( + + No services + + ); + + const visibleItems = isExpanded ? services : services.slice(0, 4); + const hiddenCount = services.length - 4; + return ( - +
+ {visibleItems.map((service, index) => ( + + + + + + + {service.data.name} + v{service.data.version} + + + + ))} + {hiddenCount > 0 && ( + + )} +
); }, footer: (info) => info.column.id, @@ -101,32 +119,46 @@ export const columns = (tableConfiguration: TableConfiguration) => [ }, cell: (info) => { const services = info.getValue(); + const [isExpanded, setIsExpanded] = useState(false); + if (services?.length === 0 || !services) - return
No services documented
; + return ( + + No services + + ); + + const visibleItems = isExpanded ? services : services.slice(0, 4); + const hiddenCount = services.length - 4; + return ( - +
+ {visibleItems.map((service, index) => ( + + + + + + + {service.data.name} + v{service.data.version} + + + + ))} + {hiddenCount > 0 && ( + + )} +
); }, footer: (info) => info.column.id, @@ -136,14 +168,22 @@ export const columns = (tableConfiguration: TableConfiguration) => [ columnHelper.accessor('data.name', { header: () => {tableConfiguration.columns?.actions?.label || 'Actions'}, cell: (info) => { - const container = info.row.original; + const item = info.row.original; return ( - - Visualiser → - + ); }, id: 'actions', diff --git a/eventcatalog/src/components/Tables/columns/DomainTableColumns.tsx b/eventcatalog/src/components/Tables/columns/DomainTableColumns.tsx index 790e7af1e..60ce0ecb4 100644 --- a/eventcatalog/src/components/Tables/columns/DomainTableColumns.tsx +++ b/eventcatalog/src/components/Tables/columns/DomainTableColumns.tsx @@ -1,5 +1,6 @@ import { ServerIcon, RectangleGroupIcon } from '@heroicons/react/20/solid'; import { createColumnHelper } from '@tanstack/react-table'; +import { useState } from 'react'; import { filterByName, filterCollectionByName } from '../filters/custom-filters'; import { buildUrl } from '@utils/url-builder'; import { createBadgesColumn } from './SharedColumns'; @@ -14,25 +15,21 @@ export const columns = (tableConfiguration: TableConfiguration) => [ header: () => {tableConfiguration.columns?.name?.label || 'Domain'}, cell: (info) => { const messageRaw = info.row.original; - const color = 'yellow'; return ( - + + + + + + + {messageRaw.data.name} + v{messageRaw.data.version} + + + ); }, footer: (info) => info.column.id, @@ -44,11 +41,16 @@ export const columns = (tableConfiguration: TableConfiguration) => [ columnHelper.accessor('data.summary', { id: 'summary', header: () => {tableConfiguration.columns?.summary?.label || 'Summary'}, - cell: (info) => ( - - {info.renderValue()} {info.row.original.data.draft ? ' (Draft)' : ''} - - ), + cell: (info) => { + const summary = info.renderValue() as string; + const isDraft = info.row.original.data.draft; + const displayText = `${summary || ''}${isDraft ? ' (Draft)' : ''}`; + return ( + + {displayText} + + ); + }, footer: (info) => info.column.id, meta: { showFilter: false, @@ -64,34 +66,46 @@ export const columns = (tableConfiguration: TableConfiguration) => [ }, cell: (info) => { const services = info.getValue(); + const [isExpanded, setIsExpanded] = useState(false); + if (services?.length === 0 || !services) - return
Domain has no services.
; + return ( + + No services + + ); + + const visibleItems = isExpanded ? services : services.slice(0, 4); + const hiddenCount = services.length - 4; return ( - +
+ {visibleItems.map((service, index) => ( + + + + + + + {service.data.name} + v{service.data.version} + + + + ))} + {hiddenCount > 0 && ( + + )} +
); }, filterFn: filterCollectionByName('services'), @@ -101,14 +115,22 @@ export const columns = (tableConfiguration: TableConfiguration) => [ id: 'actions', header: () => {tableConfiguration.columns?.actions?.label || 'Actions'}, cell: (info) => { - const domain = info.row.original; + const item = info.row.original; return ( - - Visualiser → - + ); }, meta: { diff --git a/eventcatalog/src/components/Tables/columns/FlowTableColumns.tsx b/eventcatalog/src/components/Tables/columns/FlowTableColumns.tsx index d46fab1a2..daabd5c3a 100644 --- a/eventcatalog/src/components/Tables/columns/FlowTableColumns.tsx +++ b/eventcatalog/src/components/Tables/columns/FlowTableColumns.tsx @@ -14,25 +14,21 @@ export const columns = (tableConfiguration: TableConfiguration) => [ header: () => {tableConfiguration.columns?.name?.label || 'Flow'}, cell: (info) => { const flowRaw = info.row.original; - const color = 'teal'; return ( - + + + + + + + {flowRaw.data.name} + v{flowRaw.data.version} + + + ); }, footer: (info) => info.column.id, @@ -41,22 +37,18 @@ export const columns = (tableConfiguration: TableConfiguration) => [ }, filterFn: filterByName, }), - columnHelper.accessor('data.version', { - id: 'version', - header: () => {tableConfiguration.columns?.version?.label || 'Version'}, + columnHelper.accessor('data.summary', { + id: 'summary', + header: () => {tableConfiguration.columns?.summary?.label || 'Summary'}, cell: (info) => { - const service = info.row.original; + const summary = info.renderValue() as string; return ( -
{`v${info.getValue()} ${service.data.latestVersion === service.data.version ? '(latest)' : ''}`}
+ + {summary} + ); }, footer: (info) => info.column.id, - }), - columnHelper.accessor('data.summary', { - id: 'summary', - header: () => {tableConfiguration.columns?.summary?.label || 'Summary'}, - cell: (info) => {info.renderValue()}, - footer: (info) => info.column.id, meta: { showFilter: false, className: 'max-w-md', @@ -67,14 +59,22 @@ export const columns = (tableConfiguration: TableConfiguration) => [ id: 'actions', header: () => {tableConfiguration.columns?.actions?.label || 'Actions'}, cell: (info) => { - const domain = info.row.original; + const item = info.row.original; return ( - - Visualiser → - + ); }, meta: { diff --git a/eventcatalog/src/components/Tables/columns/MessageTableColumns.tsx b/eventcatalog/src/components/Tables/columns/MessageTableColumns.tsx index ad8ed307b..cfadb7e0c 100644 --- a/eventcatalog/src/components/Tables/columns/MessageTableColumns.tsx +++ b/eventcatalog/src/components/Tables/columns/MessageTableColumns.tsx @@ -1,6 +1,6 @@ import { ServerIcon, BoltIcon, ChatBubbleLeftIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid'; import { createColumnHelper } from '@tanstack/react-table'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { filterByName, filterCollectionByName } from '../filters/custom-filters'; import { buildUrl } from '@utils/url-builder'; import { createBadgesColumn } from './SharedColumns'; @@ -32,23 +32,22 @@ export const columns = (tableConfiguration: TableConfiguration) => [ const type = useMemo(() => messageRaw.collection.slice(0, -1), [messageRaw.collection]); const { color, Icon } = getColorAndIconForMessageType(type); return ( - + + + + + {messageRaw.data.name} + v{messageRaw.data.version} + + + ); }, meta: { @@ -60,11 +59,16 @@ export const columns = (tableConfiguration: TableConfiguration) => [ columnHelper.accessor('data.summary', { id: 'summary', header: () => {tableConfiguration.columns?.summary?.label || 'Summary'}, - cell: (info) => ( - - {info.renderValue()} {info.row.original.data.draft ? ' (Draft)' : ''} - - ), + cell: (info) => { + const summary = info.renderValue() as string; + const isDraft = info.row.original.data.draft; + const displayText = `${summary || ''}${isDraft ? ' (Draft)' : ''}`; + return ( + + {displayText} + + ); + }, footer: (info) => info.column.id, meta: { showFilter: false, @@ -81,32 +85,46 @@ export const columns = (tableConfiguration: TableConfiguration) => [ }, cell: (info) => { const producers = info.getValue(); + const [isExpanded, setIsExpanded] = useState(false); + if (producers?.length === 0 || !producers) - return
No producers documented
; + return ( + + No producers + + ); + + const visibleItems = isExpanded ? producers : producers.slice(0, 4); + const hiddenCount = producers.length - 4; + return ( - +
+ {visibleItems.map((producer, index) => ( + + + + + + + {producer.data.name} + v{producer.data.version} + + + + ))} + {hiddenCount > 0 && ( + + )} +
); }, footer: (info) => info.column.id, @@ -121,33 +139,46 @@ export const columns = (tableConfiguration: TableConfiguration) => [ }, cell: (info) => { const consumers = info.getValue(); + const [isExpanded, setIsExpanded] = useState(false); + if (consumers?.length === 0 || !consumers) - return
No consumers documented
; + return ( + + No consumers + + ); + + const visibleItems = isExpanded ? consumers : consumers.slice(0, 4); + const hiddenCount = consumers.length - 4; return ( - +
+ {visibleItems.map((consumer, index) => ( + + + + + + + {consumer.data.name} + v{consumer.data.version} + + + + ))} + {hiddenCount > 0 && ( + + )} +
); }, footer: (info) => info.column.id, @@ -158,14 +189,22 @@ export const columns = (tableConfiguration: TableConfiguration) => [ id: 'actions', header: () => {tableConfiguration.columns?.actions?.label || 'Actions'}, cell: (info) => { - const domain = info.row.original; + const item = info.row.original; return ( - - Visualiser → - + ); }, meta: { diff --git a/eventcatalog/src/components/Tables/columns/ServiceTableColumns.tsx b/eventcatalog/src/components/Tables/columns/ServiceTableColumns.tsx index ffe2f32a6..df2a0877d 100644 --- a/eventcatalog/src/components/Tables/columns/ServiceTableColumns.tsx +++ b/eventcatalog/src/components/Tables/columns/ServiceTableColumns.tsx @@ -15,25 +15,21 @@ export const columns = (tableConfiguration: TableConfiguration) => [ header: () => {tableConfiguration.columns?.name?.label || 'Service'}, cell: (info) => { const messageRaw = info.row.original; - const color = 'pink'; return ( - + + + + + + + {messageRaw.data.name} + v{messageRaw.data.version} + + + ); }, meta: { @@ -44,11 +40,16 @@ export const columns = (tableConfiguration: TableConfiguration) => [ columnHelper.accessor('data.summary', { id: 'summary', header: () => {tableConfiguration.columns?.summary?.label || 'Summary'}, - cell: (info) => ( - - {info.renderValue()} {info.row.original.data.draft ? ' (Draft)' : ''} - - ), + cell: (info) => { + const summary = info.renderValue() as string; + const isDraft = info.row.original.data.draft; + const displayText = `${summary || ''}${isDraft ? ' (Draft)' : ''}`; + return ( + + {displayText} + + ); + }, footer: (info) => info.column.id, meta: { showFilter: false, @@ -64,9 +65,7 @@ export const columns = (tableConfiguration: TableConfiguration) => [ }, cell: (info) => { const receives = info.getValue() || []; - const isExpandable = receives?.length > 10; - const isOpen = isExpandable ? receives?.length < 10 : true; - const [isExpanded, setIsExpanded] = useState(isOpen); + const [isExpanded, setIsExpanded] = useState(false); const receiversWithIcons = useMemo( () => @@ -80,38 +79,44 @@ export const columns = (tableConfiguration: TableConfiguration) => [ ); if (receives?.length === 0 || !receives) - return
Service receives no messages.
; + return ( + + No messages + + ); + + const visibleItems = isExpanded ? receiversWithIcons : receiversWithIcons.slice(0, 4); + const hiddenCount = receiversWithIcons.length - 4; return ( -
- {isExpandable && ( - )} - {isExpanded && ( - - )}
); }, @@ -126,12 +131,7 @@ export const columns = (tableConfiguration: TableConfiguration) => [ }, cell: (info) => { const sends = info.getValue() || []; - const isExpandable = sends?.length > 10; - const isOpen = isExpandable ? sends?.length < 10 : true; - const [isExpanded, setIsExpanded] = useState(isOpen); - - if (sends?.length === 0 || !sends) - return
Service sends no messages.
; + const [isExpanded, setIsExpanded] = useState(false); const sendersWithIcons = useMemo( () => @@ -144,36 +144,45 @@ export const columns = (tableConfiguration: TableConfiguration) => [ [sends] ); + if (sends?.length === 0 || !sends) + return ( + + No messages + + ); + + const visibleItems = isExpanded ? sendersWithIcons : sendersWithIcons.slice(0, 4); + const hiddenCount = sendersWithIcons.length - 4; + return ( -
- {isExpandable && ( - )} - {isExpanded && ( - - )}
); }, @@ -184,14 +193,22 @@ export const columns = (tableConfiguration: TableConfiguration) => [ id: 'actions', header: () => {tableConfiguration.columns?.actions?.label || 'Actions'}, cell: (info) => { - const domain = info.row.original; + const item = info.row.original; return ( - - Visualiser → - + ); }, meta: { diff --git a/eventcatalog/src/components/Tables/columns/SharedColumns.tsx b/eventcatalog/src/components/Tables/columns/SharedColumns.tsx index deea4bcb2..29976743a 100644 --- a/eventcatalog/src/components/Tables/columns/SharedColumns.tsx +++ b/eventcatalog/src/components/Tables/columns/SharedColumns.tsx @@ -1,5 +1,6 @@ import { createColumnHelper } from '@tanstack/react-table'; import { Tag } from 'lucide-react'; +import { useState } from 'react'; import { filterByBadge } from '../filters/custom-filters'; import type { TCollectionTypes, TData } from '../Table'; import { getIcon } from '@utils/badges'; @@ -15,39 +16,43 @@ export const createBadgesColumn = ['data'], 'bad cell: (info) => { const item = info.row.original; const badges = item.data.badges || []; + const [isExpanded, setIsExpanded] = useState(false); if (badges?.length === 0 || !badges) - return
No badges documented
; + return ( + + No badges + + ); + + const visibleItems = isExpanded ? badges : badges.slice(0, 4); + const hiddenCount = badges.length - 4; return ( -
    - {badges.map((badge, index) => { +
    + {visibleItems.map((badge, index) => { + const IconComponent = badge.icon ? getIcon(badge.icon) : null; return ( -
  • -
    -
    - - - {(() => { - if (badge.icon) { - const IconComponent = getIcon(badge.icon); - return IconComponent ? ( - - ) : ( - - ); - } - return ; - })()} - - {badge.content} - -
    -
    -
  • + + + {IconComponent ? : } + + {badge.content} + ); })} -
+ {hiddenCount > 0 && ( + + )} +
); }, meta: { diff --git a/eventcatalog/src/components/Tables/columns/TeamsTableColumns.tsx b/eventcatalog/src/components/Tables/columns/TeamsTableColumns.tsx index d025046de..a3637b3f0 100644 --- a/eventcatalog/src/components/Tables/columns/TeamsTableColumns.tsx +++ b/eventcatalog/src/components/Tables/columns/TeamsTableColumns.tsx @@ -1,37 +1,39 @@ -import { MagnifyingGlassIcon } from '@heroicons/react/24/solid'; import { createColumnHelper } from '@tanstack/react-table'; -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { filterByName, filterCollectionByName } from '../filters/custom-filters'; import { buildUrl } from '@utils/url-builder'; import type { TData } from '../Table'; import type { CollectionUserTypes } from '@types'; import type { CollectionEntry } from 'astro:content'; -import { ServerIcon, BoltIcon, ChatBubbleLeftIcon } from '@heroicons/react/24/solid'; +import { ServerIcon, BoltIcon, ChatBubbleLeftIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid'; +import { Users } from 'lucide-react'; import type { TableConfiguration } from '@types'; const columnHelper = createColumnHelper>(); +const getMessageIconAndColor = (collection: string) => { + if (collection === 'events') return { Icon: BoltIcon, color: 'orange' }; + if (collection === 'commands') return { Icon: ChatBubbleLeftIcon, color: 'blue' }; + if (collection === 'queries') return { Icon: MagnifyingGlassIcon, color: 'green' }; + return { Icon: ChatBubbleLeftIcon, color: 'gray' }; +}; + export const columns = (tableConfiguration: TableConfiguration) => [ columnHelper.accessor('data.name', { id: 'name', header: () => {tableConfiguration.columns?.name?.label || 'Name'}, cell: (info) => { - const messageRaw = info.row.original; - const type = useMemo(() => messageRaw.collection.slice(0, -1), [messageRaw.collection]); + const team = info.row.original; return ( - + + + + + + + {team.data.name} + + + ); }, meta: { @@ -41,215 +43,121 @@ export const columns = (tableConfiguration: TableConfiguration) => [ filterFn: filterByName, }), - columnHelper.accessor('data.ownedEvents', { - id: 'ownedEvents', - header: () => {tableConfiguration.columns?.ownedEvents?.label || 'Owned events'}, - meta: { - filterVariant: 'collection', - collectionFilterKey: 'ownedEvents', + columnHelper.accessor( + (row) => { + const events = row.data.ownedEvents || []; + const commands = row.data.ownedCommands || []; + const queries = row.data.ownedQueries || []; + return [...events, ...commands, ...queries]; }, - cell: (info) => { - const events = info.getValue(); - if (events?.length === 0 || !events) - return
Team owns no events
; - - const isExpandable = events?.length > 10; - const isOpen = isExpandable ? events?.length < 10 : true; - const [isExpanded, setIsExpanded] = useState(isOpen); - - return ( -
- {isExpandable && ( - - )} - {isExpanded && ( - - )} -
- ); - }, - footer: (info) => info.column.id, - filterFn: filterCollectionByName('ownedEvents'), - }), - - columnHelper.accessor('data.ownedCommands', { - id: 'ownedCommands', - header: () => {tableConfiguration.columns?.ownedCommands?.label || 'Owned commands'}, - meta: { - filterVariant: 'collection', - collectionFilterKey: 'ownedCommands', - }, - cell: (info) => { - const commands = info.getValue(); - if (commands?.length === 0 || !commands) - return
Team owns no commands
; - - const isExpandable = commands?.length > 10; - const isOpen = isExpandable ? commands?.length < 10 : true; - const [isExpanded, setIsExpanded] = useState(isOpen); - - return ( -
- {isExpandable && ( - - )} - {isExpanded && ( - - )} -
- ); - - // return commands.length; - }, - footer: (info) => info.column.id, - filterFn: filterCollectionByName('ownedCommands'), - }), - - columnHelper.accessor('data.ownedQueries', { - id: 'ownedQueries', - header: () => {tableConfiguration.columns?.ownedQueries?.label || 'Owned queries'}, - meta: { - filterVariant: 'collection', - collectionFilterKey: 'ownedQueries', - }, - cell: (info) => { - const queries = info.getValue(); - if (queries?.length === 0 || !queries) - return
Team owns no queries
; - - const isExpandable = queries?.length > 10; - const isOpen = isExpandable ? queries?.length < 10 : true; - const [isExpanded, setIsExpanded] = useState(isOpen); - - return ( -
- {isExpandable && ( - - )} - {isExpanded && ( - - )} -
- ); - - // return commands.length; - }, - footer: (info) => info.column.id, - filterFn: filterCollectionByName('ownedCommands'), - }), + + + + + {message.data.name} + v{message.data.version} + + + + ); + })} + {hiddenCount > 0 && ( + + )} +
+ ); + }, + } + ), columnHelper.accessor('data.ownedServices', { id: 'ownedServices', - header: () => {tableConfiguration.columns?.ownedServices?.label || 'Owned Services'}, + header: () => {tableConfiguration.columns?.ownedServices?.label || 'Owned services'}, meta: { filterVariant: 'collection', collectionFilterKey: 'ownedServices', }, cell: (info) => { const services = info.getValue(); + const [isExpanded, setIsExpanded] = useState(false); + if (services?.length === 0 || !services) - return
Team owns no services
; + return ( + + No services + + ); - const isExpandable = services?.length > 10; - const isOpen = isExpandable ? services?.length < 10 : true; - const [isExpanded, setIsExpanded] = useState(isOpen); + const visibleItems = isExpanded ? services : services.slice(0, 4); + const hiddenCount = services.length - 4; return ( -
- {isExpandable && ( - )} - {isExpanded && ( - - )}
); }, @@ -260,13 +168,13 @@ export const columns = (tableConfiguration: TableConfiguration) => [ columnHelper.accessor('data.name', { header: () => {tableConfiguration.columns?.actions?.label || 'Actions'}, cell: (info) => { - const domain = info.row.original; + const item = info.row.original; return ( - View → + View team ); }, diff --git a/eventcatalog/src/components/Tables/columns/UserTableColumns.tsx b/eventcatalog/src/components/Tables/columns/UserTableColumns.tsx index fa7243331..e959f0e1d 100644 --- a/eventcatalog/src/components/Tables/columns/UserTableColumns.tsx +++ b/eventcatalog/src/components/Tables/columns/UserTableColumns.tsx @@ -1,5 +1,5 @@ import { createColumnHelper } from '@tanstack/react-table'; -import { useMemo, useState } from 'react'; +import { useState } from 'react'; import { filterByName, filterCollectionByName } from '../filters/custom-filters'; import { buildUrl } from '@utils/url-builder'; import type { TData } from '../Table'; @@ -10,28 +10,31 @@ import { ServerIcon, BoltIcon, ChatBubbleLeftIcon, MagnifyingGlassIcon } from '@ import type { TableConfiguration } from '@types'; const columnHelper = createColumnHelper>(); +const getMessageIconAndColor = (collection: string) => { + if (collection === 'events') return { Icon: BoltIcon, color: 'orange' }; + if (collection === 'commands') return { Icon: ChatBubbleLeftIcon, color: 'blue' }; + if (collection === 'queries') return { Icon: MagnifyingGlassIcon, color: 'green' }; + return { Icon: ChatBubbleLeftIcon, color: 'gray' }; +}; + export const columns = (tableConfiguration: TableConfiguration) => [ columnHelper.accessor('data.name', { id: 'name', header: () => {tableConfiguration.columns?.name?.label || 'Name'}, cell: (info) => { - const messageRaw = info.row.original; - const type = useMemo(() => messageRaw.collection.slice(0, -1), [messageRaw.collection]); + const user = info.row.original; return ( - + + + + + + + {user.data.name} + {user.data.role && ({user.data.role})} + + + ); }, meta: { @@ -41,221 +44,128 @@ export const columns = (tableConfiguration: TableConfiguration) => [ filterFn: filterByName, }), - columnHelper.accessor('data.ownedEvents', { - id: 'ownedEvents', - header: () => {tableConfiguration.columns?.ownedEvents?.label || 'Owned events'}, - meta: { - filterVariant: 'collection', - collectionFilterKey: 'ownedEvents', - }, - cell: (info) => { - const events = info.getValue(); - if (events?.length === 0 || !events) - return
User owns no events
; - - const isExpandable = events?.length > 10; - const isOpen = isExpandable ? events?.length < 10 : true; - const [isExpanded, setIsExpanded] = useState(isOpen); - - return ( -
- {isExpandable && ( - - )} - {isExpanded && ( - - )} -
- ); - }, - footer: (info) => info.column.id, - filterFn: filterCollectionByName('ownedEvents'), - }), - - columnHelper.accessor('data.ownedCommands', { - id: 'ownedCommands', - header: () => {tableConfiguration.columns?.ownedCommands?.label || 'Owned commands'}, - meta: { - filterVariant: 'collection', - collectionFilterKey: 'ownedCommands', - }, - cell: (info) => { - const commands = info.getValue(); - if (commands?.length === 0 || !commands) - return
User owns no commands
; - - const isExpandable = commands?.length > 10; - const isOpen = isExpandable ? commands?.length < 10 : true; - const [isExpanded, setIsExpanded] = useState(isOpen); - - return ( -
- {isExpandable && ( - - )} - {isExpanded && ( - - )} -
- ); - - // return commands.length; - }, - footer: (info) => info.column.id, - filterFn: filterCollectionByName('ownedCommands'), - }), - - columnHelper.accessor('data.ownedQueries', { - id: 'ownedQueries', - header: () => {tableConfiguration.columns?.ownedQueries?.label || 'Owned queries'}, - meta: { - filterVariant: 'collection', - collectionFilterKey: 'ownedQueries', + columnHelper.accessor( + (row) => { + const events = row.data.ownedEvents || []; + const commands = row.data.ownedCommands || []; + const queries = row.data.ownedQueries || []; + return [...events, ...commands, ...queries]; }, - cell: (info) => { - const queries = info.getValue(); - if (queries?.length === 0 || !queries) - return
User owns no queries
; - - const isExpandable = queries?.length > 10; - const isOpen = isExpandable ? queries?.length < 10 : true; - const [isExpanded, setIsExpanded] = useState(isOpen); - - return ( -
- {isExpandable && ( - - )} - {isExpanded && ( - - )} -
- ); - - // return commands.length; - }, - footer: (info) => info.column.id, - filterFn: filterCollectionByName('ownedCommands'), - }), + + + + + {message.data.name} + v{message.data.version} + + + + ); + })} + {hiddenCount > 0 && ( + + )} +
+ ); + }, + } + ), columnHelper.accessor('data.ownedServices', { id: 'ownedServices', - header: () => {tableConfiguration.columns?.ownedServices?.label || 'Owned Services'}, + header: () => {tableConfiguration.columns?.ownedServices?.label || 'Owned services'}, meta: { filterVariant: 'collection', collectionFilterKey: 'ownedServices', }, cell: (info) => { const services = info.getValue(); + const [isExpanded, setIsExpanded] = useState(false); + if (services?.length === 0 || !services) - return
User owns no services
; + return ( + + No services + + ); - const isExpandable = services?.length > 10; - const isOpen = isExpandable ? services?.length < 10 : true; - const [isExpanded, setIsExpanded] = useState(isOpen); + const visibleItems = isExpanded ? services : services.slice(0, 4); + const hiddenCount = services.length - 4; return ( -
- {isExpandable && ( - )} - {isExpanded && ( - - )}
); }, footer: (info) => info.column.id, filterFn: filterCollectionByName('ownedServices'), }), + columnHelper.accessor('data.associatedTeams', { id: 'associatedTeams', header: () => {tableConfiguration.columns?.associatedTeams?.label || 'Teams'}, @@ -266,58 +176,61 @@ export const columns = (tableConfiguration: TableConfiguration) => [ }, cell: (info) => { const teams = info.getValue(); - - const isExpandable = teams?.length > 10; - const isOpen = isExpandable ? teams?.length < 10 : true; - const [isExpanded, setIsExpanded] = useState(isOpen); + const [isExpanded, setIsExpanded] = useState(false); if (teams?.length === 0 || !teams) - return
User is not associated with any teams
; + return ( + + No teams + + ); + + const visibleItems = isExpanded ? teams : teams.slice(0, 4); + const hiddenCount = teams.length - 4; return ( -
- {isExpandable && ( - )} - {isExpanded && ( - - )}
); }, footer: (info) => info.column.id, filterFn: filterCollectionByName('associatedTeams'), }), + columnHelper.accessor('data.name', { header: () => {tableConfiguration.columns?.actions?.label || 'Actions'}, cell: (info) => { - const domain = info.row.original; + const item = info.row.original; return ( - View → + View profile ); }, diff --git a/eventcatalog/src/components/ThemeToggle.tsx b/eventcatalog/src/components/ThemeToggle.tsx new file mode 100644 index 000000000..62a908930 --- /dev/null +++ b/eventcatalog/src/components/ThemeToggle.tsx @@ -0,0 +1,18 @@ +import { useStore } from '@nanostores/react'; +import { Moon, Sun } from 'lucide-react'; +import { themeStore, toggleTheme } from '@stores/theme-store'; + +export default function ThemeToggle() { + const theme = useStore(themeStore); + + return ( + + ); +} diff --git a/eventcatalog/src/components/TreeView/index.tsx b/eventcatalog/src/components/TreeView/index.tsx deleted file mode 100644 index 570b724b1..000000000 --- a/eventcatalog/src/components/TreeView/index.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import React, { useCallback, useEffect } from 'react'; -import classes from './styles.module.css'; -import { useSlots } from './useSlots'; -import { ChevronDownIcon, ChevronRightIcon } from 'lucide-react'; - -// ---------------------------------------------------------------------------- -// Context - -const RootContext = React.createContext<{ - // We cache the expanded state of tree items so we can preserve the state - // across remounts. This is necessary because we unmount tree items - // when their parent is collapsed. - expandedStateCache: React.RefObject | null>; -}>({ - expandedStateCache: { current: new Map() }, -}); - -const ItemContext = React.createContext<{ - level: number; - isExpanded: boolean; -}>({ - level: 1, - isExpanded: false, -}); - -// ---------------------------------------------------------------------------- -// TreeView - -export type TreeViewProps = { - 'aria-label'?: React.AriaAttributes['aria-label']; - 'aria-labelledby'?: React.AriaAttributes['aria-labelledby']; - children: React.ReactNode; - flat?: boolean; - truncate?: boolean; - style?: React.CSSProperties; -}; - -/* Size of toggle icon in pixels. */ -const TOGGLE_ICON_SIZE = 12; - -const Root: React.FC = ({ - 'aria-label': ariaLabel, - 'aria-labelledby': ariaLabelledby, - children, - flat, - truncate = true, - style, -}) => { - const containerRef = React.useRef(null); - const mouseDownRef = React.useRef(false); - - const onMouseDown = useCallback(() => { - mouseDownRef.current = true; - }, []); - - useEffect(() => { - function onMouseUp() { - mouseDownRef.current = false; - } - document.addEventListener('mouseup', onMouseUp); - return () => { - document.removeEventListener('mouseup', onMouseUp); - }; - }, []); - - const expandedStateCache = React.useRef | null>(null); - - if (expandedStateCache.current === null) { - expandedStateCache.current = new Map(); - } - - return ( - -
    - {children} -
-
- ); -}; - -Root.displayName = 'TreeView'; - -// ---------------------------------------------------------------------------- -// TreeView.Item - -export type TreeViewItemProps = { - id: string; - children: React.ReactNode; - current?: boolean; - defaultExpanded?: boolean; - onSelect?: (event: React.MouseEvent | React.KeyboardEvent) => void; -}; - -const Item = React.forwardRef( - ({ id: itemId, current: isCurrentItem = false, defaultExpanded, onSelect, children }, ref) => { - const [slots, rest] = useSlots(children, { - leadingVisual: LeadingVisual, - }); - const { expandedStateCache } = React.useContext(RootContext); - - const [isExpanded, setIsExpanded] = React.useState( - expandedStateCache.current?.get(itemId) ?? defaultExpanded ?? isCurrentItem - ); - const { level } = React.useContext(ItemContext); - const { hasSubTree, subTree, childrenWithoutSubTree } = useSubTree(rest); - const [isFocused, setIsFocused] = React.useState(false); - - // Set the expanded state and cache it - const setIsExpandedWithCache = React.useCallback( - (newIsExpanded: boolean) => { - setIsExpanded(newIsExpanded); - expandedStateCache.current?.set(itemId, newIsExpanded); - }, - [itemId, setIsExpanded, expandedStateCache] - ); - - // Expand or collapse the subtree - const toggle = React.useCallback( - (event?: React.MouseEvent | React.KeyboardEvent) => { - setIsExpandedWithCache(!isExpanded); - event?.stopPropagation(); - }, - [isExpanded, setIsExpandedWithCache] - ); - - const handleKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - switch (event.key) { - case 'Enter': - case ' ': - if (onSelect) { - onSelect(event); - } else { - toggle(event); - } - event.stopPropagation(); - break; - case 'ArrowRight': - // Ignore if modifier keys are pressed - if (event.altKey || event.metaKey) return; - event.preventDefault(); - event.stopPropagation(); - setIsExpandedWithCache(true); - break; - case 'ArrowLeft': - // Ignore if modifier keys are pressed - if (event.altKey || event.metaKey) return; - event.preventDefault(); - event.stopPropagation(); - setIsExpandedWithCache(false); - break; - } - }, - [onSelect, setIsExpandedWithCache, toggle] - ); - - return ( - -
  • } - tabIndex={0} - id={itemId} - role="treeitem" - aria-level={level} - aria-expanded={isExpanded} - aria-current={isCurrentItem ? 'true' : undefined} - aria-selected={isFocused ? 'true' : 'false'} - onKeyDown={handleKeyDown} - onFocus={(event) => { - // Scroll the first child into view when the item receives focus - event.currentTarget.firstElementChild?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - - // Set the focused state - setIsFocused(true); - - // Prevent focus event from bubbling up to parent items - event.stopPropagation(); - }} - onBlur={() => setIsFocused(false)} - onClick={(event) => { - if (onSelect) { - onSelect(event); - // if has children open them too - if (hasSubTree) { - toggle(event); - } - } else { - toggle(event); - } - event.stopPropagation(); - }} - onAuxClick={(event) => { - if (onSelect && event.button === 1) { - onSelect(event); - } - event.stopPropagation(); - }} - > -
    -
    {/* */}
    - -
    - {slots.leadingVisual} - {childrenWithoutSubTree} -
    - {hasSubTree ? ( -
    { - if (onSelect) { - toggle(event); - } - }} - > - {isExpanded ? : } -
    - ) : null} -
    - {subTree} -
  • -
    - ); - } -); - -Item.displayName = 'TreeView.Item'; - -// ---------------------------------------------------------------------------- -// TreeView.SubTree - -export type TreeViewSubTreeProps = { - children?: React.ReactNode; -}; - -const SubTree: React.FC = ({ children }) => { - const { isExpanded } = React.useContext(ItemContext); - const ref = React.useRef(null); - - if (!isExpanded) { - return null; - } - - return ( -
      - {children} -
    - ); -}; - -SubTree.displayName = 'TreeView.SubTree'; - -function useSubTree(children: React.ReactNode) { - return React.useMemo(() => { - const subTree = React.Children.toArray(children).find((child) => React.isValidElement(child) && child.type === SubTree); - - const childrenWithoutSubTree = React.Children.toArray(children).filter( - (child) => !(React.isValidElement(child) && child.type === SubTree) - ); - - return { - subTree, - childrenWithoutSubTree, - hasSubTree: Boolean(subTree), - }; - }, [children]); -} - -// ---------------------------------------------------------------------------- -// TreeView.LeadingVisual - -export type TreeViewLeadingVisualProps = { - children: React.ReactNode | ((props: { isExpanded: boolean }) => React.ReactNode); -}; - -const LeadingVisual: React.FC = (props) => { - const { isExpanded } = React.useContext(ItemContext); - const children = typeof props.children === 'function' ? props.children({ isExpanded }) : props.children; - return ( -
    - {children} -
    - ); -}; - -LeadingVisual.displayName = 'TreeView.LeadingVisual'; - -// ---------------------------------------------------------------------------- -// Export - -export const TreeView = Object.assign(Root, { - Item, - SubTree, - LeadingVisual, -}); diff --git a/eventcatalog/src/components/TreeView/styles.module.css b/eventcatalog/src/components/TreeView/styles.module.css deleted file mode 100644 index e4b19dd34..000000000 --- a/eventcatalog/src/components/TreeView/styles.module.css +++ /dev/null @@ -1,264 +0,0 @@ -.TreeViewRootUlStyles { - padding: 0; - margin: 0; - list-style: none; - - /* - * WARNING: This is a performance optimization. - * - * We define styles for the tree items at the root level of the tree - * to avoid recomputing the styles for each item when the tree updates. - * We're sacrificing maintainability for performance because TreeView - * needs to be performant enough to handle large trees (thousands of items). - * - * This is intended to be a temporary solution until we can improve the - * performance of our styling patterns. - * - * Do NOT copy this pattern without understanding the tradeoffs. - */ - .TreeViewItem { - outline: none; - - &:focus-visible > div, - &.focus-visible > div { - box-shadow: var(--boxShadow-thick) /* var(--fgColor-accent) */ slategray; - - @media (forced-colors: active) { - outline: 2px solid HighlightText; - outline-offset: -2; - } - } - - &[data-has-leading-action] { - --has-leading-action: 1; - } - } - - .TreeViewItemContainer { - --level: 1; - --toggle-width: 1rem; - --min-item-height: 2rem; - - position: relative; - display: grid; - width: 100%; - font-size: var(--text-body-size-medium); - color: var(--fgColor-default); - cursor: pointer; - border-radius: var(--borderRadius-medium); - grid-template-columns: var(--spacer-width) var(--leading-action-width) 1fr var(--toggle-width); - grid-template-areas: 'spacer leadingAction content toggle'; - - --leading-action-width: calc(var(--has-leading-action, 0) * 1.5rem); - --spacer-width: calc(calc(var(--level) - 1) * (var(--toggle-width) / 2)); - - &:hover { - background-color: var(--control-transparent-bgColor-hover); - - @media (forced-colors: active) { - outline: 2px solid transparent; - outline-offset: -2px; - } - } - - @media (pointer: coarse) { - --toggle-width: 1.5rem; - --min-item-height: 2.75rem; - } - - &:has(.TreeViewItemSkeleton):hover { - cursor: default; - background-color: transparent; - - @media (forced-colors: active) { - outline: none; - } - } - } - - &:where([data-omit-spacer='true']) .TreeViewItemContainer { - grid-template-columns: 0 0 1fr 0; - } - - .TreeViewItem[aria-current='true'] > .TreeViewItemContainer { - background-color: var(--control-transparent-bgColor-selected); - - /* Current item indicator */ - /* stylelint-disable-next-line selector-max-specificity */ - &::after { - position: absolute; - top: calc(50% - var(--base-size-12)); - left: calc(-1 * var(--base-size-8)); - width: 0.25rem; - height: 1.5rem; - content: ''; - - /* - * Use fgColor accent for consistency across all themes. Using the "correct" variable, - * --bgColor-accent-emphasis, causes vrt failures for dark high contrast mode - */ - /* stylelint-disable-next-line primer/colors */ - background-color: var(--fgColor-accent); - border-radius: var(--borderRadius-medium); - - @media (forced-colors: active) { - background-color: HighlightText; - } - } - } - - .TreeViewItemToggle { - display: flex; - height: 100%; - - /* The toggle should appear vertically centered for single-line items, but remain at the top for items that wrap - across more lines. */ - /* stylelint-disable-next-line primer/spacing */ - padding-top: calc(var(--min-item-height) / 2 - var(--base-size-12) / 2); - color: var(--fgColor-muted); - grid-area: toggle; - justify-content: center; - align-items: flex-start; - } - - .TreeViewItemToggleHover:hover { - background-color: var(--control-transparent-bgColor-hover); - } - - .TreeViewItemToggleEnd { - border-top-left-radius: var(--borderRadius-medium); - border-bottom-left-radius: var(--borderRadius-medium); - } - - .TreeViewItemContent { - display: flex; - height: 100%; - padding: 0 var(--base-size-8); - - /* The dynamic top and bottom padding to maintain the minimum item height for single line items */ - /* stylelint-disable-next-line primer/spacing */ - padding-top: calc((var(--min-item-height) - var(--custom-line-height, 1.3rem)) / 2); - /* stylelint-disable-next-line primer/spacing */ - padding-bottom: calc((var(--min-item-height) - var(--custom-line-height, 1.3rem)) / 2); - line-height: var(--custom-line-height, var(--text-body-lineHeight-medium, 1.4285)); - grid-area: content; - gap: var(--stack-gap-condensed); - } - - .TreeViewItemContentText { - flex: 1 1 auto; - width: 0; - } - - &:where([data-truncate-text='true']) .TreeViewItemContentText { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - &:where([data-truncate-text='false']) .TreeViewItemContentText { - word-break: break-word; - } - - .TreeViewItemVisual { - display: flex; - - /* The visual icons should appear vertically centered for single-line items, but remain at the top for items that wrap - across more lines. */ - height: var(--custom-line-height, 1.3rem); - color: var(--fgColor-muted); - align-items: center; - } - - .TreeViewItemLeadingAction { - display: flex; - color: var(--fgColor-muted); - grid-area: leadingAction; - - & > button { - flex-shrink: 1; - } - } - - .TreeViewItemLevelLine { - width: 100%; - height: 100%; - - /* - * On devices without hover, the nesting indicator lines - * appear at all times. - */ - border-color: var(--borderColor-muted); - border-right: var(--borderWidth-thin) solid; - } - - /* - * On devices with :hover support, the nesting indicator lines - * fade in when the user mouses over the entire component, - * or when there's focus inside the component. This makes - * sure the component remains simple when not in use. - */ - @media (hover: hover) { - .TreeViewItemLevelLine { - border-color: transparent; - } - - &:hover .TreeViewItemLevelLine, - &:focus-within .TreeViewItemLevelLine { - border-color: var(--borderColor-muted); - } - } - - .TreeViewDirectoryIcon { - display: grid; - color: var(--treeViewItem-leadingVisual-iconColor-rest); - } - - .TreeViewVisuallyHidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - /* stylelint-disable-next-line primer/spacing */ - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border-width: 0; - } -} - -.TreeViewSkeletonItemContainerStyle { - display: flex; - align-items: center; - column-gap: 0.5rem; - height: 2rem; - - @media (pointer: coarse) { - height: 2.75rem; - } - - &:nth-of-type(5n + 1) { - --tree-item-loading-width: 67%; - } - - &:nth-of-type(5n + 2) { - --tree-item-loading-width: 47%; - } - - &:nth-of-type(5n + 3) { - --tree-item-loading-width: 73%; - } - - &:nth-of-type(5n + 4) { - --tree-item-loading-width: 64%; - } - - &:nth-of-type(5n + 5) { - --tree-item-loading-width: 50%; - } -} - -.TreeItemSkeletonTextStyles { - width: var(--tree-item-loading-width, 67%); -} diff --git a/eventcatalog/src/components/TreeView/useSlots.ts b/eventcatalog/src/components/TreeView/useSlots.ts deleted file mode 100644 index 7f00b0b94..000000000 --- a/eventcatalog/src/components/TreeView/useSlots.ts +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react'; -// import {warning} from '../utils/warning' - -// slot config allows 2 options: -// 1. Component to match, example: { leadingVisual: LeadingVisual } -type ComponentMatcher = React.ElementType; -// 2. Component to match + a test function, example: { blockDescription: [Description, props => props.variant === 'block'] } -type ComponentAndPropsMatcher = [ComponentMatcher, (props: Props) => boolean]; - -export type SlotConfig = Record; - -// We don't know what the props are yet, we set them later based on slot config -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Props = any; - -type SlotElements = { - [Property in keyof Config]: SlotValue; -}; - -type SlotValue = Config[Property] extends React.ElementType // config option 1 - ? React.ReactElement, Config[Property]> - : Config[Property] extends readonly [ - infer ElementType extends React.ElementType, // config option 2, infer array[0] as component - // eslint-disable-next-line @typescript-eslint/no-unused-vars - infer _testFn, // even though we don't use testFn, we need to infer it to support types for slots.*.props - ] - ? React.ReactElement, ElementType> - : never; // useful for narrowing types, third option is not possible - -/** - * Extract components from `children` so we can render them in different places, - * allowing us to implement components with SSR-compatible slot APIs. - * Note: We can only extract direct children, not nested ones. - */ -export function useSlots( - children: React.ReactNode, - config: Config -): [Partial>, React.ReactNode[]] { - // Object mapping slot names to their elements - const slots: Partial> = mapValues(config, () => undefined); - - // Array of elements that are not slots - const rest: React.ReactNode[] = []; - - const keys = Object.keys(config) as Array; - const values = Object.values(config); - - // eslint-disable-next-line github/array-foreach - React.Children.forEach(children, (child) => { - if (!React.isValidElement(child)) { - rest.push(child); - return; - } - - const index = values.findIndex((value) => { - if (Array.isArray(value)) { - const [component, testFn] = value; - return child.type === component && testFn(child.props); - } else { - return child.type === value; - } - }); - - // If the child is not a slot, add it to the `rest` array - if (index === -1) { - rest.push(child); - return; - } - - const slotKey = keys[index]; - - // If slot is already filled, ignore duplicates - if (slots[slotKey]) { - // warning(true, `Found duplicate "${String(slotKey)}" slot. Only the first will be rendered.`) - return; - } - - // If the child is a slot, add it to the `slots` object - - slots[slotKey] = child as SlotValue; - }); - - return [slots, rest]; -} - -/** Map the values of an object */ -function mapValues, V>(obj: T, fn: (value: T[keyof T]) => V) { - return Object.keys(obj).reduce( - (result, key: keyof T) => { - result[key] = fn(obj[key]); - return result; - }, - {} as Record - ); -} diff --git a/eventcatalog/src/content.config.ts b/eventcatalog/src/content.config.ts index b7d6c55db..d17352f2d 100644 --- a/eventcatalog/src/content.config.ts +++ b/eventcatalog/src/content.config.ts @@ -7,7 +7,7 @@ import fs from 'fs'; import path from 'path'; // Enterprise Collections -import { chatPromptsSchema, customPagesSchema } from './enterprise/collections'; +import { customPagesSchema } from './enterprise/collections'; export const projectDirBase = (() => { if (process.platform === 'win32') { @@ -91,8 +91,6 @@ const changelogs = defineCollection({ catalog: z .object({ path: z.string(), - absoluteFilePath: z.string(), - astroContentFilePath: z.string(), filePath: z.string(), publicPath: z.string(), type: z.string(), @@ -189,6 +187,7 @@ const baseSchema = z.object({ ]) ) .optional(), + diagrams: z.array(pointer).optional(), // Used by eventcatalog versions: z.array(z.string()).optional(), latestVersion: z.string().optional(), @@ -196,7 +195,6 @@ const baseSchema = z.object({ .object({ path: z.string(), filePath: z.string(), - astroContentFilePath: z.string(), publicPath: z.string(), type: z.string(), }) @@ -390,6 +388,7 @@ const services = defineCollection({ entities: z.array(pointer).optional(), writesTo: z.array(pointer).optional(), readsFrom: z.array(pointer).optional(), + flows: z.array(pointer).optional(), detailsPanel: z .object({ domains: detailPanelPropertySchema.optional(), @@ -449,6 +448,7 @@ const containers = defineCollection({ owners: detailPanelPropertySchema.optional(), changelog: detailPanelPropertySchema.optional(), attachments: detailPanelPropertySchema.optional(), + services: detailPanelPropertySchema.optional(), }) .optional(), services: z.array(reference('services')).optional(), @@ -468,14 +468,6 @@ const customPages = defineCollection({ schema: customPagesSchema, }); -const chatPrompts = defineCollection({ - loader: glob({ - pattern: ['chat-prompts/*.(md|mdx)', 'chat-prompts/**/*.@(md|mdx)'], - base: projectDirBase, - }), - schema: chatPromptsSchema, -}); - const domains = defineCollection({ loader: glob({ pattern: [ @@ -497,6 +489,7 @@ const domains = defineCollection({ services: z.array(pointer).optional(), domains: z.array(pointer).optional(), entities: z.array(pointer).optional(), + flows: z.array(pointer).optional(), detailsPanel: z .object({ parentDomains: detailPanelPropertySchema.optional(), @@ -700,6 +693,22 @@ const designs = defineCollection({ }), }); +const diagrams = defineCollection({ + loader: glob({ + pattern: ['**/diagrams/**/index.(md|mdx)', '**/diagrams/**/versioned/*/index.(md|mdx)'], + base: projectDirBase, + generateId: ({ data }) => `${data.id}-${data.version}`, + }), + schema: z + .object({ + id: z.string(), + name: z.string(), + version: z.string(), + summary: z.string().optional(), + }) + .merge(baseSchema), +}); + export const collections = { events, commands, @@ -720,8 +729,10 @@ export const collections = { // EventCatalog Pro Collections customPages, - chatPrompts, // EventCatalog Studio Collections designs, + + // Diagrams Collection + diagrams, }; diff --git a/eventcatalog/src/enterprise/ai/chat-api.ts b/eventcatalog/src/enterprise/ai/chat-api.ts new file mode 100644 index 000000000..142342ed9 --- /dev/null +++ b/eventcatalog/src/enterprise/ai/chat-api.ts @@ -0,0 +1,347 @@ +import type { APIContext } from 'astro'; +import { convertToModelMessages, stepCountIs, streamText, tool, type LanguageModel, type ModelMessage, type UIMessage } from 'ai'; +import { join } from 'node:path'; +import { isEventCatalogScaleEnabled } from '@utils/feature'; +import { z, getCollection, getEntry } from 'astro:content'; +import { getConsumersOfMessage, getProducersOfMessage } from '@utils/collections/services'; +import { + getResources as getResourcesImpl, + getResource as getResourceImpl, + getMessagesProducedOrConsumedByResource as getMessagesImpl, + getSchemaForResource as getSchemaImpl, + collectionSchema, + resourceCollectionSchema, + messageCollectionSchema, + toolDescriptions, +} from '@enterprise/tools/catalog-tools'; + +const catalogDirectory = process.env.PROJECT_DIR || process.cwd(); + +export const defaultConfiguration = { + temperature: 0.4, + topP: 0.9, + topK: 40, + frequencyPenalty: 0.1, + presencePenalty: 0.0, + maxTokens: 1000, +}; + +let hasChatConfiguration = false; +let model: LanguageModel; +let modelConfiguration: any; +let extendedTools: any; + +try { + const providerConfiguration = await import(/* @vite-ignore */ join(catalogDirectory, 'eventcatalog.chat.js')); + model = await providerConfiguration.default(); + modelConfiguration = providerConfiguration.configuration || defaultConfiguration; + hasChatConfiguration = true; + + if (isEventCatalogScaleEnabled()) { + extendedTools = providerConfiguration.tools || {}; + } +} catch (error) { + console.error('[Chat] Error loading chat configuration', error); + hasChatConfiguration = false; +} + +// Built-in tools metadata for client visibility +const builtInToolsMetadata = [ + { + name: 'getResources', + description: 'Get events, services, commands, queries, flows, domains, channels, entities from EventCatalog', + }, + { name: 'getResource', description: 'Get a specific resource by its id and version' }, + { name: 'getProducersAndConsumersFromSchema', description: 'Get the producers and consumers for a schema' }, + { name: 'getMessagesProducedOrConsumedByResource', description: 'Get messages produced or consumed by a resource' }, + { name: 'getProducerAndConsumerForMessage', description: 'Get the producers and consumers for a message' }, + { name: 'getConsumersOfMessage', description: 'Get the consumers for a message' }, + { name: 'getSchemaForResource', description: 'Get the schema or specifications (OpenAPI, AsyncAPI, GraphQL) for a resource' }, +]; + +// Get extended tools metadata from user configuration +const getExtendedToolsMetadata = () => { + if (!extendedTools || typeof extendedTools !== 'object') return []; + return Object.entries(extendedTools).map(([name, toolConfig]: [string, any]) => ({ + name, + description: toolConfig?.description || 'Custom tool', + isCustom: true, + })); +}; + +const getBaseSystemPrompt = ( + referrer: string +) => `You are an expert in software architecture and domain-driven design, specializing in the open source tool EventCatalog. + +You assist developers, architects, and business stakeholders who need information about their software architecture catalog. + +There are many different resource types in EventCatalog, including: +- Events (collection name 'events') (asynchronous messages that notify about something that has happened) + - example docs url: /docs/events/MyEvent/1.0.0 +- Commands (collection name 'commands') (requests to perform an action) + - example docs url: /docs/commands/MyCommand/1.0.0 +- Queries (collection name 'queries') (requests for information) + - example docs url: /docs/queries/MyQuery/1.0.0 +- Services (collection name 'services') (bounded contexts or applications that produce/consume events) + - example docs url: /docs/services/MyService/1.0.0 +- Domains (collection name 'domains') (business capabilities or functional areas) + - example docs url: /docs/domains/MyDomain/1.0.0 +- Flows (collection name 'flows') (state machines) + - example docs url: /docs/flows/MyFlow/1.0.0 +- Channels (collection name 'channels') (communication channels) + - example docs url: /docs/channels/MyChannel/1.0.0 +- Entities (collection name 'entities') (data objects) + - example docs url: /docs/entities/MyEntity/1.0.0 +- Containers (collection name 'containers') (at the moment these are data stores (databases)) + - example docs url: /docs/containers/MyContainer/1.0.0 + +The user will ask you some questions about the software architecture catalog, you should use the tools provided to you to get the information they need. + +At point the referer url (${referrer}) will be the URL of the page the user is on, you should use this to help you answer the question, +You may be able to get the resource from the URL + - Example if the url is like /docs|visualiser|architecture/{collection}/{id}/{version} + - (e.g /docs/events/MyEvent/1.0.0) in this case the id is MyEvent and the version is 1.0.0 and collection is events. + - (e.g /visualiser/domains/MyDomain/1.0.0) in this case the id is MyDomain and the version is 1.0.0 and collection is domains. + - (e.g /architecture/services/MyService/1.0.0) in this case the id is MyService and the version is 1.0.0 and collection is services. + +The referer URL is: ${referrer} + +Sometimes the user will be on the specification page (openapi, asyncapi, graphql) for a resource too +- /docs/services/OrdersService/0.0.3/asyncapi/order-service-asyncapi -> id: OrdersService, version: 0.0.3, collection: services, schema (specification): asyncapi +- /docs/services/OrdersService/0.0.3/spec/openapi-v2 -> id: OrdersService, version: 0.0.3, collection: services, schema (specification): openapi + +Your primary goal is to help users understand their software architecture through accurate documentation interpretation. + +Use the tools provided to get the context you need to an answer the question. + +When responding: +1. Explain connections between resources when relevant. +2. Use appropriate technical terminology. +3. Use clear formatting with headings and bullet points when helpful. +4. State clearly when information is missing rather than making assumptions. +5. Don't provide code examples unless specifically requested. +6. When you refer to a resource in EventCatalog, try and create a link to the resource in the response + - Example, if you return a message in the text, rather than than just the id or version you should return a markdown link to the resource e.g [MyEvent - 1.0.0](/docs/events/MyEvent/1.0.0) + - CRITICAL: NEVER use "latest" in any URL or link. Always use the actual semantic version number (e.g., 1.0.0, 2.1.3). The word "latest" is not a valid version and will result in broken links. + - The link options are: + - If you want to get the documentation for a resource use the /docs/ prefix (e.g /docs/{collection}/{id}/{version}) + - If you want to let the user know they can visualize a resource use the /visualiser/ prefix (e.g /visualiser/{collection}/{id}/{version}) + - If you want to let the user know they can see the architecture of a resource use the /architecture/ prefix (e.g /architecture/{collection}/{id}/{version}) + - If you don't know the version, use the getResource tool to fetch the resource and get the actual version number before creating the link. +7. When you return a schema, use code blocks to render the schema to the user too, for example if the schema is in JSON format use \`\`\`json and if the schema is in YAML format use \`\`\`yaml +8. IMPORTANT: After answering each question, ALWAYS use the suggestFollowUpQuestions tool to suggest 2-3 relevant follow-up questions the user might want to ask next. These should be contextual to the conversation and help the user explore related topics. + +If you have additional context, use it to answer the question.`; + +interface Message { + content: string; +} + +export const GET = async ({ request }: APIContext<{ question: string; messages: Message[]; additionalContext?: string }>) => { + if (!isEventCatalogScaleEnabled()) { + return new Response(JSON.stringify({ error: 'Chat is not enabled' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (!hasChatConfiguration) { + return new Response(JSON.stringify({ error: 'No chat configuration found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Return available tools metadata + const tools = [...builtInToolsMetadata, ...getExtendedToolsMetadata()]; + return new Response(JSON.stringify({ tools }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +}; + +export const POST = async ({ request }: APIContext<{ question: string; messages: Message[]; additionalContext?: string }>) => { + const { messages }: { messages: UIMessage[] } = await request.json(); + + if (!isEventCatalogScaleEnabled()) { + return new Response(JSON.stringify({ error: 'Chat is not enabled, please upgrade to the scale plan to use this feature' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (!hasChatConfiguration) { + return new Response(JSON.stringify({ error: 'No chat configuration found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Get the URL of the request + const referrer = request.headers.get('referer'); + + try { + const result = await streamText({ + model, + system: getBaseSystemPrompt(referrer ?? ''), + messages: await convertToModelMessages(messages), + temperature: modelConfiguration?.temperature ?? 0.7, + stopWhen: stepCountIs(5), + // maxTokens: 4000, // Increased to handle large tool results + onError: (error) => { + console.error('[Chat] On error', error); + }, + // maxOutputTokens: 40000, + // tools: tools, + tools: { + getResources: tool({ + description: toolDescriptions.getResources, + inputSchema: z.object({ + collection: collectionSchema.describe('The collection to get the resources from'), + }), + execute: async ({ collection }) => { + const result = await getResourcesImpl({ collection }); + if ('error' in result) return result; + return result.resources; + }, + }), + getResource: tool({ + description: toolDescriptions.getResource, + inputSchema: z.object({ + collection: collectionSchema.describe('The collection to get the resource from'), + id: z.string().describe('The id of the resource to get'), + version: z.string().describe('The version of the resource to get'), + }), + execute: async ({ collection, id, version }) => { + return await getResourceImpl({ collection, id, version }); + }, + }), + getProducersAndConsumersFromSchema: tool({ + description: 'Use this tool to get the producers and consumers for a schema by its id and version', + inputSchema: z.object({ + collection: messageCollectionSchema.describe('The collection to get the producers and consumers from'), + id: z.string().describe('The id of the message to get the producers and consumers for'), + version: z.string().describe('The version of the message to get the producers and consumers for'), + }), + execute: async ({ collection, id, version }) => { + const resource = await getEntry(collection as any, `${id}-${version}`); + const producers = resource.data.producers || []; + const consumers = resource.data.consumers || []; + return { + producers, + consumers, + }; + }, + }), + getMessagesProducedOrConsumedByResource: tool({ + description: toolDescriptions.getMessagesProducedOrConsumedByResource, + inputSchema: z.object({ + resourceId: z.string().describe('The id of the resource to get the messages produced or consumed for'), + resourceVersion: z.string().describe('The version of the resource to get the messages produced or consumed for'), + resourceCollection: resourceCollectionSchema + .describe('The collection of the resource to get the messages produced or consumed for') + .default('services'), + }), + execute: async ({ resourceId, resourceVersion, resourceCollection }) => { + return await getMessagesImpl({ resourceId, resourceVersion, resourceCollection }); + }, + }), + getProducerAndConsumerForMessage: tool({ + description: 'Use this tool to get the producers and consumers for a message by its id and version', + inputSchema: z.object({ + messageId: z.string().describe('The id of the message to get the producers and consumers for'), + messageVersion: z.string().describe('The version of the message to get the producers and consumers for'), + messageCollection: messageCollectionSchema + .describe('The collection of the message to get the producers and consumers for') + .default('events'), + }), + execute: async ({ messageId, messageVersion, messageCollection }) => { + const services = await getCollection('services'); + const message = await getEntry(messageCollection as any, `${messageId}-${messageVersion}`); + const consumers = await getProducersOfMessage(services, message as any); + return consumers; + }, + }), + getConsumersOfMessage: tool({ + description: 'Use this tool to get the consumers for a message by its id and version', + inputSchema: z.object({ + messageId: z.string().describe('The id of the message to get the consumers for'), + messageVersion: z.string().describe('The version of the message to get the consumers for'), + messageCollection: messageCollectionSchema + .describe('The collection of the message to get the consumers for') + .default('events'), + }), + execute: async ({ messageId, messageVersion, messageCollection }) => { + const services = await getCollection('services'); + const message = await getEntry(messageCollection as any, `${messageId}-${messageVersion}`); + const consumers = await getConsumersOfMessage(services, message as any); + return consumers; + }, + }), + getSchemaForResource: tool({ + description: toolDescriptions.getSchemaForResource, + inputSchema: z.object({ + resourceId: z.string().describe('The id of the resource to get the schema for'), + resourceVersion: z.string().describe('The version of the resource to get the schema for'), + resourceCollection: resourceCollectionSchema + .describe('The collection of the resource to get the schema for') + .default('services'), + }), + execute: async ({ resourceId, resourceVersion, resourceCollection }) => { + return await getSchemaImpl({ resourceId, resourceVersion, resourceCollection }); + }, + }), + suggestFollowUpQuestions: tool({ + description: + 'Use this tool after answering a question to suggest 2-3 relevant follow-up questions the user might want to ask. These will be displayed as clickable suggestions.', + inputSchema: z.object({ + questions: z + .array(z.string()) + .min(1) + .max(3) + .describe('Array of 2-3 follow-up questions relevant to the conversation'), + }), + execute: async ({ questions }) => { + // This tool doesn't need to do anything - it just returns the questions + // which will be captured by the UI + return { suggestions: questions }; + }, + }), + ...extendedTools, + }, + }); + return result.toUIMessageStreamResponse({ + headers: { + 'Content-Type': 'text/event-stream', + }, + }); + } catch (err: any) { + console.error('[Chat] Error during streaming:', err); + + // Extract a user-friendly error message + let errorMessage = 'An unexpected error occurred while processing your request.'; + + if (err?.message) { + // Check for common error patterns and provide friendlier messages + if (err.message.includes('API key') || err.message.includes('authentication') || err.message.includes('401')) { + errorMessage = 'Authentication error: Please check your API key configuration.'; + } else if (err.message.includes('rate limit') || err.message.includes('429')) { + errorMessage = 'Rate limit exceeded. Please wait a moment and try again.'; + } else if (err.message.includes('timeout') || err.message.includes('ETIMEDOUT')) { + errorMessage = 'Request timed out. Please try again.'; + } else if (err.message.includes('model') || err.message.includes('not found')) { + errorMessage = 'Model configuration error: ' + err.message; + } else { + // Use the original message if it's not too technical + errorMessage = err.message.length < 200 ? err.message : 'An error occurred while processing your request.'; + } + } + + return new Response(JSON.stringify({ error: errorMessage }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +}; + +export const prerender = false; diff --git a/eventcatalog/src/enterprise/auth/[...auth].ts b/eventcatalog/src/enterprise/auth/[...auth].ts new file mode 100644 index 000000000..1bb721d4c --- /dev/null +++ b/eventcatalog/src/enterprise/auth/[...auth].ts @@ -0,0 +1,3 @@ +import { AstroAuth } from 'auth-astro/server'; +export const prerender = false; +export const { GET, POST } = AstroAuth(); diff --git a/eventcatalog/src/pages/auth/error.astro b/eventcatalog/src/enterprise/auth/error.astro similarity index 100% rename from eventcatalog/src/pages/auth/error.astro rename to eventcatalog/src/enterprise/auth/error.astro diff --git a/eventcatalog/src/enterprise/auth/login.astro b/eventcatalog/src/enterprise/auth/login.astro new file mode 100644 index 000000000..dc67bc2ab --- /dev/null +++ b/eventcatalog/src/enterprise/auth/login.astro @@ -0,0 +1,420 @@ +--- +import config from '@config'; +const { title, logo } = config; +import { join } from 'node:path'; +import { isAuthEnabled, isSSR } from '@utils/feature'; +import { buildUrl } from '@utils/url-builder'; + +const catalogDirectory = process.env.PROJECT_DIR || process.cwd(); + +let hasAuthConfigurationFile = false; +let providers: string[] = []; + +try { + const authConfig = await import(/* @vite-ignore */ join(catalogDirectory, 'eventcatalog.auth.js')); + providers = Object.keys(authConfig.default.providers); + hasAuthConfigurationFile = true; +} catch (error) { + hasAuthConfigurationFile = false; +} + +// Check if we should show login (auth file exists, SSR enabled, auth enabled, and has providers) +const shouldShowLogin = hasAuthConfigurationFile && isSSR() && isAuthEnabled() && providers.length > 0; + +// Check if configuration exists but no providers are set up +const hasConfigButNoProviders = hasAuthConfigurationFile && isSSR() && isAuthEnabled() && providers.length === 0; + +// If we are not in SSR mode, redirect to home +if (!isSSR() || !isAuthEnabled()) { + return Astro.redirect('/'); +} + +// If we are in SSR mode, check if the user is already logged in +if (isSSR() && isAuthEnabled()) { + const { getSession } = await import(/* @vite-ignore */ 'auth-astro/server'); + const session = await getSession(Astro.request); + if (session) { + return Astro.redirect('/'); + } +} + +// Provider configurations +const providerConfig = { + github: { + name: 'GitHub', + bgColor: 'bg-gray-900', + hoverBg: 'hover:bg-gray-800', + textColor: 'text-white', + icon: ` + + `, + }, + google: { + name: 'Google', + bgColor: 'bg-white', + hoverBg: 'hover:bg-gray-50', + textColor: 'text-gray-700', + border: true, + icon: ` + + + + + `, + }, + okta: { + name: 'Okta', + bgColor: 'bg-[#007DC1]', + hoverBg: 'hover:bg-[#006BA8]', + textColor: 'text-white', + icon: ` + + `, + }, + auth0: { + name: 'Auth0', + bgColor: 'bg-[#EB5424]', + hoverBg: 'hover:bg-[#D44B20]', + textColor: 'text-white', + icon: ` + + `, + }, + entra: { + name: 'Microsoft', + bgColor: 'bg-white', + hoverBg: 'hover:bg-gray-50', + textColor: 'text-gray-700', + border: true, + icon: ` + + + + + `, + }, +}; +--- + + + + + + + + + Sign In | {title} + + + + + + +
    + { + shouldShowLogin ? ( +
    + {/* Logo and Title */} +
    + {logo && logo.src && ( +
    + {logo.alt} +
    + )} +

    {title}

    +

    Sign in to access your catalog

    +
    + + {/* Login Card */} +
    + {/* Purple accent bar */} +
    + +
    +
    + {providers.map((provider) => { + const cfg = providerConfig[provider as keyof typeof providerConfig]; + if (!cfg) return null; + + return ( + + ); + })} +
    +
    +
    + + {/* Footer links */} +
    +

    + Need a different provider?{' '} + + Let us know + +

    +
    +
    + ) : hasConfigButNoProviders ? ( +
    + {/* Logo */} + {logo && ( +
    +
    + {logo.alt} +
    +
    + )} + +
    +
    + +
    + {/* Warning Icon */} +
    +
    + + + +
    +
    + +
    +

    No Providers Configured

    +

    Authentication is enabled but no providers are set up in your configuration file.

    +
    + +
    +

    Setup Instructions

    +
      +
    1. + + 1 + + + Update your eventcatalog.auth.js{' '} + file + +
    2. +
    3. + + 2 + + Add at least one provider (GitHub, Google, Okta, etc.) +
    4. +
    5. + + 3 + + Restart your EventCatalog server +
    6. +
    +
    + + +
    +
    +
    + ) : ( +
    + {/* Logo */} + {logo && ( +
    +
    + {logo.alt} +
    +
    + )} + +
    +
    + +
    + {/* Info Icon */} +
    +
    + + + +
    +
    + +
    +

    Authentication Not Configured

    +

    + {!hasAuthConfigurationFile + ? 'No authentication configuration file found.' + : 'Authentication is not properly enabled.'} +

    +
    + +
    +

    Enable Authentication

    +
      + {!hasAuthConfigurationFile && ( +
    1. + + 1 + + + Create eventcatalog.auth.js in + your project root + +
    2. + )} + {!isSSR() && ( +
    3. + + {!hasAuthConfigurationFile ? '2' : '1'} + + + Set output: 'server' in your + config + +
    4. + )} + {!isAuthEnabled() && ( +
    5. + + {!hasAuthConfigurationFile ? (!isSSR() ? '3' : '2') : !isSSR() ? '2' : '1'} + + + Set auth: {'{ enabled: true }'}{' '} + in your config + +
    6. + )} +
    +
    + + +
    +
    +
    + ) + } + + {/* Footer */} +
    +

    + Powered by EventCatalog +

    +
    +
    + + + + diff --git a/eventcatalog/src/middleware-auth.ts b/eventcatalog/src/enterprise/auth/middleware/middleware-auth.ts similarity index 100% rename from eventcatalog/src/middleware-auth.ts rename to eventcatalog/src/enterprise/auth/middleware/middleware-auth.ts diff --git a/eventcatalog/src/middleware.ts b/eventcatalog/src/enterprise/auth/middleware/middleware.ts similarity index 100% rename from eventcatalog/src/middleware.ts rename to eventcatalog/src/enterprise/auth/middleware/middleware.ts diff --git a/eventcatalog/src/pages/unauthorized/index.astro b/eventcatalog/src/enterprise/auth/unauthorized.astro similarity index 97% rename from eventcatalog/src/pages/unauthorized/index.astro rename to eventcatalog/src/enterprise/auth/unauthorized.astro index 360e15f7e..ac0df1f04 100644 --- a/eventcatalog/src/pages/unauthorized/index.astro +++ b/eventcatalog/src/enterprise/auth/unauthorized.astro @@ -2,7 +2,7 @@ import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; --- - +
    diff --git a/eventcatalog/src/enterprise/collections/chat-prompts.ts b/eventcatalog/src/enterprise/collections/chat-prompts.ts deleted file mode 100644 index e170a62f4..000000000 --- a/eventcatalog/src/enterprise/collections/chat-prompts.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { z } from 'astro:content'; - -export const chatPromptsSchema = z.object({ - title: z.string(), - type: z.enum(['text', 'code']).default('text'), - inputs: z - .array( - z.object({ - id: z.string(), - label: z.string(), - type: z - .enum([ - 'text', - 'resource-list-events', - 'resource-list-services', - 'resource-list-commands', - 'resource-list-queries', - 'code', - 'text-area', - 'select', - ]) - .default('text'), - options: z.array(z.string()).optional(), - }) - ) - .optional(), - category: z.object({ - id: z.string(), - label: z.string(), - icon: z.string().optional(), - }), -}); diff --git a/eventcatalog/src/enterprise/collections/index.ts b/eventcatalog/src/enterprise/collections/index.ts index 076aabf01..f69421fe1 100644 --- a/eventcatalog/src/enterprise/collections/index.ts +++ b/eventcatalog/src/enterprise/collections/index.ts @@ -1,2 +1 @@ export { customPagesSchema } from './custom-pages'; -export { chatPromptsSchema } from './chat-prompts'; diff --git a/eventcatalog/src/enterprise/custom-documentation/components/CustomDocsNav/components/NestedItem.tsx b/eventcatalog/src/enterprise/custom-documentation/components/CustomDocsNav/components/NestedItem.tsx index 2d7f28e53..c81284209 100644 --- a/eventcatalog/src/enterprise/custom-documentation/components/CustomDocsNav/components/NestedItem.tsx +++ b/eventcatalog/src/enterprise/custom-documentation/components/CustomDocsNav/components/NestedItem.tsx @@ -28,7 +28,7 @@ const NestedItem: React.FC = ({
    -
    +
    {hasNoResults ? ( ) : ( @@ -231,7 +231,7 @@ const CustomDocsNav: React.FC = ({ sidebarItems }) => { {section.items ? (
    - diff --git a/eventcatalog/src/layouts/VisualiserLayout.astro b/eventcatalog/src/layouts/VisualiserLayout.astro index a54e65ea2..089ad12ed 100644 --- a/eventcatalog/src/layouts/VisualiserLayout.astro +++ b/eventcatalog/src/layouts/VisualiserLayout.astro @@ -1,9 +1,9 @@ --- -import { getCommands } from '@utils/commands'; +import { getCommands } from '@utils/collections/commands'; import { getDomains } from '@utils/collections/domains'; -import { getEvents } from '@utils/events'; +import { getEvents } from '@utils/collections/events'; import { getFlows } from '@utils/collections/flows'; -import { getQueries } from '@utils/queries'; +import { getQueries } from '@utils/collections/queries'; import { getServices } from '@utils/collections/services'; import VerticalSideBarLayout from './VerticalSideBarLayout.astro'; diff --git a/eventcatalog/src/pages/_index.astro b/eventcatalog/src/pages/_index.astro index 7749c6cf0..d30159e63 100644 --- a/eventcatalog/src/pages/_index.astro +++ b/eventcatalog/src/pages/_index.astro @@ -1,14 +1,22 @@ --- import { buildUrl } from '@utils/url-builder'; -import { ChatBubbleLeftIcon, RectangleGroupIcon, ServerIcon } from '@heroicons/react/24/outline'; +import { + ChatBubbleLeftIcon, + RectangleGroupIcon, + ServerIcon, + CodeBracketIcon, + DocumentTextIcon, + ArrowRightIcon, + PlusIcon, +} from '@heroicons/react/24/outline'; import config from '@config'; -import { getMessages } from '@utils/messages'; +import { getMessages } from '@utils/collections/messages'; import { getDomains } from '@utils/collections/domains'; import { getServices } from '@utils/collections/services'; import { getFlows } from '@utils/collections/flows'; import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; -import { BookOpenText, Workflow, TableProperties, House, BookUser, MessageSquare, BotMessageSquare, Users } from 'lucide-react'; +import { BookOpenText, Workflow, TableProperties, BookUser, Zap, Terminal, Code2, FileJson } from 'lucide-react'; const { commands = [], events = [], queries = [] } = await getMessages({ getAllVersions: false }); const messages = [...events, ...queries, ...commands]; @@ -16,68 +24,8 @@ const domains = await getDomains({ getAllVersions: false }); const services = await getServices({ getAllVersions: false }); const flows = await getFlows({ getAllVersions: false }); -const gettingStartedItems = [ - { - title: 'Add a New Message', - icon: ChatBubbleLeftIcon, - iconBg: 'blue', - description: 'Document a new message in your system with schemas, examples, and relationships.', - links: [ - { - text: 'How to add a message', - href: 'https://www.eventcatalog.dev/docs/messages', - }, - { - text: 'Versioning guide', - href: 'https://www.eventcatalog.dev/docs/development/guides/messages/events/versioning', - }, - { - text: 'Adding schemas', - href: 'https://www.eventcatalog.dev/docs/development/guides/messages/events/adding-schemas', - }, - ], - }, - { - title: 'Document a Service', - icon: ServerIcon, - iconBg: 'green', - description: 'Add details about a service, including its events, APIs, and dependencies.', - links: [ - { - text: 'How to add a service', - href: 'https://www.eventcatalog.dev/docs/services', - }, - { - text: 'Service ownership', - href: 'https://www.eventcatalog.dev/docs/development/guides/services/owners', - }, - { - text: 'Assign specifications to services', - href: 'https://www.eventcatalog.dev/docs/development/guides/services/adding-spec-files-to-services', - }, - ], - }, - { - title: 'Create a Domain', - icon: RectangleGroupIcon, - iconBg: 'purple', - description: 'Organize your services and events into logical business domains.', - links: [ - { - text: 'How to add a domain', - href: 'https://www.eventcatalog.dev/docs/domains', - }, - { - text: 'Adding services to domains', - href: 'https://www.eventcatalog.dev/docs/development/guides/domains/adding-services-to-domains', - }, - { - text: 'Creating a ubiquitous language', - href: 'https://www.eventcatalog.dev/docs/development/guides/domains/adding-ubiquitous-language', - }, - ], - }, -]; +// Check if catalog has content +const hasContent = domains.length > 0 || services.length > 0 || messages.length > 0 || flows.length > 0; const getDefaultUrl = (route: string, defaultValue: string) => { if (domains.length > 0) return buildUrl(`/${route}/domains/${domains[0].data.id}/${domains[0].data.latestVersion}`); @@ -90,189 +38,301 @@ const topTiles = [ { title: 'Domains', count: domains.length, - description: 'Business domains defined', - href: buildUrl('/architecture/domains'), + description: 'Business domains', + href: buildUrl('/discover/domains'), icon: RectangleGroupIcon, - bgColor: 'bg-yellow-100', + bgColor: 'bg-yellow-500', + borderColor: 'border-yellow-200', textColor: 'text-yellow-600', - arrowColor: 'text-yellow-600', + emptyText: 'No domains yet', + addHref: 'https://www.eventcatalog.dev/docs/domains', }, { title: 'Services', count: services.length, - description: 'Services documented in the catalog', - href: buildUrl('/architecture/services'), + description: 'Documented services', + href: buildUrl('/discover/services'), icon: ServerIcon, - bgColor: 'bg-pink-100', + bgColor: 'bg-pink-500', + borderColor: 'border-pink-200', textColor: 'text-pink-600', - arrowColor: 'text-pink-600', + emptyText: 'No services yet', + addHref: 'https://www.eventcatalog.dev/docs/services', }, { title: 'Messages', count: messages.length, - description: 'Messages documented in the catalog', - href: buildUrl('/architecture/messages'), + description: 'Events, commands & queries', + href: buildUrl('/discover/events'), icon: ChatBubbleLeftIcon, - bgColor: 'bg-blue-100', + bgColor: 'bg-blue-500', + borderColor: 'border-blue-200', textColor: 'text-blue-600', - arrowColor: 'text-blue-600', + emptyText: 'No messages yet', + addHref: 'https://www.eventcatalog.dev/docs/messages', + }, + { + title: 'Flows', + count: flows.length, + description: 'Business flows', + href: buildUrl('/discover/flows'), + icon: Workflow, + bgColor: 'bg-[rgb(var(--ec-accent))]', + borderColor: 'border-[rgb(var(--ec-accent)/0.3)]', + textColor: 'text-[rgb(var(--ec-accent))]', + emptyText: 'No flows yet', + addHref: 'https://www.eventcatalog.dev/docs/flows', + }, +]; + +const quickActions = [ + { + title: 'Documentation', + description: 'Browse all documented resources', + icon: BookOpenText, + href: getDefaultUrl('docs', 'domains'), + iconBg: 'bg-blue-50 dark:bg-blue-500/10', + iconColor: 'text-blue-600 dark:text-blue-400', + }, + { + title: 'Visualizer', + description: 'Interactive architecture diagrams', + icon: Workflow, + href: getDefaultUrl('visualiser', 'domains'), + iconBg: 'bg-[rgb(var(--ec-accent-subtle))]', + iconColor: 'text-[rgb(var(--ec-accent))]', + }, + { + title: 'Discover', + description: 'Search and filter all resources', + icon: TableProperties, + href: buildUrl('/discover/events'), + iconBg: 'bg-teal-50 dark:bg-teal-500/10', + iconColor: 'text-teal-600 dark:text-teal-400', + }, + { + title: 'Schema Explorer', + description: 'Browse and compare schemas', + icon: FileJson, + href: buildUrl('/schemas/explorer'), + iconBg: 'bg-amber-50 dark:bg-amber-500/10', + iconColor: 'text-amber-600 dark:text-amber-400', + }, + { + title: 'Team Directory', + description: 'Ownership & contacts', + icon: BookUser, + href: buildUrl('/directory/users'), + iconBg: 'bg-orange-50 dark:bg-orange-500/10', + iconColor: 'text-orange-600 dark:text-orange-400', + }, + { + title: 'API & SDK', + description: 'Programmatic access', + icon: Code2, + href: 'https://www.eventcatalog.dev/docs/sdk', + iconBg: 'bg-indigo-50 dark:bg-indigo-500/10', + iconColor: 'text-indigo-600 dark:text-indigo-400', + external: true, }, ]; --- - -
    -
    -

    - {config?.organizationName || 'EventCatalog'} -

    -

    - {config.tagline || 'Comprehensive event-driven architecture documentation covering events, services, domains.'} -

    -
    +
    + +
    +
    +
    +

    + {config?.organizationName || 'EventCatalog'} +

    +

    + { + config.tagline || + 'Explore and understand your event-driven architecture. Browse documentation, visualize dependencies, and discover how your systems communicate.' + } +

    -

    Architecture overview

    -
    +
    - -
    -
    -
    -
    - + +
    + +
    + +
    + { + topTiles.map((tile: any) => + tile.count > 0 ? ( + +
    +
    +
    + +
    + +
    +
    {tile.count}
    +
    {tile.description}
    +
    + ) : ( +
    +
    +
    +
    + +
    +
    +
    {tile.emptyText}
    + + + Add {tile.title.toLowerCase()} +
    -

    Users & Teams

    -
    -

    Discover service and message ownership

    -
    - + ) + ) + }
    -
    + -
    -

    Getting Started

    -
    + +
    +

    Explore

    +
    { - gettingStartedItems.map((item) => ( -
    -
    -
    - -
    -

    {item.title}

    + quickActions.map((action: any) => ( + +
    +
    -

    {item.description}

    -
    - {item.links.map((link) => ( - - → {link.text} - - ))} +
    +
    + {action.title} + {action.external && ( + + + + )} +
    +
    {action.description}
    -
    + )) }
    + + +
    + +
    - +
    diff --git a/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts b/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts new file mode 100644 index 000000000..c2efe82e6 --- /dev/null +++ b/eventcatalog/src/pages/architecture/[type]/[id]/[version]/_index.data.ts @@ -0,0 +1,70 @@ +import { isSSR } from '@utils/feature'; +import { HybridPage } from '@utils/page-loaders/hybrid-page'; +import type { PageTypes } from '@types'; +import { pageDataLoader } from '@utils/page-loaders/page-data-loader'; +import { getDomains } from '@utils/collections/domains'; +import { getServices } from '@utils/collections/services'; + +/** + * Documentation page class for all collection types with versioning + */ +export class Page extends HybridPage { + static async getStaticPaths() { + if (isSSR()) { + return []; + } + + const itemTypes: PageTypes[] = ['services', 'domains']; + + const domains = await getDomains({ enrichServices: true }); + const services = await getServices(); + + const pageData = [services, domains]; + + return pageData.flatMap((items, index) => + items.map((item) => ({ + params: { + type: itemTypes[index], + id: item.data.id, + version: item.data.version, + }, + props: { + type: itemTypes[index], + ...item, + // Not everything needs the body of the page itself. + body: undefined, + }, + })) + ); + } + + protected static async fetchData(params: any) { + const { type, id, version } = params; + + if (!type || !id || !version) { + return null; + } + + // Get all items of the specified type + const items = await pageDataLoader[type as PageTypes](); + + // Find the specific item by id and version + const item = items.find((i) => i.data.id === id && i.data.version === version); + + if (!item) { + return null; + } + + return { + type, + ...item, + }; + } + + protected static createNotFoundResponse(): Response { + return new Response(null, { + status: 404, + statusText: 'Documentation not found', + }); + } +} diff --git a/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro b/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro new file mode 100644 index 000000000..7e8e1ebfb --- /dev/null +++ b/eventcatalog/src/pages/architecture/[type]/[id]/[version]/index.astro @@ -0,0 +1,33 @@ +--- +import DomainGrid from '@components/Grids/DomainGrid'; +import MessageGrid from '@components/Grids/MessageGrid'; +import { getSpecificationsForService } from '@utils/collections/services'; + +import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; +import { Page } from './_index.data'; + +export const prerender = Page.prerender; +export const getStaticPaths = Page.getStaticPaths; + +// Get data +const props = await Page.getData(Astro); +let domain = props; + +const pageTitle = `${props.type} | ${props.data.name}`.replace(/^\w/, (c) => c.toUpperCase()); + +const type = props.type; + +// Get specifications for services +const specifications = type === 'services' ? getSpecificationsForService(props) : []; +--- + + +
    +
    +
    + {type === 'domains' && } + {type === 'services' && } +
    +
    +
    +
    diff --git a/eventcatalog/src/pages/architecture/[type]/index.astro b/eventcatalog/src/pages/architecture/[type]/index.astro deleted file mode 100644 index bed361c36..000000000 --- a/eventcatalog/src/pages/architecture/[type]/index.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -import Architecture from '../architecture.astro'; - -export async function getStaticPaths() { - const VALID_TYPES = ['domains', 'services', 'messages'] as const; - return VALID_TYPES.map((type) => ({ - params: { type }, - })); -} - -const { type } = Astro.params; ---- - - diff --git a/eventcatalog/src/pages/architecture/architecture.astro b/eventcatalog/src/pages/architecture/architecture.astro deleted file mode 100644 index 3eef231d3..000000000 --- a/eventcatalog/src/pages/architecture/architecture.astro +++ /dev/null @@ -1,110 +0,0 @@ ---- -import { getDomains, getMessagesForDomain } from '@utils/collections/domains'; -import { getServices } from '@utils/collections/services'; -import { getContainers } from '@utils/collections/containers'; -import { getMessages } from '@utils/messages'; -import type { ExtendedDomain } from '@components/Grids/DomainGrid'; -import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; -import DomainGrid from '@components/Grids/DomainGrid'; -import ServiceGrid from '@components/Grids/ServiceGrid'; -import MessageGrid from '@components/Grids/MessageGrid'; -import { removeContentFromCollection } from '@utils/collections/util'; - -import type { CollectionEntry } from 'astro:content'; -import type { CollectionMessageTypes } from '@types'; - -import { isVisualiserEnabled } from '@utils/feature'; - -import { ClientRouter, fade } from 'astro:transitions'; -// Define valid types and their corresponding data fetchers -const VALID_TYPES = ['domains', 'services', 'messages'] as const; -type ValidType = (typeof VALID_TYPES)[number]; - -interface Service extends CollectionEntry<'services'> { - sends: CollectionEntry<'events' | 'commands' | 'queries'>[]; - receives: CollectionEntry<'events' | 'commands' | 'queries'>[]; -} - -const { type, embeded = false } = Astro.props as { type: ValidType; embeded: boolean }; - -// Get data based on type -let items: Service[] | CollectionEntry<'commands'>[] | CollectionEntry[] = []; -let domains: ExtendedDomain[] = []; -let containers: CollectionEntry<'containers'>[] = []; - -const getDomainsForArchitecturePages = async () => { - const domains = await getDomains({ getAllVersions: false }); - - // Get messages for each domain - return Promise.all( - domains.map(async (domain) => { - const messages = await getMessagesForDomain(domain); - // @ts-ignore we have to remove markdown information, as it's all send to the astro components. This reduced the page size. - return { - ...domain, - sends: messages.sends.map((s) => ({ ...s, body: undefined, catalog: undefined })), - receives: messages.receives.map((r) => ({ ...r, body: undefined, catalog: undefined })), - catalog: undefined, - body: undefined, - } as ExtendedDomain; - }) - ); -}; - -if (type === 'domains' || type === 'services') { - domains = await getDomainsForArchitecturePages(); -} - -if (type === 'services') { - const services = await getServices({ getAllVersions: false }); - let filteredServices = services.map((s) => { - // @ts-ignore we have to remove markdown information, as it's all send to the astro components. This reduced the page size. - return { - ...s, - sends: (s.data.sends || []).map((s) => ({ ...s, body: undefined, catalog: undefined })), - receives: (s.data.receives || []).map((r) => ({ ...r, body: undefined, catalog: undefined })), - catalog: undefined, - body: undefined, - } as Service; - }) as unknown as Service[]; - items = filteredServices; -} else if (type === 'messages') { - const { events, commands, queries } = await getMessages({ getAllVersions: false, hydrateServices: false }); - const messages = [...events, ...commands, ...queries]; - items = removeContentFromCollection(messages) as unknown as CollectionEntry[]; - containers = await getContainers({ getAllVersions: false }); -} ---- - - -
    -
    -
    - {type === 'domains' && } - { - type === 'services' && ( - - ) - } - { - type === 'messages' && ( - []} - embeded={embeded} - containers={containers} - isVisualiserEnabled={isVisualiserEnabled()} - client:load - /> - ) - } -
    -
    - -
    -
    diff --git a/eventcatalog/src/pages/architecture/docs/[type]/index.astro b/eventcatalog/src/pages/architecture/docs/[type]/index.astro deleted file mode 100644 index f71565e2d..000000000 --- a/eventcatalog/src/pages/architecture/docs/[type]/index.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -import Architecture from '../../architecture.astro'; - -export async function getStaticPaths() { - const VALID_TYPES = ['domains', 'services', 'messages'] as const; - return VALID_TYPES.map((type) => ({ - params: { type }, - })); -} - -const { type } = Astro.params; ---- - - diff --git a/eventcatalog/src/pages/auth/login.astro b/eventcatalog/src/pages/auth/login.astro deleted file mode 100644 index fa1745c66..000000000 --- a/eventcatalog/src/pages/auth/login.astro +++ /dev/null @@ -1,280 +0,0 @@ ---- -import config from '@config'; -const { title, logo } = config; -import { join } from 'node:path'; -import { isAuthEnabled, isSSR } from '@utils/feature'; - -const catalogDirectory = process.env.PROJECT_DIR || process.cwd(); - -let hasAuthConfigurationFile = false; -let providers: string[] = []; - -try { - const authConfig = await import(/* @vite-ignore */ join(catalogDirectory, 'eventcatalog.auth.js')); - providers = Object.keys(authConfig.default.providers); - hasAuthConfigurationFile = true; -} catch (error) { - hasAuthConfigurationFile = false; -} - -// Check if we should show login (auth file exists, SSR enabled, auth enabled, and has providers) -const shouldShowLogin = hasAuthConfigurationFile && isSSR() && isAuthEnabled() && providers.length > 0; - -// Check if configuration exists but no providers are set up -const hasConfigButNoProviders = hasAuthConfigurationFile && isSSR() && isAuthEnabled() && providers.length === 0; - -// If we are not in SSR mode, redirect to home -if (!isSSR() || !isAuthEnabled()) { - return Astro.redirect('/'); -} - -// If we are in SSR mode, check if the user is already logged in -if (isSSR() && isAuthEnabled()) { - const { getSession } = await import(/* @vite-ignore */ 'auth-astro/server'); - const session = await getSession(Astro.request); - if (session) { - return Astro.redirect('/'); - } -} - -// Provider configurations -const providerConfig = { - github: { - name: 'GitHub', - icon: ` - - `, - }, - google: { - name: 'Google', - icon: ` - - - - - `, - }, - okta: { - name: 'Okta', - icon: ` - - `, - }, - auth0: { - name: 'Auth0', - icon: ``, - }, - entra: { - name: 'Microsoft', - icon: ``, - }, -}; ---- - - - - - - - - - Sign In | {title} - - - - - - -
    - -
    - { - logo && ( -
    - {logo.alt} -

    {title}

    -
    - ) - } -
    - - { - shouldShowLogin ? ( -
    -
    -
    -

    Sign in to your account

    -
    - -
    - {providers.map((provider) => { - const config = providerConfig[provider as keyof typeof providerConfig]; - if (!config) return null; - - return ( - - ); - })} -
    -
    - -
    -

    - Missing integration? - - Let us know - -

    -
    -
    - ) : hasConfigButNoProviders ? ( -
    -
    -
    -

    No Authentication Providers Configured

    -

    - Authentication is enabled but no providers are configured in your auth configuration file. -

    -
    - -
    -

    To add authentication providers:

    -
      -
    1. - Update your{' '} - eventcatalog.auth.js{' '} - file to include at least one provider (GitHub, Google, Okta, etc.) -
    2. -
    3. Configure the provider with the necessary credentials and settings
    4. -
    5. Restart your EventCatalog server to apply the changes
    6. -
    -
    - - -
    - -
    -

    - Missing integration? - - Let us know - -

    -
    -
    - ) : ( -
    -
    -
    -

    Authentication Not Configured

    -

    - {!hasAuthConfigurationFile - ? 'No authentication configuration file found.' - : 'Authentication is not properly enabled.'} -

    -
    - -
    -

    To enable authentication:

    -
      - {!hasAuthConfigurationFile && ( -
    1. - Create an{' '} - eventcatalog.auth.js{' '} - configuration file in your project root -
    2. - )} - {!isSSR() && ( -
    3. - Enable SSR (Server-Side Rendering) in your{' '} - - eventcatalog.config.js - {' '} - file by setting{' '} - output: 'server' -
    4. - )} - {!isAuthEnabled() && ( -
    5. - Enable authentication in your{' '} - - eventcatalog.config.js - {' '} - file by setting{' '} - - auth: {`{ enabled: true }`} - -
    6. - )} -
    -
    - - -
    - -
    -

    - Missing integration? - - Let us know - -

    -
    -
    - ) - } -
    - - - - diff --git a/eventcatalog/src/pages/chat/feature.astro b/eventcatalog/src/pages/chat/feature.astro deleted file mode 100644 index cf8a0d167..000000000 --- a/eventcatalog/src/pages/chat/feature.astro +++ /dev/null @@ -1,179 +0,0 @@ ---- -import VerticalSideBarLayout from '@layouts/VerticalSideBarLayout.astro'; -import { isEventCatalogChatEnabled as hasEventCatlaogChatLicense, isSSR } from '@utils/feature'; -import { BotMessageSquare } from 'lucide-react'; -const hasChatLicense = hasEventCatlaogChatLicense(); - -if (hasChatLicense) { - return Astro.redirect('/chat'); -} ---- - - - - - - - - - EventCatalog chat? - - - -
    -
    - {/* Hero Section */} -
    -
    -
    - - EventCatalog: Agent -
    -

    Ask. Understand. Ship faster.

    -

    - Get answers about your architecture — instantly. Connect to your own AI models and data. -

    - - -

    Available with EventCatalog Starter or Scale plans

    - - { - !isSSR() && ( -

    - This feature is only available on server side. You can switch to server side by - setting the output property to{' '} - server in your{' '} - eventcatalog.config.js file. -

    - ) - } -
    - -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - What services publish order.created? -
    -
    - -
    -
    -

    - The Order Service publishes the order.created event. -

    -

    This event is consumed by:

    -
      -
    • • Payment Service - Initiates payment processing
    • -
    • • Inventory Service - Updates stock levels
    • -
    • • Notification Service - Sends order confirmations
    • -
    -
    -
    -
    -
    -
    -
    -
    - - {/* Features Section */} -
    -
    -
    - - - -
    -

    Direct Answers

    -

    - Ask questions about your catalog and get direct answers, using your own models and API keys. -

    -
    - -
    -
    - - - -
    -

    Smart Insights

    -

    Get intelligent suggestions and insights about your architecture automatically.

    -
    - -
    -
    - - - -
    -

    Privacy First

    -

    - Runs on your own infrastructure, and your own models. Provide your own API keys to get started. -

    -
    -
    - - {/* Bottom Link */} - -
    -
    -
    - - - - diff --git a/eventcatalog/src/pages/chat/index.astro b/eventcatalog/src/pages/chat/index.astro deleted file mode 100644 index d8b049a98..000000000 --- a/eventcatalog/src/pages/chat/index.astro +++ /dev/null @@ -1,10 +0,0 @@ ---- -import ChatPage from '@enterprise/eventcatalog-chat/pages/chat/index.astro'; -import { isEventCatalogChatEnabled } from '@utils/feature'; -import { buildUrl } from '@utils/url-builder'; -if (!isEventCatalogChatEnabled()) { - return Astro.redirect(buildUrl('/chat/feature')); -} ---- - - diff --git a/eventcatalog/src/pages/diagrams/[id]/[version].mdx.ts b/eventcatalog/src/pages/diagrams/[id]/[version].mdx.ts new file mode 100644 index 000000000..b5d822ba5 --- /dev/null +++ b/eventcatalog/src/pages/diagrams/[id]/[version].mdx.ts @@ -0,0 +1,47 @@ +// This file exposes the markdown for diagrams in the URL +// For example http://localhost:3000/diagrams/target-architecture/1.0.0 loads the Page +// and http://localhost:3000/diagrams/target-architecture/1.0.0.mdx loads the markdown +// This is used for LLMs to load the markdown for diagrams (llms.txt) + +import type { APIRoute } from 'astro'; +import { getCollection } from 'astro:content'; +import fs from 'fs'; +import { isLLMSTxtEnabled, isSSR } from '@utils/feature'; + +const diagrams = await getCollection('diagrams'); + +export async function getStaticPaths() { + // Just return empty array if LLMs are not enabled + if (!isLLMSTxtEnabled()) { + return []; + } + + return diagrams.map((diagram) => ({ + params: { id: diagram.data.id, version: diagram.data.version }, + props: { content: diagram }, + })); +} + +export const GET: APIRoute = async ({ params, props }) => { + // Just return empty array if LLMs are not enabled + if (!isLLMSTxtEnabled()) { + return new Response('llms.txt is not enabled for this Catalog.', { status: 404 }); + } + + if (isSSR()) { + // For SSR mode, find the diagram and read its file + const diagram = diagrams.find((d) => d.data.id === params.id && d.data.version === params.version); + if (!diagram?.filePath) { + return new Response('Not found', { status: 404 }); + } + const file = fs.readFileSync(diagram.filePath, 'utf8'); + return new Response(file, { status: 200 }); + } else { + if (props?.content?.filePath) { + const file = fs.readFileSync(props.content.filePath, 'utf8'); + return new Response(file, { status: 200 }); + } + } + + return new Response('Not found', { status: 404 }); +}; diff --git a/eventcatalog/src/pages/diagrams/[id]/[version]/_index.data.ts b/eventcatalog/src/pages/diagrams/[id]/[version]/_index.data.ts new file mode 100644 index 000000000..5c3dd2bee --- /dev/null +++ b/eventcatalog/src/pages/diagrams/[id]/[version]/_index.data.ts @@ -0,0 +1,57 @@ +import { isSSR } from '@utils/feature'; +import { HybridPage } from '@utils/page-loaders/hybrid-page'; +import { getDiagrams } from '@utils/collections/diagrams'; + +/** + * Diagrams page class for full-screen diagram viewing + */ +export class Page extends HybridPage { + static async getStaticPaths() { + if (isSSR()) { + return []; + } + + const diagrams = await getDiagrams(); + + return diagrams.map((diagram) => ({ + params: { + id: diagram.data.id, + version: diagram.data.version, + }, + props: {}, + })); + } + + protected static async fetchData(params: any) { + const { id, version } = params; + + if (!id || !version) { + return null; + } + + const diagrams = await getDiagrams(); + const diagram = diagrams.find((d) => d.data.id === id && d.data.version === version); + + if (!diagram) { + return null; + } + + // Get all versions of this diagram for the version selector + const allVersions = diagrams + .filter((d) => d.data.id === id) + .map((d) => d.data.version) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + + return { + ...diagram, + allVersions, + }; + } + + protected static createNotFoundResponse(): Response { + return new Response(null, { + status: 404, + statusText: 'Diagram not found', + }); + } +} diff --git a/eventcatalog/src/pages/diagrams/[id]/[version]/embed.astro b/eventcatalog/src/pages/diagrams/[id]/[version]/embed.astro new file mode 100644 index 000000000..5637b1d3f --- /dev/null +++ b/eventcatalog/src/pages/diagrams/[id]/[version]/embed.astro @@ -0,0 +1,267 @@ +--- +import { render } from 'astro:content'; +import components from '@components/MDX/components'; +import config from '@config'; + +import { Page } from './_index.data'; + +export const prerender = Page.prerender; +export const getStaticPaths = Page.getStaticPaths; + +const props = await Page.getData(Astro); +const { Content } = await render(props); + +const currentVersion = props.data.version; +const diagramId = props.data.id; +--- + + + + + + v{currentVersion} + + + + + +
    +
    + v{currentVersion} + + Go to diagram + +
    +
    + +
    +
    + + + + diff --git a/eventcatalog/src/pages/diagrams/[id]/[version]/index.astro b/eventcatalog/src/pages/diagrams/[id]/[version]/index.astro new file mode 100644 index 000000000..b38dc67c4 --- /dev/null +++ b/eventcatalog/src/pages/diagrams/[id]/[version]/index.astro @@ -0,0 +1,411 @@ +--- +import { render } from 'astro:content'; +import { ClientRouter } from 'astro:transitions'; +import VisualiserLayout from '@layouts/VisualiserLayout.astro'; +import components from '@components/MDX/components'; +import config from '@config'; +import { buildUrl } from '@utils/url-builder'; +import { ChevronDown, GitCompare, X, Rocket } from 'lucide-react'; +import CopyAsMarkdown from '@components/CopyAsMarkdown'; +import { isLLMSTxtEnabled, isEventCatalogChatEnabled, isDiagramComparisonEnabled } from '@utils/feature'; + +import { Page } from './_index.data'; + +export const prerender = Page.prerender; +export const getStaticPaths = Page.getStaticPaths; + +const props = await Page.getData(Astro); +const { Content } = await render(props); + +const pageTitle = `Diagram | ${props.data.name}`; +const currentVersion = props.data.version; +const allVersions = props.allVersions || [currentVersion]; +const hasMultipleVersions = allVersions.length > 1; +const scaleEnabled = isDiagramComparisonEnabled(); +const chatEnabled = isEventCatalogChatEnabled(); +const markdownDownloadEnabled = isLLMSTxtEnabled(); +const chatQuery = `Tell me about the "${props.data.name}" diagram (version ${props.data.version})`; +--- + + +
    +
    +
    +
    +
    +

    + {props.data.name} +

    + { + hasMultipleVersions ? ( +
    + + +
    + ) : ( + v{currentVersion} + ) + } +
    + {props.data.summary &&

    {props.data.summary}

    } +
    + +
    + { + hasMultipleVersions && ( + + ) + } + +
    +
    +
    + +
    +
    + +
    +
    +
    + + {/* Upgrade modal - shown when Scale is not enabled */} + { + hasMultipleVersions && !scaleEnabled && ( + + ) + } + + {/* Compare modal - shown when Scale is enabled */} + { + hasMultipleVersions && scaleEnabled && ( +