diff --git a/.eslintrc b/.eslintrc index c5e7d68..12d3eee 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,124 +1,124 @@ -{ - "env": { - "browser": true, - "node": true, - "es2022": true - }, - "parserOptions": { - "sourceType": "module" - }, - "extends": "eslint:recommended", - "rules": { - "indent": "off", - "brace-style": "off", - "no-mixed-spaces-and-tabs": "off", - "no-useless-escape": "off", - "space-unary-ops": ["error", { "words": true }], - "linebreak-style": "off", - "quotes": ["off"], - "semi": "off", - "camelcase": "off", - "no-unused-vars": "off", - "no-console": ["warn"], - "no-extra-boolean-cast": ["off"], - "no-control-regex": ["off"], - }, - "root": true, - "globals": { - "frappe": true, - "Vue": true, - "SetVueGlobals": true, - "__": true, - "repl": true, - "Class": true, - "locals": true, - "cint": true, - "cstr": true, - "cur_frm": true, - "cur_dialog": true, - "cur_page": true, - "cur_list": true, - "cur_tree": true, - "msg_dialog": true, - "is_null": true, - "in_list": true, - "has_common": true, - "posthog": true, - "has_words": true, - "validate_email": true, - "open_web_template_values_editor": true, - "validate_name": true, - "validate_phone": true, - "validate_url": true, - "get_number_format": true, - "format_number": true, - "format_currency": true, - "comment_when": true, - "open_url_post": true, - "toTitle": true, - "lstrip": true, - "rstrip": true, - "strip": true, - "strip_html": true, - "replace_all": true, - "flt": true, - "precision": true, - "CREATE": true, - "AMEND": true, - "CANCEL": true, - "copy_dict": true, - "get_number_format_info": true, - "strip_number_groups": true, - "print_table": true, - "Layout": true, - "web_form_settings": true, - "$c": true, - "$a": true, - "$i": true, - "$bg": true, - "$y": true, - "$c_obj": true, - "refresh_many": true, - "refresh_field": true, - "toggle_field": true, - "get_field_obj": true, - "get_query_params": true, - "unhide_field": true, - "hide_field": true, - "set_field_options": true, - "getCookie": true, - "getCookies": true, - "get_url_arg": true, - "md5": true, - "$": true, - "jQuery": true, - "moment": true, - "hljs": true, - "Awesomplete": true, - "Sortable": true, - "Showdown": true, - "Taggle": true, - "Gantt": true, - "Slick": true, - "Webcam": true, - "PhotoSwipe": true, - "PhotoSwipeUI_Default": true, - "io": true, - "JsBarcode": true, - "L": true, - "Chart": true, - "DataTable": true, - "Cypress": true, - "cy": true, - "it": true, - "describe": true, - "expect": true, - "context": true, - "before": true, - "beforeEach": true, - "after": true, - "qz": true, - "localforage": true, - "extend_cscript": true - } -} +{ + "env": { + "browser": true, + "node": true, + "es2022": true + }, + "parserOptions": { + "sourceType": "module" + }, + "extends": "eslint:recommended", + "rules": { + "indent": "off", + "brace-style": "off", + "no-mixed-spaces-and-tabs": "off", + "no-useless-escape": "off", + "space-unary-ops": ["error", { "words": true }], + "linebreak-style": "off", + "quotes": ["off"], + "semi": "off", + "camelcase": "off", + "no-unused-vars": "off", + "no-console": ["warn"], + "no-extra-boolean-cast": ["off"], + "no-control-regex": ["off"], + }, + "root": true, + "globals": { + "frappe": true, + "Vue": true, + "SetVueGlobals": true, + "__": true, + "repl": true, + "Class": true, + "locals": true, + "cint": true, + "cstr": true, + "cur_frm": true, + "cur_dialog": true, + "cur_page": true, + "cur_list": true, + "cur_tree": true, + "msg_dialog": true, + "is_null": true, + "in_list": true, + "has_common": true, + "posthog": true, + "has_words": true, + "validate_email": true, + "open_web_template_values_editor": true, + "validate_name": true, + "validate_phone": true, + "validate_url": true, + "get_number_format": true, + "format_number": true, + "format_currency": true, + "comment_when": true, + "open_url_post": true, + "toTitle": true, + "lstrip": true, + "rstrip": true, + "strip": true, + "strip_html": true, + "replace_all": true, + "flt": true, + "precision": true, + "CREATE": true, + "AMEND": true, + "CANCEL": true, + "copy_dict": true, + "get_number_format_info": true, + "strip_number_groups": true, + "print_table": true, + "Layout": true, + "web_form_settings": true, + "$c": true, + "$a": true, + "$i": true, + "$bg": true, + "$y": true, + "$c_obj": true, + "refresh_many": true, + "refresh_field": true, + "toggle_field": true, + "get_field_obj": true, + "get_query_params": true, + "unhide_field": true, + "hide_field": true, + "set_field_options": true, + "getCookie": true, + "getCookies": true, + "get_url_arg": true, + "md5": true, + "$": true, + "jQuery": true, + "moment": true, + "hljs": true, + "Awesomplete": true, + "Sortable": true, + "Showdown": true, + "Taggle": true, + "Gantt": true, + "Slick": true, + "Webcam": true, + "PhotoSwipe": true, + "PhotoSwipeUI_Default": true, + "io": true, + "JsBarcode": true, + "L": true, + "Chart": true, + "DataTable": true, + "Cypress": true, + "cy": true, + "it": true, + "describe": true, + "expect": true, + "context": true, + "before": true, + "beforeEach": true, + "after": true, + "qz": true, + "localforage": true, + "extend_cscript": true + } +} diff --git a/.github/helper/documentation.py b/.github/helper/documentation.py new file mode 100644 index 0000000..9f787f3 --- /dev/null +++ b/.github/helper/documentation.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +Generic Documentation Link Checker for GitHub Pull Requests + +This script checks if PRs that add new features include proper documentation links. +Works with any repository by using environment variables for configuration. + +Usage: + python documentation.py [github_token] + +Environment Variables: + GITHUB_REPOSITORY: owner/repo format (e.g., "myorg/myproject") + GITHUB_TOKEN: GitHub API token (optional, increases rate limits) + DOCUMENTATION_DOMAINS: Comma-separated list of docs domains + DOCUMENTATION_KEYWORDS: Comma-separated keywords that indicate docs + SKIP_KEYWORDS: Comma-separated keywords that skip docs requirement +""" + +import os +import sys +from urllib.parse import urlparse + +import requests + + +def get_documentation_domains(): + """Get documentation domains from environment or use defaults.""" + env_domains = os.getenv("DOCUMENTATION_DOMAINS", "") + if env_domains: + return [domain.strip() for domain in env_domains.split(",")] + + # Default domains for common documentation sites + return [ + "docs.github.io", + "readthedocs.io", + "gitbook.io", + "notion.site", + "confluence.com", + "docs.google.com", + "frappeframework.com", + "docs.erpnext.com", + "docs.frappe.io", + ] + + +def get_documentation_keywords(): + """Get keywords that indicate documentation from environment.""" + env_keywords = os.getenv("DOCUMENTATION_KEYWORDS", "") + if env_keywords: + return [keyword.strip().lower() for keyword in env_keywords.split(",")] + + # Default keywords that indicate documentation + return [ + "docs", + "documentation", + "readme", + "guide", + "tutorial", + "wiki", + "manual", + "reference", + "api docs", + "user guide", + "developer guide", + ] + + +def get_skip_keywords(): + """Get keywords that skip documentation requirement.""" + env_keywords = os.getenv("SKIP_KEYWORDS", "") + if env_keywords: + return [keyword.strip().lower() for keyword in env_keywords.split(",")] + + # Default keywords that skip documentation requirement + return [ + "no-docs", + "skip-docs", + "no docs", + "skip docs", + "backport", + "revert", + "hotfix", + "emergency", + "internal", + "wip", + "work in progress", + ] + + +def is_valid_url(url: str) -> bool: + """Check if URL has valid structure.""" + try: + parts = urlparse(url) + return all((parts.scheme, parts.netloc, parts.path)) + except Exception: + return False + + +def is_documentation_link(word: str) -> bool: + """Check if a word/URL points to documentation.""" + if not word.startswith("http") or not is_valid_url(word): + return False + + parsed_url = urlparse(word) + documentation_domains = get_documentation_domains() + + # Check if domain is in documentation domains + for domain in documentation_domains: + if domain in parsed_url.netloc: + return True + + # Check for GitHub links to docs + if parsed_url.netloc == "github.com": + path_parts = parsed_url.path.split("/") + # Check for /owner/repo/wiki, /owner/repo/blob/main/docs, etc. + if len(path_parts) >= 4: + if "wiki" in path_parts or "docs" in parsed_url.path.lower(): + return True + + return False + + +def contains_documentation_keywords(text: str) -> bool: + """Check if text contains documentation-related keywords.""" + text_lower = text.lower() + documentation_keywords = get_documentation_keywords() + + return any(keyword in text_lower for keyword in documentation_keywords) + + +def contains_documentation_link(body: str) -> bool: + """Check if PR body contains documentation links.""" + words = [word for line in body.splitlines() for word in line.split()] + return any(is_documentation_link(word) for word in words) + + +def should_skip_documentation_check(title: str, body: str) -> bool: + """Check if documentation requirement should be skipped.""" + skip_keywords = get_skip_keywords() + combined_text = f"{title} {body}".lower() + + return any(keyword in combined_text for keyword in skip_keywords) + + +def get_github_repository(): + """Get GitHub repository from environment.""" + repo = os.getenv("GITHUB_REPOSITORY") + if not repo: + raise ValueError( + "GITHUB_REPOSITORY environment variable not set. " + "Should be in format 'owner/repo'" + ) + return repo + + +def get_github_headers(): + """Get GitHub API headers with optional authentication.""" + headers = { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "Documentation-Checker/1.0" + } + + token = os.getenv("DHWANI_FRAPPE_TOKEN") + if token: + headers["Authorization"] = f"token {token}" + + return headers + + +def check_pull_request(pr_number: str) -> "tuple[int, str]": + """ + Check if a pull request includes proper documentation. + + Returns: + tuple[int, str]: (exit_code, message) + exit_code: 0 for success, 1 for failure + message: Human-readable status message + """ + try: + repository = get_github_repository() + headers = get_github_headers() + + # Fetch PR details + url = f"https://api.github.com/repos/{repository}/pulls/{pr_number}" + response = requests.get(url, headers=headers, timeout=30) + + if not response.ok: + if response.status_code == 404: + return 0, f"Pull Request #{pr_number} not found - may have been deleted or merged already. Skipping documentation check. ✅" + elif response.status_code == 403: + return 0, f"GitHub API rate limit or permissions issue. Skipping documentation check. ⚠️" + else: + return 0, f"GitHub API error: {response.status_code}. Skipping documentation check. ⚠️" + + payload = response.json() + title = (payload.get("title") or "").strip() + body = (payload.get("body") or "").strip() + head_sha = (payload.get("head") or {}).get("sha") + + # Basic validation + if not head_sha: + return 1, "Invalid pull request data! ⚠️" + + # Check if this is a feature that needs documentation + title_lower = title.lower() + if not (title_lower.startswith("feat") or "feature" in title_lower): + return 0, "Not a feature PR - skipping documentation check 🏃" + + # Check if documentation should be skipped + if should_skip_documentation_check(title, body): + return 0, "Documentation check skipped (found skip keyword) 🏃" + + # Check for documentation links + if contains_documentation_link(body): + return 0, "Documentation link found! You're awesome! 🎉" + + # Check for documentation keywords (less strict) + if contains_documentation_keywords(body): + return 0, "Documentation keywords found in PR description 📚" + + # No documentation found + return 1, ( + "Documentation not found! ⚠️\n" + "Feature PRs should include:\n" + "• Link to documentation\n" + "• Documentation keywords in description\n" + "• Or add 'no-docs' if no documentation needed" + ) + + except requests.RequestException as e: + return 1, f"Network error checking documentation: {e} ⚠️" + except Exception as e: + return 1, f"Error checking documentation: {e} ⚠️" + + +def main(): + """Main entry point.""" + if len(sys.argv) < 2: + print("Usage: python documentation.py ") + print("Environment variables:") + print(" GITHUB_REPOSITORY (required): owner/repo") + print(" GITHUB_TOKEN (optional): GitHub API token") + print(" DOCUMENTATION_DOMAINS (optional): comma-separated domains") + print(" SKIP_KEYWORDS (optional): comma-separated skip keywords") + sys.exit(1) + + pr_number = sys.argv[1] + + try: + exit_code, message = check_pull_request(pr_number) + print(message) + sys.exit(exit_code) + except ValueError as e: + print(f"Configuration error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/helper/update-version.py b/.github/helper/update-version.py new file mode 100644 index 0000000..af9a905 --- /dev/null +++ b/.github/helper/update-version.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +""" +Helper script to update version in Frappe app __init__.py files +Usage: python .github/helper/update-version.py +""" +import os +import re +import sys + +def update_version_in_init_files(new_version): + """Update version in all app __init__.py files""" + updated_files = [] + + # Look for directories that contain __init__.py (potential Frappe apps) + for item in os.listdir('.'): + if os.path.isdir(item) and item not in ['node_modules', '.git', '__pycache__', '.github']: + init_file = os.path.join(item, '__init__.py') + if os.path.exists(init_file): + try: + with open(init_file, 'r') as f: + content = f.read() + + # Update version using regex + pattern = r'__version__\s*=\s*["\'][0-9]+\.[0-9]+\.[0-9]+["\']' + replacement = f'__version__ = "{new_version}"' + + if re.search(pattern, content): + updated_content = re.sub(pattern, replacement, content) + + with open(init_file, 'w') as f: + f.write(updated_content) + + updated_files.append(init_file) + print(f"Updated version in {init_file} to {new_version}") + else: + print(f"No version found in {init_file}") + + except Exception as e: + print(f"Error updating {init_file}: {e}") + + return updated_files + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: python scripts/update-version.py ") + sys.exit(1) + + new_version = sys.argv[1] + updated_files = update_version_in_init_files(new_version) + + if updated_files: + print(f"Successfully updated version to {new_version} in {len(updated_files)} files") + else: + print("No files were updated") diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..e4ebba6 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,4 @@ +changelog: + exclude: + labels: + - skip-release-notes diff --git a/.github/workflows/bot-handler.yml b/.github/workflows/bot-handler.yml new file mode 100644 index 0000000..7775760 --- /dev/null +++ b/.github/workflows/bot-handler.yml @@ -0,0 +1,162 @@ +name: Dhwani Release Bot Handler + +on: + issue_comment: + types: [created] + pull_request: + types: [opened] + branches: [ main, master ] + +permissions: + contents: write + issues: write + pull-requests: write + id-token: write + actions: write + +jobs: + handle-bot-mention: + name: Handle Bot Commands + runs-on: ubuntu-latest + if: | + (github.event.issue_comment.body != null && contains(github.event.issue_comment.body, '@dhwani-release-bot')) || + (github.event.pull_request.body != null && contains(github.event.pull_request.body, '@dhwani-release-bot')) + + steps: + - name: Generate GitHub App Token + id: generate-token + uses: tibdex/github-app-token@v1 + with: + app_id: ${{ secrets.DHWANI_RELEASE_BOT_APP_ID }} + private_key: ${{ secrets.DHWANI_RELEASE_BOT_PRIVATE_KEY }} + + - name: Checkout Repository + uses: actions/checkout@v5 + with: + token: ${{ steps.generate-token.outputs.token }} + fetch-depth: 0 + + - name: Parse Bot Command + id: parse-command + run: | + if [ "${{ github.event_name }}" == "issue_comment" ]; then + COMMENT_BODY="${{ github.event.issue_comment.body }}" + else + COMMENT_BODY="${{ github.event.pull_request.body }}" + fi + + echo "comment_body<> $GITHUB_OUTPUT + echo "$COMMENT_BODY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + # Extract command after @dhwani-release-bot + COMMAND=$(echo "$COMMENT_BODY" | grep -oP '@dhwani-release-bot\s+\K\S+' | head -1 || echo "") + + if [ -z "$COMMAND" ]; then + COMMAND="help" + fi + + echo "command=$COMMAND" >> $GITHUB_OUTPUT + echo "Command detected: $COMMAND" + + - name: Handle Release Command + if: steps.parse-command.outputs.command == 'release' || steps.parse-command.outputs.command == 'trigger-release' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + const context = github.context; + const command = '${{ steps.parse-command.outputs.command }}'; + + // Trigger the release workflow + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release.yml', + ref: context.ref || 'main' + }); + + // Add a comment + const comment = `🚀 Release workflow triggered by @${context.actor}!\n\nI've started the semantic release process. Check the [workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions) for progress.`; + + if (context.eventName === 'issue_comment') { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: comment + }); + } else if (context.eventName === 'pull_request') { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: comment + }); + } + + - name: Handle Help Command + if: steps.parse-command.outputs.command == 'help' || steps.parse-command.outputs.command == '' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + const context = github.context; + const helpText = `## 🤖 Dhwani Release Bot Commands + + Available commands: + + - \`@dhwani-release-bot release\` or \`@dhwani-release-bot trigger-release\` - Trigger a semantic release + - \`@dhwani-release-bot help\` - Show this help message + + ### How it works: + - The bot automatically creates releases when code is pushed to \`main\` or \`master\` branches + - You can also manually trigger releases using the commands above + - Developers can commit normally - the bot only handles releases + + Need help? Check the [workflow documentation](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/workflows/release.yml).`; + + if (context.eventName === 'issue_comment') { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: helpText + }); + } else if (context.eventName === 'pull_request') { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: helpText + }); + } + + - name: Handle Unknown Command + if: steps.parse-command.outputs.command != 'release' && steps.parse-command.outputs.command != 'trigger-release' && steps.parse-command.outputs.command != 'help' && steps.parse-command.outputs.command != '' + uses: actions/github-script@v7 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + const context = github.context; + const command = '${{ steps.parse-command.outputs.command }}'; + const unknownCommandText = `❓ Unknown command: \`${command}\` + + Type \`@dhwani-release-bot help\` to see available commands.`; + + if (context.eventName === 'issue_comment') { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: unknownCommandText + }); + } else if (context.eventName === 'pull_request') { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: unknownCommandText + }); + } + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1add21e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,270 @@ +name: CI + +on: + push: + branches: [ main, master ] + paths: + - '**.py' + - '**.js' + - '**.json' + - '**.cfg' + - '**.toml' + - '**.txt' + - '**.yml' + - '**.yaml' + pull_request: + branches: [ main, master, develop, development ] + paths: + - '**.py' + - '**.js' + - '**.json' + - '**.cfg' + - '**.toml' + - '**.txt' + - '**.yml' + - '**.yaml' + workflow_dispatch: + +permissions: + contents: read + +# Cancel previous runs on the same PR/branch (saves minutes on rapid pushes) +concurrency: + group: ci-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + frappe-bench-test: + name: 'Frappe Bench App Tests' + runs-on: ubuntu-latest + # Only on merge to main/master — not on PRs (too expensive) + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + + services: + mariadb: + image: mariadb:10.11 + env: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: test_frappe + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping -h localhost" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd="redis-cli ping" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Check if Frappe app exists + id: check-app + run: | + if [ -f "__init__.py" ] || [ -f "hooks.py" ]; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "No Frappe app files found, skipping bench tests" + fi + + - name: Setup Python + if: steps.check-app.outputs.exists == 'true' + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + + - name: Setup Node.js + if: steps.check-app.outputs.exists == 'true' + uses: actions/setup-node@v4 + with: + node-version: 18 + + - name: Install system dependencies + if: steps.check-app.outputs.exists == 'true' + run: | + sudo apt-get update + sudo apt-get install -y \ + mariadb-client \ + redis-tools \ + curl \ + git \ + wget \ + xvfb \ + libfontconfig1 \ + libfreetype6 \ + libxrender1 \ + libjpeg-turbo8 \ + xfonts-75dpi \ + xfonts-base + + - name: Install Frappe Bench CLI + if: steps.check-app.outputs.exists == 'true' + run: pip install frappe-bench + + - name: Wait for MariaDB + if: steps.check-app.outputs.exists == 'true' + run: | + for i in {1..30}; do + if mysqladmin ping -h 127.0.0.1 -P 3306 -u root -proot --silent; then + echo "MariaDB is ready" + break + fi + echo "Waiting for MariaDB... ($i/30)" + sleep 2 + done + + - name: Wait for Redis + if: steps.check-app.outputs.exists == 'true' + run: | + for i in {1..30}; do + if redis-cli -h 127.0.0.1 -p 6379 ping | grep -q PONG; then + echo "Redis is ready" + break + fi + echo "Waiting for Redis... ($i/30)" + sleep 2 + done + + - name: Detect app name + if: steps.check-app.outputs.exists == 'true' + id: app-name + run: | + REPO_NAME=$(echo "${{ github.repository }}" | cut -d'/' -f2) + APP_NAME=$(echo "$REPO_NAME" | tr '-' '_' | tr '[:upper:]' '[:lower:]' | sed 's/^frappe_//') + + if [ -f "__init__.py" ] || [ -f "hooks.py" ]; then + echo "Repo root is the app: $APP_NAME" + else + FOUND_APP=$(find . -maxdepth 2 -name "__init__.py" -type f | head -1 | xargs dirname | xargs basename) + if [ -n "$FOUND_APP" ] && [ "$FOUND_APP" != "." ]; then + APP_NAME="$FOUND_APP" + fi + fi + + if [ -z "$APP_NAME" ] || [ "$APP_NAME" = "." ]; then + echo "Error: Could not detect app name" + exit 1 + fi + + echo "name=$APP_NAME" >> $GITHUB_OUTPUT + echo "APP_PATH=$(pwd)" >> $GITHUB_OUTPUT + echo "Detected app name: $APP_NAME" + + - name: Initialize Frappe Bench + if: steps.check-app.outputs.exists == 'true' + run: bench init --skip-redis-config-generation --skip-assets --frappe-branch version-15 frappe-bench + + - name: Get app into bench + if: steps.check-app.outputs.exists == 'true' + working-directory: frappe-bench + run: | + APP_NAME="${{ steps.app-name.outputs.name }}" + APP_PATH="${{ steps.app-name.outputs.APP_PATH }}" + + if [ -f "$APP_PATH/__init__.py" ] || [ -f "$APP_PATH/hooks.py" ]; then + bench get-app $APP_NAME $APP_PATH + elif [ -d "$APP_PATH/$APP_NAME" ] && [ -f "$APP_PATH/$APP_NAME/__init__.py" ]; then + bench get-app $APP_NAME $APP_PATH/$APP_NAME + else + echo "Error: Could not find app at expected location" + exit 1 + fi + + - name: Create test site + if: steps.check-app.outputs.exists == 'true' + working-directory: frappe-bench + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_ROOT_USER: root + DB_ROOT_PASSWORD: root + REDIS_CACHE: redis://127.0.0.1:6379 + REDIS_QUEUE: redis://127.0.0.1:6379 + run: | + APP_NAME="${{ steps.app-name.outputs.name }}" + bench new-site test_site \ + --db-type mariadb \ + --admin-password admin \ + --no-mariadb-socket \ + --mariadb-host 127.0.0.1 \ + --mariadb-port 3306 \ + --mariadb-root-password root \ + --install-app $APP_NAME || { + echo "Site creation or app installation failed" + exit 1 + } + + - name: Run app-specific tests + if: steps.check-app.outputs.exists == 'true' + working-directory: frappe-bench + env: + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + REDIS_CACHE: redis://127.0.0.1:6379 + REDIS_QUEUE: redis://127.0.0.1:6379 + run: | + APP_NAME="${{ steps.app-name.outputs.name }}" + bench --site test_site run-tests --app $APP_NAME || { + echo "Tests failed for app $APP_NAME" + exit 1 + } + + - name: Upload test results + if: always() && steps.check-app.outputs.exists == 'true' + uses: actions/upload-artifact@v4 + with: + name: frappe-test-results + path: frappe-bench/sites/test_site/logs/*.log + if-no-files-found: ignore + + build: + name: 'Build Check' + runs-on: ubuntu-latest + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build + if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then npm ci; else echo "No npm lockfile, skipping npm ci"; fi + + - name: Build Python package + run: python -m build || echo "No Python package to build" + + - name: Build frontend assets + run: | + if [ -f package.json ]; then npm run build || echo "No build script found"; else echo "No package.json, skipping frontend build"; fi + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: build-artifacts + path: | + dist/ + build/ + if-no-files-found: ignore diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..0f2dac9 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,108 @@ +name: Quality Checks + +on: + pull_request: + branches: [ main, master, develop, development ] + types: [opened, synchronize, reopened, ready_for_review] + paths: + - '**.py' + - '**.js' + - '**.css' + - '**.html' + - '**.json' + - '.pre-commit-config.yaml' + - 'commitlint.config.js' + workflow_dispatch: + +permissions: + contents: read + +# Cancel previous quality check runs on the same PR +concurrency: + group: quality-${{ github.repository }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + commit-lint: + name: 'Semantic Commits' + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 200 + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Check commit titles + run: | + npm install @commitlint/cli @commitlint/config-conventional + npx commitlint --verbose --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} + + linter: + name: 'Semgrep Rules' + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: pip + + - name: Cache Semgrep rules + uses: actions/cache@v4 + with: + path: frappe-semgrep-rules + key: semgrep-rules-${{ github.run_id }} + restore-keys: semgrep-rules- + + - name: Download Semgrep rules + run: | + if [ -d "frappe-semgrep-rules" ]; then + cd frappe-semgrep-rules && git pull --ff-only || true + else + git clone --depth 1 https://github.com/frappe/semgrep-rules.git frappe-semgrep-rules + fi + + - name: Run Semgrep rules + run: | + pip install semgrep + semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness + + precommit: + name: 'Pre-Commit' + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + cache: pip + + - name: Check for pre-commit config + run: | + if [ ! -f .pre-commit-config.yaml ]; then + curl -s -o .pre-commit-config.yaml https://raw.githubusercontent.com/dhwani-ris/frappe-pre-commit/main/examples/.pre-commit-config.yaml + fi + + - name: Cache pre-commit hooks + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + restore-keys: pre-commit- + + - name: Install and run pre-commit + run: | + pip install pre-commit + pre-commit install --install-hooks + SKIP=no-commit-to-branch pre-commit run --all-files --show-diff-on-failure || { + echo "❌ Pre-commit checks failed. Run 'pre-commit run --all-files' locally." + exit 1 + } diff --git a/.github/workflows/devops-checklist.yml b/.github/workflows/devops-checklist.yml new file mode 100644 index 0000000..383e529 --- /dev/null +++ b/.github/workflows/devops-checklist.yml @@ -0,0 +1,386 @@ +name: DevOps Checklist Reminder + +on: + pull_request: + # Only on open/reopen — not on synchronize (saves a full run per push to PR) + types: [opened, reopened] + branches: [ main, master ] + +permissions: + pull-requests: write + contents: read + +jobs: + checklist-reminder: + name: Add DevOps Checklist Reminder + runs-on: ubuntu-latest + if: | + github.event.pull_request.base.ref == 'main' || + github.event.pull_request.base.ref == 'master' + + steps: + - name: Check if deployment notes comment exists + id: check-deployment-notes + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const pr = context.payload.pull_request; + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + }); + + const botComment = comments.data.find( + comment => comment.user.type === 'Bot' && + comment.body.includes('Production Deployment Release Document') + ); + + return { exists: !!botComment, commentId: botComment?.id }; + + - name: Generate Deployment Notes automatically (Comment 1) + if: steps.check-deployment-notes.outputs.exists != 'true' || github.event.action == 'opened' + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const pr = context.payload.pull_request; + + // Get all commits in the PR using GitHub API + let commits = []; + try { + const { data: prCommits } = await github.rest.pulls.listCommits({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + + commits = prCommits.map(commit => ({ + hash: commit.sha.substring(0, 7), + message: commit.commit.message.split('\n')[0], + author: commit.commit.author.name, + date: commit.commit.author.date.split('T')[0] + })); + } catch (e) { + console.log('Error getting commits:', e.message); + } + + // Determine release type from commits + let releaseType = 'Patch'; + const hasBreaking = commits.some(c => + c.message.includes('BREAKING') || + c.message.includes('BREAKING CHANGE') || + c.message.startsWith('feat!') || + c.message.match(/^feat\(.+\)!:/) + ); + const hasFeature = commits.some(c => + c.message.startsWith('feat') && !c.message.startsWith('feat!') + ); + const hasFix = commits.some(c => c.message.startsWith('fix')); + + if (hasBreaking) { + releaseType = 'Major'; + } else if (hasFeature) { + releaseType = 'Minor'; + } else if (hasFix) { + releaseType = 'Patch'; + } + + // Get version from latest release or estimate from release type + let version = 'TBA'; + try { + const { data: releases } = await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 1 + }); + + if (releases.length > 0) { + const latestRelease = releases[0]; + const versionMatch = latestRelease.tag_name.match(/v?(\d+\.\d+\.\d+)/); + if (versionMatch) { + const [major, minor, patch] = versionMatch[1].split('.').map(Number); + if (releaseType === 'Major') { + version = `${major + 1}.0.0`; + } else if (releaseType === 'Minor') { + version = `${major}.${minor + 1}.0`; + } else { + version = `${major}.${minor}.${patch + 1}`; + } + } + } else { + // No releases yet, start with 1.0.0 + version = releaseType === 'Major' ? '1.0.0' : releaseType === 'Minor' ? '0.1.0' : '0.0.1'; + } + } catch (e) { + console.log('Could not determine version:', e.message); + } + + // Get reviewers + let reviewers = []; + try { + const { data: reviews } = await github.rest.pulls.listReviews({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + const approvedReviewers = reviews + .filter(r => r.state === 'APPROVED') + .map(r => r.user.login) + .filter((v, i, a) => a.indexOf(v) === i); // unique + reviewers = approvedReviewers; + } catch (e) { + console.log('Could not get reviewers:', e.message); + } + + // Group commits by type for feature details + const features = commits.filter(c => c.message.startsWith('feat')).map(c => c.message.replace(/^feat(\(.+?\))?:\s*/i, '')); + const fixes = commits.filter(c => c.message.startsWith('fix')).map(c => c.message.replace(/^fix(\(.+?\))?:\s*/i, '')); + const other = commits.filter(c => !c.message.startsWith('feat') && !c.message.startsWith('fix') && !c.message.startsWith('chore') && !c.message.startsWith('ci')); + + // Build feature details + let featureDetails = []; + if (features.length > 0) { + featureDetails.push(...features.map(f => `1) ${f}`)); + } + if (fixes.length > 0) { + featureDetails.push(...fixes.map(f => `2) ${f}`)); + } + if (other.length > 0) { + featureDetails.push(...other.slice(0, 5).map((o, i) => `${i + 3}) ${o.message}`)); + } + + const today = new Date().toISOString().split('T')[0]; + const releaseDate = today.split('-').reverse().join('-'); // Format: DD-MM-YYYY + + // Format feature details better + const formatFeatureDetails = (details) => { + if (details.length === 0) return 'See commits above'; + return details.map((f, i) => `${i + 1}) ${f}`).join('
'); + }; + + const deploymentNotes = `## 📝 Production Deployment Release Document + + **Release Information:** + - **Version:** \`${version}\` + - **Release Date:** \`${releaseDate}\` + - **Prepared by:** \`${pr.user.login}\` + - **Approved by:** \`${reviewers.length > 0 ? reviewers.join(', ') : 'Pending'}\` + - **Release Type:** \`${releaseType}\` + + **Deployment Branches:** + \`\`\` + ${pr.base.ref} + \`\`\` + + **Overview:** + - Here are the key feature details for this release: + + **Repository Details:** + + | S.No. | Repository Name | Release Number | Feature Details | + |-------|-----------------|----------------|-----------------| + | 1. | \`${context.repo.repo}\` | \`${pr.base.ref}-release-${version}\` | ${formatFeatureDetails(featureDetails)} | + + **Dependencies:** + - Dependencies updated: \`TBD\` *(Please review and update)* + \`\`\` + + \`\`\` + + **Database Changes (Queries to run):** + - Database changes required: \`TBD\` *(Please review and update)* + \`\`\` + + \`\`\` + + **Testing:** + - [ ] Unit tests passed + - [ ] Integration tests passed + - [ ] E2E tests passed + - [ ] Manual testing completed + \`\`\` + + \`\`\` + + **Known Issues:** + - Known issues: \`TBD\` *(Please review and update)* + \`\`\` + + \`\`\` + + **Contact Information:** + - Support Team Email: \`\`\`\`\`\` + - Support Team Phone: \`\`\`\`\`\` + + **Attachments:** + - Deployment files attached/committed: \`TBD\` *(Please review and update)* + \`\`\` + + \`\`\` + + --- + + ### For DevOps Team Use Only + *(To be filled by the DevOps team after deploying the release)* + + **Deployment Details:** + - Date and time of deployment: \`\`\`\`\`\` + - Deployed by: \`\`\`\`\`\` + - Deployment Status: \`\`\`\`\`\` + + **Deployment Instructions:** + - [ ] Pre-deployment tasks completed (backups, etc.) + - [ ] Production environment accessed securely + - [ ] Latest release pulled from version control + - [ ] Dependencies installed/updated + - [ ] Database migrations run (if applicable) + - [ ] Application services restarted + - [ ] Deployment monitored and verified + + **Rollback Plan:** + - [ ] Rollback procedure documented + - [ ] Previous version tag identified: \`\`\`\`\`\` + - [ ] Database rollback scripts prepared (if applicable) + - [ ] Rollback tested in staging environment + + **Post-Deployment Checklist:** + - [ ] Service availability and response times verified + - [ ] System resources monitored + - [ ] Critical user scenarios tested + - [ ] Data integrity confirmed + - [ ] Error logs reviewed + - [ ] Security scans completed + - [ ] Server and infrastructure health checked + - [ ] Backup and disaster recovery procedures validated + + **Notes:** + \`\`\` + + \`\`\` + + **Acknowledgment:** + - [ ] Deployment acknowledged and system ready for production use + + --- + **Note:** This deployment document was **automatically generated** from PR commits and information. Please review and update the TBD sections before merging.`; + + // Check if comment already exists + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + }); + + const existingComment = comments.data.find( + comment => comment.user.type === 'Bot' && + comment.body.includes('Production Deployment Release Document') + ); + + if (existingComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: deploymentNotes + }); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: deploymentNotes + }); + } + + - name: Check if checklist comment exists + id: check-comment + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const pr = context.payload.pull_request; + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + }); + + const botComment = comments.data.find( + comment => comment.user.type === 'Bot' && + comment.body.includes('DevOps Checklist - Workflow Review') + ); + + return { exists: !!botComment, commentId: botComment?.id }; + + - name: Add or update DevOps checklist reminder (Comment 2) + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const pr = context.payload.pull_request; + const checklist = `## 🔧 DevOps Checklist - Workflow Review + + **Please review all workflows and checks before merging:** + + ### Workflow Status Review + - [ ] All CI/CD workflows are passing + - [ ] Quality Checks workflow passed + - [ ] Security Scan workflow passed + - [ ] Code quality checks passed + - [ ] Test coverage meets requirements + + ### Review Status + - [ ] All required reviewers have approved + - [ ] Code review completed + - [ ] Security review completed (if applicable) + + ### Pre-Merge Verification + - [ ] Deployment Notes document reviewed (see Deployment Notes comment above) + - [ ] All commits reviewed + - [ ] Breaking changes identified (if any) + - [ ] Version number verified (if applicable) + + ### Final Checks + - [ ] No blocking issues or errors + - [ ] Ready for production deployment + - [ ] Rollback plan understood (if high-risk) + + --- + **Note:** This checklist is for DevOps team to verify all workflows and checks before merging.`; + + // Check if comment already exists + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + }); + + const existingComment = comments.data.find( + comment => comment.user.type === 'Bot' && + comment.body.includes('DevOps Checklist - Workflow Review') + ); + + if (existingComment) { + // Update existing comment + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existingComment.id, + body: checklist + }); + console.log('Updated existing DevOps Checklist comment'); + } else { + // Create new comment + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: checklist + }); + console.log('Created new DevOps Checklist comment'); + } + diff --git a/.github/workflows/notifications.yml b/.github/workflows/notifications.yml new file mode 100644 index 0000000..bcfce93 --- /dev/null +++ b/.github/workflows/notifications.yml @@ -0,0 +1,127 @@ +name: Workflow Notifications + +on: + workflow_run: + workflows: + - "CI" + - "Generate Semantic Release" + types: [completed] + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + notify-on-failure: + name: Notify on Critical Failures + runs-on: ubuntu-latest + if: | + (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'master') && + github.event.workflow_run.conclusion == 'failure' + + steps: + - name: Get workflow run details and notify + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const run = context.payload.workflow_run; + const workflowName = run.name; + const workflowUrl = run.html_url; + const commitSha = run.head_sha.substring(0, 7); + const branch = run.head_branch; + const actor = run.actor.login; + + // Get commit message + let commitMessage = 'N/A'; + try { + const { data: commit } = await github.rest.repos.getCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: run.head_sha + }); + commitMessage = commit.commit.message.split('\n')[0]; + } catch (e) { + console.log('Could not fetch commit details:', e.message); + } + + const title = `🚨 Workflow Failure: ${workflowName}`; + const body = `## Workflow Failure Alert + + **Workflow:** ${workflowName} + **Branch:** \`${branch}\` + **Commit:** ${commitSha} — ${commitMessage} + **Triggered by:** @${actor} + + [View failed workflow run](${workflowUrl}) + + --- + *Auto-generated by workflow notification system.*`; + + // Check for existing open issue + const { data: issues } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + labels: 'workflow-failure' + }); + + const existing = issues.find(i => + i.title.includes(workflowName) && i.body.includes(commitSha) + ); + + if (!existing) { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: title, + body: body, + labels: ['workflow-failure', 'bug'] + }); + } + + - name: Send Slack notification (optional) + if: env.SLACK_WEBHOOK_URL != '' + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + run: | + curl -s -X POST -H 'Content-type: application/json' \ + --data "{\"text\":\"🚨 ${GITHUB_REPOSITORY}: ${{ github.event.workflow_run.name }} failed on ${{ github.event.workflow_run.head_branch }}. ${{ github.event.workflow_run.html_url }}\"}" \ + $SLACK_WEBHOOK_URL || true + + notify-on-success: + name: Notify on Release Success + runs-on: ubuntu-latest + if: | + (github.event.workflow_run.head_branch == 'main' || github.event.workflow_run.head_branch == 'master') && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.name == 'Generate Semantic Release' + + steps: + - name: Check for new release + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { data: releases } = await github.rest.repos.listReleases({ + owner: context.repo.owner, + repo: context.repo.repo, + per_page: 1 + }); + + if (releases.length === 0) { + console.log('No releases found'); + return; + } + + const latest = releases[0]; + const diffMinutes = (new Date() - new Date(latest.created_at)) / (1000 * 60); + + if (diffMinutes > 10) { + console.log('Release older than 10 minutes, skipping'); + return; + } + + console.log(`New release: ${latest.tag_name}`); diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 0000000..61e3b00 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,127 @@ +name: "PR Setup" + +on: + pull_request: + types: [opened, reopened, ready_for_review] + branches: [main, master] + +permissions: + contents: read + pull-requests: write + +jobs: + pr-setup: + name: Label & Assign Reviewer + runs-on: ubuntu-latest + if: contains(fromJson('["main", "master"]'), github.event.pull_request.base.ref) + steps: + - name: Auto Label and Request Review + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const pr = context.payload.pull_request; + const title = pr.title.toLowerCase(); + const labels = []; + + // --- Label based on title --- + const skipPrefixes = ['chore', 'ci', 'style', 'test', 'refactor']; + if (skipPrefixes.some(p => title.startsWith(p + ':') || title.startsWith(p + '('))) { + labels.push('skip-release-notes'); + } + + if (title.includes('feat') || title.includes('feature')) { + labels.push('feature'); + } else if (title.includes('fix') || title.includes('bug')) { + labels.push('bug'); + } else if (title.includes('docs') || title.includes('doc')) { + labels.push('docs'); + } else if (title.includes('refactor')) { + labels.push('refactor'); + } + + labels.push('release'); + + // Apply labels + if (labels.length > 0) { + try { + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: labels + }); + console.log(`Added labels: ${labels.join(', ')}`); + } catch (error) { + if (error.status === 422) { + const labelConfigs = { + 'skip-release-notes': { color: '6c757d', description: 'Skip in release notes' }, + 'feature': { color: '0e8a16', description: 'New feature' }, + 'bug': { color: 'd73a4a', description: 'Bug fix' }, + 'docs': { color: '0052cc', description: 'Documentation' }, + 'refactor': { color: 'fbca04', description: 'Code refactoring' }, + 'release': { color: 'e83e8c', description: 'Production release PR' } + }; + for (const label of labels) { + if (labelConfigs[label]) { + try { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label, + color: labelConfigs[label].color, + description: labelConfigs[label].description + }); + } catch (e) { /* label may already exist */ } + } + } + // Retry + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: labels + }).catch(e => console.log(`Label retry failed: ${e.message}`)); + } + } + } + + // --- Request review --- + if (pr.draft) { + console.log('PR is draft, skipping review request'); + return; + } + + const reviewer = 'dhwani-ankit'; + try { + const { data: currentReviews } = await github.rest.pulls.listRequestedReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + }); + + const alreadyRequested = currentReviews.users?.some( + u => u.login.toLowerCase() === reviewer.toLowerCase() + ); + + if (!alreadyRequested) { + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + reviewers: [reviewer], + }); + console.log(`Requested review from ${reviewer}`); + } + } catch (error) { + console.log(`Could not request review: ${error.message}`); + // Fallback: add as assignee + try { + await github.rest.issues.addAssignees({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + assignees: [reviewer], + }); + } catch (e) { /* ignore */ } + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c61b8e1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Generate Semantic Release +on: + push: + branches: [ main, master ] + paths: + - '**.py' + - '**.js' + - '**.json' + - '**.toml' + - '**.cfg' + workflow_dispatch: + inputs: + force_release: + description: 'Force release even if no changes detected' + required: false + default: 'false' + type: boolean + +permissions: + contents: write + issues: write + pull-requests: write + id-token: write + actions: read + +# Wait for init workflow to complete first +concurrency: + group: repo-init-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App Token + id: generate-token + uses: tibdex/github-app-token@v1 + with: + app_id: ${{ secrets.DHWANI_RELEASE_BOT_APP_ID }} + private_key: ${{ secrets.DHWANI_RELEASE_BOT_PRIVATE_KEY }} + + - name: Checkout Entire Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ steps.generate-token.outputs.token }} + persist-credentials: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Setup dependencies + run: | + npm install semantic-release @semantic-release/git @semantic-release/exec @semantic-release/github @semantic-release/changelog @semantic-release/commit-analyzer @semantic-release/release-notes-generator --no-save + + - name: Configure Git + run: | + git config --global user.name "dhwani-release-bot" + git config --global user.email "dhwani-release-bot[bot]@users.noreply.github.com" + + - name: Create Release + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + GIT_AUTHOR_NAME: "dhwani-release-bot" + GIT_AUTHOR_EMAIL: "dhwani-release-bot[bot]@users.noreply.github.com" + GIT_COMMITTER_NAME: "dhwani-release-bot" + GIT_COMMITTER_EMAIL: "dhwani-release-bot[bot]@users.noreply.github.com" + NODE_ENV: production + run: | + npx semantic-release || { + echo "No release needed or semantic-release exited with code $?" + exit 0 + } diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml new file mode 100644 index 0000000..d87ad19 --- /dev/null +++ b/.github/workflows/security-scan.yml @@ -0,0 +1,137 @@ +name: Enhanced Security Scan + +on: + push: + branches: [ main, master ] + paths: + - '**.py' + - '**.js' + - '**/requirements*.txt' + - '**/pyproject.toml' + - '**/setup.py' + - '**/setup.cfg' + - '**/package.json' + - '**/package-lock.json' + schedule: + # Weekly on Monday at 2 AM UTC (was daily — saves ~85% scheduled minutes) + - cron: '0 2 * * 1' + workflow_dispatch: + +permissions: + contents: read + security-events: write + actions: read + +# Cancel in-progress runs for the same branch +concurrency: + group: security-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + codeql-analysis: + name: CodeQL Security Analysis + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + # Single language — Python is the primary language in Frappe apps. + # JS/TS CodeQL rarely finds issues beyond what Semgrep catches in code-quality. + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: python + queries: security-extended + + - name: Autobuild + uses: github/codeql-action/autobuild@v3 + + - name: Perform CodeQL Analysis + id: codeql-analysis + uses: github/codeql-action/analyze@v3 + continue-on-error: true + with: + category: "/language:python" + + - name: Check CodeQL upload status + if: always() && steps.codeql-analysis.outcome == 'failure' + run: | + echo "⚠️ CodeQL analysis completed but upload failed." + echo "This is expected if Code Security is not enabled for this repository." + echo "To enable: Settings > Code security and analysis > Code scanning" + + dependency-scanning: + name: Dependency Vulnerability Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-security-pip-${{ hashFiles('**/requirements*.txt', '**/pyproject.toml') }} + restore-keys: ${{ runner.os }}-security-pip- + + - name: Run pip-audit + run: | + pip install pip-audit + pip-audit --desc --format json --output pip-audit-report.json || true + + - name: Run Bandit Security Scan + run: | + pip install bandit[toml] + bandit -r . -f json -o bandit-report.json --exclude ./.git,./node_modules,./frappe-bench || true + + - name: Upload security reports + uses: actions/upload-artifact@v4 + with: + name: security-reports-${{ github.run_id }} + path: | + pip-audit-report.json + bandit-report.json + retention-days: 14 + + security-summary: + name: Security Summary + runs-on: ubuntu-latest + needs: [codeql-analysis, dependency-scanning] + if: always() + + steps: + - name: Generate Security Summary + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const needs = ${{ toJSON(needs) }}; + const codeqlResult = needs['codeql-analysis']?.result || 'unknown'; + const dependencyResult = needs['dependency-scanning']?.result || 'unknown'; + + const getStatus = (result) => { + if (result === 'success') return '✅ Passed'; + if (result === 'failure') return '❌ Failed'; + return '⚠️ Skipped'; + }; + + const summary = `## 🔒 Security Scan Summary + + | Scan Type | Status | + |-----------|--------| + | CodeQL Analysis (Python) | ${getStatus(codeqlResult)} | + | Dependency Scan | ${getStatus(dependencyResult)} | + + **View detailed results in the Security tab or workflow artifacts.**`; + + core.summary.addRaw(summary).write(); diff --git a/.gitignore b/.gitignore index b50d332..f7f0335 100644 --- a/.gitignore +++ b/.gitignore @@ -1,55 +1,55 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class -*.pyc -*.py~ - -# Distribution / packaging -.Python -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -tags -MANIFEST - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Dependency directories -node_modules/ -jspm_packages/ - -# IDEs and editors -.vscode/ -.vs/ -.idea/ -.kdev4/ -*.kdev4 -*.DS_Store -*.swp -*.comp.js -.wnf-lang-status -*debug.log - -# Helix Editor -.helix/ - -# Aider AI Chat -.aider* +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class +*.pyc +*.py~ + +# Distribution / packaging +.Python +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +tags +MANIFEST + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Dependency directories +node_modules/ +jspm_packages/ + +# IDEs and editors +.vscode/ +.vs/ +.idea/ +.kdev4/ +*.kdev4 +*.DS_Store +*.swp +*.comp.js +.wnf-lang-status +*debug.log + +# Helix Editor +.helix/ + +# Aider AI Chat +.aider* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 18828e4..7d1e0e5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,69 +1,100 @@ -exclude: 'node_modules|.git' -default_stages: [pre-commit] -fail_fast: false - - -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 - hooks: - - id: trailing-whitespace - files: "frappe_desk_theme.*" - exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" - - id: check-merge-conflict - - id: check-ast - - id: check-json - - id: check-toml - - id: check-yaml - - id: debug-statements - - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.1 - hooks: - - id: ruff - name: "Run ruff import sorter" - args: ["--select=I", "--fix"] - - - id: ruff - name: "Run ruff linter" - - - id: ruff-format - name: "Run ruff formatter" - - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v2.7.1 - hooks: - - id: prettier - types_or: [javascript, vue, scss] - # Ignore any files that might contain jinja / bundles - exclude: | - (?x)^( - frappe_desk_theme/public/dist/.*| - .*node_modules.*| - .*boilerplate.*| - frappe_desk_theme/templates/includes/.*| - frappe_desk_theme/public/js/lib/.* - )$ - - - - repo: https://github.com/pre-commit/mirrors-eslint - rev: v8.44.0 - hooks: - - id: eslint - types_or: [javascript] - args: ['--quiet'] - # Ignore any files that might contain jinja / bundles - exclude: | - (?x)^( - frappe_desk_theme/public/dist/.*| - cypress/.*| - .*node_modules.*| - .*boilerplate.*| - frappe_desk_theme/templates/includes/.*| - frappe_desk_theme/public/js/lib/.* - )$ - -ci: - autoupdate_schedule: weekly - skip: [] - submodules: false +# Copy this to your project root as .pre-commit-config.yaml +# IMPORTANT: First install the package: pip install frappe-pre-commit + +exclude: 'node_modules|.git' +default_stages: [pre-commit] +fail_fast: false + + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + files: "frappe.*" + exclude: ".*json$|.*txt$|.*csv|.*md|.*svg" + - id: no-commit-to-branch + args: ['--branch', 'develop'] + - id: check-merge-conflict + - id: check-ast + - id: check-json + - id: check-toml + - id: check-yaml + - id: debug-statements + exclude: ^frappe/tests/classes/context_managers\.py$ + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.1 + hooks: + - id: ruff + name: "Run ruff import sorter" + args: ["--select=I", "--fix"] + exclude: ^\.github/helper/ + + - id: ruff + name: "Run ruff linter" + exclude: ^\.github/helper/ + + - id: ruff-format + name: "Run ruff formatter" + exclude: ^\.github/helper/ + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.7.1 + hooks: + - id: prettier + types_or: [javascript, vue, scss] + # Ignore any files that might contain jinja / bundles + exclude: | + (?x)^( + frappe/public/dist/.*| + .*node_modules.*| + .*boilerplate.*| + frappe/www/website_script.js| + frappe/templates/includes/.*| + frappe/public/js/lib/.*| + frappe/website/doctype/website_theme/website_theme_template.scss + )$ + + + - repo: https://github.com/pre-commit/mirrors-eslint + rev: v8.44.0 + hooks: + - id: eslint + types_or: [javascript] + args: ['--quiet'] + # Ignore any files that might contain jinja / bundles + exclude: | + (?x)^( + frappe/public/dist/.*| + cypress/.*| + .*node_modules.*| + .*boilerplate.*| + frappe/www/website_script.js| + frappe/templates/includes/.*| + frappe/public/js/lib/.* + )$ + + # Frappe-specific coding standards + # The frappe-pre-commit package will be installed automatically + - repo: https://github.com/dhwani-ris/frappe-pre-commit + rev: v1.0.4 + hooks: + - id: frappe-sql-security + - id: frappe-doctype-naming + - id: frappe-coding-standards + +# Alternative: Use all checks in one hook +# - repo: https://github.com/dhwani-ris/frappe-pre-commit +# rev: v1.0.0 +# hooks: +# - id: frappe-all-checks + +# Configuration for specific tools +default_language_version: + python: python3 + +ci: + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/.releaserc b/.releaserc new file mode 100644 index 0000000..f12ae44 --- /dev/null +++ b/.releaserc @@ -0,0 +1,79 @@ +{ + "branches": ["main", "master"], + "plugins": [ + [ + "@semantic-release/commit-analyzer", + { + "preset": "angular", + "releaseRules": [ + { "type": "feat", "release": "minor" }, + { "type": "fix", "release": "patch" }, + { "type": "perf", "release": "patch" }, + { "type": "revert", "release": "patch" }, + { "type": "docs", "release": false }, + { "type": "style", "release": false }, + { "type": "chore", "release": false }, + { "type": "refactor", "release": false }, + { "type": "test", "release": false }, + { "type": "build", "release": false }, + { "type": "ci", "release": false }, + { "breaking": true, "release": "major" } + ], + "parserOpts": { + "noteKeywords": ["BREAKING CHANGE", "BREAKING CHANGES"] + } + } + ], + [ + "@semantic-release/release-notes-generator", + { + "preset": "angular", + "presetConfig": { + "types": [ + { "type": "feat", "section": "✨ Features" }, + { "type": "fix", "section": "🐛 Bug Fixes" }, + { "type": "perf", "section": "⚡ Performance Improvements" }, + { "type": "revert", "section": "⏪ Reverts" }, + { "type": "docs", "section": "📝 Documentation", "hidden": false }, + { "type": "style", "section": "💎 Styles", "hidden": false }, + { "type": "refactor", "section": "♻️ Code Refactoring", "hidden": false }, + { "type": "test", "section": "✅ Tests", "hidden": false }, + { "type": "build", "section": "🏗️ Build System", "hidden": false }, + { "type": "ci", "section": "👷 Continuous Integration", "hidden": false }, + { "type": "chore", "section": "🔧 Miscellaneous Chores", "hidden": false } + ] + }, + "writerOpts": { + "commitsSort": ["scope", "subject"] + } + } + ], + [ + "@semantic-release/changelog", + { + "changelogFile": "CHANGELOG.md" + } + ], + [ + "@semantic-release/exec", + { + "prepareCmd": "python $GITHUB_WORKSPACE/.github/helper/update-version.py ${nextRelease.version}" + } + ], + [ + "@semantic-release/git", + { + "assets": ["CHANGELOG.md", "*/__init__.py"], + "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" + } + ], + [ + "@semantic-release/github", + { + "successComment": false, + "releasedLabels": false, + "assets": [] + } + ] + ] +} \ No newline at end of file diff --git a/commitlint.config.js b/commitlint.config.js new file mode 100644 index 0000000..55f1dff --- /dev/null +++ b/commitlint.config.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ["@commitlint/config-conventional"], + // Disable default 100-char (or 72-char) header length limit for commit messages + rules: { + "header-max-length": [0, "always", 100], + }, +}; diff --git a/frappe_desk_theme/api.py b/frappe_desk_theme/api.py index 85bf55c..6c3dbe7 100644 --- a/frappe_desk_theme/api.py +++ b/frappe_desk_theme/api.py @@ -1,5 +1,33 @@ import frappe +from frappe import _ + @frappe.whitelist(allow_guest=True) def get_custom_theme(): - return frappe.get_doc("Desk Theme") \ No newline at end of file + theme = frappe.get_doc("Desk Theme") + data = theme.as_dict() + # Add carousel data if present + carousel_data = theme.get_carousel_data() if hasattr(theme, "get_carousel_data") else None + if carousel_data: + data["carousel"] = carousel_data + return data + + +@frappe.whitelist(allow_guest=True) +def get_footer_html(): + """Get rendered footer HTML template with theme data""" + try: + theme = frappe.get_doc("Desk Theme") + + # Prepare context for template + context = { + "copyright_text": theme.copyright_text, + "footer_powered_by": theme.footer_powered_by, + "sticky_footer": theme.sticky_footer, + } + + # Render the template + return frappe.render_template("frappe_desk_theme/templates/includes/desk_footer.html", context) + except Exception as e: + frappe.log_error(f"Error rendering footer template: {e!s}") + return "" diff --git a/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.js b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.js index 751c19e..d42b3a7 100644 --- a/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.js +++ b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.js @@ -9,81 +9,11 @@ frappe.ui.form.on("Desk Theme", { refresh(frm) { - // Load app options for default_app field - frappe.xcall("frappe.apps.get_apps").then((r) => { - let apps = r?.map((r) => r.name) || []; - frm.set_df_property("default_app", "options", ["", ...apps]); + // Add refresh theme button + frm.add_custom_button(__("Refresh Theme"), function () { + window.frappeDeskTheme?.clearCache(); + window.frappeDeskTheme?.refreshTheme(); + frappe.show_alert({ message: __("Theme refreshed"), indicator: "green" }); }); - - // Load current system default app if hide_app_switcher is enabled - if (frm.doc.hide_app_switcher) { - frappe.call({ - method: "frappe.client.get_value", - args: { - doctype: "System Settings", - fieldname: "default_app" - }, - callback: function(r) { - if (r.message && r.message.default_app) { - frm.set_value("default_app", r.message.default_app); - } - } - }); - } - - // Add refresh theme button - frm.add_custom_button(__('Refresh Theme'), function() { - window.frappeDeskTheme?.clearCache(); - window.frappeDeskTheme?.refreshTheme(); - frappe.show_alert({message: __('Theme refreshed'), indicator: 'green'}); - }); - }, - - hide_app_switcher(frm) { - if (frm.doc.hide_app_switcher) { - // Load current system default app when hide_app_switcher is checked - frappe.call({ - method: "frappe.client.get_value", - args: { - doctype: "System Settings", - fieldname: "default_app" - }, - callback: function(r) { - if (r.message && r.message.default_app) { - frm.set_value("default_app", r.message.default_app); - } - } - }); - } else { - // Clear default_app when hide_app_switcher is unchecked - frm.set_value("default_app", ""); - } }, - - validate(frm) { - // Validate that default_app is set when hide_app_switcher is checked - if (frm.doc.hide_app_switcher && !frm.doc.default_app) { - frappe.throw(__("Default App is required when App Switcher is hidden")); - } - }, - - after_save(frm) { - // Update system settings with the selected default app - if (frm.doc.hide_app_switcher && frm.doc.default_app) { - frappe.call({ - method: "frappe_desk_theme.frappe_desk_theme.doctype.desk_theme.desk_theme.update_system_default_app", - args: { - default_app: frm.doc.default_app - }, - callback: function(r) { - if (r.message && r.message.success) { - frappe.show_alert({ - message: __("System default app updated successfully"), - indicator: "green" - }); - } - } - }); - } - } }); diff --git a/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.json b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.json index 22e5d08..9078a54 100644 --- a/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.json +++ b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.json @@ -8,27 +8,28 @@ "login_page_tab", "login_page_section", "login_button_background_color", + "login_button_text_color", + "column_break_umnl", "login_page_button_hover_background_color", + "login_page_button_hover_text_color", + "column_break_ntnk", + "login_box_background_color", + "page_heading_text_color", + "page_setting_section", + "login_page_title", "page_background_type", "login_page_background_color", "login_page_background_image", - "is_app_details_inside_the_box", - "login_page_title", - "column_break_umnl", - "login_button_text_color", - "login_page_button_hover_text_color", + "column_break_xyqa", "login_box_position", - "page_heading_text_color", - "login_box_background_color", + "is_app_details_inside_the_box", + "carousel_images", + "allow_manual_navigation", "navbar_tab", "navbar_section", "navbar_color", - "hide_help_button", - "hide_app_switcher", - "default_app", "column_break_jevx", "navbar_text_color", - "hide_search", "buttons_tab", "primary_button_section", "button_background_color", @@ -49,10 +50,18 @@ "column_break_ojdd", "main_body_content_box_background_color", "main_body_content_box_text_color", + "sidebar_tab", "sidebar_section", "sidebar_background_color", + "sidebar_hover_background_color", + "hide_standard_menu", + "hide_notification", + "hide_search", "column_break_wdml", "sidebar_text_color", + "sidebar_hover_text_color", + "sidebar_section_break_text_color", + "sidebar_section_break_bold", "table_tab", "list_table_section", "table_head_background_color", @@ -74,7 +83,19 @@ "input_border_color", "column_break_pdaj", "input_text_color", - "input_label_color" + "input_label_color", + "footer_tab", + "footer_section", + "copyright_text", + "footer_background_color", + "sticky_footer", + "column_break_footer", + "footer_powered_by", + "footer_text_color", + "desk_behavior_tab", + "fixed_sidebar", + "column_break_cgnw", + "redirect_to_sidebar_on_login" ], "fields": [ { @@ -121,7 +142,8 @@ }, { "fieldname": "login_page_section", - "fieldtype": "Section Break" + "fieldtype": "Section Break", + "label": "Color Management" }, { "fieldname": "login_button_text_color", @@ -146,7 +168,7 @@ "fieldname": "page_background_type", "fieldtype": "Select", "label": "Page Background Type", - "options": "\nColor\nImage" + "options": "\nColor\nImage\nCarousel" }, { "depends_on": "eval:doc.page_background_type == \"Color\"", @@ -318,12 +340,6 @@ "fieldtype": "Tab Break", "label": "Table" }, - { - "default": "0", - "fieldname": "hide_help_button", - "fieldtype": "Check", - "label": "Hide Help Button" - }, { "default": "0", "fieldname": "table_hide_like_comment_section", @@ -334,7 +350,7 @@ "default": "0", "fieldname": "disable_card_view_on_mobile_view", "fieldtype": "Check", - "label": "Disable card view on mobile view" + "label": "Disable Card View On Mobile View" }, { "fieldname": "input_tab", @@ -396,7 +412,7 @@ "depends_on": "eval:doc.disable_card_view_on_mobile_view == 0", "fieldname": "disable_flex_card_content_on_mobile_view", "fieldtype": "Check", - "label": "Disable flex card content on mobile view" + "label": "Disable Flex Card Content On Mobile View" }, { "fieldname": "main_body_content_box_text_color", @@ -405,8 +421,7 @@ }, { "fieldname": "sidebar_section", - "fieldtype": "Section Break", - "label": "Sidebar" + "fieldtype": "Section Break" }, { "fieldname": "sidebar_background_color", @@ -422,19 +437,139 @@ "fieldtype": "Color", "label": "Sidebar Text Color" }, + { + "fieldname": "footer_tab", + "fieldtype": "Tab Break", + "label": "Footer" + }, + { + "fieldname": "footer_section", + "fieldtype": "Section Break", + "label": "Footer Settings" + }, + { + "description": "Copyright text to display in footer", + "fieldname": "copyright_text", + "fieldtype": "Data", + "label": "Copyright Text" + }, { "default": "0", - "fieldname": "hide_app_switcher", + "description": "Makes footer stick to bottom of screen", + "fieldname": "sticky_footer", "fieldtype": "Check", - "label": "Hide App Switcher" + "label": "Sticky Footer" }, { - "depends_on": "eval:doc.hide_app_switcher == 1", - "description": "Select default app when app switcher is hidden. Required when app switcher is hidden.", - "fieldname": "default_app", - "fieldtype": "Select", - "label": "Default App", - "mandatory_depends_on": "eval:doc.hide_app_switcher == 1" + "fieldname": "column_break_footer", + "fieldtype": "Column Break" + }, + { + "description": "Custom powered by text for footer", + "fieldname": "footer_powered_by", + "fieldtype": "Data", + "label": "Footer Powered By" + }, + { + "depends_on": "eval:doc.page_background_type==\"Carousel\"", + "fieldname": "carousel_images", + "fieldtype": "Table", + "label": "Carousel Images", + "mandatory_depends_on": "eval:doc.page_background_type==\"Carousel\"", + "options": "Desk Theme Carousel Images" + }, + { + "default": "0", + "depends_on": "eval:doc.page_background_type==\"Carousel\"", + "fieldname": "allow_manual_navigation", + "fieldtype": "Check", + "label": "Allow Manual Navigation" + }, + { + "fieldname": "footer_background_color", + "fieldtype": "Color", + "label": "Background Color" + }, + { + "fieldname": "footer_text_color", + "fieldtype": "Color", + "label": "Text Color" + }, + { + "fieldname": "sidebar_hover_background_color", + "fieldtype": "Color", + "label": "Hover Background Color" + }, + { + "fieldname": "sidebar_hover_text_color", + "fieldtype": "Color", + "label": "Hover Text Color" + }, + { + "description": "Color for sidebar section headers (e.g. Modules, Tools). Leave blank to use default sidebar text color.", + "fieldname": "sidebar_section_break_text_color", + "fieldtype": "Color", + "label": "Section Break Text Color" + }, + { + "default": "0", + "description": "Make section break labels bold.", + "fieldname": "sidebar_section_break_bold", + "fieldtype": "Check", + "label": "Section Break Bold" + }, + { + "fieldname": "desk_behavior_tab", + "fieldtype": "Tab Break", + "label": "Desk Behavior" + }, + { + "description": "Fixed workspace sidebar to show for all modules", + "fieldname": "fixed_sidebar", + "fieldtype": "Link", + "label": "Fixed Workspace Sidebar", + "options": "Workspace Sidebar" + }, + { + "default": "0", + "description": "If enabled, after login redirect to the first item from the fixed sidebar instead of the default desktop screen", + "fieldname": "redirect_to_sidebar_on_login", + "fieldtype": "Check", + "label": "Redirect to Sidebar After Login" + }, + { + "fieldname": "column_break_ntnk", + "fieldtype": "Column Break" + }, + { + "fieldname": "sidebar_tab", + "fieldtype": "Tab Break", + "label": "Sidebar" + }, + { + "fieldname": "column_break_cgnw", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "hide_standard_menu", + "fieldtype": "Check", + "label": "Hide Standard Menu" + }, + { + "default": "0", + "fieldname": "hide_notification", + "fieldtype": "Check", + "label": "Hide Notification" + }, + { + "fieldname": "page_setting_section", + "fieldtype": "Section Break", + "label": "Page Setting" + }, + { + "fieldname": "column_break_xyqa", + "fieldtype": "Column Break" } ], "grid_page_length": 50, @@ -442,7 +577,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-06-26 12:58:37.343023", + "modified": "2026-03-02 17:01:54.837120", "modified_by": "Administrator", "module": "Frappe Desk Theme", "name": "Desk Theme", diff --git a/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.py b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.py index 6b3b258..6fb68f4 100644 --- a/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.py +++ b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/desk_theme.py @@ -7,31 +7,43 @@ class DeskTheme(Document): def validate(self): - # Validate that default_app is set when hide_app_switcher is checked - if self.hide_app_switcher and not self.default_app: - frappe.throw("Default App is required when App Switcher is hidden") + # Carousel validation: if carousel selected, must have at least one image + if self.page_background_type == "Carousel": + if not self.carousel_images or not any(img.image for img in self.carousel_images): + # Fallback: clear page_background_type + self.page_background_type = "" + frappe.msgprint("No carousel images found. Falling back to default background.") def on_update(self): - # Update system settings with the selected default app - if self.hide_app_switcher and self.default_app: - update_system_default_app(self.default_app) - - -@frappe.whitelist() -def update_system_default_app(default_app): - """Update the system default app setting""" - try: - # Check if the app exists in installed apps - installed_apps = frappe.get_installed_apps() - if default_app not in installed_apps: - frappe.throw(f"App '{default_app}' is not installed") - - # Update system settings - system_settings = frappe.get_single("System Settings") - system_settings.default_app = default_app - system_settings.save(ignore_permissions=True) - - return {"success": True} - except Exception as e: - frappe.log_error(f"Error updating system default app: {str(e)}") - frappe.throw(f"Failed to update system default app: {str(e)}") + # Update website settings with footer information + self.update_website_settings() + + def update_website_settings(self): + """Update Website Settings with copyright and powered by text from Desk Theme""" + try: + website_settings = frappe.get_single("Website Settings") + + # Update copyright text if provided + if self.copyright_text: + website_settings.copyright = self.copyright_text + + # Update footer powered by text if provided + if self.footer_powered_by: + website_settings.footer_powered = self.footer_powered_by + + # Save without triggering permissions check + website_settings.save(ignore_permissions=True) + + except Exception as e: + frappe.log_error(f"Error updating website settings: {e!s}") + + def get_carousel_data(self): + """Return carousel images and config for API""" + if self.page_background_type != "Carousel": + return None + images = [img.image for img in self.carousel_images if img.image] + return { + "images": images, + "manual_navigation": getattr(self, "allow_manual_navigation", True), + "auto_advance": getattr(self, "carousel_auto_advance", True), + } diff --git a/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/test_desk_theme.py b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/test_desk_theme.py index 3b58e1d..4153c63 100644 --- a/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/test_desk_theme.py +++ b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme/test_desk_theme.py @@ -4,7 +4,6 @@ # import frappe from frappe.tests import IntegrationTestCase - # On IntegrationTestCase, the doctype test records and all # link-field test record dependencies are recursively loaded # Use these module variables to add/remove to/from that list @@ -12,7 +11,6 @@ IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] - class IntegrationTestDeskTheme(IntegrationTestCase): """ Integration tests for DeskTheme. diff --git a/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme_carousel_images/__init__.py b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme_carousel_images/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme_carousel_images/desk_theme_carousel_images.json b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme_carousel_images/desk_theme_carousel_images.json new file mode 100644 index 0000000..8a3051e --- /dev/null +++ b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme_carousel_images/desk_theme_carousel_images.json @@ -0,0 +1,34 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-07-21 18:09:28.145951", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "image" + ], + "fields": [ + { + "fieldname": "image", + "fieldtype": "Attach Image", + "label": "Image", + "make_attachment_public": 1, + "reqd": 1 + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-08-03 13:15:22.432727", + "modified_by": "Administrator", + "module": "Frappe Desk Theme", + "name": "Desk Theme Carousel Images", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme_carousel_images/desk_theme_carousel_images.py b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme_carousel_images/desk_theme_carousel_images.py new file mode 100644 index 0000000..0a68fd7 --- /dev/null +++ b/frappe_desk_theme/frappe_desk_theme/doctype/desk_theme_carousel_images/desk_theme_carousel_images.py @@ -0,0 +1,15 @@ +# Copyright (c) 2025, Dhwani RIS and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class DeskThemeCarouselImages(Document): + def validate(self): + # Validate image size (max 5 MB) + if self.image: + file_doc = frappe.get_doc("File", {"file_url": self.image}) + if file_doc.file_size > 1 * 1024 * 1024: + frappe.throw(_("Carousel image size must be 1 MB or less.")) diff --git a/frappe_desk_theme/hooks.py b/frappe_desk_theme/hooks.py index 762c6ab..ccdec54 100644 --- a/frappe_desk_theme/hooks.py +++ b/frappe_desk_theme/hooks.py @@ -23,14 +23,17 @@ # Includes in # ------------------ -import time # include js, css files in header of desk.html -app_include_css = "/assets/frappe_desk_theme/css/frappe_desk_theme.css?v={}".format(time.time()) -app_include_js = "/assets/frappe_desk_theme/js/frappe_desk_theme.js?v={}".format(time.time()) +app_include_css = "/assets/frappe_desk_theme/css/frappe_desk_theme.bundle.css" +# app_include_js = "/assets/frappe_desk_theme/js/frappe_desk_theme.bundle.js" +app_include_js = [ + "/assets/frappe_desk_theme/js/frappe_desk_theme.bundle.js", + "/assets/frappe_desk_theme/js/sidebar/sidebar_override.bundle.js", +] # include js, css files in header of web template -web_include_css = "/assets/frappe_desk_theme/css/frappe_desk_theme.css?v={}".format(time.time()) -web_include_js = "/assets/frappe_desk_theme/js/frappe_desk_theme.js?v={}".format(time.time()) +web_include_css = "/assets/frappe_desk_theme/css/frappe_desk_theme.bundle.css" +web_include_js = "/assets/frappe_desk_theme/js/frappe_desk_theme.bundle.js" # include custom scss in every website theme (without file extension ".scss") # website_theme_scss = "frappe_desk_theme/public/scss/website" @@ -51,7 +54,7 @@ # Svg Icons # ------------------ # include app icons in desk -# app_include_icons = "frappe_desk_theme/public/icons.svg" +app_include_icons = "/assets/frappe_desk_theme/icons.svg" # Home Pages # ---------- @@ -236,4 +239,3 @@ # default_log_clearing_doctypes = { # "Logging DocType Name": 30 # days to retain logs # } - diff --git a/frappe_desk_theme/public/css/frappe_desk_theme.bundle.css b/frappe_desk_theme/public/css/frappe_desk_theme.bundle.css new file mode 100644 index 0000000..160d580 --- /dev/null +++ b/frappe_desk_theme/public/css/frappe_desk_theme.bundle.css @@ -0,0 +1,804 @@ +/** + * Frappe Desk Theme - Custom CSS Styling + * + * This stylesheet provides comprehensive theming support for Frappe Desk through CSS custom properties. + * It covers login page customization, navigation styling, form elements, tables, widgets, and responsive design. + * + * The theme system works by: + * 1. JavaScript sets CSS custom properties (--variable-name) based on theme configuration + * 2. CSS rules use these variables with var() function for dynamic styling + * 3. Fallback values ensure functionality when theme data is unavailable + */ + +/* ======================================== + ROOT VARIABLES & GLOBAL SETTINGS + ======================================== */ + +:root { + /* Global layout constraints - ensures content fits properly in all screen sizes */ + --page-max-width: 100% !important; +} + +/* ======================================== + LOGIN PAGE STYLING + ======================================== */ + +/* Main login page container - supports custom backgrounds and full viewport height */ +#page-login { + background: var(--login-bg-color, transparent); + background-image: var(--login-bg-image, none) !important; + background-size: cover; + height: 100vh; + background-repeat: no-repeat; + background-position: center center; + height: 100vh; + margin: 0; +} + +#page-login::before { + content: ''; + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: var(--login-bg-carousel-image, var(--login-bg-image, none)); + background-size: cover; + background-repeat: no-repeat; + background-position: center center; + opacity: var(--carousel-fade-opacity, 1); + transition: opacity 0.7s; +} + + +/* Login form buttons - primary action buttons with hover states */ +.btn-primary.btn-login, +.btn-primary.btn-forgot { + background-color: var(--login-btn-bg, #171717); + color: var(--login-btn-color, #fff); +} + +/* Login button hover effects - provides visual feedback on interaction */ +.btn-primary.btn-login:hover, +.btn-primary.btn-forgot:hover { + background-color: var(--login-btn-hover-bg, #171717); + color: var(--login-btn-hover-color, #fff); +} + +/* Form input fields - customizable appearance for text inputs */ +.form-control { + background-color: var(--input-bg, #f3f3f3); + color: var(--input-color, #383838); + border: 2px solid var(--input-border, #f3f3f3); +} + +/* Form field labels - customizable text color for better contrast */ +label.control-label { + color: var(--input-label-color, #383838); +} + +/* Login form container - supports absolute positioning (Left/Right/Center) */ +.for-login,.for-signup,.for-forgot, .for-login-with-email-link { + position: var(--login-box-position, static); + right: var(--login-box-right, auto); + left: var(--login-box-left, auto); + top: var(--login-box-top, 18%); + background-color: var(--login-box-bg-override, transparent) !important; + border-radius: var(--login-box-border-radius, 0) !important; +} + +/* Login animation - only for login forms */ +.for-login { + opacity: 0; + transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; + transform: translateY(20px); + /* Fallback animation - show login box after 1 second if JavaScript fails */ + animation: showLoginFallback 0.2s ease-in-out 1s forwards; +} + +/* Show login box after theme is applied */ +.for-login.theme-ready { + opacity: 1; + transform: translateY(0); + animation: none; /* Disable fallback animation when theme is ready */ +} + +/* Fallback keyframe animation to prevent login box from staying hidden */ +@keyframes showLoginFallback { + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Login form card - inner container with padding and width controls */ +.login-content.page-card, .signup-content.page-card { + background-color: var(--login-box-bg,#fff) !important; + border: 2px solid var(--login-box-bg,#fff) !important; + width: var(--login-box-width,400px); + padding: var(--login-box-padding); +} + +/* Login content border - customizable border for form container */ +/* .login-content, .forgot-content, .signup-content { + border: var(--login-content-border,none); + box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1); +} */ + +/* Signup message - when app details are inside the box, make it part of the container */ +.sign-up-message { + background: transparent !important; + border: none !important; + box-shadow: none !important; + margin-top: 20px; + padding: 0 !important; + color: inherit; +} + +/* Default login page title - can be hidden when custom title is used */ +.for-login .page-card-head h4 { + display: var(--login-title-display); +} + +/* Custom login page title - uses CSS ::after pseudo-element for dynamic content */ +.for-login .page-card-head:after { + display: var(--login-title-after-display); + justify-content: var(--login-title-after-justify); + margin-top: var(--login-title-after-margin); + content: var(--login-title-after-content); + color: var(--login-title-after-color); +} + +/* Page headings - consistent styling for all page titles */ +.page-card-head h4 { + color: var(--page-heading-color) !important; +} + +/* ======================================== + NAVIGATION BAR STYLING (Desk Theme > Navbar) + ======================================== */ + +/* Main navigation bar - Background Color, Text Color, Toggler Border, Breadcrumb Disabled */ +.navbar { + background-color: var(--navbar-bg, #fff); +} + +/* Navbar text elements - brand name and container text color */ +.navbar.container, +.navbar-brand { + color: var(--navbar-color, #555); +} + +/* Navbar icons and toggle button - ensures consistent coloring for SVG elements */ +.navbar-toggler, +.navbar-toggler span svg, +.navbar svg.es-icon.icon-sm use, +.notifications-seen > .es-icon { + fill: var(--navbar-color); + stroke-width: 0; +} + +/* Mobile menu toggle button - border color for hamburger menu (theme: Navbar > Toggler Border Color) */ +button.navbar-toggler { + border-color: var(--navbar-toggler-border, var(--navbar-color, #dee2e6)) !important; +} + +/* Page head bar - navbar background only (override any body-bg inheritance or other rules) */ +.page-head, +.page-head.flex { + background-color: var(--navbar-bg, #fff) !important; +} + +/* Page head breadcrumbs - override Frappe's ink-gray vars; use Desk Theme Navbar colors */ +.page-head .navbar-breadcrumbs li a { + color: var(--navbar-color, #555) !important; +} + +.page-head .navbar-breadcrumbs li a svg, +.page-head .navbar-breadcrumbs li a svg use { + fill: var(--navbar-color, #555) !important; + stroke: var(--navbar-color, #555) !important; +} + +/* Breadcrumb separators - match navbar color (Frappe uses "/", we keep theme color) */ +.page-head .navbar-breadcrumbs li a::before { + color: var(--navbar-color, #555) !important; +} + +/* Last/current breadcrumb item - Desk Theme Breadcrumb Disabled Color (overrides Frappe's li:last-child) */ +.page-head .navbar-breadcrumbs li.disabled a, +.page-head .navbar-breadcrumbs li:last-child a { + color: var(--breadcrumb-disabled-color, var(--navbar-color, #6c757d)) !important; +} + +/* Sidebar toggle in page head - match navbar color */ +.page-head .sidebar-toggle-btn.navbar-brand, +.page-head .sidebar-toggle-btn.navbar-brand svg, +.page-head .sidebar-toggle-btn.navbar-brand svg use { + color: var(--navbar-color, #555) !important; + fill: var(--navbar-color, #555) !important; + stroke: var(--navbar-color, #555) !important; +} + +/* Navigation link buttons - text and icon color consistency */ +.btn-reset.nav-link span { + color: var(--navbar-color) !important; +} + +/* Navigation link icons - ensures SVG icons match navbar color scheme */ +.btn-reset.nav-link span svg, +.btn-reset.nav-link span svg use { + stroke: var(--navbar-color) !important; + fill: var(--navbar-color) !important; +} + +/* ======================================== + BUTTON STYLING (Desk Theme > Buttons) + ======================================== */ + +/* Primary buttons - Background/Text/Hover from theme */ +.btn-primary, +.btn-primary:active { + background-color: var(--btn-primary-bg,#171717) !important; +} + +/* Primary button text - ensures text visibility on custom backgrounds */ +.btn-primary span, +.btn-primary:active span { + color: var(--btn-primary-color) !important; +} + +/* Primary button hover state - fallback to primary when not set in theme */ +.btn-primary:hover { + background-color: var(--btn-primary-hover-bg, var(--btn-primary-bg, #171717)); +} + +/* Primary button hover text - fallback to primary color when not set in theme */ +.btn-primary:hover span { + color: var(--btn-primary-hover-color, var(--btn-primary-color, #fff)); +} + +/* Secondary/default buttons - alternative action buttons with distinct styling */ +.btn.btn-default.ellipsis svg, +.btn-default svg, +.btn-default svg use { + stroke: var(--btn-secondary-color); + fill: var(--btn-secondary-color); +} +.btn.btn-default.icon-btn, +.btn.btn-default.ellipsis, +.btn-default, +.btn-default:active { + background-color: var(--btn-secondary-bg,#f3f3f3); + color: var(--btn-secondary-color); +} + +/* Secondary button hover effects - fallback to secondary when not set in theme */ +.btn.btn-default.icon-btn:hover, +.btn.btn-default.ellipsis:hover, +.btn-default:hover { + background-color: var(--btn-secondary-hover-bg, var(--btn-secondary-bg, #f3f3f3)); + color: var(--btn-secondary-hover-color, var(--btn-secondary-color, inherit)); +} + +/* ======================================== + MAIN CONTENT AREA STYLING + ======================================== */ + +/* Body background - sets overall page background color */ +body { + background-color: var(--body-bg,#fff); +} + +/* Page containers - main content wrapper background */ +.content.page-container { + background-color: var(--body-bg,#fff); +} + +/* Content sections - form layouts and main content areas with rounded corners */ +.layout-main-section, +.form-layout, +.layout-main, +.form-page, +.nav.form-tabs, +.row.form-section.card-section.visible-section { + background-color: var(--content-bg) !important; + border-radius: 10px; + color: var(--content-text-color) !important; +} + +/* ======================================== + SIDEBAR STYLING + ======================================== */ + +/* Main sidebar container - left navigation panel with custom background */ +.body-sidebar { + background-color: var(--sidebar-bg,#f8f8f8) !important; + color: var(--sidebar-text-color,#525252) !important; +} + +.body-sidebar .body-sidebar-top{ + scrollbar-width: none; +} + +/* Sidebar structural sections - ensure all sidebar areas use theme colors */ +.body-sidebar .standard-items-sections, +.body-sidebar .body-sidebar-cards, +.body-sidebar .body-sidebar-bottom { + background-color: var(--sidebar-bg,#f8f8f8) !important; + color: var(--sidebar-text-color,#525252) !important; + scrollbar-width: none; + +} + +/* Sidebar items - all text elements in sidebar navigation */ +.standard-sidebar-item, +.item-anchor, +.sidebar-item-label, +.sidebar-item-icon, +.sidebar-item-icon svg, +.sidebar-item-control button { + color: var(--sidebar-text-color,#525252) !important; +} + +/* Sidebar header - use sidebar background and text colors */ +.sidebar-header { + background-color: var(--sidebar-bg,#f8f8f8) !important; + color: var(--sidebar-text-color,#525252) !important; +} + +.sidebar-header .sidebar-item-label, +.sidebar-header .header-title, +.sidebar-header .header-subtitle { + color: var(--sidebar-text-color,#525252) !important; +} + +/* Sidebar item hover state - background and text color on hover */ +.standard-sidebar-item:hover { + background-color: var(--sidebar-hover-bg, #e9ecef) !important; + color: var(--sidebar-hover-text-color, #212529) !important; +} + +/* Sidebar header hover + active state - match sidebar items */ +.sidebar-header:hover, +.sidebar-header.active-sidebar, +.sidebar-header.active-sidebar:hover { + background-color: var(--sidebar-hover-bg, #e9ecef) !important; + color: var(--sidebar-hover-text-color, #212529) !important; +} + +.sidebar-header:hover .sidebar-item-label, +.sidebar-header:hover .header-title, +.sidebar-header:hover .header-subtitle, +.sidebar-header.active-sidebar .sidebar-item-label, +.sidebar-header.active-sidebar .header-title, +.sidebar-header.active-sidebar .header-subtitle { + color: var(--sidebar-hover-text-color, #212529) !important; +} + +/* Sidebar item hover state - child elements inherit hover colors */ +.standard-sidebar-item:hover .item-anchor, +.standard-sidebar-item:hover .sidebar-item-label, +.standard-sidebar-item:hover .sidebar-item-icon, +.standard-sidebar-item:hover .sidebar-item-icon svg { + color: var(--sidebar-hover-text-color, #212529) !important; +} + +/* Sidebar item active state - same as hover for active items */ +.standard-sidebar-item.active-sidebar, +.standard-sidebar-item.active-sidebar:hover { + background-color: var(--sidebar-hover-bg, #e9ecef) !important; + color: var(--sidebar-hover-text-color, #212529) !important; +} + +/* Sidebar item active state - child elements inherit active colors */ +.standard-sidebar-item.active-sidebar .item-anchor, +.standard-sidebar-item.active-sidebar .sidebar-item-label, +.standard-sidebar-item.active-sidebar .sidebar-item-icon, +.standard-sidebar-item.active-sidebar .sidebar-item-icon svg, +.standard-sidebar-item.active-sidebar:hover .item-anchor, +.standard-sidebar-item.active-sidebar:hover .sidebar-item-label, +.standard-sidebar-item.active-sidebar:hover .sidebar-item-icon, +.standard-sidebar-item.active-sidebar:hover .sidebar-item-icon svg { + color: var(--sidebar-hover-text-color, #212529) !important; +} + +/* Sidebar icons - SVG elements with proper fill and stroke colors */ +.sidebar-item-icon svg { + fill: var(--sidebar-bg,#f8f8f8) !important; + stroke: var(--sidebar-text-color,#525252) !important; +} + +/* Sidebar icons hover state - SVG elements with hover colors */ +.standard-sidebar-item:hover .sidebar-item-icon svg, +.standard-sidebar-item.active-sidebar .sidebar-item-icon svg { + fill: var(--sidebar-hover-bg, #e9ecef) !important; + stroke: var(--sidebar-hover-text-color, #212529) !important; +} + +/* Section break labels (sidebar group headers) - custom color and bold */ +.body-sidebar .section-break .sidebar-item-label { + color: var(--sidebar-section-break-color, var(--sidebar-text-color, #525252)) !important; + font-weight: var(--sidebar-section-break-font-weight, normal) !important; +} + +/* Collapse sidebar link */ +.collapse-sidebar-link { + color: var(--sidebar-text-color, #525252) !important; +} + +/* Collapse sidebar and bottom actions hover - use sidebar hover colors */ +.body-sidebar .collapse-sidebar-link:hover, +.body-sidebar .onboarding-sidebar:hover, +.body-sidebar .promotional-banner:hover, +.body-sidebar .dropdown-navbar-user:hover, +.body-sidebar .dropdown-navbar-user .nav-link:hover, +.body-sidebar .dropdown-navbar-user:hover .nav-link { + background-color: var(--sidebar-hover-bg, #e9ecef) !important; + color: var(--sidebar-hover-text-color, #212529) !important; +} + +/* User block (avatar + name) in sidebar - use sidebar text color */ +.body-sidebar .dropdown-navbar-user, +.body-sidebar .dropdown-navbar-user .nav-link, +.body-sidebar .dropdown-navbar-user span { + color: var(--sidebar-text-color, #525252) !important; +} + +.body-sidebar .dropdown-navbar-user svg, +.body-sidebar .dropdown-navbar-user svg use { + fill: var(--sidebar-text-color, #525252) !important; + stroke: var(--sidebar-text-color, #525252) !important; +} + +/* User menu dropdown (inside sidebar) - background and hover colors */ +.body-sidebar .dropdown-menu { + background-color: var(--sidebar-bg, #f8f8f8) !important; + color: var(--sidebar-text-color, #525252) !important; +} + +.body-sidebar .dropdown-menu .dropdown-item { + background-color: transparent !important; + color: var(--sidebar-text-color, #525252) !important; +} + +.body-sidebar .dropdown-menu .dropdown-item:hover, +.body-sidebar .dropdown-menu .dropdown-item:focus { + background-color: var(--sidebar-hover-bg, #e9ecef) !important; + color: var(--sidebar-hover-text-color, #212529) !important; +} + +/* App/toolbar context menu (frappe-menu) - match sidebar theme */ +.frappe-menu.context-menu { + background-color: var(--sidebar-bg, #f8f8f8) !important; + color: var(--sidebar-text-color, #525252) !important; +} + +.frappe-menu.context-menu .dropdown-menu-item a, +.frappe-menu.context-menu .menu-item-title { + color: var(--sidebar-text-color, #525252) !important; +} + +.frappe-menu.context-menu .menu-item-icon svg, +.frappe-menu.context-menu .menu-item-icon svg use { + fill: var(--sidebar-text-color, #525252) !important; + stroke: var(--sidebar-text-color, #525252) !important; +} + +.frappe-menu.context-menu .dropdown-menu-item:hover, +.frappe-menu.context-menu .dropdown-menu-item:focus { + background-color: var(--sidebar-hover-bg, #e9ecef) !important; + color: var(--sidebar-hover-text-color, #212529) !important; +} + +.frappe-menu.context-menu .dropdown-menu-item:hover .menu-item-title, +.frappe-menu.context-menu .dropdown-menu-item:hover .menu-item-icon svg, +.frappe-menu.context-menu .dropdown-menu-item:hover .menu-item-icon svg use { + color: var(--sidebar-hover-text-color, #212529) !important; + fill: var(--sidebar-hover-text-color, #212529) !important; + stroke: var(--sidebar-hover-text-color, #212529) !important; +} + +/* Collapse sidebar link SVG icons */ +.collapse-sidebar-link svg { + fill: var(--sidebar-text-color, #525252) !important; + stroke: var(--sidebar-text-color, #525252) !important; +} + +/* App switcher dropdown - main container styling */ +.app-switcher-dropdown { + background-color: var(--sidebar-bg, #f8f8f8) !important; + color: var(--sidebar-text-color, #525252) !important; +} + +.app-switcher-dropdown:hover { + background-color: var(--sidebar-hover-bg, #e9ecef) !important; + color: var(--sidebar-hover-text-color, #212529) !important; +} + +/* App switcher dropdown - child elements inherit colors */ +.app-switcher-dropdown .app-title, +.app-switcher-dropdown .sidebar-item-label { + color: var(--sidebar-text-color, #525252) !important; +} + +.app-switcher-dropdown:hover .app-title, +.app-switcher-dropdown:hover .sidebar-item-label { + color: var(--sidebar-hover-text-color, #212529) !important; +} + +/* App switcher menu container */ +.app-switcher-menu { + background-color: var(--sidebar-bg, #f8f8f8) !important; +} + +/* App switcher menu items */ +.app-item { + background-color: var(--sidebar-bg, #f8f8f8) !important; + color: var(--sidebar-text-color, #525252) !important; +} + +.app-item:hover { + background-color: var(--sidebar-hover-bg, #e9ecef) !important; + color: var(--sidebar-hover-text-color, #212529) !important; +} + +/* App item titles */ +.app-item-title { + color: var(--sidebar-text-color, #525252) !important; +} + +.app-item:hover .app-item-title { + color: var(--sidebar-hover-text-color, #212529) !important; +} + +/* ======================================== + TABLE STYLING + ======================================== */ + +/* Table headers - column headers with custom background and text colors */ +.level.list-row-head.text-muted { + background-color: var(--table-head-bg); +} + +/* Table header content - text elements in table headers */ +.level-left.list-header-subject, +span.level-item, +div.level-right { + color: var(--table-head-color); +} + +/* Table rows - data rows with customizable background and text colors */ +.level.list-row, +.level-item.bold.ellipsis a, +.filterable.ellipsis { + background-color: var(--table-body-bg); + color: var(--table-body-color); +} + +/* ======================================== + CONDITIONAL ELEMENT VISIBILITY + ======================================== */ + +/* Social interaction elements - like buttons and comment counts (conditionally hidden) */ +.like-icon, +.comment-count, +.level-item.list-row-activity .mx-2 { + display: var(--hide-like-comment, block) !important; +} + +/* Help buttons - assistance elements that can be hidden based on theme settings */ +.d-lg-block, +.d-sm-block { + display: var(--hide-help, block) !important; +} + +/* ======================================== + WIDGET/CARD STYLING + ======================================== */ + +/* Number widgets - dashboard widgets and statistical cards */ +.widget.number-widget-box, +.widget.dashboard-widget-box { + background-color: var(--widget-bg); + border: 2px solid var(--widget-border); + box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1); +} + +/* Widget content - all text elements within widgets */ +.widget-head, +.widget-label, +.widget-title, +.widget-body, +.widget-content div.number { + color: var(--widget-color); +} + +/* ======================================== + RESPONSIVE DESIGN + ======================================== */ + +/* Mobile breakpoint - adjustments for smaller screens */ +@media (max-width: 768px) { + /* Login form positioning - removes absolute positioning on mobile */ + .for-login { + position: static; + } + + /* Login form width - allows full width on mobile devices */ + .login-content.page-card { + width: auto; + } +} + +/* ======================================== + FOOTER STYLING + ======================================== */ + +/* Ensure main-section takes full height for proper footer positioning */ +.main-section { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Main content area should flex to push footer down */ +.main-section > .container, +.main-section > .page-container { + flex: 1; +} + +/* Main footer container - positioned to align with main content area */ +.desk-footer { + background-color: var(--footer-bg, #f8f9fa); + color: var(--footer-color, #495057); + padding: 15px 20px; + border-top: 1px solid var(--footer-border, #dee2e6); + font-size: 14px; + display: var(--footer-display, flex); + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; + margin-top: auto; + /* Position footer to align with main content area, not spanning full width */ + margin-left: 50px; /* Default collapsed sidebar width */ + transition: margin-left 200ms ease; + /* Ensure footer has consistent height */ + min-height: 50px; + flex-shrink: 0; /* Prevent footer from shrinking */ +} + +/* Adjust footer margin when sidebar is expanded */ +.body-sidebar-container.expanded ~ * .desk-footer, +.body-sidebar-container.expanded + .main-section .desk-footer { + margin-left: var(--left-sidebar-width, 220px); +} + +/* Alternative approach: Position footer within main-section if it exists */ +.main-section .desk-footer { + margin-left: 0 !important; +} + +/* Sticky footer positioning - aligns with content area */ +.desk-footer.sticky { + position: fixed; + bottom: 0; + left: 50px; /* Default collapsed sidebar width */ + right: 0; + z-index: 1000; + box-shadow: 0 -2px 4px rgba(0,0,0,0.1); + margin-left: 0; + transition: left 200ms ease-in-out; + /* Ensure consistent height for sticky footer */ + height: 50px; + box-sizing: border-box; +} + +/* JavaScript will handle dynamic positioning, but keep CSS fallback */ +.body-sidebar-container.expanded ~ * .desk-footer.sticky, +body[data-sidebar="1"] .desk-footer.sticky { + left: 220px; /* Use fixed value instead of CSS variable for better performance */ +} + +/* When footer is sticky, add padding to main section to prevent content overlap */ +.main-section.has-sticky-footer, +body.has-sticky-footer .main-section { + padding-bottom: 60px; /* 50px footer height + 10px buffer */ +} + +/* Fallback: If main-section doesn't exist, add padding to body */ +body.has-sticky-footer:not(:has(.main-section)) { + padding-bottom: 60px; +} + +/* Ensure content doesn't overlap with sticky footer */ +.main-section.has-sticky-footer { + min-height: calc(100vh - 60px); /* Full height minus footer and buffer */ +} + +/* Footer left section - copyright text */ +.desk-footer-left { + display: flex; + align-items: center; + gap: 5px; +} + +/* Footer right section - powered by text */ +.desk-footer-right { + display: flex; + align-items: center; + gap: 5px; + color: var(--footer-powered-color, #6c757d); + font-size: 13px; +} + +/* Footer links styling */ +.desk-footer a { + color: var(--footer-link-color, #007bff); + text-decoration: none; +} + +.desk-footer a:hover { + color: var(--footer-link-hover-color, #0056b3); + text-decoration: underline; +} + +/* Responsive footer for mobile and tablets */ +@media (max-width: 992px) { + /* On mobile/tablet, footer should span full width as sidebar becomes overlay */ + .desk-footer { + margin-left: 0 !important; + } + + .desk-footer.sticky { + left: 0 !important; + } +} + +@media (max-width: 768px) { + .desk-footer { + flex-direction: column; + text-align: center; + padding: 12px 15px; + gap: 8px; + } + + .desk-footer-left, + .desk-footer-right { + justify-content: center; + } + + .main-section.has-sticky-footer, + body.has-sticky-footer .main-section, + body.has-sticky-footer:not(:has(.main-section)) { + padding-bottom: 70px; /* Reduced padding for mobile */ + } +} + +.carousel-nav { + background: var(--carousel-nav-bg, rgba(0,0,0,0.3)); + color: var(--carousel-nav-color, #fff); + border: none; + font-size: var(--carousel-nav-size, 2rem); + cursor: pointer; + border-radius: var(--carousel-nav-radius, 50%); + width: var(--carousel-nav-size, 2rem); + height: var(--carousel-nav-size, 2rem); + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: var(--carousel-nav-z, 2); + pointer-events: auto; +} + +.carousel-nav-left { + left: 20px; +} + +.carousel-nav-right { + right: 20px; +} + diff --git a/frappe_desk_theme/public/css/frappe_desk_theme.css b/frappe_desk_theme/public/css/frappe_desk_theme.css deleted file mode 100644 index 23c1973..0000000 --- a/frappe_desk_theme/public/css/frappe_desk_theme.css +++ /dev/null @@ -1,359 +0,0 @@ -/** - * Frappe Desk Theme - Custom CSS Styling - * - * This stylesheet provides comprehensive theming support for Frappe Desk through CSS custom properties. - * It covers login page customization, navigation styling, form elements, tables, widgets, and responsive design. - * - * The theme system works by: - * 1. JavaScript sets CSS custom properties (--variable-name) based on theme configuration - * 2. CSS rules use these variables with var() function for dynamic styling - * 3. Fallback values ensure functionality when theme data is unavailable - */ - -/* ======================================== - ROOT VARIABLES & GLOBAL SETTINGS - ======================================== */ - -:root { - /* Global layout constraints - ensures content fits properly in all screen sizes */ - --page-max-width: 100% !important; -} - -/* ======================================== - LOGIN PAGE STYLING - ======================================== */ - -/* Main login page container - supports custom backgrounds and full viewport height */ -#page-login { - background: var(--login-bg-color, transparent); - background-image: var(--login-bg-image, none) !important; - background-size: cover; - height: 100vh; -} - -/* Login form buttons - primary action buttons with hover states */ -.btn-primary.btn-login, -.btn-primary.btn-forgot { - background-color: var(--login-btn-bg, #171717); - color: var(--login-btn-color, #fff); -} - -/* Login button hover effects - provides visual feedback on interaction */ -.btn-primary.btn-login:hover, -.btn-primary.btn-forgot:hover { - background-color: var(--login-btn-hover-bg, #171717); - color: var(--login-btn-hover-color, #fff); -} - -/* Form input fields - customizable appearance for text inputs */ -.form-control { - background-color: var(--input-bg, #f3f3f3); - color: var(--input-color, #383838); - border: 2px solid var(--input-border, #f3f3f3); -} - -/* Form field labels - customizable text color for better contrast */ -label.control-label { - color: var(--input-label-color, #383838); -} - -/* Login form container - supports absolute positioning (Left/Right/Center) */ -.for-login { - position: var(--login-box-position, static); - right: var(--login-box-right, auto); - left: var(--login-box-left, auto); - top: var(--login-box-top, 18%); - background-color: var(--login-box-bg-override, transparent) !important; - border-radius: var(--login-box-border-radius, 0) !important; - opacity: 0; - transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; - transform: translateY(20px); - /* Fallback animation - show login box after 1 second if JavaScript fails */ - animation: showLoginFallback 0.2s ease-in-out 1s forwards; -} - -/* Show login box after theme is applied */ -.for-login.theme-ready { - opacity: 1; - transform: translateY(0); - animation: none; /* Disable fallback animation when theme is ready */ -} - -/* Fallback keyframe animation to prevent login box from staying hidden */ -@keyframes showLoginFallback { - to { - opacity: 1; - transform: translateY(0); - } -} - -/* Login form card - inner container with padding and width controls */ -.login-content.page-card { - background-color: var(--login-box-bg,#fff) !important; - border: 2px solid var(--login-box-bg,#fff) !important; - width: var(--login-box-width,400px); - padding: var(--login-box-padding); -} - -/* Login content border - customizable border for form container */ -.login-content { - border: var(--login-content-border,none); -} - -/* Default login page title - can be hidden when custom title is used */ -.for-login .page-card-head h4 { - display: var(--login-title-display); -} - -/* Custom login page title - uses CSS ::after pseudo-element for dynamic content */ -.for-login .page-card-head:after { - display: var(--login-title-after-display); - justify-content: var(--login-title-after-justify); - margin-top: var(--login-title-after-margin); - content: var(--login-title-after-content); - color: var(--login-title-after-color); -} - -/* Page headings - consistent styling for all page titles */ -.page-card-head h4 { - color: var(--page-heading-color) !important; -} - -/* ======================================== - NAVIGATION BAR STYLING - ======================================== */ - -/* Main navigation bar - top-level container with custom background */ -.navbar { - background-color: var(--navbar-bg, #fff); -} - -/* Navbar text elements - brand name and container text color */ -.navbar.container, -.navbar-brand { - color: var(--navbar-color, #555); -} - -/* Navbar icons and toggle button - ensures consistent coloring for SVG elements */ -.navbar-toggler, -.navbar-toggler span svg, -.navbar svg.es-icon.icon-sm use, -.notifications-seen > .es-icon { - fill: var(--navbar-color); - stroke-width: 0; -} - -/* Mobile menu toggle button - border color for hamburger menu */ -button.navbar-toggler { - border-color: var(--navbar-color) !important; -} - -/* Breadcrumb navigation links - maintains consistent color scheme */ -#navbar-breadcrumbs li a { - color: var(--navbar-color); -} - -/* Breadcrumb separators - adds arrow separators between breadcrumb items */ -#navbar-breadcrumbs li a::before { - content: '›'; -} - -/* Disabled breadcrumb items - styling for non-clickable breadcrumb elements */ -#navbar-breadcrumbs li.disabled a { - color: var(--navbar-color) !important; -} - -/* Navigation link buttons - text and icon color consistency */ -.btn-reset.nav-link span { - color: var(--navbar-color) !important; -} - -/* Navigation link icons - ensures SVG icons match navbar color scheme */ -.btn-reset.nav-link span svg, -.btn-reset.nav-link span svg use { - stroke: var(--navbar-color) !important; - fill: var(--navbar-color) !important; -} - -/* ======================================== - BUTTON STYLING - ======================================== */ - -/* Primary buttons - main action buttons throughout the application */ -.btn-primary, -.btn-primary:active { - background-color: var(--btn-primary-bg,#171717) !important; -} - -/* Primary button text - ensures text visibility on custom backgrounds */ -.btn-primary span, -.btn-primary:active span { - color: var(--btn-primary-color) !important; -} - -/* Primary button hover state - provides visual feedback on mouse over */ -.btn-primary:hover { - background-color: var(--btn-primary-hover-bg); -} - -/* Primary button hover text - maintains readability during hover state */ -.btn-primary:hover span { - color: var(--btn-primary-hover-color); -} - -/* Secondary/default buttons - alternative action buttons with distinct styling */ -.btn.btn-default.ellipsis, -.btn-default, -.btn-default:active { - background-color: var(--btn-secondary-bg,#f3f3f3); - color: var(--btn-secondary-color); -} - -/* Secondary button hover effects - consistent interaction feedback */ -.btn.btn-default.ellipsis:hover, -.btn-default:hover { - background-color: var(--btn-secondary-hover-bg,#f3f3f3); - color: var(--btn-secondary-hover-color); -} - -/* ======================================== - MAIN CONTENT AREA STYLING - ======================================== */ - -/* Body background - sets overall page background color */ -body { - background-color: var(--body-bg,#fff); -} - -/* Page containers - main content wrapper background */ -.content.page-container, -.page-head { - background-color: var(--body-bg,#fff); -} - -/* Content sections - form layouts and main content areas with rounded corners */ -.layout-main-section, -.form-layout, -.layout-main, -.form-page, -.nav.form-tabs, -.row.form-section.card-section.visible-section { - background-color: var(--content-bg) !important; - border-radius: 10px; - color: var(--content-text-color) !important; -} - -/* ======================================== - SIDEBAR STYLING - ======================================== */ - -/* Main sidebar container - left navigation panel with custom background */ -.body-sidebar { - background-color: var(--sidebar-bg,#f8f8f8) !important; - color: var(--sidebar-text-color,#525252) !important; -} - -/* Sidebar items - all text elements in sidebar navigation */ -.standard-sidebar-item, -.item-anchor, -.sidebar-item-label, -.sidebar-item-icon, -.sidebar-item-icon svg, -.sidebar-item-control button { - color: var(--sidebar-text-color,#525252) !important; -} - -/* Sidebar icons - SVG elements with proper fill and stroke colors */ -.sidebar-item-icon svg { - fill: var(--sidebar-bg,#f8f8f8) !important; - stroke: var(--sidebar-text-color,#525252) !important; -} - -/* ======================================== - TABLE STYLING - ======================================== */ - -/* Table headers - column headers with custom background and text colors */ -.level.list-row-head.text-muted { - background-color: var(--table-head-bg); -} - -/* Table header content - text elements in table headers */ -.level-left.list-header-subject, -span.level-item, -div.level-right { - color: var(--table-head-color); -} - -/* Table rows - data rows with customizable background and text colors */ -.level.list-row, -.level-item.bold.ellipsis a, -.filterable.ellipsis { - background-color: var(--table-body-bg); - color: var(--table-body-color); -} - -/* ======================================== - CONDITIONAL ELEMENT VISIBILITY - ======================================== */ - -/* Social interaction elements - like buttons and comment counts (conditionally hidden) */ -.like-icon, -.comment-count, -.level-item.list-row-activity .mx-2 { - display: var(--hide-like-comment, block) !important; -} - -/* Help buttons - assistance elements that can be hidden based on theme settings */ -.d-lg-block, -.d-sm-block { - display: var(--hide-help, block) !important; -} - -/* App switcher - navigation dropdown that can be hidden based on theme settings */ -.sidebar-item-control{ - display: var(--hide-app-switcher, block) !important; -} - -/* App switcher anchor - disable clicking when app switcher is hidden */ -.app-switcher-dropdown { - pointer-events: var(--app-switcher-pointer-events, auto) !important; -} - - -/* ======================================== - WIDGET/CARD STYLING - ======================================== */ - -/* Number widgets - dashboard widgets and statistical cards */ -.widget.number-widget-box { - background-color: var(--widget-bg); - border: 2px solid var(--widget-border); -} - -/* Widget content - all text elements within widgets */ -.widget-head, -.widget-label, -.widget-title, -.widget-body, -.widget-content div.number { - color: var(--widget-color); -} - -/* ======================================== - RESPONSIVE DESIGN - ======================================== */ - -/* Mobile breakpoint - adjustments for smaller screens */ -@media (max-width: 768px) { - /* Login form positioning - removes absolute positioning on mobile */ - .for-login { - position: static; - } - - /* Login form width - allows full width on mobile devices */ - .login-content.page-card { - width: auto; - } -} - diff --git a/frappe_desk_theme/public/icons.svg b/frappe_desk_theme/public/icons.svg new file mode 100644 index 0000000..ce98548 --- /dev/null +++ b/frappe_desk_theme/public/icons.svg @@ -0,0 +1,1275 @@ + + \ No newline at end of file diff --git a/frappe_desk_theme/public/js/frappe_desk_theme.bundle.js b/frappe_desk_theme/public/js/frappe_desk_theme.bundle.js new file mode 100644 index 0000000..2436adb --- /dev/null +++ b/frappe_desk_theme/public/js/frappe_desk_theme.bundle.js @@ -0,0 +1,1291 @@ +/** + * FrappeDeskTheme - Main theme management class + * Handles loading, applying, and managing custom theme configurations for Frappe Desk + * Supports dynamic theme changes, user role-based hiding, and real-time DOM updates + */ +class FrappeDeskTheme { + constructor() { + // Store theme configuration data from server + this.themeData = null; + // Cache configuration + this.cacheKey = "frappe_desk_theme_cache"; + this.footerCacheStorageKey = "frappe_desk_theme_footer_cache"; + this.cacheTimeout = 30 * 24 * 60 * 60 * 1000; // 30 days (1 month) in milliseconds + // Footer creation throttling and caching + this.footerCreating = false; + this.footerHtmlCache = null; + this.footerCacheKey = null; // Track what theme data the footer was cached for + this.stickyFooterListenerSetup = false; + // Internal flag to ensure we only perform one redirect from sidebar after login + this.didInitialSidebarLoginRedirect = false; + this.init(); + } + + /** + * Initialize the theme system + * First applies cached theme immediately, then loads fresh data if needed + * Uses async/await pattern with graceful error handling + */ + async init() { + try { + // Apply cached theme immediately to prevent flickering + this.applyCachedTheme(); + + // Load fresh theme data if needed (async) + await this.loadThemeIfNeeded(); + + // Apply fresh theme if we got new data + if (this.themeData) { + this.applyTheme(); + } + + this.setupEventListeners(); + } catch (error) { + // Production-ready silent fail - apply default theme and show login box + this.applyTheme(); + this.showLoginBoxFallback(); + } + } + + /** + * Fallback method to show login box if theme loading fails + * Ensures login form is always visible even if theme fails to load + */ + showLoginBoxFallback() { + const loginBox = document.querySelector(".for-login"); + if (loginBox && !loginBox.classList.contains("theme-ready")) { + setTimeout(() => { + loginBox.classList.add("theme-ready"); + }, 100); + } + } + + /** + * Apply cached theme immediately to prevent UI flickering + */ + applyCachedTheme() { + const cachedData = this.getCachedTheme(); + if (cachedData && cachedData.data) { + this.themeData = cachedData.data; + this.applyTheme(); + } else { + // No cached theme, but still show login box to prevent indefinite hiding + this.showLoginBoxFallback(); + } + } + + /** + * Get cached theme data from localStorage + * @returns {Object|null} Cached theme data with timestamp + */ + getCachedTheme() { + try { + const cached = localStorage.getItem(this.cacheKey); + return cached ? JSON.parse(cached) : null; + } catch (error) { + return null; + } + } + + /** + * Save theme data to localStorage with timestamp + * @param {Object} themeData Theme configuration data + */ + setCachedTheme(themeData) { + try { + const cacheData = { + data: themeData, + timestamp: Date.now(), + version: 1, // Increment this when theme structure changes + }; + localStorage.setItem(this.cacheKey, JSON.stringify(cacheData)); + } catch (error) { + // localStorage might be full or disabled + } + } + + /** + * Check if cached theme is still valid + * @returns {boolean} True if cache is valid and not expired + */ + isCacheValid() { + const cachedData = this.getCachedTheme(); + if (!cachedData) return false; + + const now = Date.now(); + const cacheAge = now - cachedData.timestamp; + + return cacheAge < this.cacheTimeout; // 30 days + } + + /** + * Load theme only if cache is invalid or doesn't exist + */ + async loadThemeIfNeeded() { + // Skip API call if cache is still valid + if (this.isCacheValid()) { + return; + } + + await this.loadTheme(); + } + + /** + * Load theme configuration from server API + * Fetches custom theme data via REST API endpoint + * Handles response parsing and error states + */ + async loadTheme() { + try { + const response = await fetch("/api/method/frappe_desk_theme.api.get_custom_theme", { + method: "GET", + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const data = await response.json(); + // Handle different response formats - some APIs wrap data in 'message' property + this.themeData = data?.message || data; + + if (!this.themeData) { + throw new Error("No theme data received"); + } + + // Cache the new theme data + this.setCachedTheme(this.themeData); + } catch (error) { + // If API fails, try to use cached data as fallback + const cachedData = this.getCachedTheme(); + if (cachedData && cachedData.data) { + this.themeData = cachedData.data; + } else { + throw error; + } + } + } + + /** + * Force refresh theme from server (ignores cache) + * Useful for manual theme updates or admin changes + */ + async refreshTheme() { + try { + // Clear footer cache to ensure fresh data + this.footerHtmlCache = null; + this.footerCacheKey = null; + + await this.loadTheme(); + this.applyTheme(); + + // Dispatch event for other components + document.dispatchEvent( + new CustomEvent("themeRefreshed", { + detail: { themeData: this.themeData }, + }) + ); + } catch (error) { + // Silent fail - theme refresh errors should not interrupt user experience + } + } + + /** + * Save footer cache to localStorage + */ + saveFooterCache(footerHtml, cacheKey) { + try { + const cacheData = { + html: footerHtml, + key: cacheKey, + timestamp: Date.now(), + }; + localStorage.setItem(this.footerCacheStorageKey, JSON.stringify(cacheData)); + } catch (error) { + // localStorage might be full or disabled + } + } + + /** + * Load footer cache from localStorage + */ + loadFooterCache() { + try { + const cached = localStorage.getItem(this.footerCacheStorageKey); + if (!cached) return null; + + const cacheData = JSON.parse(cached); + const now = Date.now(); + const cacheAge = now - cacheData.timestamp; + + // Return cached data if it's still valid (within 30-day timeout) + if (cacheAge < this.cacheTimeout) { + return cacheData; + } else { + // Remove expired cache + localStorage.removeItem(this.footerCacheStorageKey); + return null; + } + } catch (error) { + return null; + } + } + + /** + * Clear theme cache (useful for debugging or forced refresh) + */ + clearCache() { + try { + localStorage.removeItem(this.cacheKey); + localStorage.removeItem(this.footerCacheStorageKey); + // Also clear footer cache + this.footerHtmlCache = null; + this.footerCacheKey = null; + } catch (error) { + // Ignore localStorage errors + } + } + + /** + * Check if current user's roles match hide_search configuration + * Used to conditionally hide search bar based on user permissions + * @returns {boolean} True if search should be hidden for current user + */ + getUserRoles() { + const currentUser = frappe?.boot?.user?.roles; + // Exit early if no user roles or no hide_search config + if (!currentUser || !this.themeData?.hide_search) { + return false; + } + + // Special handling for Administrator role + if (currentUser.includes("Administrator")) { + return this.themeData.hide_search.some((u) => u.role === "Administrator"); + } + + // Check if any user role matches hide_search configuration + return currentUser.some((role) => this.themeData.hide_search.some((u) => u.role === role)); + } + + /** + * Clear all theme-related CSS custom properties from document root + * Used to reset theme state before applying new theme values + * Ensures clean slate for theme updates + */ + clearCSSVariables() { + const root = document.documentElement; + // Comprehensive list of all theme CSS variables + const cssVariables = [ + "--login-bg-color", + "--login-bg-image", + "--login-box-position", + "--login-box-right", + "--login-box-left", + "--login-btn-bg", + "--login-btn-color", + "--login-btn-hover-bg", + "--login-btn-hover-color", + "--login-box-bg", + "--page-heading-color", + "--input-bg", + "--input-color", + "--input-border", + "--input-label-color", + "--navbar-bg", + "--navbar-color", + "--hide-help", + "--btn-primary-bg", + "--btn-primary-color", + "--btn-primary-hover-bg", + "--btn-primary-hover-color", + "--btn-secondary-bg", + "--btn-secondary-color", + "--btn-secondary-hover-bg", + "--btn-secondary-hover-color", + "--body-bg", + "--content-bg", + "--table-head-bg", + "--table-head-color", + "--table-body-bg", + "--table-body-color", + "--hide-like-comment", + "--widget-bg", + "--widget-border", + "--widget-color", + "--sidebar-expanded", + "--sidebar-bg", + "--sidebar-text-color", + "--sidebar-hover-bg", + "--sidebar-hover-text-color", + "--login-content-border", + "--login-title-display", + "--login-title-after-display", + "--login-title-after-justify", + "--login-title-after-margin", + "--login-title-after-content", + "--login-title-after-color", + "--login-box-top", + "--login-box-bg-override", + "--login-box-border-radius", + "--search-bar-display", + "--navbar-toggler-border", + "--breadcrumb-disabled-color", + "--help-nav-link-color", + "--help-nav-link-stroke", + "--footer-bg", + "--footer-color", + "--footer-border", + "--footer-display", + "--footer-powered-color", + "--footer-link-color", + "--footer-link-hover-color", + "--carousel-fade-opacity", + "--login-bg-carousel-image", + ]; + + // Remove each CSS variable from document root + cssVariables.forEach((variable) => { + root.style.removeProperty(variable); + }); + } + + /** + * Set default CSS variable values + * Provides fallback values when theme configuration is missing or incomplete + * Ensures UI remains functional even without complete theme data + */ + setDefaultCSSVariables() { + const root = document.documentElement; + + // Login page defaults - ensures login form remains usable + root.style.setProperty("--login-box-position", "static"); + root.style.setProperty("--login-box-right", "auto"); + root.style.setProperty("--login-box-left", "auto"); + root.style.setProperty("--login-box-top", "18%"); + root.style.setProperty("--login-box-bg", "#fff"); + root.style.setProperty("--login-content-border", "2px solid #d1d8dd"); + root.style.setProperty("--login-title-display", "block"); + root.style.setProperty("--login-title-after-display", "none"); + + // UI element visibility defaults + root.style.setProperty("--hide-help", "block"); + root.style.setProperty("--hide-like-comment", "block"); + root.style.setProperty("--sidebar-expanded", ""); + root.style.setProperty("--sidebar-hover-bg", "#e9ecef"); + root.style.setProperty("--sidebar-hover-text-color", "#212529"); + root.style.setProperty("--login-box-width", "400px"); + root.style.setProperty("--search-bar-display", "block"); + + // Navigation and UI component defaults + root.style.setProperty("--navbar-toggler-border", "#dee2e6"); + root.style.setProperty("--breadcrumb-disabled-color", "#6c757d"); + root.style.setProperty("--help-nav-link-color", "inherit"); + root.style.setProperty("--help-nav-link-stroke", "currentColor"); + + // Footer defaults + root.style.setProperty("--footer-display", "flex"); + root.style.setProperty("--footer-bg", "#f8f9fa"); + root.style.setProperty("--footer-color", "#495057"); + root.style.setProperty("--footer-border", "#dee2e6"); + root.style.setProperty("--footer-powered-color", "#6c757d"); + root.style.setProperty("--footer-link-color", "#007bff"); + root.style.setProperty("--footer-link-hover-color", "#0056b3"); + + // Carousel fade default + root.style.setProperty("--carousel-fade-opacity", "1"); + } + + /** + * Apply theme configuration to CSS custom properties + * Maps theme data fields to corresponding CSS variables + * Only sets variables when theme values are provided (conditional application) + */ + setCSSVariables() { + const root = document.documentElement; + const theme = this.themeData; + + // Reset all variables to clean state + this.clearCSSVariables(); + + // Establish default values first + this.setDefaultCSSVariables(); + + // Login page background customization + if (theme.carousel && theme.carousel.images && theme.carousel.images.length > 0) { + // Skip static background image/color for carousel mode + } else { + if (theme.login_page_background_color) { + root.style.setProperty("--login-bg-color", theme.login_page_background_color); + } + if (theme.login_page_background_image) { + root.style.setProperty( + "--login-bg-image", + `url("${theme.login_page_background_image}")` + ); + } + } + + // Login box positioning - supports Left, Right, or Default positioning + if (theme.login_box_position && theme.login_box_position !== "Default") { + root.style.setProperty("--login-box-position", "absolute"); + root.style.setProperty( + "--login-box-right", + theme.login_box_position === "Right" ? "10%" : "auto" + ); + root.style.setProperty( + "--login-box-left", + theme.login_box_position === "Left" ? "10%" : "auto" + ); + root.style.setProperty( + "--login-box-padding", + theme.is_app_details_inside_the_box === 1 ? "18px 40px 40px 40px" : "40px" + ); + } + + // Login box vertical positioning and app details integration + if (theme.is_app_details_inside_the_box !== undefined) { + root.style.setProperty( + "--login-box-top", + theme.is_app_details_inside_the_box === 1 ? "26%" : "18%" + ); + } + + // Special styling when app details are inside the login box + if (theme.is_app_details_inside_the_box === 1) { + root.style.setProperty("--login-box-bg-override", theme.login_box_background_color); + root.style.setProperty("--login-box-border-radius", "10px"); + } + + // Login button styling + if (theme.login_button_background_color) { + root.style.setProperty("--login-btn-bg", theme.login_button_background_color); + } + if (theme.login_button_text_color) { + root.style.setProperty("--login-btn-color", theme.login_button_text_color); + } + if (theme.login_page_button_hover_background_color) { + root.style.setProperty( + "--login-btn-hover-bg", + theme.login_page_button_hover_background_color + ); + } + if (theme.login_page_button_hover_text_color) { + root.style.setProperty( + "--login-btn-hover-color", + theme.login_page_button_hover_text_color + ); + } + if (theme.login_box_background_color) { + root.style.setProperty("--login-box-bg", theme.login_box_background_color); + } + if (theme.page_heading_text_color) { + root.style.setProperty("--page-heading-color", theme.page_heading_text_color); + } + + // Login content border - removed when app details are inside box + if (theme.is_app_details_inside_the_box === 1) { + root.style.setProperty("--login-content-border", "none"); + } + + // Custom login page title - replaces default Frappe title + if (theme.login_page_title) { + root.style.setProperty("--login-title-display", "none"); + root.style.setProperty("--login-title-after-display", "flex"); + root.style.setProperty("--login-title-after-justify", "center"); + root.style.setProperty("--login-title-after-margin", "10px"); + root.style.setProperty("--login-title-after-content", `'${theme.login_page_title}'`); + if (theme.page_heading_text_color) { + root.style.setProperty("--login-title-after-color", theme.page_heading_text_color); + } + } + + // Form input field customization + if (theme.input_background_color) { + root.style.setProperty("--input-bg", theme.input_background_color); + } + if (theme.input_text_color) { + root.style.setProperty("--input-color", theme.input_text_color); + } + if (theme.input_border_color) { + root.style.setProperty("--input-border", theme.input_border_color); + } + if (theme.input_label_color) { + root.style.setProperty("--input-label-color", theme.input_label_color); + } + + // Navigation bar customization + if (theme.navbar_color) { + root.style.setProperty("--navbar-bg", theme.navbar_color); + } + if (theme.navbar_text_color) { + root.style.setProperty("--navbar-color", theme.navbar_text_color); + } + if (theme.navbar_toggler_border_color) { + root.style.setProperty("--navbar-toggler-border", theme.navbar_toggler_border_color); + } + if (theme.navbar_breadcrumb_disabled_color) { + root.style.setProperty( + "--breadcrumb-disabled-color", + theme.navbar_breadcrumb_disabled_color + ); + } + if (theme.hide_help_button !== undefined) { + root.style.setProperty("--hide-help", theme.hide_help_button ? "none" : "block"); + } + + // Primary button styling (hover fallbacks to normal when not set) + if (theme.button_background_color) { + root.style.setProperty("--btn-primary-bg", theme.button_background_color); + } + if (theme.button_text_color) { + root.style.setProperty("--btn-primary-color", theme.button_text_color); + } + if (theme.button_hover_background_color) { + root.style.setProperty("--btn-primary-hover-bg", theme.button_hover_background_color); + } else if (theme.button_background_color) { + root.style.setProperty("--btn-primary-hover-bg", theme.button_background_color); + } + if (theme.button_hover_text_color) { + root.style.setProperty("--btn-primary-hover-color", theme.button_hover_text_color); + } else if (theme.button_text_color) { + root.style.setProperty("--btn-primary-hover-color", theme.button_text_color); + } + + // Secondary button styling + if (theme.secondary_button_background_color) { + root.style.setProperty("--btn-secondary-bg", theme.secondary_button_background_color); + } + if (theme.secondary_button_text_color) { + root.style.setProperty("--btn-secondary-color", theme.secondary_button_text_color); + } + if (theme.secondary_button_hover_background_color) { + root.style.setProperty( + "--btn-secondary-hover-bg", + theme.secondary_button_hover_background_color + ); + } else if (theme.secondary_button_background_color) { + root.style.setProperty( + "--btn-secondary-hover-bg", + theme.secondary_button_background_color + ); + } + if (theme.secondary_button_hover_text_color) { + root.style.setProperty( + "--btn-secondary-hover-color", + theme.secondary_button_hover_text_color + ); + } else if (theme.secondary_button_text_color) { + root.style.setProperty( + "--btn-secondary-hover-color", + theme.secondary_button_text_color + ); + } + + // Main body and content area styling + if (theme.body_background_color) { + root.style.setProperty("--body-bg", theme.body_background_color); + } + if (theme.main_body_content_box_background_color) { + root.style.setProperty("--content-bg", theme.main_body_content_box_background_color); + } + if (theme.main_body_content_box_text_color) { + root.style.setProperty("--content-text-color", theme.main_body_content_box_text_color); + } + + // Sidebar customization + if (theme.sidebar_background_color) { + root.style.setProperty("--sidebar-bg", theme.sidebar_background_color); + } + if (theme.sidebar_text_color) { + root.style.setProperty("--sidebar-text-color", theme.sidebar_text_color); + } + if (theme.sidebar_hover_background_color) { + root.style.setProperty("--sidebar-hover-bg", theme.sidebar_hover_background_color); + } + if (theme.sidebar_hover_text_color) { + root.style.setProperty("--sidebar-hover-text-color", theme.sidebar_hover_text_color); + } + if (theme.sidebar_section_break_text_color) { + root.style.setProperty( + "--sidebar-section-break-color", + theme.sidebar_section_break_text_color + ); + } + if (theme.sidebar_section_break_bold) { + root.style.setProperty("--sidebar-section-break-font-weight", "700"); + } else { + root.style.setProperty("--sidebar-section-break-font-weight", "normal"); + } + + // Data table styling + if (theme.table_head_background_color) { + root.style.setProperty("--table-head-bg", theme.table_head_background_color); + } + if (theme.table_head_text_color) { + root.style.setProperty("--table-head-color", theme.table_head_text_color); + } + if (theme.table_body_background_color) { + root.style.setProperty("--table-body-bg", theme.table_body_background_color); + } + if (theme.table_body_text_color) { + root.style.setProperty("--table-body-color", theme.table_body_text_color); + } + if (theme.table_hide_like_comment_section !== undefined) { + root.style.setProperty( + "--hide-like-comment", + theme.table_hide_like_comment_section ? "none" : "block" + ); + } + + // Widget/card styling (number cards, dashboard widgets) + if (theme.number_card_background_color) { + root.style.setProperty("--widget-bg", theme.number_card_background_color); + } + if (theme.number_card_border_color) { + root.style.setProperty("--widget-border", theme.number_card_border_color); + } + if (theme.number_card_text_color) { + root.style.setProperty("--widget-color", theme.number_card_text_color); + } + + // Footer styling + if (theme.footer_background_color) { + root.style.setProperty("--footer-bg", theme.footer_background_color); + } + if (theme.footer_text_color) { + root.style.setProperty("--footer-color", theme.footer_text_color); + root.style.setProperty("--footer-powered-color", theme.footer_text_color); + } + + // Sidebar visibility control + if (theme.hide_side_bar !== undefined) { + root.style.setProperty( + "--sidebar-expanded", + theme.hide_side_bar === 0 ? "expanded" : "" + ); + } + } + + /** + * Apply all theme configurations to the current page + * Orchestrates the application of CSS variables and UI element toggles + */ + applyTheme() { + this.setCSSVariables(); + this.toggleSidebar(); + this.toggleSearchBar(); + this.hideStandardMenu(); + this.applyFixedSidebarBehavior(); + this.performInitialSidebarLoginRedirect(); + if ( + this.themeData.carousel && + this.themeData.carousel.images && + this.themeData.carousel.images.length > 0 + ) { + this.renderLoginCarousel(); + } else { + this.removeLoginCarousel(); + } + this.showLoginBox(); + this.createFooter(); + } + + /** + * Show login box with smooth transition after theme is applied + * Prevents flickering by revealing the login form only after positioning is set + */ + showLoginBox() { + const loginBox = document.querySelector(".for-login"); + if (loginBox) { + // Small delay to ensure CSS variables are applied + setTimeout(() => { + loginBox.classList.add("theme-ready"); + }, 50); + } + } + + /** + * Toggle sidebar visibility based on theme configuration + * Adds/removes 'expanded' class to control sidebar state + */ + toggleSidebar() { + const sidebarContainer = document.querySelector(".body-sidebar-container"); + if (!sidebarContainer) { + return; + } + + if (this.themeData.hide_side_bar === 0) { + sidebarContainer.classList.add("expanded"); + } else { + sidebarContainer.classList.remove("expanded"); + } + } + + /** + * Toggle search bar visibility based on user roles + * Hides search bar if current user's role matches hide_search configuration + */ + toggleSearchBar() { + const searchBar = document.querySelector(".input-group.search-bar.text-muted"); + if (!searchBar) { + return; + } + + if (this.getUserRoles()) { + searchBar.style.display = "none"; + } + } + + /** + * Hide the standard toolbar/context menu when configured + * Uses the 'hide_standard_menu' flag from Desk Theme doctype + */ + hideStandardMenu() { + // Only act when explicitly enabled in theme configuration + if (!this.themeData || !this.themeData.hide_standard_menu) { + return; + } + + // Hide all Frappe context menus that use the standard menu class + const menus = document.querySelectorAll(".frappe-menu.context-menu"); + menus.forEach((menu) => { + menu.style.display = "none"; + }); + } + + /** + * Force Desk to always use a single, fixed workspace sidebar + * Uses the 'fixed_sidebar' link field from the Desk Theme doctype + */ + applyFixedSidebarBehavior() { + // Only applicable inside Desk (not on login page) + if ( + document.body.classList.contains("login-page") || + document.querySelector("#page-login") + ) { + return; + } + + if (!this.themeData || !this.themeData.fixed_sidebar) { + return; + } + + if (typeof frappe === "undefined" || !frappe.app || !frappe.app.sidebar) { + return; + } + + const sidebar = frappe.app.sidebar; + + // Only patch once + if (sidebar.__fixed_sidebar_patched) { + return; + } + sidebar.__fixed_sidebar_patched = true; + + const fixedSidebarLabel = this.themeData.fixed_sidebar; + + // Override sidebar switching methods to always use the configured sidebar + sidebar.set_workspace_sidebar = function () { + this.setup(fixedSidebarLabel); + this.set_active_workspace_item(); + }; + + sidebar.show_sidebar_for_module = function () { + // No-op: keep using the fixed sidebar + return; + }; + + sidebar.set_sidebar_for_page = function () { + this.setup(fixedSidebarLabel); + }; + + // Apply immediately for current page if possible + try { + sidebar.setup(fixedSidebarLabel); + sidebar.set_active_workspace_item(); + } catch (e) { + // Silent fail – sidebar might not be fully initialised yet + } + } + + /** + * On first Desk load after login, redirect user directly to a page + * derived from the fixed sidebar (first link item), instead of the + * default desktop / workspace landing. + */ + performInitialSidebarLoginRedirect() { + // Only run once per browser tab (use sessionStorage so it persists across reloads) + const redirectFlagKey = "frappe_desk_theme_sidebar_redirect_done"; + try { + if (sessionStorage.getItem(redirectFlagKey) === "1") { + return; + } + } catch (e) { + // If sessionStorage is unavailable, fall back to in-memory flag + if (this.didInitialSidebarLoginRedirect) { + return; + } + } + + // Must be enabled in theme and have a fixed sidebar configured + if ( + !this.themeData || + !this.themeData.redirect_to_sidebar_on_login || + !this.themeData.fixed_sidebar + ) { + return; + } + + // Not applicable on the login page + if ( + document.body.classList.contains("login-page") || + document.querySelector("#page-login") + ) { + return; + } + + // Only act inside Desk + if (!window.location.pathname.startsWith("/desk")) { + return; + } + + if (typeof frappe === "undefined" || !frappe.get_route || !frappe.boot) { + return; + } + + const route = frappe.get_route() || []; + + // Heuristic: only redirect from generic initial desk routes + // We *don't* treat specific workspace routes as initial, so reloads + // on "Workspaces / " won't be redirected. + const isInitialDeskRoute = route.length === 0 || route[0] === "desktop"; + + if (!isInitialDeskRoute) { + return; + } + + const fixedSidebarLabel = this.themeData.fixed_sidebar; + const sidebarBoot = frappe.boot.workspace_sidebar_item || {}; + const sidebarKey = (fixedSidebarLabel || "").toLowerCase(); + const sidebarData = sidebarBoot[sidebarKey]; + + if (!sidebarData || !Array.isArray(sidebarData.items) || !sidebarData.items.length) { + return; + } + + // Choose first link-type item from the configured sidebar + const firstLink = sidebarData.items.find((item) => item.type === "Link" && item.link_to); + if (!firstLink) { + return; + } + + this.didInitialSidebarLoginRedirect = true; + try { + sessionStorage.setItem(redirectFlagKey, "1"); + } catch (e) { + // Ignore storage errors + } + + try { + const linkType = (firstLink.link_type || "").toLowerCase(); + + if (linkType === "workspace") { + // Open workspace from sidebar link + frappe.set_route("Workspaces", firstLink.link_to); + } else if (linkType === "doctype") { + // Go to list view for the DocType + frappe.set_route("List", firstLink.link_to); + } else if (linkType === "page") { + // Use desk/page-name instead of desk/Page/page-name + frappe.set_route(firstLink.link_to); + } else if (linkType === "report") { + frappe.set_route("query-report", firstLink.link_to); + } else if (linkType === "url") { + window.location.href = firstLink.link_to; + } + } catch (e) { + // Silent fail – don't break desk if redirect fails + } + } + + /** + * Create and display footer in desk view using HTML template + * Much more efficient than creating DOM elements dynamically + */ + async createFooter() { + // Don't create footer on login page + if ( + document.body.classList.contains("login-page") || + document.querySelector("#page-login") + ) { + return; + } + + // Remove existing footer if any + const existingFooter = document.querySelector("#desk-footer"); + if (existingFooter) { + existingFooter.remove(); + // Clean up sticky footer classes + document.body.classList.remove("has-sticky-footer"); + const mainSection = document.querySelector(".main-section"); + if (mainSection) { + mainSection.classList.remove("has-sticky-footer"); + } + } + + // Check if footer should be displayed (basic check to avoid unnecessary API calls) + if (!this.themeData.copyright_text && !this.themeData.footer_powered_by) { + return; + } + + // Throttle footer creation to prevent multiple simultaneous calls + if (this.footerCreating) { + return; + } + this.footerCreating = true; + + try { + // Create a cache key from footer-related theme data + const currentFooterKey = JSON.stringify({ + copyright_text: this.themeData.copyright_text, + footer_powered_by: this.themeData.footer_powered_by, + sticky_footer: this.themeData.sticky_footer, + }); + + let footerHtml = this.footerHtmlCache; + + // Check in-memory cache first, then localStorage, then API + if (!footerHtml || this.footerCacheKey !== currentFooterKey) { + // Try to load from localStorage + const cachedFooter = this.loadFooterCache(); + if (cachedFooter && cachedFooter.key === currentFooterKey) { + footerHtml = cachedFooter.html; + this.footerHtmlCache = footerHtml; + this.footerCacheKey = currentFooterKey; + } else { + // Get rendered footer HTML from server + const response = await fetch( + "/api/method/frappe_desk_theme.api.get_footer_html", + { + method: "GET", + headers: { + Accept: "application/json", + }, + } + ); + + if (!response.ok) { + throw new Error(`HTTP error! Status: ${response.status}`); + } + + const data = await response.json(); + footerHtml = data?.message || ""; + + // Cache the HTML and key for subsequent calls (both memory and localStorage) + this.footerHtmlCache = footerHtml; + this.footerCacheKey = currentFooterKey; + this.saveFooterCache(footerHtml, currentFooterKey); + } + } + + if (footerHtml.trim()) { + // Create a temporary container to hold the HTML + const tempDiv = document.createElement("div"); + tempDiv.innerHTML = footerHtml; + + // Get the footer element from the template + const footerElement = tempDiv.querySelector("#desk-footer"); + if (footerElement) { + // Try to append to main-section first, then fall back to body + const mainSection = document.querySelector(".main-section"); + if (mainSection) { + mainSection.appendChild(footerElement); + if (this.themeData.sticky_footer) { + mainSection.classList.add("has-sticky-footer"); + // Set up sticky footer sidebar toggle listener + this.setupStickyFooterToggle(); + } + } else { + // Fallback to body if main-section doesn't exist + document.body.appendChild(footerElement); + if (this.themeData.sticky_footer) { + document.body.classList.add("has-sticky-footer"); + // Set up sticky footer sidebar toggle listener + this.setupStickyFooterToggle(); + } + } + } + } + } catch (error) { + // Silent fail - footer is optional, don't show errors to user + } finally { + this.footerCreating = false; + } + } + + /** + * Set up dynamic positioning for sticky footer when sidebar toggles + * Ensures footer position updates in real-time with sidebar state + */ + setupStickyFooterToggle() { + // Avoid setting up multiple listeners + if (this.stickyFooterListenerSetup) { + return; + } + this.stickyFooterListenerSetup = true; + + // Function to update sticky footer position + const updateStickyFooterPosition = () => { + const footer = document.querySelector("#desk-footer.sticky"); + if (!footer) return; + + const sidebarContainer = document.querySelector(".body-sidebar-container"); + const isExpanded = sidebarContainer && sidebarContainer.classList.contains("expanded"); + + // Update footer position based on sidebar state + if (isExpanded) { + footer.style.left = "220px"; + } else { + footer.style.left = "50px"; + } + }; + + // Listen for sidebar toggle events + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if ( + mutation.type === "attributes" && + mutation.attributeName === "class" && + mutation.target.classList.contains("body-sidebar-container") + ) { + // Delay to ensure CSS transitions complete + setTimeout(updateStickyFooterPosition, 50); + } + }); + }); + + // Observe sidebar container for class changes + const sidebarContainer = document.querySelector(".body-sidebar-container"); + if (sidebarContainer) { + observer.observe(sidebarContainer, { + attributes: true, + attributeFilter: ["class"], + }); + } + + // Also listen for sidebar toggle via click events + document.addEventListener("click", (event) => { + // Check if clicked element or its parent is a sidebar toggle + const isToggle = event.target.closest( + '.collapse-sidebar-link, .sidebar-toggle, [data-toggle="sidebar"]' + ); + if (isToggle) { + setTimeout(updateStickyFooterPosition, 200); // Allow time for animation + } + }); + + // Initial position update + updateStickyFooterPosition(); + } + + /** + * Set up event listeners for dynamic theme updates and DOM changes + * Handles real-time theme changes and new element detection + */ + setupEventListeners() { + // Listen for theme changes - allows for runtime theme updates + document.addEventListener("themeChanged", () => { + this.loadTheme().then(() => this.applyTheme()); + }); + + // Listen for DOM changes to apply theme to dynamically added elements + // Frappe uses dynamic content loading, so we need to monitor for new elements + let footerTimeout; + const observer = new MutationObserver(() => { + this.toggleSearchBar(); + this.hideStandardMenu(); + this.applyFixedSidebarBehavior(); + this.performInitialSidebarLoginRedirect(); + + // Debounce footer creation to avoid performance issues + clearTimeout(footerTimeout); + footerTimeout = setTimeout(() => { + // Only create footer if it doesn't exist + if (!document.querySelector("#desk-footer")) { + this.createFooter(); + } + }, 500); // 500ms delay to avoid constant recreation + }); + + // Observe all changes in document body and its children + observer.observe(document.body, { + childList: true, // Watch for element additions/removals + subtree: true, // Watch all descendant nodes + }); + } + + // Navigation buttons + + ensureButton(loginPage, images, id, html, onClick) { + const manual = !!this.themeData.carousel.manual_navigation; + let btn = document.getElementById(id); + if (!manual || images.length <= 1) { + if (btn) btn.remove(); + return null; + } + if (!btn) { + btn = document.createElement("button"); + btn.id = id; + btn.className = `carousel-nav ${ + id === "carousel-nav-left" ? "carousel-nav-left" : "carousel-nav-right" + }`; + btn.innerHTML = html; + btn.addEventListener("click", onClick); + loginPage.appendChild(btn); + } + return btn; + } + + renderLoginCarousel() { + const loginPage = document.querySelector("#page-login"); + if (!loginPage) return; + const root = document.documentElement; + const images = this.themeData.carousel.images; + if (!images || images.length === 0) return; + + // Set initial state and background + if (typeof this._carouselIndex !== "number" || this._carouselIndex >= images.length) { + this._carouselIndex = 0; + } + root.style.setProperty( + "--login-bg-carousel-image", + `url("${images[this._carouselIndex]}")` + ); + + // Remove any previous timer + if (this._carouselTimer) { + clearTimeout(this._carouselTimer); + this._carouselTimer = null; + } + + this.ensureButton(loginPage, images, "carousel-nav-left", "←", (e) => { + e.stopPropagation(); + e.preventDefault(); + if (this._carouselTimer) { + clearTimeout(this._carouselTimer); + this._carouselTimer = null; + } + this.carouselShowImage(this._carouselIndex - 1, images, root, -1); + }); + this.ensureButton(loginPage, images, "carousel-nav-right", "→", (e) => { + e.stopPropagation(); + e.preventDefault(); + if (this._carouselTimer) { + clearTimeout(this._carouselTimer); + this._carouselTimer = null; + } + this.carouselShowImage(this._carouselIndex + 1, images, root, 1); + }); + + // Auto-advance: handled in carouselShowImage after animation + if ( + this.themeData.carousel.auto_advance !== false && + images.length > 1 && + !this._carouselTimer + ) { + this._carouselTimer = setTimeout(() => { + this._carouselTimer = null; + this.carouselShowImage(this._carouselIndex + 1, images, root, 1); + }, 5000); + } + } + + carouselShowImage(idx, images, root, direction = 1) { + const total = images.length; + idx = (idx + total) % total; + if (idx === this._carouselIndex || this._carouselSliding) return; + + this._carouselSliding = true; + // Fade out + root.style.setProperty("--carousel-fade-opacity", "0"); + setTimeout(() => { + root.style.setProperty("--login-bg-carousel-image", `url("${images[idx]}")`); + root.style.setProperty("--carousel-fade-opacity", "1"); + this._carouselIndex = idx; + this._carouselSliding = false; + // Auto-advance + const auto = this.themeData.carousel.auto_advance !== false; + if (auto && images.length > 1 && !this._carouselTimer) { + this._carouselTimer = setTimeout(() => { + this._carouselTimer = null; + this.carouselShowImage(this._carouselIndex + 1, images, root, 1); + }, 5000); + } + }, 400); + } + + removeLoginCarousel() { + // Remove navigation buttons if present + const left = document.getElementById("carousel-nav-left"); + const right = document.getElementById("carousel-nav-right"); + if (left) left.remove(); + if (right) right.remove(); + if (this._carouselTimer) { + clearTimeout(this._carouselTimer); + this._carouselTimer = null; + } + // Remove the CSS variable + document.documentElement.style.removeProperty("--login-bg-carousel-image"); + this._carouselIndex = 0; + } +} + +// Initialize theme system when DOM is ready +// Handles both immediate initialization and delayed initialization for slow-loading pages +if (document.readyState === "loading") { + // DOM is still loading, wait for DOMContentLoaded event + document.addEventListener("DOMContentLoaded", () => { + window.frappeDeskTheme = new FrappeDeskTheme(); + // Hook into Frappe's reload / clear cache button to also refresh theme + attachFrappeReloadThemeHook(); + }); +} else { + // DOM is already loaded, initialize immediately + window.frappeDeskTheme = new FrappeDeskTheme(); + attachFrappeReloadThemeHook(); +} + +/** + * Attach a hook so that when the user clicks Frappe's + * "Reload / Clear Cache" button, the desk theme cache + * is cleared and refreshed as well. + */ +function attachFrappeReloadThemeHook() { + if (typeof frappe === "undefined") return; + if (!frappe.ui || !frappe.ui.toolbar || !frappe.ui.toolbar.clear_cache) return; + + // Avoid double-wrapping + if (frappe.ui.toolbar.__theme_reload_hooked) { + return; + } + frappe.ui.toolbar.__theme_reload_hooked = true; + + const originalClearCache = frappe.ui.toolbar.clear_cache; + + frappe.ui.toolbar.clear_cache = function () { + try { + if (window.frappeDeskTheme) { + // Clear local theme cache and force a fresh fetch + window.frappeDeskTheme.clearCache(); + window.frappeDeskTheme.refreshTheme(); + } + } catch (e) { + // Ignore theme errors; still allow core clear_cache to run + } + + return originalClearCache.apply(this, arguments); + }; +} diff --git a/frappe_desk_theme/public/js/frappe_desk_theme.js b/frappe_desk_theme/public/js/frappe_desk_theme.js deleted file mode 100644 index cfde41c..0000000 --- a/frappe_desk_theme/public/js/frappe_desk_theme.js +++ /dev/null @@ -1,586 +0,0 @@ -/** - * FrappeDeskTheme - Main theme management class - * Handles loading, applying, and managing custom theme configurations for Frappe Desk - * Supports dynamic theme changes, user role-based hiding, and real-time DOM updates - */ -class FrappeDeskTheme { - constructor() { - // Store theme configuration data from server - this.themeData = null; - // Cache configuration - this.cacheKey = 'frappe_desk_theme_cache'; - this.cacheTimeout = 24 * 60 * 60 * 1000; // 24 hours in milliseconds - this.init(); - } - - /** - * Initialize the theme system - * First applies cached theme immediately, then loads fresh data if needed - * Uses async/await pattern with graceful error handling - */ - async init() { - try { - // Apply cached theme immediately to prevent flickering - this.applyCachedTheme(); - - // Load fresh theme data if needed (async) - await this.loadThemeIfNeeded(); - - // Apply fresh theme if we got new data - if (this.themeData) { - this.applyTheme(); - } - - this.setupEventListeners(); - } catch (error) { - // Silent fail in production - apply default theme and show login box - this.applyTheme(); - this.showLoginBoxFallback(); - } - } - - /** - * Fallback method to show login box if theme loading fails - * Ensures login form is always visible even if theme fails to load - */ - showLoginBoxFallback() { - const loginBox = document.querySelector('.for-login'); - if (loginBox && !loginBox.classList.contains('theme-ready')) { - setTimeout(() => { - loginBox.classList.add('theme-ready'); - }, 100); - } - } - - /** - * Apply cached theme immediately to prevent UI flickering - */ - applyCachedTheme() { - const cachedData = this.getCachedTheme(); - if (cachedData && cachedData.data) { - this.themeData = cachedData.data; - this.applyTheme(); - } else { - // No cached theme, but still show login box to prevent indefinite hiding - this.showLoginBoxFallback(); - } - } - - /** - * Get cached theme data from localStorage - * @returns {Object|null} Cached theme data with timestamp - */ - getCachedTheme() { - try { - const cached = localStorage.getItem(this.cacheKey); - return cached ? JSON.parse(cached) : null; - } catch (error) { - return null; - } - } - - /** - * Save theme data to localStorage with timestamp - * @param {Object} themeData Theme configuration data - */ - setCachedTheme(themeData) { - try { - const cacheData = { - data: themeData, - timestamp: Date.now(), - version: 1 // Increment this when theme structure changes - }; - localStorage.setItem(this.cacheKey, JSON.stringify(cacheData)); - } catch (error) { - // localStorage might be full or disabled - } - } - - /** - * Check if cached theme is still valid - * @returns {boolean} True if cache is valid and not expired - */ - isCacheValid() { - const cachedData = this.getCachedTheme(); - if (!cachedData) return false; - - const now = Date.now(); - const cacheAge = now - cachedData.timestamp; - - return cacheAge < this.cacheTimeout; - } - - /** - * Load theme only if cache is invalid or doesn't exist - */ - async loadThemeIfNeeded() { - // Skip API call if cache is still valid - if (this.isCacheValid()) { - return; - } - - await this.loadTheme(); - } - - /** - * Load theme configuration from server API - * Fetches custom theme data via REST API endpoint - * Handles response parsing and error states - */ - async loadTheme() { - try { - const response = await fetch('/api/method/frappe_desk_theme.api.get_custom_theme', { - method: 'GET', - headers: { - 'Accept': 'application/json', - } - }); - - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); - } - - const data = await response.json(); - // Handle different response formats - some APIs wrap data in 'message' property - this.themeData = data?.message || data; - - if (!this.themeData) { - throw new Error('No theme data received'); - } - - // Cache the new theme data - this.setCachedTheme(this.themeData); - - } catch (error) { - // If API fails, try to use cached data as fallback - const cachedData = this.getCachedTheme(); - if (cachedData && cachedData.data) { - this.themeData = cachedData.data; - } else { - throw error; - } - } - } - - /** - * Force refresh theme from server (ignores cache) - * Useful for manual theme updates or admin changes - */ - async refreshTheme() { - try { - await this.loadTheme(); - this.applyTheme(); - - // Dispatch event for other components - document.dispatchEvent(new CustomEvent('themeRefreshed', { - detail: { themeData: this.themeData } - })); - } catch (error) { - console.error('Failed to refresh theme:', error); - } - } - - /** - * Clear theme cache (useful for debugging or forced refresh) - */ - clearCache() { - try { - localStorage.removeItem(this.cacheKey); - } catch (error) { - // Ignore localStorage errors - } - } - - /** - * Check if current user's roles match hide_search configuration - * Used to conditionally hide search bar based on user permissions - * @returns {boolean} True if search should be hidden for current user - */ - getUserRoles() { - const currentUser = frappe?.boot?.user?.roles; - // Exit early if no user roles or no hide_search config - if (!currentUser || !this.themeData?.hide_search) { - return false; - } - - // Special handling for Administrator role - if (currentUser.includes('Administrator')) { - return this.themeData.hide_search.some(u => u.role === 'Administrator'); - } - - // Check if any user role matches hide_search configuration - return currentUser.some(role => - this.themeData.hide_search.some(u => u.role === role) - ); - } - - /** - * Clear all theme-related CSS custom properties from document root - * Used to reset theme state before applying new theme values - * Ensures clean slate for theme updates - */ - clearCSSVariables() { - const root = document.documentElement; - // Comprehensive list of all theme CSS variables - const cssVariables = [ - '--login-bg-color', '--login-bg-image', '--login-box-position', '--login-box-right', '--login-box-left', - '--login-btn-bg', '--login-btn-color', '--login-btn-hover-bg', '--login-btn-hover-color', - '--login-box-bg', '--page-heading-color', '--input-bg', '--input-color', '--input-border', - '--input-label-color', '--navbar-bg', '--navbar-color', '--hide-help', '--btn-primary-bg', - '--btn-primary-color', '--btn-primary-hover-bg', '--btn-primary-hover-color', '--btn-secondary-bg', - '--btn-secondary-color', '--btn-secondary-hover-bg', '--btn-secondary-hover-color', '--body-bg', - '--content-bg', '--table-head-bg', '--table-head-color', '--table-body-bg', '--table-body-color', - '--hide-like-comment', '--widget-bg', '--widget-border', '--widget-color', '--sidebar-expanded', - '--login-content-border', '--login-title-display', '--login-title-after-display', - '--login-title-after-justify', '--login-title-after-margin', '--login-title-after-content', '--login-title-after-color', - '--login-box-top', '--login-box-bg-override', '--login-box-border-radius', '--search-bar-display', - '--navbar-toggler-border', '--breadcrumb-disabled-color', '--help-nav-link-color', '--help-nav-link-stroke', - '--hide-app-switcher', '--app-switcher-pointer-events' - ]; - - // Remove each CSS variable from document root - cssVariables.forEach(variable => { - root.style.removeProperty(variable); - }); - } - - /** - * Set default CSS variable values - * Provides fallback values when theme configuration is missing or incomplete - * Ensures UI remains functional even without complete theme data - */ - setDefaultCSSVariables() { - const root = document.documentElement; - - // Login page defaults - ensures login form remains usable - root.style.setProperty('--login-box-position', 'static'); - root.style.setProperty('--login-box-right', 'auto'); - root.style.setProperty('--login-box-left', 'auto'); - root.style.setProperty('--login-box-top', '18%'); - root.style.setProperty('--login-box-bg', '#fff'); - root.style.setProperty('--login-content-border', '2px solid #d1d8dd'); - root.style.setProperty('--login-title-display', 'block'); - root.style.setProperty('--login-title-after-display', 'none'); - - // UI element visibility defaults - root.style.setProperty('--hide-help', 'block'); - root.style.setProperty('--hide-like-comment', 'block'); - root.style.setProperty('--hide-app-switcher', 'block'); - root.style.setProperty('--app-switcher-pointer-events', 'auto'); - root.style.setProperty('--sidebar-expanded', ''); - root.style.setProperty('--login-box-width', '400px'); - root.style.setProperty('--search-bar-display', 'block'); - - // Navigation and UI component defaults - root.style.setProperty('--navbar-toggler-border', '#dee2e6'); - root.style.setProperty('--breadcrumb-disabled-color', '#6c757d'); - root.style.setProperty('--help-nav-link-color', 'inherit'); - root.style.setProperty('--help-nav-link-stroke', 'currentColor'); - } - - /** - * Apply theme configuration to CSS custom properties - * Maps theme data fields to corresponding CSS variables - * Only sets variables when theme values are provided (conditional application) - */ - setCSSVariables() { - const root = document.documentElement; - const theme = this.themeData; - - // Reset all variables to clean state - this.clearCSSVariables(); - - // Establish default values first - this.setDefaultCSSVariables(); - - // Login page background customization - if (theme.login_page_background_color) { - root.style.setProperty('--login-bg-color', theme.login_page_background_color); - } - if (theme.login_page_background_image) { - root.style.setProperty('--login-bg-image', `url("${theme.login_page_background_image}")`); - } - - // Login box positioning - supports Left, Right, or Default positioning - if (theme.login_box_position && theme.login_box_position !== 'Default') { - root.style.setProperty('--login-box-position', 'absolute'); - root.style.setProperty('--login-box-right', theme.login_box_position === 'Right' ? '10%' : 'auto'); - root.style.setProperty('--login-box-left', theme.login_box_position === 'Left' ? '10%' : 'auto'); - root.style.setProperty('--login-box-padding', theme.is_app_details_inside_the_box === 1 ? '18px 40px 40px 40px' : '40px'); - } - - // Login box vertical positioning and app details integration - if (theme.is_app_details_inside_the_box !== undefined) { - root.style.setProperty('--login-box-top', theme.is_app_details_inside_the_box === 1 ? '26%' : '18%'); - } - - // Special styling when app details are inside the login box - if (theme.is_app_details_inside_the_box === 1) { - root.style.setProperty('--login-box-bg-override', theme.login_box_background_color); - root.style.setProperty('--login-box-border-radius', '10px'); - } - - // Login button styling - if (theme.login_button_background_color) { - root.style.setProperty('--login-btn-bg', theme.login_button_background_color); - } - if (theme.login_button_text_color) { - root.style.setProperty('--login-btn-color', theme.login_button_text_color); - } - if (theme.login_page_button_hover_background_color) { - root.style.setProperty('--login-btn-hover-bg', theme.login_page_button_hover_background_color); - } - if (theme.login_page_button_hover_text_color) { - root.style.setProperty('--login-btn-hover-color', theme.login_page_button_hover_text_color); - } - if (theme.login_box_background_color) { - root.style.setProperty('--login-box-bg', theme.login_box_background_color); - } - if (theme.page_heading_text_color) { - root.style.setProperty('--page-heading-color', theme.page_heading_text_color); - } - - // Login content border - removed when app details are inside box - if (theme.is_app_details_inside_the_box === 1) { - root.style.setProperty('--login-content-border', 'none'); - } - - // Custom login page title - replaces default Frappe title - if (theme.login_page_title) { - root.style.setProperty('--login-title-display', 'none'); - root.style.setProperty('--login-title-after-display', 'flex'); - root.style.setProperty('--login-title-after-justify', 'center'); - root.style.setProperty('--login-title-after-margin', '10px'); - root.style.setProperty('--login-title-after-content', `'${theme.login_page_title}'`); - if (theme.page_heading_text_color) { - root.style.setProperty('--login-title-after-color', theme.page_heading_text_color); - } - } - - // Form input field customization - if (theme.input_background_color) { - root.style.setProperty('--input-bg', theme.input_background_color); - } - if (theme.input_text_color) { - root.style.setProperty('--input-color', theme.input_text_color); - } - if (theme.input_border_color) { - root.style.setProperty('--input-border', theme.input_border_color); - } - if (theme.input_label_color) { - root.style.setProperty('--input-label-color', theme.input_label_color); - } - - // Navigation bar customization - if (theme.navbar_color) { - root.style.setProperty('--navbar-bg', theme.navbar_color); - } - if (theme.navbar_text_color) { - root.style.setProperty('--navbar-color', theme.navbar_text_color); - } - if (theme.hide_help_button !== undefined) { - root.style.setProperty('--hide-help', theme.hide_help_button ? 'none' : 'block'); - } - if (theme.hide_app_switcher !== undefined) { - root.style.setProperty('--hide-app-switcher', theme.hide_app_switcher ? 'none' : 'block'); - root.style.setProperty('--app-switcher-pointer-events', theme.hide_app_switcher ? 'none' : 'auto'); - } - - // Primary button styling - if (theme.button_background_color) { - root.style.setProperty('--btn-primary-bg', theme.button_background_color); - } - if (theme.button_text_color) { - root.style.setProperty('--btn-primary-color', theme.button_text_color); - } - if (theme.button_hover_background_color) { - root.style.setProperty('--btn-primary-hover-bg', theme.button_hover_background_color); - } - if (theme.button_hover_text_color) { - root.style.setProperty('--btn-primary-hover-color', theme.button_hover_text_color); - } - - // Secondary button styling - if (theme.secondary_button_background_color) { - root.style.setProperty('--btn-secondary-bg', theme.secondary_button_background_color); - } - if (theme.secondary_button_text_color) { - root.style.setProperty('--btn-secondary-color', theme.secondary_button_text_color); - } - if (theme.secondary_button_hover_background_color) { - root.style.setProperty('--btn-secondary-hover-bg', theme.secondary_button_hover_background_color); - } - if (theme.secondary_button_hover_text_color) { - root.style.setProperty('--btn-secondary-hover-color', theme.secondary_button_hover_text_color); - } - - // Main body and content area styling - if (theme.body_background_color) { - root.style.setProperty('--body-bg', theme.body_background_color); - } - if (theme.main_body_content_box_background_color) { - root.style.setProperty('--content-bg', theme.main_body_content_box_background_color); - } - if (theme.main_body_content_box_text_color) { - root.style.setProperty('--content-text-color', theme.main_body_content_box_text_color); - } - - // Sidebar customization - if (theme.sidebar_background_color) { - root.style.setProperty('--sidebar-bg', theme.sidebar_background_color); - } - if (theme.sidebar_text_color) { - root.style.setProperty('--sidebar-text-color', theme.sidebar_text_color); - } - - // Data table styling - if (theme.table_head_background_color) { - root.style.setProperty('--table-head-bg', theme.table_head_background_color); - } - if (theme.table_head_text_color) { - root.style.setProperty('--table-head-color', theme.table_head_text_color); - } - if (theme.table_body_background_color) { - root.style.setProperty('--table-body-bg', theme.table_body_background_color); - } - if (theme.table_body_text_color) { - root.style.setProperty('--table-body-color', theme.table_body_text_color); - } - if (theme.table_hide_like_comment_section !== undefined) { - root.style.setProperty('--hide-like-comment', theme.table_hide_like_comment_section ? 'none' : 'block'); - } - - // Widget/card styling (number cards, dashboard widgets) - if (theme.number_card_background_color) { - root.style.setProperty('--widget-bg', theme.number_card_background_color); - } - if (theme.number_card_border_color) { - root.style.setProperty('--widget-border', theme.number_card_border_color); - } - if (theme.number_card_text_color) { - root.style.setProperty('--widget-color', theme.number_card_text_color); - } - - // Sidebar visibility control - if (theme.hide_side_bar !== undefined) { - root.style.setProperty('--sidebar-expanded', theme.hide_side_bar === 0 ? 'expanded' : ''); - } - } - - /** - * Apply all theme configurations to the current page - * Orchestrates the application of CSS variables and UI element toggles - */ - applyTheme() { - this.setCSSVariables(); - this.toggleSidebar(); - this.toggleSearchBar(); - this.setDefaultApp(); - this.showLoginBox(); - } - - /** - * Show login box with smooth transition after theme is applied - * Prevents flickering by revealing the login form only after positioning is set - */ - showLoginBox() { - const loginBox = document.querySelector('.for-login'); - if (loginBox) { - // Small delay to ensure CSS variables are applied - setTimeout(() => { - loginBox.classList.add('theme-ready'); - }, 50); - } - } - - /** - * Toggle sidebar visibility based on theme configuration - * Adds/removes 'expanded' class to control sidebar state - */ - toggleSidebar() { - const sidebarContainer = document.querySelector('.body-sidebar-container'); - if (!sidebarContainer) { - return; - } - - if (this.themeData.hide_side_bar === 0) { - sidebarContainer.classList.add('expanded'); - } else { - sidebarContainer.classList.remove('expanded'); - } - } - - /** - * Toggle search bar visibility based on user roles - * Hides search bar if current user's role matches hide_search configuration - */ - toggleSearchBar() { - const searchBar = document.querySelector('.input-group.search-bar.text-muted'); - if (!searchBar) { - return; - } - - if (this.getUserRoles()) { - searchBar.style.display = 'none'; - } - } - - /** - * Set current app to default app when app switcher is hidden - * Similar to breadcrumbs.js line 83 functionality - */ - setDefaultApp() { - // Only proceed if hide_app_switcher is enabled and default_app is set - if (!this.themeData.hide_app_switcher || !this.themeData.default_app) { - return; - } - - // Check if frappe.app.sidebar.apps_switcher exists (similar to breadcrumbs.js) - if (frappe?.app?.sidebar?.apps_switcher?.set_current_app) { - try { - // Set the current app to the default app (same as breadcrumbs.js line 83) - frappe.app.sidebar.apps_switcher.set_current_app(this.themeData.default_app); - } catch (error) { - // Silent fail if app switcher is not available or app doesn't exist - console.warn('Failed to set default app:', error); - } - } - } - - /** - * Set up event listeners for dynamic theme updates and DOM changes - * Handles real-time theme changes and new element detection - */ - setupEventListeners() { - // Listen for theme changes - allows for runtime theme updates - document.addEventListener('themeChanged', () => { - this.loadTheme().then(() => this.applyTheme()); - }); - - // Listen for DOM changes to apply theme to dynamically added elements - // Frappe uses dynamic content loading, so we need to monitor for new elements - const observer = new MutationObserver(() => { - this.toggleSearchBar(); - }); - - // Observe all changes in document body and its children - observer.observe(document.body, { - childList: true, // Watch for element additions/removals - subtree: true // Watch all descendant nodes - }); - - } - -} - -// Initialize theme system when DOM is ready -// Handles both immediate initialization and delayed initialization for slow-loading pages -if (document.readyState === 'loading') { - // DOM is still loading, wait for DOMContentLoaded event - document.addEventListener('DOMContentLoaded', () => { - window.frappeDeskTheme = new FrappeDeskTheme(); - }); -} else { - // DOM is already loaded, initialize immediately - window.frappeDeskTheme = new FrappeDeskTheme(); -} \ No newline at end of file diff --git a/frappe_desk_theme/public/js/sidebar/sidebar_override.bundle.js b/frappe_desk_theme/public/js/sidebar/sidebar_override.bundle.js new file mode 100644 index 0000000..ddd99a4 --- /dev/null +++ b/frappe_desk_theme/public/js/sidebar/sidebar_override.bundle.js @@ -0,0 +1,89 @@ +frappe.ui.Sidebar = class CustomSidebar extends frappe.ui.Sidebar { + // Improved method to handle sidebar item clicks + handle_sidebar_click(item_element, item_name, item_title) { + $(".standard-sidebar-item").removeClass("active-sidebar"); + $(item_element).closest(".standard-sidebar-item").addClass("active-sidebar"); + this.active_item = $(item_element).closest(".standard-sidebar-item"); + localStorage.setItem("sidebar-active-item", item_name || item_title); + } + set_active_workspace_item() { + const current_route = frappe.get_route(); + if (!current_route || !current_route.length) return; + + // For workspaces: route is ["Workspaces", workspace_name]. For doctype list: ["List", "DocType", ...]. + // For Page doctype: route is ["page-name"] (desk/page-name) or ["Page", "page-name"] (desk/Page/page-name). + const current_item = current_route[1] || current_route[0]; + + const $match = this.$sidebar.find(`.sidebar-item-container[item-name="${current_item}"]`); + if ($match.length) { + this.$sidebar.find(".standard-sidebar-item").removeClass("active-sidebar"); + $match.find(".standard-sidebar-item").addClass("active-sidebar"); + this.active_item = $match; + + // If nested, expand parent + const $parent_container = $match.closest(".sidebar-child-item"); + if ($parent_container.length) { + $parent_container.removeClass("hidden"); + const $toggle_btn = $parent_container + .siblings(".sidebar-item-control") + .find(".drop-icon"); + $toggle_btn.find("use").attr("href", "#icon-chevron-up"); + } + } + } + build_sidebar_section(title, root_pages) { + let sidebar_section = $( + `
` + ); + + this.prepare_sidebar(root_pages, sidebar_section, this.wrapper.find(".sidebar-items")); + + // Rewrite Page links from desk/Page/page-name or desk/page/page-name to desk/page-name + sidebar_section.find(".item-anchor[href]").each(function () { + const href = $(this).attr("href") || ""; + const match = href.match(/^\/desk\/(?:Page|page)\/([^/?#]+)/); + if (match) { + $(this).attr("href", "/desk/" + match[1]); + } + }); + + if (Object.keys(root_pages).length === 0) { + sidebar_section.addClass("hidden"); + } + + // Fixed single-click active + breadcrumb update + $(".item-anchor") + .off("click") + .on("click", (e) => { + const $target = $(e.currentTarget); + const item_name = $target.closest(".sidebar-item-container").attr("item-name"); + const item_title = $target.attr("title"); + + // Delay to let route update + setTimeout(() => { + this.set_active_workspace_item(); + this.handle_sidebar_click(e.currentTarget, item_name, item_title); + frappe.breadcrumbs.update(); + + // Scroll to item if needed + if (!frappe.dom.is_element_in_viewport($target)) { + $target[0].scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, 50); + + $(".list-sidebar.hidden-xs.hidden-sm").removeClass("opened"); + $("body").css("overflow", "auto"); + + if (frappe.is_mobile()) { + this.close_sidebar(); + } + }); + + if ( + sidebar_section.find(".sidebar-item-container").length && + sidebar_section.find("> [item-is-hidden='0']").length == 0 + ) { + sidebar_section.addClass("hidden show-in-edit-mode"); + } + } +}; diff --git a/frappe_desk_theme/templates/includes/desk_footer.html b/frappe_desk_theme/templates/includes/desk_footer.html new file mode 100644 index 0000000..989f72e --- /dev/null +++ b/frappe_desk_theme/templates/includes/desk_footer.html @@ -0,0 +1,22 @@ +{% if copyright_text or footer_powered_by %} + + +{% if sticky_footer %} + +{% endif %} +{% endif %} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 79f8dc5..c60b79f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,9 +15,9 @@ dependencies = [ requires = ["flit_core >=3.4,<4"] build-backend = "flit_core.buildapi" -# These dependencies are only installed when developer mode is enabled -[tool.bench.dev-dependencies] -# package_name = "~=1.1.0" +[tool.bench.frappe-dependencies] +frappe = ">=16.0.0,<17.0.0" + [tool.ruff] line-length = 110