diff --git a/.browserslistrc b/.browserslistrc index a7d888b50797..c076e797c5e7 100644 --- a/.browserslistrc +++ b/.browserslistrc @@ -1,2 +1,9 @@ last 3 versions +Chrome >= 100 +Firefox >= 100 +Edge >= 100 +Safari >= 15.5 +iOS >= 15.5 +Android >= 100 +ChromeAndroid >= 100 not dead diff --git a/.cursor/rules/general-code-style.mdc b/.cursor/rules/general-code-style.mdc new file mode 100644 index 000000000000..fdec4c4ac79d --- /dev/null +++ b/.cursor/rules/general-code-style.mdc @@ -0,0 +1,21 @@ +--- +description: +globs: +alwaysApply: true +--- +# Code Style + +## Avoid Magic Numbers +- Do not use unexplained hardcoded values ("magic numbers") in code or tests. +- Define such values as named constants or use existing constants to clarify their meaning. + +## Consistent Error Codes and Status +- When returning error codes and HTTP status, always be very specific to use the correct code, not only 200 and 500. + +## Prioritize Style and Developer Experience +- Always pay attention for clarity, maintainability, and ease of understanding, even if the underlying logic does not change. +- Code style and developer experience are important for long-term project health. + +## Self Documented Code +- Avoid adding comments that can be a constants or a well named function +- Always prefer to create small funcitons that describe themself diff --git a/.cursor/rules/tests-code-style.mdc b/.cursor/rules/tests-code-style.mdc new file mode 100644 index 000000000000..c96cf9556864 --- /dev/null +++ b/.cursor/rules/tests-code-style.mdc @@ -0,0 +1,10 @@ +--- +description: +globs: *test* +alwaysApply: false +--- +# Testing Best Practices + +## Reduce Code Duplication +- For repeated code such as test setup, mocks or assertions, extract them into helper methods or setup functions. +- Example: If multiple tests initialize the same mocks or objects, move this logic to a shared setup function rather than duplicating code in each test. diff --git a/.env.example b/.env.example index 90075cd7ac8f..b7036b34b956 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ USERNAME=admin PASSWORD=password BASE_URL=http://localhost:8888 -ELEMENTS_REGRESSION_BASE_URL=http://localhost:8888 +ELEMENTS_REGRESSION_BASE_URL=http://localhost:8889 diff --git a/.eslintignore b/.eslintignore index 208b7a688edc..66bd1358b887 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,7 +3,10 @@ assets/js/ **/*.min.js **/node_modules/** **/vendor/** +**/vendor_prefixed/** build/** -packages/**/* tests/qunit/setup/tinymce.js /tmp/** +packages/**/* +eslint-local-rules.js +.eslintrc.js diff --git a/.eslintrc.js b/.eslintrc.js index 6338a53caaee..4c5ab67bdaae 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,15 +3,14 @@ module.exports = { 'plugin:react/recommended', 'plugin:no-jquery/deprecated', 'plugin:@wordpress/eslint-plugin/recommended-with-formatting', - 'plugin:@elementor/editor/recommended', 'plugin:import/typescript', ], plugins: [ 'babel', 'react', - '@elementor/editor', 'no-jquery', '@typescript-eslint', + 'local-rules', ], parser: '@typescript-eslint/parser', globals: { @@ -53,11 +52,19 @@ module.exports = { '@typescript-eslint/await-thenable': 'error', '@typescript-eslint/no-var-requires': 'error', '@typescript-eslint/ban-ts-comment': 'error', + // Local rules + 'local-rules:no-react-namespace': 'error', }, parserOptions: { project: [ './tsconfig.json' ], }, }, + { + files: [ 'tests/**/*.ts', 'tests/**/*.tsx' ], + rules: { + 'local-rules:no-react-namespace': 'off', + }, + }, ], rules: { // Custom canceled rules diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 43e451004664..44736bce4129 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -2,73 +2,104 @@ ![logo](https://user-images.githubusercontent.com/1778512/191041718-728d179e-07cb-4cb4-953a-6c294ee8c4db.png) -**The Elementor GitHub organization page and all respective repositories are collaborative spaces for web creators, developers, enthusiasts, and users alike to contribute, share ideas, and help shape the future of Elementor in a positive and welcoming way.** +**The Elementor GitHub organization and its repositories are collaborative spaces for web creators, developers, enthusiasts, and users to contribute, share ideas, and help shape the future of Elementor in a positive and welcoming way.** -All participants are expected to follow this Code of Conduct without exceptions. +All participants are expected to follow this Code of Conduct — without exception. +
+
## Our Pledge -In the interest of fostering an open, inclusive, and welcoming environment, we strive to maintain, in all collaborative spaces, an undiscriminatory and harassment-free experience for everyone, regardless of skill level, ability, disability, socioeconomic status, political inclination, age, appearance, nationality, ethnicity, gender identity, sexual orientation, or religion. +To foster an open, inclusive, and respectful environment, we are committed to providing a harassment-free and equitable experience for everyone — regardless of skill level, ability, socioeconomic status, political belief, age, appearance, nationality, ethnicity, gender identity, sexual orientation, or religion. -This Code of Conduct outlines our expectations for all those who participate in any Elementor GitHub space, as well as the consequences of unacceptable behavior. +This Code of Conduct outlines expectations for everyone participating in any Elementor GitHub space, as well as the consequences of violating these expectations. -**We invite all those who participate to help us create safe and positive experiences for everyone.** +**We invite all contributors to help us make this a welcoming and positive experience for everyone.** +
+
## Guidelines -To ensure everyone has a good, welcoming, and positive experience, we outlined a few rules that should be followed and enforced by all participants. +To ensure that everyone feels welcome and safe, we ask all participants to follow these guidelines: -### Be Respectful and Empathetic +### Be Respecful and Kind -We embrace healthy conversations but don't tolerate behavior that disrespects or discriminates against other contributors or maintainers. Be aware of diverse backgrounds and perspectives. - -**Let's make this a safe space for anyone to contribute and share ideas.** +Treat everyone with kindness and consideration. We welcome healthy discussions, but we do not tolerate disrespect, discrimination, or exclusionary behavior. ### Be Welcoming and Open-minded -Our spaces are open for contributors with any experience level. Be receptive to other ideas or points of view and welcoming to all contributors, especially those who are inexperienced. +Our community is open to contributors of all backgrounds and experience levels. Be patient, especially with newcomers. Value different perspectives. + +### Be constructive and stay on topic + +Critique ideas, not individuals. +Keep discussions relevant to Elementor products — political, religious, or unrelated topics are not allowed. -### Be Constructive, Relevant and keep the conversation On-topic +> _Inflammatory, offensive, or off-topic discussions may be removed without warning or explanation._ -Criticize ideas, not people. -
Explain your thoughts and keep discussions relevant to Elementor products. We don't engage in talks about politics, religion, or other unrelated topics. +### Stay organized -**Heated, offensive, and off-topic discussions will be removed at our discretion without warning or requiring any explanation.** +Help maintain a clean and navigable community by following these best practices: -### Keep it tidy +- Follow our [Contribution Guidelines](https://elemn.to/gh-contributing). +- Use [Issues](https://elemn.to/gh-issues) only for reproducible bugs. +- Use [Feature Request discussions](https://elemn.to/gh-feature-requests) to request new features. +- Do not use GitHub for support requests or complaints — use our [official support channels](https://elemn.to/support-ticket). +- Search before opening new threads. +- Troubleshoot before reporting bugs. +- Stay on topic in all conversations. +- Do not use unrelated issues to post new bugs or feature requests. +- Avoid “+1” or “Agreed” comments — use the upvote button where available. +- Provide complete information when asked, especially when triaging or debugging. +
+
-Organize and submit your issues, discussions, and comments correctly: +## Expected Behavior -- **Always follow** our [Contribution Guidelines](https://elemn.to/gh-contributing). -- Submit [Issues](https://elemn.to/gh-issues) **only to report bugs.** -- Submit [Feature Request Discussions](https://elemn.to/gh-feature-requests) **only to request features.** -- Don't use any space to request support or to file complaints.
**Use the official Elementor support channels** instead. -- **Search before submitting** new threads to avoid duplicates. -- **Troubleshoot** your issues before submitting a bug report. -- **Provide instant and thorough information** when requested. -- **Stay on topic** when commenting. -- **Don't use other issues or discussions to report different bugs or feature requests.**
Instead, submit new ones or join the ones that are already open. -- Avoid commenting _"+1"_ or _"Agreed,"_ **use the upvote button** when available. +We expect participants to: -## Expected behaviors +- Be respectful and inclusive +- Assume good intent +- Listen actively and respond constructively +- Acknowledge mistakes and learn from them +- Create a welcoming atmosphere for everyone +
+
-We're here to create a positive and welcoming environment. -
Always be respectful, show empathy, respect differing opinions, give and accept feedback gracefully, apologize for mistakes, and focus on keeping all spaces welcoming for all contributors by following our Guidelines above. +## Unacceptable Behavior -## Unacceptable behaviors +We do not tolerate: -Please avoid inappropriate behavior, including using offensive or sexualized language, trolling, harassment, discrimination, disclosing private information without permission, spam or off-topic discussions, and posting complaints about the Elementor company or other subjects. +- Harassment, trolling, or personal attacks +- Discrimination or exclusionary comments +- Sexualized or inappropriate language or content +- Disclosing private or personal information without consent +- Spam, off-topic content, or disruptive behavior +- Using GitHub to submit complaints about the company or staff +
+
## Enforcement -Our maintainers will enforce these guidelines and take fair action if necessary. +Moderators and maintainers are responsible for upholding this Code of Conduct and may take appropriate action, including: + +- Removing content +- Locking or closing threads +- Issuing warnings or temporary bans +- Enforcing permanent bans in cases of repeated or severe violations + +These actions may be taken without warning or explanation, at the discretion of the moderation team. -We reserve the right to close, lock, or remove any thread or comment at any time without warning or explanation. Continuing inappropriate behavior will result in a permanent ban at our discretion. +This Code of Conduct applies across all Elementor GitHub spaces — including Issues, Discussions, Pull Requests, and Comments. +
+
-**This Code of Conduct applies to all Elementor GitHub pages and spaces, including but not limited to Issues and Discussions.** +## Reporting Concerns -If you wish to file a complaint, please [reach out to Elementor Support](https://elemn.to/contact). +If you experience or witness behavior that violates this Code of Conduct, please [contact Elementor Support](https://elemn.to/contact). We will review all reports and take them seriously. +
+
## Attribution -Our Code of Conduct is an adaptation of the [Contributor Covenant](https://www.contributor-covenant.org/) and the [GitHub Community Code of Conduct](https://docs.github.com/en/site-policy/github-terms/github-community-code-of-conduct). +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/) and the [GitHub Community Code of Conduct](https://docs.github.com/en/site-policy/github-terms/github-community-code-of-conduct). diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index bac7595342e5..785d37344edf 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -7,32 +7,54 @@ Community contributions are always welcomed and help us remain the Number 1 WordPress Website Builder. Contributors can answer questions on all Elementor GitHub spaces or submit their contributions in the appropriate channels. **Before submitting contributions, all participants should read and follow these Guidelines and our [Code of Conduct](https://elemn.to/gh-code-of-conduct).** +
+
## How to use GitHub We use GitHub **exclusively** for well-documented **Bug Reports**, **Feature Requests** and **Code Contributions (Pull Requests)**.
Communication should always be in **English**. -### Need Help, Guidance, Assistance, or Support? +For any other topic, please use the appropriate channels. For all things Elementor visit our website: [https://elementor.com](https://elementor.com/?utm_source=github-repo&utm_medium=link&utm_campaign=contribution). +
+
-**Note that we are unable to offer any support through this repository.** +> [!NOTE] +> ### Contributing to Editor V4 +> +> We are currently collecting feedback and reports for the new **Editor V4**, a complete re-architecture of the Elementor Editor focused on performance, modularity, and a CSS-first design system. +> +> If you're testing **Editor V4**, please use the following dedicated forms: +> +> - [Report bugs in Editor V4](https://elemn.to/editor-v4-issues) +> - [Join discussions and share feedback](https://elemn.to/editor-v4-feedback) +> +> Please note that the **Editor V4** is in active development, may be unstable, and may lack some functionality found in Elementor V3. Use it only in testing environments, and make sure to review the scope and purpose of each form before submitting your reports or feedback. +
-Please **DO NOT** open issues or discussions to request support. +### Need Personal Help, Guidance, Assistance, or Support? + +**Note that we are unable to offer any level of support through this repository.** + +Please **DO NOT** open issues or discussions to request for Personal Support.
For those, use the appropriate channels. **Find out how to [submit a Support Ticket here](https://elemn.to/support-ticket).** Alternatively, you can visit one of these resources: -- **Help Center**
Visit the [Elementor Help Center](https://elemn.to/gh-to-help-center) to find solutions to the most frequent problems, or read the documentation about Elementor's many features. -- **Academy**
Grow your Web Creator skills and learn to make the most of Elementor in the [Elementor Academy](https://elemn.to/gh-to-academy). +- **Help Center**
Visit the [Elementor Help Center](https://elemn.to/help) to find solutions to the most frequent problems, or read the documentation about Elementor's many features. +- **Academy**
Grow your Web Creator skills and learn to make the most of Elementor in the [Elementor Academy](https://elemn.to/academy). +- **Discord Community**
Connect and chat with other Web Creator in different channels (including on multiple languages) through our official [Elementor Discord Community](https://elemn.to/discord). - **Global Community**
Join the [Elementor Global Community](https://elemn.to/community-on-fb) on Facebook. Where all kinds of users come together to help each other. - **Support Forum**
If you have more questions, visit the free [Support Forum](https://elemn.to/wp-support-forum) on our WordPress plugin page. -- **Elementor Website**
For more information about features, FAQs, and documentation, check out our website at [Elementor Website Builder](https://elemn.to/gh-to-elementor). +- **Elementor Website**
For more information about features, FAQs, and documentation, check out our website at [Elementor Website Builder](https://elementor.com/?utm_source=github-repo&utm_medium=link&utm_campaign=contribution). -**If you have an active [Elementor Pro](https://elemn.to/gh-to-elementor-pro) subscription, you are entitled to personal support. Please see your purchase email or visit your [Elementor Account page](https://elemn.to/my-elementor) for details.** +**If you have an active [Elementor Pro](https://elemn.to/gh-to-elementor-pro) subscription, you are entitled to personal support. Please see your purchase email or visit your [Elementor Account page](https://my.elementor.com) for details.** **As a gentle reminder, we may close support requests submitted to GitHub spaces without action.** +
+
## How to report Security Issues @@ -45,45 +67,154 @@ We leverage the collective expertise of our community, offering round-the-clock Join our [Managed Security Bug Bounty programs](https://elemn.to/gh-to-bounty-programs) instead and help us maintain Elementor, a secure software, responsibly. **To learn more about our security efforts and how to contact us for security inquiries, please visit our [Trust Center](https://elemn.to/gh-to-trust-center).** +
+
## How to Report a Bug If you found a bug in the source code and can reproduce it consistently after troubleshooting it, you can help us by [submitting a Bug Report](https://elemn.to/gh-new-bug-report). -### Before submitting your Bug Report, follow these best practices to help save time +### Requirements + +To help us review and resolve your report efficiently, all submitted bug reports must meet the following requirements. These rules ensure that reported issues are actionable, reproducible, and relevant. + +- **All fields in the bug report form are required — no exceptions.** + Missing or incomplete information leads to unnecessary delays and often makes issues unreviewable. Issues missing required fields may be closed without review. + +- **Search for similar issues before opening a new one.** + Duplicate reports create unnecessary noise and slow down the triaging process. Before posting, search the [open and closed issues](https://elemn.to/gh-issues) to see if your problem has already been reported or addressed. + +- **Ensure you are using the latest stable versions.** + Bugs that occur in outdated versions may already be fixed. Please make sure Elementor, Elementor Pro, WordPress core, your theme, and all plugins are up to date. + Check the [changelog](https://elemn.to/changelog). + +- **Clear all levels of cache.** + Issues caused by outdated or conflicting cached data are common. Before submitting a report, clear your browser cache, CDN cache, plugin cache, and server cache — in that order — to avoid false positives. + +- **Test with only Elementor, Elementor Pro, and the Hello Elementor theme active.** + Conflicts from third-party plugins or themes are not actionable by our team. Isolate the issue by deactivating all unrelated plugins and switching to the Hello theme before reporting the problem. + See: [Troubleshooting the Editor](https://elemn.to/help-troubleshooting) + +- **Include your full Elementor System Info.** + This diagnostic report helps us understand your environment and replicate the issue. Reports submitted without this information will be closed. + [How to get your System Info](https://elemn.to/help-system-info) + +- **If you are a third-party developer, please identify yourself.** + Include your name, company, and a way to reach you. If your report involves compatibility with your own plugin, theme, or integration, this helps us follow up appropriately. + +- **Only one issue per report.** + Submitting multiple bugs in a single issue makes tracking and resolving them harder. Please open separate reports for separate problems. + +### Scope + +We can only accept bug reports that fall within the scope of what we actively support. Submissions that do not meet this criteria will be closed without action. + +- **Requests for personal support** + GitHub is not an efficient support channel. For assistance with setup, common issues, troubleshooting, or how-to questions, please contact our [Support Team](https://elemn.to/support-ticket) or visit our [Help Center](https://elemn.to/help). + +- **Conflicts with third-party plugins, themes, or services** + If the issue only occurs when using tools not officially supported by Elementor, please contact the third-party developer. We do not triage or resolve external compatibility issues. + +- **Feature requests or missing functionality** + If something is not broken but simply not yet supported, it's not a bug. Please use the [Feature Request form](https://elemn.to/gh-new-feature-request) instead. + +- **Custom code and unsupported modifications** + We cannot help debug issues related to custom snippets, hooks, filters, overrides, or undocumented modifications. Refer to our [Developer Documentation](https://elemn.to/dev-docs) if you're building custom integrations. + +- **Security issues** + We take security very seriously. And we want to resolved in a responsible manner. Please, do not disclose vulnerabilities as bug reports. Use our [Bug Bounty Program](https://elemn.to/gh-to-bounty-programs) for coordinated and responsible security reporting. + +- **Complaints, or inappropriate content** + GitHub issues are not the right place for complaints about Elementor's products, services, or policies. Reach out to Elementor Support Instead. We also do not tolerate hostile, discriminatory, or disrespectful remarks. See our [Code of Conduct](https://elemn.to/gh-code-of-conduct). + +- **Invalid, incomplete, or vague reports** + Reports missing required fields, lacking reproduction steps, or submitted without proper validation will be closed to maintain the quality and focus of the issue tracker. + +### Instructions + +To ensure we can understand and resolve the issue effectively, follow these writing guidelines when filling out the form: -- **Search Before Posting**
Please [search for similar issues](https://elemn.to/gh-issues) (both open and closed) to prevent creating duplicates.
Use [GitHub Labels](https://elemn.to/gh-labels) to refine your search. -- **Stay Up-to-date**
Ensure you are using the [most recent and stable versions](https://elemn.to/changelog) of Elementor (and Elementor Pro), as well as all installed plugins, themes, and WordPress. -- **Troubleshoot your Issue**
Visit our [Help Center](https://elemn.to/gh-to-help-center) and use the search function to find [troubleshooting articles](https://elemn.to/help-troubleshooting) for the most common problems that can be solved quickly. -- **Isolate the Problem and Validate your Issue**
Ensure your issue happens when only the **Elementor (and Elementor Pro)** plugins are active while also using the [Hello Elementor theme](https://elemn.to/wp-hello-elementor-theme).
Isolating the problem helps identify if Elementor causes the issue you're experiencing -- **Write a Clear and Concise Title**
Summarize your title in a short sentence that can help identify your issue at a glance without needing to read the description. -- **Provide a Detailed Description**
Please help us understand in detail what problems you are experiencing.
Give a detailed description of your problem, and provide examples, screenshots, screen recordings, and related links. -- **Explain how we can Recreate your Issue**
Enumerate all the steps needed to reproduce the issue in detail so we can replicate it in our environment. -- **Tell us what you were Expecting to Happen**
Describe the **Expected Behavior** to help identify the root of the issue. -- **Share the Elementor System Info**
Be ready to share a full copy of the [Elementor System Info](https://elemn.to/help-system-info) report from your WordPress **`Dashboard`** > **`Elementor`** > **`System Info`** page (available at the bottom of the page).
**We won't accept issues without it. [Learn how to copy it here](https://elemn.to/help-system-info).** -- **Write One Bug Report per Issue**
If you are experiencing more than one bug, submit only **ONE BUG PER ISSUE**. +- **Use a clear and descriptive title** + A well-written title helps other users (and our team) quickly understand what the report is about and whether it might be related to other reports. -**As a gentle reminder, we may close issues that do not comply with these guidelines without action.** +- **Describe the issue in detail** + Explain what happened, when it happened, and what part of Elementor is affected. Include any error messages, behavior patterns, and relevant context. Screenshots and screen recordings are especially helpful. + +- **List all steps to reproduce the issue** + Reproduction steps are essential for verifying and fixing bugs. Provide a step-by-step guide of what you did, so we can reproduce it - ideally on a clean WordPress install. If we cannot reproduce the issue, we cannot resolve it. + +- **Explain what you expected to happen** + Make clear what the correct or intended behavior should be. This helps us understand if the issue is due to a bug, a misunderstanding, or a missing feature. + +**As a gentle reminder, issues that do not meet these guidelines may be closed without further action. These standards ensure that our team and community contributors can manage reports effectively and focus on actionable feedback.** +
+
## How to Request a Feature -Do you have a brilliant idea, enhancement, or feature you would love to see in Elementor? We're all ears! +Have an idea to improve Elementor? We're always open to feedback that helps us create better tools for web creators. If you'd like to request a new feature or enhancement, you can do so by [submitting a Feature Request](https://elemn.to/gh-new-feature-request). + +You can also support existing ideas by [voting for your favorite requests](https://elemn.to/gh-feature-requests). +
+ +### Requirements + +To help us evaluate suggestions effectively, all submitted feature requests must meet the following requirements. These rules ensure each request is unique, well-defined, and relevant to Elementor's core product vision. + +- **Search for existing requests before posting.** + Duplicate requests reduce clarity and fragment community support. Please search both [open and closed requests](https://elemn.to/gh-feature-requests) to avoid duplicates. Prefer to always Vote for existing requests instead of submitting new ones. + +- **Verify that the feature is not already available.** + Before suggesting a new feature, check the [Features page](https://elemn.to/features) and your current Elementor setup to confirm it doesn't already exist. + +- **Submit only one request per thread.** + Submitting multiple suggestions in one request makes it harder to discuss, prioritize, and track individual ideas. Use one form per idea. + +- **Be specific about what problem the feature solves.** + A request that's grounded in a clear problem is easier to evaluate and more likely to be prioritized. Vague requests or "nice-to-have" ideas without a real use case may be closed. + +- **Explain your proposed solution clearly.** + The more detail you provide, the easier it is for our team (and the community) to understand how this could be implemented ad Vote for it. + +### Scope + +To stay focused and productive, we may close requests that do not meet the following criteria: + +- **Out of scope for Elementor's core mission.** + Requests that require major architectural changes, niche workflows, or overlap with third-party integrations that Elementor doesn't directly support may fall outside the product roadmap. + +- **Already supported or recently released features.** + If the feature already exists or was just released, we may close the request and link to the existing implementation. + +- **Bug reports, support requests, or custom code questions.** + These belong in other dedicated forms. Please report confirmed bugs using the [Bug Report form](https://elemn.to/gh-new-bug-report), and use [Support Channels](https://elemn.to/support-ticket) for troubleshooting or help with custom code. + +- **Low-detail or unclear submissions.** + Requests lacking context, justification, or clear value may be closed to keep the list relevant and actionable. + +### Instructions + +To ensure your suggestion is well understood, please include the following in your request: + +- **A clear and descriptive title.** + Helps others quickly understand the purpose of your request. + +- **The problem it solves.** + Describe the need or limitation this feature would address. -Suggest new ideas, features, or enhancements by [submitting a Feature Request](https://elemn.to/gh-new-feature-request). -
You can also support existing ideas by [voting for your favorite requests](https://elemn.to/gh-feature-requests). +- **Your proposed solution.** + Explain what you'd like to see added or changed in Elementor. -### Before submitting a new Feature Request, follow these best practices to help save time +- **Any alternatives you've considered.** + If you've tried workarounds or plugins, share your experience. -- **Search Before Posting**
Before creating a new Feature Request, please [search for similar requests](https://elemn.to/gh-feature-requests) to prevent creating duplicates. -- **Confirm Before Posting**
Please check the [Elementor Features page](https://elemn.to/features) before posting to avoid requesting an existing feature. -- **Write a Clear and Concise Title**
Summarize your title in a short sentence that can help identify your request at a glance without needing to read the description. -- **Provide a Detailed Description**
Elaborate on how you envision the feature. Include examples, use cases, and any other relevant details. -- **Describe the Solution**
Describe how you'd like a solution to be implemented. -- **Provide Alternatives**
Mention any alternative solutions or workarounds you've considered. -- **Give Additional Context**
Add context to your suggestion by providing examples, screenshots, screen recordings, related links, and additional information that might help us understand your request better. -- **Write One Request Per Thread**
If you have multiple requests, please submit only **ONE REQUEST PER THREAD**. +- **Extra context.** + Screenshots, examples, or links to related ideas can help others understand your vision. +
-**As a gentle reminder, we may close requests that do not comply with these guidelines without action.** +**As a gentle reminder, Feature Request discussions that do not meet these guidelines may be closed without further action. These standards ensure that our team and community contributors can manage reports effectively and focus on actionable feedback.** +
+
## I want to Contribute @@ -128,5 +259,5 @@ Your contributions, big or small, play a significant part in the continued devel - [Elementor Developers Center](https://elemn.to/gh-to-dev-center) - [Elementor Developers Documentation](https://elemn.to/gh-to-dev-docs) -- [GitHub Support and General Documentation](https://elemn.to/gh-support-center) -- [GitHub Pull Request Documentation](https://elemn.to/gh-support-pull-requests) +- [GitHub's official Support and General Documentation](https://elemn.to/gh-support-center) +- [GitHub's official Pull Request Documentation](https://elemn.to/gh-support-pull-requests) diff --git a/.github/DISCUSSION_TEMPLATE/editor-v4.yml b/.github/DISCUSSION_TEMPLATE/editor-v4.yml new file mode 100644 index 000000000000..9fb42f6aa197 --- /dev/null +++ b/.github/DISCUSSION_TEMPLATE/editor-v4.yml @@ -0,0 +1,59 @@ +labels: ["product/editor-v4"] +body: + - type: markdown + attributes: + value: | + # 🧪 Editor V4 Discussion Form +
+ + 👋 **Welcome, and thank you for helping shape the future of Elementor!** + + We're thrilled to have you here testing **Editor V4** - a major evolution of the Elementor Editor, rebuilt to improve performance, responsiveness, and your overall workflow. + + **This space is for sharing your thoughts, insights, and suggestions about Editor V4's features. Your feedback is incredibly valuable and helps guide the direction of the product during its development.** + + Want to learn more about Editor V4? **[Visit our Editor V4 landing page](https://elemn.to/editor-v4)**, it will be updated regularly! +
+ + ## 🧭 Guidelines + + Before getting started, please take a moment to review our **[Contribution Guidelines](https://elemn.to/gh-contributing)** and **[Code of Conduct](https://elemn.to/gh-code-of-conduct)**. These help us keep this space organized, respectful, and inclusive for everyone. +
+ + > [!IMPORTANT] + > ### 🧪 This space is dedicated to discussing the Editor V4, its features, and progress. + > + > If you're here to report a bug in the **Editor V4**, please use the **[🐛 Editor V4 Bug Report form](https://elemn.to/editor-v4-new-issue)** instead. + > + > ℹ️ Keep in mind **the Editor V4 is still a work in progress** - it may not include all existing features from Elementor V3, is subject to change or instability, and should not be used on production websites. + > + > ⚠️ _**Discussions that are unrelated to the Editor V4 may be closed without further action.**_
_**Be kind. Keep this space positive, respectful, and welcoming.**_ +
+ + ## 💡 Help Us Keep Editor V4 Discussions Organized + + - 🔍 **[Search for existing Editor V4 discussions](https://elemn.to/editor-v4-feedback)**.
Someone may have already started a thread on the same topic. Add your thoughts there to keep conversations centralized. + + - ✒️ **Use a clear and descriptive title.**
A good title helps others quickly understand what your post is about, and participate on the discussion instead of creating a new one. + + - ☝️ **Stick to one topic per discussion thread.**
This makes it easier to track feedback and follow up on specific subjects. + - type: textarea + id: description + validations: + required: true + attributes: + label: "Description" + description: "Please share your thoughts, feedback, or questions about the Editor V4 below." + placeholder: "Start a discussion about the Editor V4..." + - type: markdown + attributes: + value: | +
+ - type: checkboxes + id: final_agreement + attributes: + label: "Agreement" + description: "By filling out this form, you confirm that you have read the guidelines outlined above, and you agree your discussion may be closed without warning if it doesn't meet them." + options: + - label: "I confirm I have read and followed the Editor V4 Discussion Form guidelines, and agree this discussion may be closed without warning." + required: true diff --git a/.github/DISCUSSION_TEMPLATE/feature-request.yml b/.github/DISCUSSION_TEMPLATE/feature-request.yml index 49309ee1b90d..f3ec37cd820a 100644 --- a/.github/DISCUSSION_TEMPLATE/feature-request.yml +++ b/.github/DISCUSSION_TEMPLATE/feature-request.yml @@ -3,102 +3,115 @@ body: - type: markdown attributes: value: | - **👋 Hello, welcome to Elementor Feature Requests!** - # ⚠️ BEFORE POSTING READ THIS CAREFULLY - ## 🚀 Please only use this form to request an Elementor feature or enhancement. - **🚩 Check our [Roadmap](https://elemn.to/gh-to-roadmap) to learn what's in the pipeline.** - ### 🛟 Need Help, Guidance, Assistance, or Support?
[Click here to get support](https://elemn.to/support-ticket). - ### 🐞 Need to Report an Elementor Bug?
[Click here to report a bug](https://elemn.to/gh-new-bug-report). - - **ℹ️ For more information, please read our [Contribution Guidelines](https://elemn.to/gh-contributing).** + # 🚀 Request a Feature or Enhancement in Elementor
- ### 😉 Follow these best practices to help save time - - ✒️ **Write a Clear and Concise Title**
Summarize your title in a short sentence that can help identify your request at a glance without needing to read the description. - - ☝️ **One Request Per Thread**
If you have multiple requests, please submit only **ONE REQUEST PER THREAD**.
Requests with more than one subject may be closed without action. -
+ 👋 **Hello, and thank you for helping shape the future of Elementor!** + Use this form to suggest new features or enhancements that could improve Elementor. - ### ✳️ Fill-in all required fields! - - ❌ Requests lacking detail or submitted for any other reason than to suggest new ideas, features, or enhancements for the Elementor plugin may be closed without action. -
+ ## 🧭 Guidelines - --- + Before getting started, please take a moment to review our **[Contribution Guidelines](https://elemn.to/gh-contributing)** and **[Code of Conduct](https://elemn.to/gh-code-of-conduct)**. These help us keep this space organized, respectful, and inclusive for everyone.
- ## THE REQUEST - ### 📋 Prerequisites - - 🔍 **Search Before Posting**
Before creating a new Feature Request, please [search for similar requests](https://elemn.to/gh-feature-requests) to prevent creating duplicates. - - ✔️ **Confirm Before Posting**
Please check the [Elementor Features page](https://elemn.to/features) before posting to avoid requesting an existing feature. - - type: checkboxes - id: prerequisites - attributes: - label: "Prerequisites" - options: - - label: "I have searched for similar feature requests in both open and closed discussions and cannot find an existing request." - required: true - - label: "I have verified that the feature is still missing from the latest stable versions of Elementor or Elementor Pro." - required: true - - type: markdown - attributes: - value: | -
+ > [!IMPORTANT] + > ### 🚀 This form is specifically for requesting features or enhancements in Elementor. + > If you're experiencing a confirmed bug, and can reproduce it consistently after troubleshooting it, please use the **[🐞 Bug Report Form](https://elemn.to/gh-new-bug-report)** instead. + > + > For Personal Help, Assistance or Support, please use one of our **[🛟 Personal Support Channels](https://elemn.to/support-ticket)** instead. + > + > Before posting, make sure the feature doesn't already exist by checking the **[✔️ Features Page](https://elemn.to/features)** and our **[🚩 Roadmap](https://elemn.to/roadmap)**. + > + > ⚠️ _**Submitting unrelated or incomplete discussions may result in them being closed without further action.**_ + +
+ +

📋 Requirements

(click to expand) + + To increase the chances of your request being selected for implementation, please ensure your Feature Request meets the following requirements. +
+ + - 🔍 **Search for similar Feature Requests before opening a new one.**
Avoid duplicates and save time by **[using the filters at the top of the Feature Requests list](https://elemn.to/gh-feature-requests)** to narrow your search. + + - 🗳️ **Vote for existing Feature Requests.**
Instead of opening a new discussion or writing "+1", use the **Upvote** button to express your interest in suggested features. The most voted Feature Requests are more likely to be considered for implementation in future versions. + + - ☝️ **Only one request per discussion.**
Please don't bundle multiple Feature Requests in a single submission. This helps us track and prioritize features that the majority of users are interested. Discussions with bundled requests can't be considered for implementation. +
+
+ +

🎯 Scope

(click to expand) + + To keep this repository focused and efficient, there are some types of discussions that we are unable to handle here. These discussions will be **closed without further action**. +
+ + - 🛟 **Requests for personal support**
This includes help with setup, troubleshooting, or usage guidance. For that, please contact one of our **[official Support Channels](https://elemn.to/support-ticket)**. + + - 🐞 **Bug Reports and conflicts**
If you're here to report issues with existing features in Elementor, please **[use our 🐞 Bug Report form instead](https://elemn.to/gh-new-bug-report)**
We're only able to address issues related to Elementor and Elementor Pro directly. If the problem occurs when using a plugin, theme, or integration that is not officially supported by Elementor, please reach out to the 3rd-party provider for assistance. + + - 📚 **Questions about custom code or extending Elementor.**
We cannot provide assistance with writing or debugging custom code. If your goal is to extend Elementor, we recommend referring to our **[Developers Documentation](https://elemn.to/dev-docs)**. + + - 🛡️ **Security and vulnerability disclosures.**
We take security seriously. Please use our managed **[Bug Bounty Programs](https://elemn.to/bug-bounty)** to responsibly report security issues. + + - ☹️ **Complaints or offensive content.**
While we are open to feedback, this is not the appropriate place to submit complaints about Elementor's products or services. We also do not tolerate offensive, discriminatory, or hostile remarks. Please keep discussions respectful as outlined in our **[Code of Conduct](https://elemn.to/gh-code-of-conduct)**. + + - ❌ **Incomplete, invalid, or out-of-scope discussions.**
Discussions without clear descriptions, or unrelated to Elementor or Elementor Pro feature suggestions will be closed without further action. +
+ + ## 📝 Instructions - ### 💬 Provide a Detailed Description - - Please explain in detail what problem your idea is going to solve. (Required) - - Describe how you'd like a solution to be implemented. (Required) - - Give examples of alternative solutions you've considered. - - Add context to your suggestion by providing examples, screenshots, screen recordings, related links, and additional information. + To help us understand and consider your request, follow these best practices: + + - ✒️ **Use a clear and descriptive title.**
A good title helps everyone quickly understand your suggestion and increases the chances of votes. Try to summarize the problem in a few words. + + - 💬 **Describe the problem your feature would solve.**
What is the pain point or limitation this feature would improve? + + - 💡 **Describe your proposed solution.**
How do you imagine it working? + + - 🔄 **Optionally suggest alternatives or workarounds.**
If you've tried other approaches, share those too. + + - 🖼️ **Attach screenshots or examples.**
Help us visualize your idea. - type: textarea id: the_problem validations: required: true attributes: - label: "What problem is your feature request going to solve? Please describe.." - description: "Help everyone understand in detail what problems you are trying to solve." - placeholder: "I'm always frustrated when ..." + label: "Describe the Problem" + description: "What problems are you experiencing?
Help everyone understand in detail what problems you encountered that could be solved by your suggestion." + placeholder: "Example: \nI'm always frustrated when ..." + - type: markdown + attributes: + value: | +
- type: textarea id: the_solution validations: required: true attributes: - label: "Describe the solution you'd like." - description: "Help everyone understand in a clear and concise description what you want to happen." - placeholder: "It would be nice if ..." - - type: textarea - id: alternatives + label: "Propose a Solution" + description: "Help everyone understand in a clear and concise description what solution would you like to be implemented." + placeholder: "Example: \nIt would be nice if we could ..." + - type: markdown attributes: - label: "Describe alternatives you've considered." - description: "Give examples of any alternative solutions or features you've considered." - placeholder: "It can also be solved by ..." + value: | +
- type: textarea id: additional_context attributes: - label: "Additional context" - description: "Add any other context, references, or illustrations about the feature request here." - placeholder: "Here's an example of ..." + label: "Additional Context" + description: "Optional: Give examples of any alternative solutions or features you've considered, or share any other information you think should be relevant." + placeholder: "Examples: \nIt can also be solved by ... \nHere's an example of ... \nDrag and drop your attachments here:" - type: markdown attributes: value: |
- - ### 📃 Agreement - - ⚠️ Please follow all the instructions above and fill in all the required fields with valid information.
We reserve the right to close requests that do not comply with these guidelines without any action. - type: checkboxes id: final_agreement attributes: label: "Agreement" + description: "By submitting this request, you confirm that you have read and followed all the guidelines, requirements, scope, and instructions outlined above, and you agree your discussion may be closed without further action if it doesn't meet them." options: - - label: "I agree that my request may be closed without action if it doesn't meet all the requirements." + - label: "I confirm I have read and followed all the guidelines and instructions outlined in the Feature Request form." required: true - - type: markdown - attributes: - value: | -
- - --- - ### Thank you for contributing and helping shape the future of Elementor! 🙏 - - We are thrilled to receive your request, and we will consider adding it to our [Roadmap](https://elemn.to/gh-to-roadmap) if we see enough demand from other users. - - _Please understand that due to the high volume of requests we receive and our commitment to continuous improvement, we can't guarantee that every suggestion will be implemented. We will, however, continue to monitor every submission.
Thank you for understanding!_ + - label: "I agree that my discussion may be closed without further action if it doesn't meet all the requirements outlined in the Feature Request form." + required: true \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml index 950649d01468..3947933ddce8 100644 --- a/.github/ISSUE_TEMPLATE/bug.yml +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -1,142 +1,153 @@ -name: "🐞 Bug Report" -description: "Did you find a bug in the source code of Elementor, and can reproduce it consistently after troubleshooting it? Report it here." +name: "🐞 Report a Confirmed Bug in Elementor" +description: "Found a reproducible bug in Elementor's source code after troubleshooting? Submit it here to help us improve." labels: ["status/awaiting_triage"] body: - type: markdown attributes: value: | - **👋 Hello, welcome to the Elementor Bug Reports!** - # ⚠️ BEFORE POSTING READ THIS CAREFULLY - ## 🐞 Please only use this form to report validated Elementor Bugs. - ### 🛟 Need Help, Guidance, Assistance, or Support?
[Click here to get support](https://elemn.to/support-ticket). - ### 🔧 Before submitting your issue, troubleshoot and validate it.
[Click here to start troubleshooting](https://elemn.to/help-troubleshooting). - - **ℹ️ For more information please read our [Contribution Guidelines](https://elemn.to/gh-contributing).** + # 🐞 Report a Confirmed Bug in Elementor
- ### 😉 Follow these best practices to help save time - - ✒️ **Write a Clear and Concise Title**
Summarize your title in a short sentence that can help identify your issue at a glance without needing to read the description. - - ☝️ **One Bug per Thread**
If you are experiencing more than one bug, submit only **ONE BUG PER ISSUE**.
Issues with more than one reported bug may be closed without action. + 👋 **Hello, welcome to Elementor's repository!** + + Thank you for contributing - your input helps make **Elementor** better for everyone!
+ ## 🧭 Guidelines - ### ✳️ All fields are required! - - ❌ Issues lacking detail or submitted for any other reason than to report Elementor-specific bugs may be closed without action. - - 🧩 We do not provide solutions for 3rd-party add-ons, extensions, and other plugins or themes that Elementor does not offer official integration. + Before getting started, please take a moment to review our **[Contribution Guidelines](https://elemn.to/gh-contributing)** and **[Code of Conduct](https://elemn.to/gh-code-of-conduct)**. These help us keep this space organized, respectful, and inclusive for everyone.
- ## THE ISSUE - ### 📋 Prerequisites - - 🔍 **Search Before Posting**
Please **[search for similar issues](https://elemn.to/gh-issues)** (both open and closed) to prevent creating duplicates.
Use **[GitHub Labels](https://elemn.to/gh-labels)** to refine your search. - - 🆕 **Stay Up-to-date**
Ensure you are using the **[most recent and stable versions](https://elemn.to/changelog)** of Elementor (and Elementor Pro), as well as all installed plugins, themes, and WordPress. - - type: checkboxes - id: prerequisites - attributes: - label: "Prerequisites" - options: - - label: "I have searched for similar issues in open and closed tickets and cannot find a duplicate." - required: true - - label: "I have troubleshooted my issue, and it still exists against the latest stable version of Elementor." - required: true - - type: markdown - attributes: - value: | + > [!IMPORTANT] + > ### 🐞 This form is specifically for reporting confirmed Elementor bugs. + > **We welcome your input, but please only use this form if you've already investigated and validated that the issue is caused by Elementor itself.** + > + > For Personal Help, Assistance or Support, please use one of our **[🛟 Personal Support Channels](https://elemn.to/support-ticket)** instead. + > + > Take a look at the 🎯 **Scope** section below to understand which types of reports are not handled here. + > + > ⚠️ _**Submitting unrelated or incomplete issues may result in them being closed without further action.**_ + +
+ +

📋 Requirements

(click to expand) + + For a quick review and efficient resolution, please ensure your report meets the following requirements. +
+ + - *️⃣ **All fields in this form are required** - no exceptions.
Incomplete issues will be closed without further action. + + - 🔍 **Search for similar issues before opening a new one.**
Avoid duplicates and save time by using the filters at the top of the **[Issues list](https://elemn.to/gh-issues)** to narrow your search. + + - 🆕 **Ensure you have the latest stable versions installed.**
Including Elementor, Elementor Pro, WordPress core, your active theme, and all installed plugins.
👉 Stay updated: **[Elementor Changelogs](https://elemn.to/changelog)** + + - 🗑️ **Clear all relevant cache layers.**
Confirm the issue is not being caused by old cached data.
Please clear server cache, site-level cache (including plugin-based cache), CDN cache, and your browser cache - in that order to avoid possible cache conflicts. + + - 🕵️ **Test with only Elementor, Elementor Pro, and the Hello Theme active.**
Temporarily disable all other plugins and themes. This step helps confirm that the issue is not being caused by a 3rd-party conflict.
👉 Reference: **[Troubleshooting the Editor](https://elemn.to/help-troubleshooting)** + + - 💾 **Copy and paste your complete Elementor System Info.**
This is not optional. It helps us better reproduce and understand your issue. Reports without the full Elementor System Info will be closed.
👉 Learn how to copy it here: **[Share system information with Elementor](https://elemn.to/help-system-info)** + + - 🪪 **If you're a 3rd-party developer**, please introduce yourself.
Include your name, company, product name, and a way for us to contact you (such as an email address) if needed.
This helps us collaborate more effectively. + + - ☝️ **Only one issue per report.**
Please don't bundle multiple bugs in a single submission. This helps us track, reproduce, and fix each issue accurately. +
+
+ +

🎯 Scope

(click to expand) + + To keep this repository focused and efficient, there are some types of reports that we are unable to handle here. These issues will be **closed without further action**. +
+ + - 🛟 **Requests for personal support**
This includes help with setup, troubleshooting, or usage guidance. For that, please contact one of our **[official Support Channels](https://elemn.to/support-ticket)**. + + - 🧩 **Conflicts with 3rd-party plugins, themes, or services.**
We're only able to address issues related to Elementor and Elementor Pro directly. If the problem occurs when using a plugin, theme, or integration that is not officially supported by Elementor, please reach out to the 3rd-party provider for assistance. + + - 🚀 **Feature requests or suggestions.**
We love your ideas! But this form is only for confirmed bugs. This includes missing but not supported or intended features. Please use the dedicated Feature Request form instead.
See: **[Request a Feature](https://elemn.to/gh-new-feature-request)** + + - 📚 **Questions about custom code or extending Elementor.**
We cannot provide assistance with writing or debugging custom code. If your goal is to extend Elementor, we recommend referring to our **[Developers Documentation](https://elemn.to/dev-docs)**. + + - 🛡️ **Security and vulnerability disclosures.**
We take security seriously. Please use our managed **[Bug Bounty Programs](https://elemn.to/bug-bounty)** to responsibly report security issues. + + - ☹️ **Complaints or offensive content.**
While we are open to feedback, this is not the appropriate place to submit complaints about Elementor's products or services. We also do not tolerate offensive, discriminatory, or hostile remarks. Please keep discussions respectful as outlined in our **[Code of Conduct](https://elemn.to/gh-code-of-conduct)**. + + - ❌ **Incomplete, invalid, or out-of-scope reports.**
Issues without clear steps to reproduce, missing required fields, or unrelated to Elementor or Elementor Pro will be closed without further action. +
+ + > [!NOTE] + > ### 🧪 Elementor Editor V4 + > If you are testing the new **[Editor V4](https://elemn.to/editor-v4)** features and wish to report bugs or share feedback, please use one of the forms below: + > + > **[🐛 Report bugs specific to Editor V4](https://elemn.to/editor-v4-new-issue)** + > **[🗣️ Discuss and share feedback about the Editor V4](https://elemn.to/editor-v4-feedback)**
+ + ## 📝 Instructions + + To help us understand and resolve your issue, please follow these best practices: + + - ✒️ **Use a clear and descriptive title.**
A good title helps everyone quickly understand what the issue is about. Try to summarize the problem in a few words. + + - 💬 **Describe the issue in detail.**
Include as much context as you can. What happened? When did it start? What page or feature does it affect?
Feel free to attach screenshots or screen recordings - just drag and drop them into the text field when filling out the form. + + - 👣 **List all steps to reproduce the issue.**
Pease be specific!
We need to follow the exact steps you took to reproduce the issue - ideally on a clean WordPress installation.
Reports without clear reproduction steps may be closed, as we cannot fix what we cannot replicate. - ### 💬 Provide a Detailed Description - Please describe the problem in detail, provide examples, screenshots, and related links. + - 🧠 **Explain what you expected to happen.**
Help us understand what you believe the correct or intended behavior should be. - type: textarea id: description validations: required: true attributes: label: "Description" - description: "Please help us understand in detail what problems you are experiencing." + description: "Describe the issue in detail and include as much context as possible.

Feel free to attach screenshots or screen recordings - just drag and drop them into the field below." + placeholder: "Please be thorough. What happened? When did it start? What page or feature does it affect? \n\nThe more information you share, the quicker we can find a solution! \n\nDrag and drop your attachments here:" - type: markdown attributes: value: |
- - ### 👣 Steps to Reproduce - Enumerate all the steps needed to reproduce the issue in detail so we can replicate it in our environment. - - Be thorough. We will only be able to reproduce your issue if you thoroughly explain all the steps to replicate it. - - Please **share a screen recording**. It helps a lot! - type: textarea id: steps_to_reproduce validations: required: true attributes: label: "Steps to reproduce" - description: "Provide steps that we can recreate on our own." - placeholder: "1. ... \n2. ... \n3. ..." + description: "List all steps to reproduce the issue. Be specific!

We need to follow the exact steps you took to reproduce the issue - ideally on a clean WordPress installation." + placeholder: "Example: \n1. In the WordPress Dashboard, go to... \n2. Create a Template... \n3. Drag & Drop the element... \n4. ..." - type: markdown attributes: value: |
- - ### 🧐 Expected Behavior - Describe the expected behavior to help identify the root of the issue. - type: textarea id: expected_behavior validations: required: true attributes: label: "Expected behavior" - description: "Please help us understand what you are expecting to happen." - placeholder: "1. ... \n2. ... \n3. ..." - - type: markdown - attributes: - value: | -
- - ### 🕵️ Isolate the problem and Validate your issue - - Deactivate all plugins _**except Elementor (and Elementor Pro)**_, and temporarily change the theme to the **[Hello Elementor theme](https://wordpress.org/themes/hello-elementor/)**. - - Verify if your problem happens when only the Elementor (and Elementor Pro) plugins are active while also using the Hello Elementor theme. - - If the issue can't be recreated consistently or reproduced following the steps above, it's likely **not an Elementor bug**.
In that case, use the **[support link](https://elemn.to/support-ticket)** at the top of this form, or **[troubleshoot your issue again](https://elemn.to/help-troubleshooting)**. - - type: checkboxes - id: isolating_the_problem - attributes: - label: "Isolating the problem" - options: - - label: "This bug happens when only the Elementor (and Elementor Pro) plugins are active." - - label: "This bug happens with the Hello Elementor theme active." - - label: "I can reproduce this bug consistently by following the steps I described above." + description: "Explain what you expected to happen.

Help us understand what you believe the correct or intended behavior should be.
This way we can identify the root of the issue quickly and find a solution faster." + placeholder: "Examples: \nAccording to the Documentation in the Help Center I expected... \nWhen I apply a style to an element I expect it to... \nWhen I visit a page in the fontend, I expect..." - type: markdown attributes: value: |
- - ### 💾 Elementor System Info - **⚠️ This field is REQUIRED** - - Please copy and paste the **full Elementor System Info** report from your WordPress **`Dashboard`** > **`Elementor`** > **`System Info`** page (available at the bottom of the page).
**We won't accept issues without it. [Learn how to copy it here](https://elemn.to/help-system-info).** - type: textarea id: system_info validations: required: true attributes: label: "Elementor System Info" - description: "Redact or remove sensitive information (admin email, website links, or root paths) if necessary. Keep other details, as these are vital for understanding your problem and recreating it correctly." + description: "⚠️ This field is required and is not optional.

Redact or remove sensitive information (like admin email, or root paths) if necessary.
Keep other details, as these are vital for understanding your problem and recreating it correctly.

To learn how to copy and paste the full report visit https://elemn.to/help-system-info." placeholder: "== Server Environment == \n== WordPress Environment == \n== Theme == \n== User == \n== Active Plugins == \n== Elements Usage == \n== Settings == \n== Features == \n== Integrations == \n== Elementor Experiments == \n== Log == \n== Elementor - Compatibility Tag == \n== Elementor Pro - Compatibility Tag ==" render: txt - type: markdown attributes: value: |
- - ### 📃 Agreement - ⚠️ **Please follow all the instructions above** and fill in all the required fields with valid information. - ⛔ **As a gentle reminder, we may close issues that do not comply with these guidelines without action.** - type: checkboxes id: final_agreement attributes: label: "Agreement" + description: "By filling out this form, you confirm that you have read and followed all the guidelines, requirements, scope, and instructions outlined above, and you agree that your issue may be closed without further action if it doesn't meet them." options: - - label: "I agree that my issue may be closed without action if it doesn't meet all the requirements." + - label: "I confirm I have read and followed all the guidelines and instructions outlined in the Elementor Bug Report form." + required: true + - label: "I agree that my issue may be closed without further action if it doesn't meet all the requirements outlined in the Elementor Bug Report form." required: true - - type: markdown - attributes: - value: | -
- - --- - ### 🙏 Thank you for contributing and helping make Elementor better. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0464e12034f2..bc4ccd093697 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,32 +1,20 @@ blank_issues_enabled: false contact_links: - - name: 🛟 Get Support + - name: 🛟 Get Personal Support url: https://elemn.to/support-ticket - about: Need Help, Guidance, Assistance, or Support? Follow these instructions. - - name: 📖 Contributing Guidelines + about: Need help, guidance, or personal support? Follow these steps to get assistance from Elementor Support. + - name: 🚀 Feature Requests + url: https://elemn.to/gh-feature-requests + about: Have an idea to improve Elementor? Submit your feature request here. + - name: 🧪 Editor V4 Discussions + url: https://elemn.to/editor-v4-feedback + about: Share your feedback, ideas, and thoughts about Editor V4's features. + - name: 📚 Visit the Developers Center + url: https://elemn.to/dev-center + about: Get technical documentation and resources to extend Elementor with custom code and integrations. + - name: 📖 Contribution Guidelines url: https://elemn.to/gh-contributing - about: Before filing a bug report, a feature request, or sending pull requests, read the Contribution Guidelines. + about: Before submitting a bug report, feature request, or pull request, please read our Contribution Guidelines. - name: 🤝 Code of Conduct url: https://elemn.to/gh-code-of-conduct - about: To foster a safe, open, and welcoming environment, we have a Code of Conduct that all participants should follow. - - name: 🚩 Elementor Roadmap - url: https://elemn.to/gh-to-roadmap - about: Before submitting a request, visit the Elementor Roadmap and learn about the status of upcoming or newly-released features. - - name: 🚀 Feature Request - url: https://elemn.to/gh-new-feature-request - about: Suggest new ideas, features, or enhancements for the Elementor plugin. - - name: 📑 Help Center - url: https://elemn.to/gh-to-help-center - about: Visit our Help Center to find solutions to the most frequent problems or learn how to use Elementor's many features. - - name: 🎓 Elementor Academy - url: https://elemn.to/gh-to-academy - about: Grow your Web Creator skills and learn how to make the most of Elementor. - - name: 🗨️ Elementor Global Community - url: https://elemn.to/community-on-fb - about: The main Facebook group is where all kinds of users come together to help each other. - - name: 📚 Elementor Developers Center - url: https://elemn.to/gh-to-dev-center - about: Technical information and Elementor Developers documentation. Learn everything about extending Elementor. - - name: 🌐 Elementor Developers Community - url: https://elemn.to/dev-community - about: Share tips, tricks, and knowledge with other developers about extending and developing for Elementor. + about: Help us maintain a safe, open, and welcoming environment by following our Code of Conduct. diff --git a/.github/ISSUE_TEMPLATE/editor-v4-bug.yml b/.github/ISSUE_TEMPLATE/editor-v4-bug.yml new file mode 100644 index 000000000000..7fa777ed25c9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/editor-v4-bug.yml @@ -0,0 +1,147 @@ +name: "🐛 Report a Confirmed Bug in Editor V4" +description: "Report confirmed bugs in Editor V4's features and help us improve the experience before the final release." +labels: ["status/awaiting_triage", "product/editor-v4"] +body: + - type: markdown + attributes: + value: | + # 🐛 Report a Confirmed Bug in Editor V4 +
+ + 👋 **Welcome, and thank you for helping shape the future of Elementor!** + + We're excited to introduce the **Editor V4** - a refreshed foundation for the Elementor Editor that improves performance, responsiveness, and workflow experience. + + Your feedback is incredibly valuable. Use this form to report bugs related to Editor V4's features. Every report helps us create a better product before its full release. +
+ + ## 🧭 Guidelines + + Before getting started, please take a moment to review our **[Contribution Guidelines](https://elemn.to/gh-contributing)** and **[Code of Conduct](https://elemn.to/gh-code-of-conduct)**. These help us keep this space organized, respectful, and inclusive for everyone. +
+ + > [!IMPORTANT] + > ### 🐛 This form is for reporting confirmed bugs in Editor V4 features only. + > **We welcome your input, but please only use this form if you've already investigated and confirmed that the issue is caused specifically by Editor V4 features.** + > + > ℹ️ Keep in mind **the Editor V4 is still a work in progress** - it may not include all existing features from Elementor V3, is subject to change or instability, and should not be used on production websites. + > + > 🧪 For discussing and sharing feedback about the **Editor V4**, please go to the dedicated Discussion Category instead: **[Discuss the Editor V4](https://elemn.to/editor-v4-feedback)** + > + > ⚠️ _**Issues that are unrelated to Editor V4 or missing required information may be closed without further action.**_ + +
+ +

📋 Requirements

(click to expand) + + For a quick review and efficient resolution, please ensure your report meets the following requirements. +
+ + - *️⃣ **All fields in this form are required** - no exceptions.
Incomplete issues will be closed without further action. + + - 🔍 **Search for similar issues before opening a new one.**
Avoid duplicates and save time by using the **[editor-v4](https://elemn.to/editor-v4-issues)** label to narrow your search. + + - 🗑️ **Clear all relevant cache layers.**
Confirm the issue is not being caused by old cached data.
Please clear server cache, site-level cache (including plugin-based cache), CDN cache, and your browser cache - in that order to avoid possible cache conflicts. + + - 🕵️ **Test with only Elementor, Elementor Pro, and the Hello Theme active.**
Temporarily disable all other plugins and themes. This step helps confirm that the issue is not being caused by a 3rd-party conflict.
👉 Reference: **[Troubleshooting the Editor](https://elemn.to/help-troubleshooting)** + + - 💾 **Copy and paste your complete Elementor System Info.**
This is not optional. It helps us better reproduce and understand your issue. Reports without the full Elementor System Info will be closed.
👉 Learn how to copy it here: **[Share system information with Elementor](https://elemn.to/help-system-info)** + + - 🪪 **If you're a 3rd-party developer**, please introduce yourself.
Include your name, company, product name, and a way for us to contact you (such as an email address) if needed.
This helps us collaborate more effectively. + + - ☝️ **Only one issue per report.**
Please don't bundle multiple bugs in a single submission. This helps us track, reproduce, and fix each issue accurately. +
+
+ +

🎯 Scope

(click to expand) + + To keep this repository focused and efficient, there are some types of reports that we are unable to handle here. These issues will be **closed without further action**. +
+ + - 🐞 **Issues unrelated to bugs in the Editor V4.**
If you're here to report issues with existing features in Elementor V3, please **[use our general 🐞 Bug Report form instead](https://elemn.to/gh-new-bug-report)**. + + - 🛟 **Requests for personal support**
This includes help with setup, troubleshooting, or usage guidance. For that, please contact one of our **[official Support Channels](https://elemn.to/support-ticket)**. + + - 🧩 **Conflicts with 3rd-party plugins, themes, or services.**
We're only able to address issues related to **Editor V4** features enable. If the problem occurs when using a plugin, theme, or integration that is not officially supported by Elementor, please reach out to the 3rd-party provider for assistance. + + - 🚀 **Feature requests or suggestions.**
We love your ideas! But this form is only for confirmed bugs. This includes missing but not supported or intended features. Please use the dedicated Feature Request form instead.
See: **[Request a Feature](https://elemn.to/gh-new-feature-request)** + + - 📚 **Questions about custom code or extending Elementor.**
We cannot provide assistance with writing or debugging custom code. If your goal is to extend Elementor, we recommend referring to our **[Developers Documentation](https://elemn.to/dev-docs)**. + + - 🛡️ **Security and vulnerability disclosures.**
We take security seriously. Please use our managed **[Bug Bounty Programs](https://elemn.to/bug-bounty)** to responsibly report security issues. + + - ☹️ **Complaints or offensive content.**
While we are open to feedback, this is not the appropriate place to submit complaints about Elementor's products or services. We also do not tolerate offensive, discriminatory, or hostile remarks. Please keep discussions respectful as outlined in our **[Code of Conduct](https://elemn.to/gh-code-of-conduct)**. + + - ❌ **Incomplete, invalid, or out-of-scope reports.**
Issues without clear steps to reproduce, missing required fields, or unrelated to **Editor V4** will be closed without further action. +
+ + ## 📝 Instructions + + To help us understand and resolve your issue, please follow these best practices: + + - ✒️ **Use a clear and descriptive title.**
A good title helps everyone quickly understand what the issue is about. Try to summarize the problem in a few words. + + - 💬 **Describe the issue in detail.**
Include as much context as you can. What happened? When did it start? What page or feature does it affect?
Feel free to attach screenshots or screen recordings - just drag and drop them into the text field when filling out the form. + + - 👣 **List all steps to reproduce the issue.**
Pease be specific!
We need to follow the exact steps you took to reproduce the issue - ideally on a clean WordPress installation.
Reports without clear reproduction steps may be closed, as we cannot fix what we cannot replicate. + + - 🧠 **Explain what you expected to happen.**
Help us understand what you believe the correct or intended behavior should be. + - type: textarea + id: description + validations: + required: true + attributes: + label: "Description" + description: "Describe the issue in detail and include as much context as possible.

Feel free to attach screenshots or screen recordings - just drag and drop them into the field below." + placeholder: "Please be thorough. What happened? When did it start? What page or feature does it affect? \n\nThe more information you share, the quicker we can find a solution! \n\nDrag and drop your attachments here:" + - type: markdown + attributes: + value: | +
+ - type: textarea + id: steps_to_reproduce + validations: + required: true + attributes: + label: "Steps to reproduce" + description: "List all steps to reproduce the issue. Be specific!

We need to follow the exact steps you took to reproduce the issue - ideally on a clean WordPress installation." + placeholder: "Example: \n1. In the WordPress Dashboard, go to... \n2. Create a Template... \n3. Drag & Drop the element... \n4. ..." + - type: markdown + attributes: + value: | +
+ - type: textarea + id: expected_behavior + validations: + required: true + attributes: + label: "Expected behavior" + description: "Explain what you expected to happen.

Help us understand what you believe the correct or intended behavior should be.
This way we can identify the root of the issue quickly and find a solution faster." + placeholder: "Examples: \nAccording to the Documentation in the Help Center I expected... \nWhen I apply a style to an element I expect it to... \nWhen I visit a page in the fontend, I expect..." + - type: markdown + attributes: + value: | +
+ - type: textarea + id: system_info + validations: + required: true + attributes: + label: "Elementor System Info" + description: "⚠️ This field is required and is not optional.

Redact or remove sensitive information (like admin email, or root paths) if necessary.
Keep other details, as these are vital for understanding your problem and recreating it correctly.

To learn how to copy and paste the full report visit https://elemn.to/help-system-info." + placeholder: "== Server Environment == \n== WordPress Environment == \n== Theme == \n== User == \n== Active Plugins == \n== Elements Usage == \n== Settings == \n== Features == \n== Integrations == \n== Elementor Experiments == \n== Log == \n== Elementor - Compatibility Tag == \n== Elementor Pro - Compatibility Tag ==" + render: txt + - type: markdown + attributes: + value: | +
+ - type: checkboxes + id: final_agreement + attributes: + label: "Agreement" + description: "By filling out this form, you confirm that you have read and followed all the guidelines, requirements, scope, and instructions outlined above, and you agree your issue may be closed without further action if it doesn't meet them." + options: + - label: "I confirm I have read and followed all the guidelines and instructions outlined in the Editor V4 Bug Report form." + required: true + - label: "I agree that my issue may be closed without further action if it doesn't meet all the requirements outlined in the Editor V4 Bug Report form." + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml index e4a15bcdea42..b0c9bc7539bf 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -16,6 +16,6 @@ updates: labels: - "dependencies" groups: - elementor-packages: + packages: patterns: - "@elementor/*" diff --git a/.github/scripts/build-zip.sh b/.github/scripts/build-zip.sh index 716bbdc96534..1a4c89b3a4d1 100644 --- a/.github/scripts/build-zip.sh +++ b/.github/scripts/build-zip.sh @@ -7,8 +7,11 @@ if [[ -z "$PACKAGE_VERSION" ]]; then fi PLUGIN_ZIP_FILENAME="elementor-${PACKAGE_VERSION}.zip" -npx grunt build +npm run build mv build elementor + +# Make sure not to upload the .zip file to the artifact! zip -r $PLUGIN_ZIP_FILENAME elementor + echo "PLUGIN_ZIP_FILENAME=${PLUGIN_ZIP_FILENAME}" >> $GITHUB_ENV echo "PLUGIN_ZIP_PATH=./elementor/**/*" >> $GITHUB_ENV diff --git a/.github/scripts/compare-tag-to-branch.js b/.github/scripts/compare-tag-to-branch.js deleted file mode 100644 index 54ee8ed8b820..000000000000 --- a/.github/scripts/compare-tag-to-branch.js +++ /dev/null @@ -1,59 +0,0 @@ -'use strict'; - -const { repoToOwnerAndOwner, getPrCommits } = require('./repo-utils'); -const { Octokit } = require("@octokit/core"); -const { REPOSITORY, HEAD_BRANCH_NAME, BASE_TAG_NAME, TOKEN } = process.env; - -if (!TOKEN) { - console.error('missing TOKEN env var'); - process.exit(1); - return; -} - -if (!BASE_TAG_NAME) { - console.error('missing BASE_TAG_NAME env var'); - process.exit(1); - return; -} - -if (!HEAD_BRANCH_NAME) { - console.error('missing HEAD_BRANCH_NAME env var'); - process.exit(1); - return; -} - -if (!REPOSITORY) { - console.error('missing REPOSITORY env var'); - process.exit(1); - return; -} - -const octokit = new Octokit({ auth: TOKEN }); - -(async () => { - try { - const { owner, repo } = repoToOwnerAndOwner(REPOSITORY); - const res = await octokit.request('GET /repos/{owner}/{repo}/compare/{base}...{head}', { - owner, - repo, - base: BASE_TAG_NAME, - head: HEAD_BRANCH_NAME, - }); - const compareStatus = res.data.status; - console.log(`Tag: ${BASE_TAG_NAME} '${compareStatus}' to branch: ${HEAD_BRANCH_NAME}`); - if (compareStatus !== 'identical') { - console.log(`compareStatus ${compareStatus}`); - // A Dev Edition version must include some Change Log lines. so, validate that there are commits that are not "Internal" and contain a Squash commiit pattern - const prAndVerifiedCommits = getPrCommits(res.data); - console.log(prAndVerifiedCommits); - if (prAndVerifiedCommits.length > 0) { - console.log(`No public commits, exit = 1`); - process.exit(1); - } - } - console.log(`exit = 0`); - } catch (err) { - console.error(`Failed to compare tag: ${BASE_TAG_NAME} to branch: ${HEAD_BRANCH_NAME} error: ${err.message}`); - process.exit(1); - } -})(); diff --git a/.github/scripts/create-git-tag.sh b/.github/scripts/create-git-tag.sh deleted file mode 100644 index 635a41db2651..000000000000 --- a/.github/scripts/create-git-tag.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash - -if [[ -z "$PACKAGE_VERSION" ]]; then - echo "Missing PACKAGE_VERSION env var" - exit 1 -fi - -bash "${GITHUB_WORKSPACE}/.github/scripts/set-git-user.sh" - -echo "Create tag v${PACKAGE_VERSION}" -git tag "v${PACKAGE_VERSION}" -git push origin "v${PACKAGE_VERSION}" diff --git a/.github/scripts/generate-dev-changelog-md.js b/.github/scripts/generate-dev-changelog-md.js deleted file mode 100644 index 64de8536fc4a..000000000000 --- a/.github/scripts/generate-dev-changelog-md.js +++ /dev/null @@ -1,42 +0,0 @@ -const got = require('got'); -const fs = require('fs/promises'); - -const GITHUB_URL = "https://api.github.com/repos/elementor/elementor/releases?per_page=100"; -const CHANGELOG_HEADER = '# Elementor Developer Edition - by Elementor.com\n'; -const DEV_CHANGELOG_NAME = 'DEV-CHANGELOG.md'; - -const currentDateString = (date) => { - let dd = date.getDate(); - let mm = date.getMonth() + 1; - - const yyyy = date.getFullYear(); - if (dd < 10) { - dd = `0${dd}`; - } - if (mm < 10) { - mm = `0${mm}`; - } - return `${yyyy}-${mm}-${dd}`; -}; - - -(async () => { - try { - const response = await got(GITHUB_URL, { - responseType: "json" - }); - const devReleases = response.body.filter(({ tag_name }) => tag_name.includes('-dev')); - const changeLogText = devReleases.reduce((allChangelog, { body, tag_name, published_at }) => { - if (!body) { - return allChangelog; - } - const versionHeader = `#### ${tag_name.substring(1)} - ${currentDateString(new Date(published_at))}` - allChangelog = `${allChangelog}\n${versionHeader}\n${body}\n`; - return allChangelog; - }, CHANGELOG_HEADER); - await fs.writeFile(DEV_CHANGELOG_NAME, changeLogText, 'utf-8'); - } catch (error) { - console.error(`Failed to generate developer-edition changelog`, error.message); - process.exit(1); - } -})() diff --git a/.github/scripts/sync-branches.sh b/.github/scripts/sync-branches.sh deleted file mode 100644 index 8ed54fd4c882..000000000000 --- a/.github/scripts/sync-branches.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash - -bash "${GITHUB_WORKSPACE}/.github/scripts/set-git-user.sh" - -npm i semver@7.3.4 --no-package-lock --no-save -PACKAGE_VERSION=$(node -p "require('./package.json').version") -NEXT_PACKAGE_VERSION=$(npx semver $PACKAGE_VERSION -i minor) -NEXT_RELEASE_BRANCH="release/${NEXT_PACKAGE_VERSION}" - -git checkout "${NEXT_RELEASE_BRANCH}" -if [ $? -eq 0 ]; then - git merge origin/develop - git push - git checkout developer-edition - git merge "${NEXT_RELEASE_BRANCH}" - git push - echo "NEXT_RELEASE_BRANCH=${NEXT_RELEASE_BRANCH}" >> $GITHUB_ENV -else - git checkout developer-edition - git merge origin/develop - git push -fi diff --git a/.github/scripts/sync-developer-edition-branch.sh b/.github/scripts/sync-developer-edition-branch.sh deleted file mode 100644 index 734d84a65c4d..000000000000 --- a/.github/scripts/sync-developer-edition-branch.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/bash -set -eo pipefail - -bash "${GITHUB_WORKSPACE}/.github/scripts/set-git-user.sh" - -echo "Trying to install semver" - -npm i semver@7.3.4 --no-package-lock --no-save -PACKAGE_VERSION=$(node -p "require('./package.json').version") -NEXT_PACKAGE_VERSION=$(npx semver $PACKAGE_VERSION -i minor) -NEXT_RELEASE_BRANCH="release/${NEXT_PACKAGE_VERSION}" - -NEXT_NEXT_PACKAGE_VERSION=$(npx semver $NEXT_PACKAGE_VERSION -i minor) -NEXT_NEXT_RELEASE_BRANCH="release/${NEXT_NEXT_PACKAGE_VERSION}" - -SEPARATOR="\n=====\n\n" - -# Merge master -> develop -echo "Merging master -> develop" -git checkout develop -git merge origin/master -git push origin develop - -# Merge develop -> next release -echo -e "${SEPARATOR}Merging develop -> next release ${NEXT_RELEASE_BRANCH}" -git checkout "${NEXT_RELEASE_BRANCH}" -git merge origin/develop -git push origin "${NEXT_RELEASE_BRANCH}" - -# Merge next release -> next next release -echo -e "${SEPARATOR}Merging next release ${NEXT_RELEASE_BRANCH} -> next next release ${NEXT_NEXT_RELEASE_BRANCH}" -set +e -bash -e < developer-edition -echo -e "${SEPARATOR}Merging next next release ${NEXT_NEXT_RELEASE_BRANCH} -> developer-edition" -git checkout developer-edition -git merge "${NEXT_NEXT_RELEASE_BRANCH}" -git push origin developer-edition - -#echo "NEXT_RELEASE_BRANCH=${NEXT_RELEASE_BRANCH}" >> $GITHUB_ENV diff --git a/.github/scripts/sync-features-branch-to-developer-branch.js b/.github/scripts/sync-features-branch-to-developer-branch.js deleted file mode 100644 index 3a9fa5b781c6..000000000000 --- a/.github/scripts/sync-features-branch-to-developer-branch.js +++ /dev/null @@ -1,49 +0,0 @@ -'use strict'; - -const { - repoToOwnerAndOwner, - getFeatureBranches, - mergeBranch, -} = require('./repo-utils'); - -const { REPOSITORY, TOKEN } = process.env; - -const TARGET_BRANCH = 'developer-edition'; - -if (!TOKEN) { - console.error('missing TOKEN env var'); - process.exit(1); - return; -} - -if (!REPOSITORY) { - console.error('missing REPOSITORY env var'); - process.exit(1); - return; -} - -const { owner, repo } = repoToOwnerAndOwner(REPOSITORY); - -(async () => { - const failedBranches = []; - try { - const featureBranches = await getFeatureBranches(TOKEN, owner, repo); - for (const branchName of featureBranches) { - try { - await mergeBranch(TOKEN, owner, repo, TARGET_BRANCH, branchName, `Auto merge feature branch: ${branchName}`); - } catch (err) { - if (err.status === 401) { - throw err; - } - failedBranches.push(branchName); - } - } - if (failedBranches.length > 0) { - console.error(`Failed to merge feature branches: ${failedBranches.join(",")} to: ${TARGET_BRANCH} branches`); - process.exit(1); - } - } catch (err) { - console.error(`Failed to merge feature branches to: ${TARGET_BRANCH} branch ${err.head ? `from: ${err.head} branch` : ''} error: ${err.message}`); - process.exit(1); - } -})(); diff --git a/.github/scripts/sync-next-release-to-features-branch.js b/.github/scripts/sync-next-release-to-features-branch.js deleted file mode 100644 index cc33c299b6d9..000000000000 --- a/.github/scripts/sync-next-release-to-features-branch.js +++ /dev/null @@ -1,41 +0,0 @@ -'use strict'; - -const { - repoToOwnerAndOwner, - getFeatureBranches, - mergeBranch, -} = require('./repo-utils'); - -const { REPOSITORY, TOKEN, NEXT_RELEASE_BRANCH } = process.env; - -if (!TOKEN) { - console.error('missing TOKEN env var'); - process.exit(1); - return; -} - -if (!REPOSITORY) { - console.error('missing REPOSITORY env var'); - process.exit(1); - return; -} - -if (!NEXT_RELEASE_BRANCH) { - console.error('missing NEXT_RELEASE_BRANCH env var'); - process.exit(1); - return; -} - -const { owner, repo } = repoToOwnerAndOwner(REPOSITORY); - -(async () => { - try { - const featureBranches = await getFeatureBranches(TOKEN, owner, repo); - for (const branchName of featureBranches) { - await mergeBranch(TOKEN, owner, repo, branchName, NEXT_RELEASE_BRANCH, `Auto merge ${NEXT_RELEASE_BRANCH} branch into feature branch: ${branchName}`); - } - } catch (err) { - console.error(`Failed to merge ${NEXT_RELEASE_BRANCH} branch into ${err.base || ''} error: ${err.message}`); - process.exit(1); - } -})(); diff --git a/.github/scripts/update-prerelease-version.js b/.github/scripts/update-prerelease-version.js deleted file mode 100644 index 7d0258475a54..000000000000 --- a/.github/scripts/update-prerelease-version.js +++ /dev/null @@ -1,55 +0,0 @@ -'use strict'; - -const semverInc = require('semver/functions/inc'); -const packageJson = require('../../package.json'); -const fs = require('fs'); - -const preId = process.argv[2]; -if (!['dev', 'beta', 'cloud' ].includes(preId)) { - console.error('missing argument dev or beta mode'); - process.exit(1); -} - -const bumpVersion = (relativeVersion, lastVersionTagName, bumpsFromCurrentVersion = 1) => { - const lastVersion = packageJson[lastVersionTagName] || ''; - let expectedVersion = relativeVersion; - (new Array(bumpsFromCurrentVersion).fill(1)).forEach(() => { - expectedVersion = semverInc(expectedVersion, 'minor'); - }); - let currentLastVersionNumber = 0; - - if (lastVersion) { - const splitVersion = lastVersion.split(`-${preId}`); - if (splitVersion[0] === expectedVersion) { - const currentLastVersion = splitVersion[splitVersion.length - 1]; - currentLastVersionNumber = Number(currentLastVersion); - if (Number.isNaN(currentLastVersionNumber)) { - console.error(`invalid ${preId} version: ${currentLastVersion}`); - process.exit(1); - } - } - } - - const newVersion = `${expectedVersion}-${preId}${currentLastVersionNumber + 1}`; - packageJson[lastVersionTagName] = newVersion; - fs.writeFileSync('./package.json', JSON.stringify(packageJson, null, 4)); - console.log(newVersion); -} - -if (['beta','cloud'].includes(preId)) { - const relativeVersion = packageJson.version; - bumpVersion(relativeVersion,`last_${preId}_version`); - return; -} - -if (preId === 'dev') { - const lastBetaVersion = (() => { - const attrValue = packageJson.last_beta_version; - if (!attrValue) { - return ''; - } - return attrValue.split('-')[0]; - })(); - const relativeVersion = lastBetaVersion || packageJson.version; - bumpVersion(relativeVersion, 'last_dev_version'); -} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6b2e20a182f6..87ea5ebc3b87 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,17 @@ name: Build on: - workflow_dispatch: pull_request: + branches: + - 'main' + - '3.[0-9][0-9]' + paths-ignore: + - '**.md' + - '**.txt' + - '.github/config.json' + - '.gitignore' + - 'docs/**' + workflow_dispatch: workflow_call: outputs: artifact_name: @@ -12,7 +21,14 @@ on: push: branches: - 'main' - - '3.*' + - '3.[0-9][0-9]' + paths-ignore: + - '**.md' + - '**.txt' + - '.github/config.json' + - 'bin/**' + - '.gitignore' + - 'docs/**' jobs: build-plugin: @@ -25,60 +41,79 @@ jobs: steps: - name: Checkout source code uses: actions/checkout@v4 - - name: Install Node.js 20.x - uses: actions/setup-node@v4 - with: - node-version: 20.x - cache: 'npm' + - name: Check if this is only a changelog PR id: changelog_diff_files uses: technote-space/get-diff-action@v6 with: - # PATTERNS are: - # Everything: **/* - # Everything in directories starting with a period: .*/**/* - # Not readme.txt: !readme.txt - # Not changelog.txt: !changelog.txt PATTERNS: | **/* .*/**/* !readme.txt !changelog.txt - - name: Prevent git tag versioning - shell: bash - run: npm config set git-tag-version false - - - name: Install Dependencies - if: github.event.pull_request.title == null || steps.changelog_diff_files.outputs.diff - uses: ./.github/workflows/install-dependencies + - name: Install Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: 'npm' - name: Set ENV variables - shell: bash run: | DATE_VERSION=$(date '+%Y%m%d.%H%M') - VERSION=$(cat package.json \ - | grep version \ - | head -1 \ - | awk -F: '{ print $2 }' \ - | sed 's/[",]//g' \ - | tr -d '[[:space:]]') + VERSION=$(node -p "require('./package.json').version") PACKAGE_VERSION=${VERSION}-${DATE_VERSION} PLUGIN_FOLDER_FILENAME="elementor-${PACKAGE_VERSION}" + echo "DATE_VERSION=$DATE_VERSION" >> $GITHUB_ENV echo "PACKAGE_VERSION=$PACKAGE_VERSION" >> $GITHUB_ENV echo "PLUGIN_FOLDER_FILENAME=$PLUGIN_FOLDER_FILENAME" >> $GITHUB_ENV + - name: Install dependencies + uses: ./.github/workflows/install-dependencies + - name: Build plugin - if: github.event.pull_request.title == null || steps.changelog_diff_files.outputs.diff uses: ./.github/workflows/build-plugin with: PACKAGE_VERSION: ${{ env.PACKAGE_VERSION }} BUILD_SCRIPT_PATH: "./.github/scripts/build-zip.sh" - uses: actions/upload-artifact@v4 - if: github.event.pull_request.title == null || steps.changelog_diff_files.outputs.diff + id: upload-artifact with: name: ${{ env.PLUGIN_FOLDER_FILENAME }} path: elementor + if-no-files-found: error + compression-level: 9 + retention-days: 3 + + - name: Find existing comment on PR + if: github.event_name == 'pull_request' + uses: peter-evans/find-comment@v3 + id: find + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: 'Elementor Build' + + - name: Comment build info on PR + if: github.event_name == 'pull_request' + uses: peter-evans/create-or-update-comment@v4 + with: + comment-id: ${{ steps.find.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body: | + ## Elementor Build + Last updated at: ${{ env.DATE_VERSION }} + Version: ${{ env.PACKAGE_VERSION }} + + ✅ Elementor build is ready for download. + + You can download the latest build from the link below: + + 🔗 [${{ env.PLUGIN_ZIP_FILENAME }}](https://github.com/elementor/elementor/actions/runs/${{ github.run_id }}/artifacts/${{ steps.upload-artifact.outputs.artifact-id }}) + + The build is available for 3 days. + + edit-mode: replace diff --git a/.github/workflows/bump-version/action.yml b/.github/workflows/bump-version/action.yml deleted file mode 100644 index 1d2a1144caaa..000000000000 --- a/.github/workflows/bump-version/action.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Bump Version -description: Update package.json with the new version number & Sets the version as ENV variable. - -inputs: - CHANNEL: - required: true - -# TODO - Remove this when publish-cloud is removed. -outputs: - prev_version: - description: "previous cloud version" - value: ${{ steps.bump_version_step.outputs.prev_version }} - -runs: - using: "composite" - steps: - - shell: bash - id: bump_version_step - run: | - npm install --no-package-lock --no-save semver@7.3.4 - PREV_PACKAGE_VERSION=$(node -p "require('./package.json').last_${{ inputs.CHANNEL }}_version") - NEW_PACKAGE_VERSION=$(node ./.github/scripts/update-prerelease-version.js ${{ inputs.CHANNEL }}) - PACKAGE_VERSION=$(node -p "require('./package.json').last_${{ inputs.CHANNEL }}_version") - echo "PACKAGE_VERSION=${NEW_PACKAGE_VERSION}" >> $GITHUB_ENV - echo "::set-output name=prev_version::${PREV_PACKAGE_VERSION}" - echo "PACKAGE_VERSION : ${PACKAGE_VERSION}" diff --git a/.github/workflows/cherry-pick-pr.yml b/.github/workflows/cherry-pick-pr.yml new file mode 100644 index 000000000000..350b3e97ed1c --- /dev/null +++ b/.github/workflows/cherry-pick-pr.yml @@ -0,0 +1,170 @@ +name: Cherry Pick Merged PR + +on: + pull_request_target: + types: + - closed + - labeled + +permissions: + contents: write + pull-requests: write + id-token: write + +jobs: + cherry-pick: + if: ${{ github.event.pull_request.merged == true && (github.event.action == 'closed' || (github.event.action == 'labeled' && startsWith(github.event.label.name, 'cp_'))) }} + runs-on: ubuntu-latest + + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ github.event.pull_request.base.ref }} + fetch-depth: 0 + persist-credentials: true + + - name: Check if PR is from fork + id: check_fork + run: | + IS_FORK="${{ github.event.pull_request.head.repo.full_name != github.repository }}" + echo "is_fork=$IS_FORK" >> "$GITHUB_OUTPUT" + echo "Fork status: $IS_FORK" + shell: bash + + - name: Log trigger and PR information + run: | + echo "Trigger: ${{ github.event.action }}" + if [ "${{ github.event.action }}" = "labeled" ]; then + echo "Added label: ${{ github.event.label.name }}" + fi + echo "PR #${{ github.event.pull_request.number }} from: ${{ github.event.pull_request.head.repo.full_name }}" + echo "Target repository: ${{ github.repository }}" + echo "Is fork PR: ${{ steps.check_fork.outputs.is_fork }}" + echo "PR merged: ${{ github.event.pull_request.merged }}" + shell: bash + + - name: Get branch labels + id: get_labels + run: | + LABELS=$(jq -r '.[].name' <<< '${{ toJSON(github.event.pull_request.labels) }}' | grep '^cp_' | paste -sd ',' || echo "") + echo "filtered_labels_csv=$LABELS" >> "$GITHUB_OUTPUT" + shell: bash + + - name: Fetch all branches + run: git fetch --all + shell: bash + + - name: Cherry-Pick and Create PRs + if: ${{ steps.get_labels.outputs.filtered_labels_csv != '' }} + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_USER_LOGIN: ${{ github.event.pull_request.user.login }} + LABEL_NAME: ${{ github.event.label.name }} + run: | + PR_NUMBER="${{ github.event.pull_request.number }}" + MERGE_SHA="${{ github.event.pull_request.merge_commit_sha }}" + ORIG_URL="${{ github.event.pull_request.html_url }}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + IFS=',' read -ra BRANCHES <<< "${{ steps.get_labels.outputs.filtered_labels_csv }}" + for lbl in "${BRANCHES[@]}"; do + TARGET=${lbl#cp_} + BRANCH="cherry-pick-pr-${PR_NUMBER}-${TARGET}" + + # Create branch + if ! git checkout -b "$BRANCH" "origin/$TARGET"; then + echo "::warning:: Branch $TARGET does not exist - skipping" + continue + fi + + # Cherry-pick + if ! git cherry-pick -m 1 "$MERGE_SHA"; then + echo "::error:: Cherry-pick conflicts detected for PR #${PR_NUMBER} on branch ${TARGET}" + + # Create a conflict resolution branch + CONFLICT_BRANCH="cherry-pick-pr-${PR_NUMBER}-${TARGET}-conflicts" + + # Add conflict markers and create a commit for manual resolution + git add . + git commit -m "Cherry-pick PR #${PR_NUMBER} with conflicts - manual resolution needed" + + # Push the conflict branch + if git push --force-with-lease origin "$BRANCH:$CONFLICT_BRANCH"; then + # Create draft PR with conflict information + if ! gh pr list --head "$CONFLICT_BRANCH" --base "$TARGET" --state open | grep -q .; then + CONFLICT_TRIGGER_INFO="" + if [ "${{ github.event.action }}" = "labeled" ]; then + CONFLICT_TRIGGER_INFO=" + **Trigger:** Label \`${LABEL_NAME}\` added to closed PR" + fi + + gh pr create \ + --base "$TARGET" \ + --head "$CONFLICT_BRANCH" \ + --title "🔧 [CONFLICTS] Cherry-pick PR #${PR_NUMBER} → ${TARGET}: ${PR_TITLE}" \ + --body "⚠️ **Manual Resolution Required** + + This cherry-pick of [#${PR_NUMBER}](${ORIG_URL}) to \`${TARGET}\` branch has conflicts that need manual resolution. + + **Conflict Files:** + The conflicted files are included in this branch with conflict markers. + + **Resolution Steps:** + 1. Check out this branch: \`git checkout $CONFLICT_BRANCH\` + 2. Resolve conflicts in the marked files + 3. Stage resolved files: \`git add \` + 4. Amend the commit: \`git commit --amend\` + 5. Push changes: \`git push --force-with-lease\` + 6. Mark this PR as ready for review + + **Original PR:** [#${PR_NUMBER}](${ORIG_URL})${CONFLICT_TRIGGER_INFO}" \ + --draft + + echo "::notice:: Created draft PR for manual conflict resolution: $CONFLICT_BRANCH" + else + echo "::notice:: Draft PR already exists for conflict resolution: $CONFLICT_BRANCH" + fi + else + echo "::warning:: Failed to push conflict branch $CONFLICT_BRANCH" + git cherry-pick --abort + fi + continue + else + echo "Cherry-pick successful" + fi + + # Push (force push to handle existing branches) + if ! git push --force-with-lease origin "$BRANCH"; then + echo "::warning:: Failed to push branch $BRANCH" + continue + fi + + # Create PR via gh CLI (token already in env: GH_TOKEN) + # Check if PR already exists + if gh pr list --head "$BRANCH" --base "$TARGET" --state open | grep -q .; then + echo "PR already exists for branch $BRANCH -> $TARGET, skipping creation" + else + TRIGGER_INFO="" + if [ "${{ github.event.action }}" = "labeled" ]; then + TRIGGER_INFO=" + **Trigger:** Label \`${LABEL_NAME}\` added to closed PR" + fi + + gh pr create \ + --base "$TARGET" \ + --head "$BRANCH" \ + --title "Cherry-pick PR #${PR_NUMBER} → ${TARGET}: ${PR_TITLE}" \ + --body "Automatic cherry-pick of [#${PR_NUMBER}](${ORIG_URL}) to \`${TARGET}\` branch. + + **Source:** ${{ github.event.pull_request.head.repo.full_name }} + **Original Author:** @${PR_USER_LOGIN}${TRIGGER_INFO}" + fi + done + shell: bash diff --git a/.github/workflows/config.json b/.github/workflows/config.json index 620a55aab798..f7e6957b67cb 100644 --- a/.github/workflows/config.json +++ b/.github/workflows/config.json @@ -2,6 +2,6 @@ "deployment" : { "repository_owner" : "elementor", "environment" : "prod", - "permitted" : "Batsirai-muchareva,TzviRabinovitch,davseve,louiswol94,mykytamurzin,ronenelementor,hein-obox,MichaelLiamin" + "permitted" : "Batsirai-muchareva,TzviRabinovitch,davseve,louiswol94,mykytamurzin,ronenelementor,hein-obox,MichaelLiamin,elchugreeva,asafdl,mugurelLupu,baghdasarovelementor,mike-elementor,Svitlana-Dykun,IshayMaya,MiryamOren,eyalmrejen-elementor,marcin-elementor,nami-p,Nevoss,Omerisra6,ronros-elementor,YaelAvitan,ntnelbaba" } } diff --git a/.github/workflows/create-release-branch/action.yml b/.github/workflows/create-release-branch/action.yml deleted file mode 100644 index 052a15b4e6db..000000000000 --- a/.github/workflows/create-release-branch/action.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Create GitHub Branch -description: 'Create a new branch in GitHub.' - -inputs: - REF: - description: 'The name of the source branch.' - required: true - MAINTAIN_USERNAME: - description: 'The username of the maintainer.' - required: true - MAINTAIN_EMAIL: - description: 'The email of the maintainer.' - required: true - -runs: - using: "composite" - steps: - - name: Create Branch - shell: bash - run: | - git config --global user.email "${{ inputs.MAINTAIN_EMAIL }}" - git config --global user.name "${{ inputs.MAINTAIN_USERNAME }}" - - #e.g `refs/heads/3.14` - current_branch_version=$(echo "${{ inputs.REF }}" | cut -d'/' -f 3) - echo "Current branch version is $current_branch_version" - - # e.g `main` - if ! [[ $current_branch_version =~ ^[0-9]+\.[0-9]+$ ]]; then - echo "Invalid version ${{ inputs.REF }}" - exit 1 - fi - - # e.g 3.15 - increment_version=$(echo $current_branch_version | awk -F. -v OFS=. '{$NF++;print}') - echo "Increment version is $increment_version" - - git checkout -b $increment_version - npm version "$increment_version.0" --no-git-tag-version - git add package.json - git add package-lock.json - - git commit -m "Release branch $increment_version created" - git push origin $increment_version diff --git a/.github/workflows/create-revert-pr/action.yml b/.github/workflows/create-revert-pr/action.yml new file mode 100644 index 000000000000..da1aec77d0a5 --- /dev/null +++ b/.github/workflows/create-revert-pr/action.yml @@ -0,0 +1,114 @@ +name: Create Revert PR +description: Creates a revert PR when tests fail on push events to main or version branches + +inputs: + GITHUB_TOKEN: + required: true + description: 'GitHub token for API access' + COMMIT_SHA: + required: true + description: 'The commit SHA that caused the test failure' + BRANCH_NAME: + required: true + description: 'The branch where the failing commit was pushed' + WORKFLOW_NAME: + required: true + description: 'The name of the workflow that failed (e.g., "Playwright")' + WORKFLOW_RUN_URL: + required: true + description: 'URL to the failed workflow run' + ALLOW_WORKFLOW_CHANGES: + required: false + description: 'Allow creating revert PRs for commits that modify workflow files' + default: 'false' + +runs: + using: "composite" + steps: + - name: Create revert PR + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.GITHUB_TOKEN }} + run: | + set -e + + # Get commit information + COMMIT_SHA="${{ inputs.COMMIT_SHA }}" + PARENT_SHA=$(git rev-parse "${COMMIT_SHA}^") + COMMIT_MESSAGE=$(git log --format=%B -n 1 "${COMMIT_SHA}") + COMMIT_AUTHOR=$(git log --format='%an' -n 1 "${COMMIT_SHA}") + BRANCH_NAME="${{ inputs.BRANCH_NAME }}" + WORKFLOW_NAME="${{ inputs.WORKFLOW_NAME }}" + + # Check if the commit contains workflow changes + WORKFLOW_CHANGES=$(git diff --name-only "${PARENT_SHA}" "${COMMIT_SHA}" | grep "^\.github/workflows/" || echo "") + + if [ -n "$WORKFLOW_CHANGES" ]; then + if [ "${{ inputs.ALLOW_WORKFLOW_CHANGES }}" != "true" ]; then + echo "::warning::Cannot create revert PR: Commit contains workflow changes that require 'workflows' permission" + echo "::warning::Workflow files changed: $WORKFLOW_CHANGES" + echo "::warning::Please create revert PR manually or grant 'workflows' permission to GitHub Actions" + echo "::warning::Or set ALLOW_WORKFLOW_CHANGES to 'true' to bypass this check" + exit 0 + else + echo "::notice::Commit contains workflow changes: $WORKFLOW_CHANGES" + echo "::notice::Proceeding with revert PR creation (ALLOW_WORKFLOW_CHANGES=true)" + fi + fi + + # Create revert branch name + REVERT_BRANCH="revert-${COMMIT_SHA:0:7}-$(date +%s)" + + # Check if a revert PR already exists for this commit + EXISTING_PR=$(gh pr list --state open --search "revert ${COMMIT_SHA:0:7}" | head -n 1 | awk '{print $1}' || echo "") + + if [ -n "$EXISTING_PR" ]; then + echo "Revert PR already exists: #$EXISTING_PR" + exit 0 + fi + + # Configure git + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Create and checkout revert branch from parent commit + git checkout -b "${REVERT_BRANCH}" "${PARENT_SHA}" + + # Push the revert branch + git push origin "${REVERT_BRANCH}" + + # Create PR with revert + PR_TITLE="Revert \"$(echo "${COMMIT_MESSAGE}" | head -n1)\"" + PR_BODY="This PR automatically reverts commit ${COMMIT_SHA} due to ${WORKFLOW_NAME} test failures. + + **Original commit:** ${COMMIT_SHA} + **Original author:** ${COMMIT_AUTHOR} + **Branch:** ${BRANCH_NAME} + **Failed workflow:** ${{ inputs.WORKFLOW_RUN_URL }} + + **Original commit message:** + \`\`\` + ${COMMIT_MESSAGE} + \`\`\` + + This revert was created automatically because the ${WORKFLOW_NAME} tests failed after the original commit was pushed to ${BRANCH_NAME}. + + The 'run-tests' label has been added to trigger tests on this revert PR." + + # Create the PR + PR_URL=$(gh pr create \ + --title "${PR_TITLE}" \ + --body "${PR_BODY}" \ + --base "${BRANCH_NAME}" \ + --head "${REVERT_BRANCH}") + + # Extract PR number from URL + PR_NUMBER=$(echo "${PR_URL}" | grep -o '[0-9]*$') + + echo "Created revert PR #${PR_NUMBER}" + + # Add the 'run-tests' label + gh pr edit "${PR_NUMBER}" --add-label "run-tests" + + echo "Added 'run-tests' label to PR #${PR_NUMBER}" + echo "::notice::Created revert PR #${PR_NUMBER} for commit ${COMMIT_SHA}" \ No newline at end of file diff --git a/.github/workflows/css-file-check.yml b/.github/workflows/css-file-check.yml new file mode 100644 index 000000000000..891033a70702 --- /dev/null +++ b/.github/workflows/css-file-check.yml @@ -0,0 +1,50 @@ +name: Check CSS Size Limit + +on: + push: + branches: + - 'main' + - '3.[0-9][0-9]' + paths-ignore: + - '**.md' + - '**.txt' + - '.github/config.json' + - 'bin/**' + - '.gitignore' + - 'docs/**' + +jobs: + build-plugin: + name: Build plugin + uses: ./.github/workflows/build.yml + + check-size-limit: + runs-on: ubuntu-22.04 + needs: build-plugin + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: 'npm' + + - name: Install dependencies + run: | + npm run prepare-environment:ci + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-plugin.outputs.artifact_name }} + path: ./build + + - name: Install WordPress environment + run: | + npm run start-local-server + + - name: Check file sizes + run: node ./tests/scripts/checkFileSize.js diff --git a/.github/workflows/get-next-release-branch/action.yml b/.github/workflows/get-next-release-branch/action.yml deleted file mode 100644 index 3bfe7fbeaa71..000000000000 --- a/.github/workflows/get-next-release-branch/action.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Get Next Release Branch -description: Get the next release branch version channel & number and increment it. Sets the version as ENV variable. - -inputs: - INPUT_VERSION: - required: true - -runs: - using: "composite" - steps: - - shell: bash - run: | - if [ "${{ inputs.INPUT_VERSION }}" == "" ]; - then - PACKAGE_VERSION=$(node -p "require('./package.json').version") - npm install --no-package-lock --no-save semver@7.3.4 - NEXT_PACKAGE_VERSION=$(npx semver $PACKAGE_VERSION -i minor) - RELEASE_BRANCH="release/${NEXT_PACKAGE_VERSION}" - else - echo "Version var is set to ${{ inputs.INPUT_VERSION }}" - RELEASE_BRANCH="release/${{ inputs.INPUT_VERSION }}" - fi - echo "RELEASE_BRANCH=${RELEASE_BRANCH}" >> $GITHUB_ENV - echo "RELEASE_BRANCH : ${RELEASE_BRANCH}" - diff --git a/.github/workflows/install-dependencies/action.yml b/.github/workflows/install-dependencies/action.yml index ca53d7ab9f0d..afb792e6da90 100644 --- a/.github/workflows/install-dependencies/action.yml +++ b/.github/workflows/install-dependencies/action.yml @@ -4,9 +4,14 @@ description: 'A composite action to install npm packages and Composer dependenci runs: using: 'composite' steps: + # Using PHP 7.4 to make sure it'll bundle the polyfills for PHP >= 8.0 + - name: Setup PHP 7.4 + id: setup-php + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + - shell: bash run: | - npm ci - composer install --optimize-autoloader --prefer-dist # Reason for running this is so that have opportunity to execute composer scripts - composer install --no-scripts --no-dev # Now we want the final `composer install` for the build with flags `--no-dev` that will strip off the dev dependencies from vendor folder added by the command above - composer dump-autoload + npm run prepare-environment:ci + npm run composer:no-dev diff --git a/.github/workflows/jest.yml b/.github/workflows/jest.yml index 90526b2dc165..2e0a9fa5dc56 100644 --- a/.github/workflows/jest.yml +++ b/.github/workflows/jest.yml @@ -2,6 +2,13 @@ name: Jest on: pull_request: + paths-ignore: + - '**.md' + - '**.txt' + - '.github/config.json' + - 'bin/**' + - '.gitignore' + - 'docs/**' merge_group: # This allows a subsequently queued workflow run to interrupt previous runs @@ -40,14 +47,8 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20.x - - uses: actions/cache@v4 - id: npm-cache - with: - path: ~/.npm - key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-node- + cache: npm - name: Install Dependencies - run: npm ci + run: npm run prepare-environment:ci - name: "Run Jest" - run: npm run test:jest + run: npm run test diff --git a/.github/workflows/js-qunit.yml b/.github/workflows/js-qunit.yml index 4e03213b5535..6a74889e6a88 100644 --- a/.github/workflows/js-qunit.yml +++ b/.github/workflows/js-qunit.yml @@ -2,6 +2,14 @@ name: Qunit on: pull_request: + paths: + - '**/*.jsx?' + - '**/*.tsx?' + - '**/*.json' + - 'package.json' + - 'package-lock.json' + - '.github/workflows/js-qunit.yml' + merge_group: # This allows a subsequently queued workflow run to interrupt previous runs @@ -10,28 +18,8 @@ concurrency: cancel-in-progress: true jobs: - file-diff: - runs-on: ubuntu-20.04 - name: Qunit - File Diff - if: startsWith( github.repository, 'elementor/' ) - outputs: - js_diff: ${{ steps.js_diff_files.outputs.diff }} - steps: - - name: Checkout source code - uses: actions/checkout@v4 - - name: Check JS files diff - id: js_diff_files - uses: technote-space/get-diff-action@v6 - with: - PATTERNS: | - **/*.+(js|ts|json|jsx|tsx) - package*.json - .github/**/*.yml - q-unit: - runs-on: ubuntu-20.04 - needs: [ 'file-diff' ] - if: ${{ needs.file-diff.outputs.js_diff || github.event.pull_request.title == null }} + runs-on: ubuntu-latest name: Qunit - Test steps: - name: Checkout source code @@ -40,20 +28,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20.x - - name: Cache node modules - uses: actions/cache@v4 - env: - cache-name: cache-node-modules - with: - path: ~/.npm - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + cache: npm - name: Install Dependencies - run: npm ci - - name: "Grunt Scripts" - run: npx grunt scripts + run: npm run prepare-environment:ci + - name: "Build Scripts" + run: npm run build:ci - name: "Run Qunit" run: npx grunt karma:unit diff --git a/.github/workflows/lighthouse.yml b/.github/workflows/lighthouse.yml index cc2f102d7c1c..281df3153ab4 100644 --- a/.github/workflows/lighthouse.yml +++ b/.github/workflows/lighthouse.yml @@ -2,15 +2,22 @@ name: Lighthouse on: pull_request: + push: + branches: + - 'main' + - '3.[0-9][0-9]' + paths-ignore: + - '**.md' + - '**.txt' + - '.github/config.json' + - 'bin/**' + - '.gitignore' + - 'docs/**' merge_group: -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' - cancel-in-progress: true - jobs: build-plugin-lh: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') name: Build plugin uses: ./.github/workflows/build.yml @@ -18,7 +25,7 @@ jobs: name: Lighthouse test - WP latest on PHP 7.4 runs-on: ubuntu-latest needs: [build-plugin-lh] - if: ${{ github.event.pull_request.title == null || needs.build-plugin-lh.outputs.changelog_diff }} + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') steps: - name: Checkout source code uses: actions/checkout@v4 @@ -55,7 +62,7 @@ jobs: elementor-templates - name: Install lhci - run: npm install --no-package-lock --no-save @lhci/cli@0.11.1 + run: npm install --no-package-lock --no-save @lhci/cli@0.14.0 - name: Run Lighthouse tests run: | @@ -68,7 +75,7 @@ jobs: - name: Upload Lighthouse reports on failure if: failure() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: lighthouseci-reports path: ${{ github.workspace }}/.lighthouseci/reports/**/* @@ -76,7 +83,7 @@ jobs: - name: Upload Lighthouse HTML dumps on failure if: failure() - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: lighthouseci-htmls path: ${{ github.workspace }}/.lighthouseci/dumps/**/* diff --git a/.github/workflows/lint-php-changes-only.yml b/.github/workflows/lint-php-changes-only.yml new file mode 100644 index 000000000000..1a500217dfdf --- /dev/null +++ b/.github/workflows/lint-php-changes-only.yml @@ -0,0 +1,49 @@ +name: Lint-PHP-Changes-Only + +on: + pull_request: + merge_group: + +# This allows a subsequently queued workflow run to interrupt previous runs +concurrency: + group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' + cancel-in-progress: true + +jobs: + php-file-diff: + runs-on: ubuntu-22.04 + name: PHP Linter - File Diff + if: startsWith( github.repository, 'elementor/' ) + permissions: + pull-requests: read + outputs: + any_changed: ${{ steps.changed-php-files.outputs.any_changed }} + all_changed_files: ${{ steps.changed-php-files.outputs.all_changed_files }} + steps: + - name: Get all changed PHP files + id: changed-php-files + uses: tj-actions/changed-files@v45 + with: + files: | + **.php + + PHP-Linter: + runs-on: ubuntu-22.04 + needs: [ 'php-file-diff' ] + if: ${{ needs.php-file-diff.outputs.any_changed == 'true' || github.event.pull_request.title == null }} + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Setup PHP 8.1 + uses: shivammathur/setup-php@v2 + with: + php-version: '8.1' + - name: Install Dependencies + run: | + composer install + - name: Run PHP Linter + env: + PHP_CHANGED_FILES: ${{ needs.php-file-diff.outputs.all_changed_files }} + run: | + #composer lint $(echo "${PHP_CHANGED_FILES}" | tr '\n' ' ') + vendor/bin/phpcs --extensions=php --standard=./ruleset.xml --warning-severity=0 $(echo "${PHP_CHANGED_FILES}" | tr '\n' ' ') diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 61514fd398c8..7a2292a970a2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,6 +2,13 @@ name: Lint on: pull_request: + paths-ignore: + - '**.md' + - '**.txt' + - '.github/config.json' + - 'bin/**' + - '.gitignore' + - 'docs/**' merge_group: # This allows a subsequently queued workflow run to interrupt previous runs @@ -49,21 +56,13 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20.x - - name: Cache node modules - uses: actions/cache@v4 - env: - cache-name: cache-node-modules - with: - path: ~/.npm - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- + cache: npm - name: Install Dependencies - run: npm ci + run: npm run prepare-environment:ci + - name: Build tools + run: npm run build:tools - name: Run Lint - run: ./node_modules/eslint/bin/eslint.js . + run: npm run lint PHP-Lint: runs-on: ubuntu-22.04 @@ -91,7 +90,7 @@ jobs: run: | export PATH=$HOME/.composer/vendor/bin:$PATH php -v - php vendor/bin/parallel-lint --blame --exclude node_modules --exclude vendor . + php vendor/bin/parallel-lint --blame --exclude node_modules --exclude vendor --exclude vendor_prefixed . - name: Setup PHP 8.0 # not included in ubuntu 22.04 uses: shivammathur/setup-php@v2 with: @@ -100,7 +99,7 @@ jobs: run: | export PATH=$HOME/.composer/vendor/bin:$PATH php -v - php vendor/bin/parallel-lint --blame --exclude node_modules --exclude vendor . + php vendor/bin/parallel-lint --blame --exclude node_modules --exclude vendor --exclude vendor_prefixed . - name: Setup PHP 8.1 # not included in ubuntu 22.04 uses: shivammathur/setup-php@v2 with: @@ -109,4 +108,4 @@ jobs: run: | export PATH=$HOME/.composer/vendor/bin:$PATH php -v - php vendor/bin/parallel-lint --blame --exclude node_modules --exclude vendor . + php vendor/bin/parallel-lint --blame --exclude node_modules --exclude vendor --exclude vendor_prefixed . diff --git a/.github/workflows/one-click-release.yml b/.github/workflows/one-click-release.yml index 08567a0cd04b..6fe7f37b42a6 100644 --- a/.github/workflows/one-click-release.yml +++ b/.github/workflows/one-click-release.yml @@ -6,15 +6,19 @@ on: channel: required: true type: choice - description: Select a channel. For cloud, use main branch only! + description: Select a channel. options: - - ga - beta - - cloud - pre_release: + - ga + custom_core_executed: type: boolean - description: 'Pre-release?' + description: 'Was the Custom Core process executed and completed successful?' + required: true + dry_run: + type: boolean + description: 'Run in dry-run mode (no actual release will be created)' required: false + default: true env: CHANNEL: ${{inputs.channel}} @@ -28,6 +32,11 @@ jobs: release: runs-on: ubuntu-22.04 steps: + - name: Check if Custom Core was executed + if: github.event.inputs.custom_core_executed == 'false' + run: | + echo "Custom Core was not executed. Please run Custom Core before running this workflow." + exit 1 - name: checkout branch uses: actions/checkout@v4 with: @@ -64,7 +73,6 @@ jobs: POSTFIX: '' OVERRIDE_PACKAGE_VERSION: true - name: Update Readme.txt - if: env.CHANNEL != 'cloud' uses: ./.github/workflows/update-readme-txt with: README_TXT_PATH: $GITHUB_WORKSPACE/readme.txt @@ -103,10 +111,19 @@ jobs: files: | elementor-*.zip ${{ env.CHANGELOG_FILE }} - prerelease: ${{ github.event.inputs.pre_release }} + prerelease: ${{ github.event.inputs.dry_run }} + draft: ${{ github.event.inputs.dry_run }} body_path: ${{ env.CHANGELOG_FILE }} + - name: Validate Build Files + if: github.repository_owner == 'elementor' + env: + PLUGIN_VERSION: ${{ env.RELEASE_NAME }} + CHANNEL: ${{ env.CHANNEL }} + PACKAGE_VERSION : ${{ env.PACKAGE_VERSION }} + run: | + bash "${GITHUB_WORKSPACE}/.github/scripts/validate-build-files.sh" - name: Publish to WordPress.org SVN - if: env.CHANNEL != 'cloud' && github.repository_owner == 'elementor' && github.event.inputs.pre_release == 'false' # We don't publish cloud to WordPress.org, ga, beta, dev are published. + if: github.repository_owner == 'elementor' && github.event.inputs.dry_run == 'false' env: PLUGIN_VERSION: ${{ env.RELEASE_NAME }} SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} @@ -114,10 +131,9 @@ jobs: CHANNEL: ${{ env.CHANNEL }} PACKAGE_VERSION : ${{ env.PACKAGE_VERSION }} run: | - bash "${GITHUB_WORKSPACE}/.github/scripts/validate-build-files.sh" bash "${GITHUB_WORKSPACE}/.github/scripts/publish-to-wordpress-org.sh" - name: Release Dev From Beta - if: env.CHANNEL == 'beta' # Only for beta releases + if: env.CHANNEL == 'beta' && github.event.inputs.dry_run == 'false' uses: ./.github/workflows/release-dev-from-beta with: BUILD_ZIP_FILE_PATH: ${{ github.event.repository.name }}-${{ env.RELEASE_FILENAME }}.zip @@ -125,10 +141,10 @@ jobs: REPOSITORY_OWNER: ${{ github.repository_owner }} SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} SVN_USERNAME: ${{ secrets.SVN_USERNAME }} - PRE_RELEASE: ${{ github.event.inputs.pre_release }} + DRY_RUN: ${{ github.event.inputs.dry_run }} PACKAGE_VERSION : ${{ env.PACKAGE_VERSION }} - name: Post To Slack Created Release - if: github.event.inputs.pre_release == 'false' + if: github.event.inputs.dry_run == 'false' uses : ./.github/workflows/post-to-slack with: SLACK_BOT_TOKEN: ${{ secrets.SLACK_TOKEN }} diff --git a/.github/workflows/parse-branch-name/action.yml b/.github/workflows/parse-branch-name/action.yml deleted file mode 100644 index e381179deed4..000000000000 --- a/.github/workflows/parse-branch-name/action.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Parse Branch Name -description: Parse the branch name, and set parts as environment variables (PACKAGE_VERSION, CHANNEL, CLEAN_PACKAGE_NAME). - -inputs: - BRANCH_NAME: - description: The branch name to parse (e.g. refs/tags/v3.11.2-cloud-rc) - required: true - -runs: - using: "composite" - steps: - - shell: bash - run: | - CLEAN_PACKAGE_NAME=${{ inputs.BRANCH_NAME }} - CLEAN_PACKAGE_NAME=${CLEAN_PACKAGE_NAME##*/} - CLEAN_PACKAGE_NAME=${CLEAN_PACKAGE_NAME:1} - - PACKAGE_VERSION=$(echo $CLEAN_PACKAGE_NAME | cut -d "/" -f3 | cut -d "-" -f1 | sed -e 's/^v//') - CHANNEL=$(echo $CLEAN_PACKAGE_NAME | cut -d "-" -f2) - - # e.g. 3.11.1 - echo "PACKAGE_VERSION=${PACKAGE_VERSION}" >> $GITHUB_ENV - echo "PACKAGE_VERSION=${PACKAGE_VERSION}" - - # e.g. 3.17 - CLEAN_REF=$(echo $PACKAGE_VERSION | cut -d'.' -f1-2) - echo "CLEAN_REF=${CLEAN_REF}" >> $GITHUB_ENV - echo "CLEAN_REF=${CLEAN_REF}" - - # e.g. beta, cloud, ga - echo "CHANNEL=${CHANNEL}" >> $GITHUB_ENV - echo "CHANNEL=${CHANNEL}" - - # e.g. 3.11.1-cloud-rc - echo "CLEAN_PACKAGE_NAME=${CLEAN_PACKAGE_NAME}" >> $GITHUB_ENV - echo "CLEAN_PACKAGE_NAME=${CLEAN_PACKAGE_NAME}" diff --git a/.github/workflows/performance-check.yml b/.github/workflows/performance-check.yml new file mode 100644 index 000000000000..6a7ba011bd4b --- /dev/null +++ b/.github/workflows/performance-check.yml @@ -0,0 +1,130 @@ +name: Performance Check + +on: + push: + branches: + - 'main' + - '3.*' + paths-ignore: + - '**.md' + - '**.txt' + - '.github/config.json' + - 'bin/**' + - '.gitignore' + - 'docs/**' + - 'tests/**' + workflow_dispatch: + +jobs: + build-plugin: + name: Build plugin + uses: ./.github/workflows/build.yml + + compare-v4-to-v3: + name: Compare V4 to V3 + runs-on: ubuntu-24.04 + needs: [build-plugin] + env: + PERFORMANCE_SCORE_THRESHOLD: 0.87 + steps: + + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Install Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: 'npm' + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-plugin.outputs.artifact_name }} + path: ./elementor + + - name: Setup wp-env + uses: elementor/elementor-editor-github-actions/actions/setup-wp-env@main + with: + php: 7.4 + active-theme: 'hello-elementor' + plugins: |- + ./elementor + themes: |- + https://downloads.wordpress.org/theme/hello-elementor.zip + mappings: |- + templates:./tests/lighthouse/compare-templates + + - name: Setup Elementor ENV + uses: elementor/elementor-editor-github-actions/actions/setup-elementor-env@main + with: + env: 'testing' + enable-svg-upload: true + experiments: |- + e_optimized_markup:true + e_atomic_elements:true + e_classes:true + templates: |- + templates + + - name: Warm up the cache + run: | + curl -o /dev/null http://localhost:8889/v3-only/ && + curl -o /dev/null http://localhost:8889/mixed-template/ + + - name: Run Performance Check + uses: elementor/elementor-editor-github-actions/actions/run-lighthouse-tests@main + id: run-performance-check + with: + number-of-runs: 3 + urls: |- + v3:http://localhost:8889/v3-only/ + mixed:http://localhost:8889/mixed-template/ + categories: |- + performance + + - name: Check if score meets the threshold + uses: actions/github-script@v7 + env: + V3_SCORE: ${{ steps.run-performance-check.outputs.v3-performance-median-score }} + MIXED_SCORE: ${{ steps.run-performance-check.outputs.mixed-performance-median-score }} + with: + script: | + const v3Score = parseFloat( process.env.V3_SCORE ); + const mixedScore = parseFloat( process.env.MIXED_SCORE ); + const threshold = parseFloat( process.env.PERFORMANCE_SCORE_THRESHOLD ); + + if ( mixedScore < v3Score ) { + core.setFailed( `The performance score of the mixed page (v3 and v4 elements) is ${mixedScore}, which is below the performance score of the V3 page ${v3Score}.` ); + } + + if ( mixedScore < threshold ) { + core.setFailed( `The performance score of the mixed page (v3 and v4 elements) is ${mixedScore}, which is below the threshold of ${threshold}.` ); + } + + - name: Upload reports + id: upload-reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: compare-v4-to-v3 + path: ${{ steps.run-performance-check.outputs.reports-path }} + retention-days: 1 + + - name: Send Slack notification + uses: slackapi/slack-github-action@v2.0.0 + if: failure() + env: + RUN_ID: ${{ github.run_id }} + ARTIFACT_ID: ${{ steps.upload-reports.outputs.artifact-id }} + V3_SCORE: ${{ steps.run-performance-check.outputs.v3-performance-median-score }} + MIXED_SCORE: ${{ steps.run-performance-check.outputs.mixed-performance-median-score }} + with: + webhook: ${{ secrets.SLACK_COMPARE_PERFORMANCE_WEBHOOK_URL }} + webhook-type: webhook-trigger + payload: | + run_id: ${{ env.RUN_ID }} + artifact_id: ${{ env.ARTIFACT_ID }} + v3_score: ${{ env.V3_SCORE }} + mixed_score: ${{ env.MIXED_SCORE }} + threshold: ${{ env.PERFORMANCE_SCORE_THRESHOLD }} diff --git a/.github/workflows/permissions/action.yml b/.github/workflows/permissions/action.yml index 91f1ab089df1..dd8b820f0cdc 100644 --- a/.github/workflows/permissions/action.yml +++ b/.github/workflows/permissions/action.yml @@ -25,7 +25,7 @@ runs: echo "Pass validation in the development environment" exit 0 fi - if [[ "${{ inputs.DEPLOYMENT_PERMITTED }}" != *"${{ github.actor }}"* ]];then + if ! echo "${{ inputs.DEPLOYMENT_PERMITTED }}" | grep -iq "${{ github.actor }}"; then echo "::error::No deployment permissions have been granted to the user : ${{ github.actor }}" exit 1 fi diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index dfdd62cbdc55..78d8e896b3c1 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -2,6 +2,13 @@ name: PHPUnit on: pull_request: + paths-ignore: + - '**.md' + - '**.txt' + - '.github/config.json' + - 'bin/**' + - '.gitignore' + - 'docs/**' merge_group: # This allows a subsequently queued workflow run to interrupt previous runs @@ -11,7 +18,7 @@ concurrency: jobs: file-diff: - runs-on: ubuntu-22.04 + runs-on: ubuntu-latest name: File Diff if: startsWith( github.repository, 'elementor/' ) outputs: @@ -25,6 +32,7 @@ jobs: with: PATTERNS: | **/*.php + **/*.twig composer.+(json|lock) .github/**/*.yml install-wp-tests.sh @@ -35,12 +43,12 @@ jobs: strategy: fail-fast: false matrix: - wordpress_versions: ['nightly', 'latest', '6.5', '6.4', '6.3'] - php_versions: ['7.4', '8.0'] + wordpress_versions: ['nightly', 'latest', '6.6', '6.5'] + php_versions: ['7.4', '8.0', '8.1', '8.2', '8.3'] name: PHPUnit - WordPress ${{ matrix.wordpress_versions }} - PHP version ${{ matrix.php_versions }} env: WP_TESTS_DIR: /tmp/wordpress-tests-lib - COVERAGE: ${{ matrix.php_versions >= 8.0 && matrix.wordpress_versions == 'latest' }} + COVERAGE: ${{ matrix.php_versions >= 8.3 && matrix.wordpress_versions == 'latest' }} services: mysql: image: mysql:5.7 @@ -81,7 +89,7 @@ jobs: test-result: needs: test if: ${{ always() }} # Will be run even if 'test' matrix will be skipped - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 name: PHPUnit - Test Results steps: - name: Test status diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 8ade6707d45d..69f2e93f82e1 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -2,59 +2,67 @@ name: Playwright on: pull_request: + types: [ labeled, synchronize, opened, reopened ] + push: + branches: + - 'main' + - '3.[0-9][0-9]' + paths-ignore: + - '**.md' + - '**.txt' + - '.github/config.json' + - 'bin/**' + - '.gitignore' + - 'docs/**' schedule: - cron: '30 08 * * 0,1,2,3,4,5' workflow_dispatch: inputs: - reporter: - required: false - description: 'Select a reporter' - type: choice - options: - - allure-playwright - - html - - blob - - list - default: allure-playwright - path-to-results: - required: false - description: 'Provide path to reporter files' - default: allure-results - type: choice - options: - - test-results/ - - tests/playwright/blob-report - - allure-results fail_fast: type: boolean required: true description: 'Cancel tests when one of them fails' default: false + tag: + description: 'Provide @tag or a keyword' + required: false + +permissions: + contents: write + pull-requests: write + actions: read -# This allows a subsequently queued workflow run to interrupt previous runs concurrency: - group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' - cancel-in-progress: true + group: playwright-${{ github.event_name }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: build-plugin: + if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') name: Build plugin uses: ./.github/workflows/build.yml Playwright: - name: Playwright test - ${{ matrix.shardIndex }} on PHP 8.0 - runs-on: ubuntu-latest + name: Playwright test - ${{ matrix.shardIndex }} on PHP 8.1 + runs-on: ubuntu-22.04 needs: [build-plugin] - if: ${{ github.event.pull_request.title == null || needs.build-plugin.outputs.changelog_diff }} + if: github.event.inputs.tag == '' && (github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests')) strategy: - fail-fast: ${{ inputs.fail_fast || true }} + fail-fast: ${{ inputs.fail_fast || false }} matrix: shardIndex: [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 ] shardTotal: [ 10 ] include: + - shardIndex: "v4-tests" - shardIndex: "elements-regression" + - shardIndex: "plugin_tester_section" + - shardIndex: "plugin_tester_container" steps: - name: Checkout source code uses: actions/checkout@v4 + - name: Cache Docker images. + uses: ScribeMD/docker-cache@0.5.0 + with: + key: docker-${{ runner.os }}-${{ hashFiles('tests/playwright/.playwright-wp-lite-env.json') }} - name: Install Node.js 20.x uses: actions/setup-node@v4 with: @@ -62,39 +70,52 @@ jobs: cache: 'npm' - name: Install dependencies run: | - npm ci - sed -i -e "s/image: 'mariadb:lts'/image: 'mariadb:11.2.4-jammy'/g" node_modules/@wordpress/env/lib/build-docker-compose-config.js + npm run prepare-environment:ci - name: Download build artifact uses: actions/download-artifact@v4 with: name: ${{ needs.build-plugin.outputs.artifact_name }} path: ./build - - name: Update wp-env.json file - env: - PHP_VERSION: '8.0' - WP_CORE_VERSION: 'latest' - run: node ./.github/scripts/build-wp-env.js + - name: Download hello-elementor theme + run: | + curl -L --output hello-elementor.zip "https://downloads.wordpress.org/theme/hello-elementor.zip" + unzip hello-elementor - name: Install WordPress environment run: | npm run start-local-server - name: Update wordpress to nightly build if: ${{ github.event_name == 'schedule' }} - run: npx wp-env run cli wp core update https://wordpress.org/nightly-builds/wordpress-latest.zip + run: npx wp-lite-env cli --config=./tests/playwright/.playwright-wp-lite-env.json --port=8888 --command="wp core update https://wordpress.org/nightly-builds/wordpress-latest.zip" - name: Setup test data run: npm run test:setup:playwright - name: WordPress debug information run: | - npx wp-env run cli wp core version - npx wp-env run cli wp --info + npx wp-lite-env cli --config=./tests/playwright/.playwright-wp-lite-env.json --port=8888 --command="wp core version" + npx wp-lite-env cli --config=./tests/playwright/.playwright-wp-lite-env.json --port=8888 --command="wp --info" - name: Install playwright/test run: | npx playwright install chromium - name: Run Playwright tests - if: ${{ matrix.shardIndex != 'elements-regression' }} - run: npm run test:playwright -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --reporter=${{ inputs.reporter || 'list,github' }} + if: ${{ + matrix.shardIndex != 'elements-regression' && + matrix.shardIndex != 'plugin_tester_container' && + matrix.shardIndex != 'plugin_tester_section' && + matrix.shardIndex != 'v4-tests' + }} + run: | + npm run test:playwright -- --grep-invert="@v4-tests|@plugin_tester_container|@plugin_tester_section" --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + - name: Run v4 tests + if: ${{ matrix.shardIndex == 'v4-tests' }} + run: npm run test:playwright -- --grep="@v4-tests" + - name: Run Playwright Plugin Container tests + if: ${{ matrix.shardIndex == 'plugin_tester_container' }} + run: npm run test:playwright -- --grep="@plugin_tester_container" + - name: Run Playwright Plugin Container tests + if: ${{ matrix.shardIndex == 'plugin_tester_section' }} + run: npm run test:playwright -- --grep="@plugin_tester_section" - name: Run element regression tests if: ${{ matrix.shardIndex == 'elements-regression' }} - run: npm run test:playwright:elements-regression -- --reporter=${{ inputs.reporter || 'list,github' }} + run: npm run test:playwright:elements-regression - uses: actions/upload-artifact@v4 if: always() with: @@ -102,37 +123,108 @@ jobs: path: ${{ inputs.path-to-results || 'test-results/' }} if-no-files-found: ignore retention-days: 2 + PlaywrightWithTag: + name: Playwright test - tagged tests on PHP 8.1 + runs-on: ubuntu-22.04 + needs: [ build-plugin ] + if: ${{ github.event.inputs.tag }} + steps: + - name: Checkout source code + uses: actions/checkout@v4 + - name: Install Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: 'npm' + - name: Install dependencies + run: | + npm run prepare-environment:ci + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build-plugin.outputs.artifact_name }} + path: ./build + - name: Download hello-elementor theme + run: | + curl -L --output hello-elementor.zip "https://downloads.wordpress.org/theme/hello-elementor.zip" + unzip hello-elementor + - name: Install WordPress environment + run: | + npm run start-local-server + - name: Setup test data + run: npm run test:setup:playwright + - name: WordPress debug information + run: | + npx wp-lite-env cli --config=./tests/playwright/.playwright-wp-lite-env.json --port=8888 --command="wp core version" + npx wp-lite-env cli --config=./tests/playwright/.playwright-wp-lite-env.json --port=8888 --command="wp --info" + - name: Install playwright/test + run: | + npx playwright install chromium + - name: Run Playwright tests + run: | + npm run test:playwright -- --grep="${{ inputs.tag }}" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-test-results-tagged-tests + path: ${{ inputs.path-to-results || 'test-results/' }} + if-no-files-found: ignore + retention-days: 2 + test-result: - needs: Playwright - if: ${{ always() }} # Will be run even if 'Playwright' matrix will be skipped + needs: [ Playwright, PlaywrightWithTag ] + if: always() && (needs.Playwright.result != 'skipped') runs-on: ubuntu-22.04 name: Playwright - Test Results steps: - name: Test status run: echo "Test status is - ${{ needs.Playwright.result }}" - name: Checkout source code - if: ${{ needs.Playwright.result == 'failure' && github.event_name == 'schedule' }} + if: ${{ (needs.Playwright.result == 'failure') }} uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + - name: Create revert PR on push failure + if: ${{ needs.Playwright.result == 'failure' && github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/3.')) }} + uses: ./.github/workflows/create-revert-pr + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT_SHA: ${{ github.sha }} + BRANCH_NAME: ${{ github.ref_name }} + WORKFLOW_NAME: "Playwright" + WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + ALLOW_WORKFLOW_CHANGES: 'true' - name: Send slack message - if: ${{ needs.Playwright.result == 'failure' && github.event_name == 'schedule' }} + if: ${{ needs.Playwright.result == 'failure' && github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request' }} uses: ./.github/workflows/post-to-slack with: SLACK_BOT_TOKEN: ${{ secrets.SLACK_TOKEN }} SLACK_TAG_CHANNELS: ${{ secrets.TEST_AUTOMATION_RESULTS }} PAYLOAD: | { - "text": "Elementor Core: Playwright with WordPress nightly has failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "text": "Elementor Core: Playwright - ${{github.event_name}} has failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", "blocks": [ { "type": "section", "text": { "type": "mrkdwn", - "text": "Elementor Core: Playwright with WordPress nightly failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + "text": "Elementor Core: Playwright - ${{github.event_name}} failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + } + }, + { + "type": "divider" + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Github User : <${{ github.actor }}>" } } ] } - name: Check Playwright matrix status - if: ${{ needs.Playwright.result != 'success' && needs.Playwright.result != 'skipped' }} + if: ${{ (needs.Playwright.result != 'success' && needs.Playwright.result != 'skipped') || (needs.PlaywrightWithTag.result != 'success' && needs.PlaywrightWithTag.result != 'skipped') }} run: exit 1 diff --git a/.github/workflows/plugin-upgrade-test.yml b/.github/workflows/plugin-upgrade-test.yml index cb8ec9806240..6326f59006ca 100644 --- a/.github/workflows/plugin-upgrade-test.yml +++ b/.github/workflows/plugin-upgrade-test.yml @@ -1,7 +1,17 @@ name: Upgrade Elementor test on: - pull_request: + push: + branches: + - 'main' + - '3.[0-9][0-9]' + paths-ignore: + - '**.md' + - '**.txt' + - '.github/config.json' + - 'bin/**' + - '.gitignore' + - 'docs/**' workflow_dispatch: inputs: version: @@ -9,17 +19,12 @@ on: required: false default: '' -# This allows a subsequently queued workflow run to interrupt previous runs -concurrency: - group: '${{ github.workflow }} @ ${{ github.head_ref || github.ref }}' - cancel-in-progress: true - jobs: build-plugin: uses: ./.github/workflows/build.yml run-upgrade-test: name: Playwright plugin upgrade test - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 needs: [build-plugin] if: ${{ github.event.pull_request.title == null || needs.build-plugin.outputs.changelog_diff }} steps: @@ -30,26 +35,41 @@ jobs: with: node-version: 20.x cache: 'npm' + - name: Install dependencies + run: | + npm run prepare-environment:ci - name: Download build artifact uses: actions/download-artifact@v4 with: name: ${{ needs.build-plugin.outputs.artifact_name }} path: ./build - - name: Install dependencies + - name: Download hello-elementor theme + run: | + curl -L --output hello-elementor.zip "https://downloads.wordpress.org/theme/hello-elementor.zip" + unzip hello-elementor + - name: Install WordPress environment run: | - npm ci - cd ./tests/playwright/upgrade-test && npm ci - - name: Run upgrade test - run: cd ./tests/playwright/upgrade-test && npm run test:upgrade:elementor - env: - ELEMENTOR_PLUGIN_VERSION: ${{inputs.version}} - - name: Setup playwright tests - run: cd ./tests/playwright/upgrade-test && npm run test:setup - - name: Install chromium - run: npx playwright install chromium + npm run setup-templates + npx wp-lite-env start --config=./tests/playwright/upgrade-test/.upgrade-test-wp-lite-env.json --port=8888 + - name: Setup test data + run: | + npx wp-lite-env cli --config=./tests/playwright/upgrade-test/.upgrade-test-wp-lite-env.json --command="wp plugin install elementor" --port=8888 + npx wp-lite-env cli --config=./tests/playwright/upgrade-test/.upgrade-test-wp-lite-env.json --command="bash elementor-config/setup.sh" --port=8888 + - name: WordPress debug information + run: | + npx wp-lite-env cli --config=./tests/playwright/upgrade-test/.upgrade-test-wp-lite-env.json --command="wp core version" --port=8888 + npx wp-lite-env cli --config=./tests/playwright/upgrade-test/.upgrade-test-wp-lite-env.json --command="wp --info" --port=8888 + - name: Install playwright/test + run: | + npx playwright install chromium + - name: Upgrade site + run: | + zip -r ./templates/playwright/elementor.zip ./build + npx wp-lite-env cli --config=./tests/playwright/upgrade-test/.upgrade-test-wp-lite-env.json --command="wp plugin install ./elementor-playwright-templates/elementor.zip --force" --port=8888 + npx wp-lite-env cli --config=./tests/playwright/upgrade-test/.upgrade-test-wp-lite-env.json --command="wp plugin list" --port=8888 - name: Run Playwright tests run: npm run test:playwright:elements-regression -- --grep="Test heading template" - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: always() with: name: playwright-test-results-elements-regression @@ -67,5 +87,3 @@ jobs: - name: Check Playwright matrix status if: ${{ needs.run-upgrade-test.result != 'success' && needs.run-upgrade-test.result != 'skipped' }} run: exit 1 - - diff --git a/.github/workflows/pr-checks-noop.yml b/.github/workflows/pr-checks-noop.yml new file mode 100644 index 000000000000..86efbd92f927 --- /dev/null +++ b/.github/workflows/pr-checks-noop.yml @@ -0,0 +1,56 @@ +name: PR Checks Noop +# This workflow is meant to send success to required pr checks. this is a workaround for the issue https://github.com/orgs/community/discussions/142210 +on: + pull_request: + paths: + - '**.md' + - '**.txt' + - '.github/config.json' + - 'bin/**' + - '.gitignore' + - 'docs/**' +jobs: + build-plugin: + name: Build plugin + runs-on: ubuntu-latest + steps: + - run: | + exit 0 + test-result: + name: Playwright - Test Results + runs-on: ubuntu-latest + steps: + - run: | + exit 0 + test-result-phpunit: + name: PHPUnit - Test Results + runs-on: ubuntu-latest + steps: + - run: | + exit 0 + pr_name_lint: + runs-on: ubuntu-latest + steps: + - run: | + exit 0 + JS-Lint: + runs-on: ubuntu-latest + steps: + - run: | + exit 0 + PHP-Lint: + runs-on: ubuntu-latest + steps: + - run: | + exit 0 + lighthouse: + runs-on: ubuntu-latest + steps: + - run: | + exit 0 + test-result-lighthouse: + name: Lighthouse - Test Results + runs-on: ubuntu-latest + steps: + - run: | + exit 0 diff --git a/.github/workflows/pr-linter.yml b/.github/workflows/pr-linter.yml index 9d9a76aec664..f462bc42ed75 100644 --- a/.github/workflows/pr-linter.yml +++ b/.github/workflows/pr-linter.yml @@ -2,6 +2,13 @@ name: PR Linter on: pull_request: types: ['opened', 'edited', 'reopened', 'synchronize'] + paths-ignore: + - '**.md' + - '**.txt' + - '.github/config.json' + - 'bin/**' + - '.gitignore' + - 'docs/**' merge_group: # This allows a subsequently queued workflow run to interrupt previous runs @@ -15,13 +22,22 @@ jobs: if: startsWith( github.repository, 'elementor/' ) steps: - uses: actions/checkout@v4 + - name: Install Node.js 20.x uses: actions/setup-node@v4 with: node-version: 20.x + + - name: Cache Node Modules + uses: actions/cache@v4 + with: + path: ~/.npm + key: ${{ runner.os }}-pr-name-linter-12 + - name: Install Dependencies if: ${{ github.event.pull_request.title != null }} - run: npm install --no-package-lock --no-save @commitlint/config-conventional@12.1.1 @commitlint/cli@12.1.1 + run: npm install -g @commitlint/config-conventional@12.1.1 @commitlint/cli@12.1.1 + - name: Run PR name linter if: ${{ github.event.pull_request.title != null }} env: diff --git a/.github/workflows/release-dev-from-beta/action.yml b/.github/workflows/release-dev-from-beta/action.yml index 0f493079ee6e..fd0b4a2edf2f 100644 --- a/.github/workflows/release-dev-from-beta/action.yml +++ b/.github/workflows/release-dev-from-beta/action.yml @@ -17,8 +17,8 @@ inputs: SVN_USERNAME: description: 'The username for the SVN.' required: true - PRE_RELEASE: - description: 'flag to indicate if the release is pre-release.' + DRY_RUN: + description: 'flag to indicate if the release is dry-run (pre-release).' required: true PACKAGE_VERSION: description: 'The version of the package.' @@ -62,7 +62,7 @@ runs: run: | bash "${GITHUB_WORKSPACE}/.github/scripts/validate-build-files.sh" - name: Upload Dev Build To GitHub Artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: dev-svn-preview path: elementor @@ -73,20 +73,15 @@ runs: tag_name: ${{ env.DEV_RELEASE_NAME }} files: ${{ env.DEV_BUILD_ZIP_FILE_PATH }} body_path: ${{ env.CHANGELOG_FILE }} - prerelease: ${{ inputs.PRE_RELEASE }} + prerelease: ${{ inputs.DRY_RUN }} + draft: ${{ inputs.DRY_RUN }} - name: Publish to WordPress.org SVN - if: inputs.PRE_RELEASE == 'false' + if: inputs.DRY_RUN == 'false' && inputs.REPOSITORY_OWNER == 'elementor' shell: bash env: PLUGIN_VERSION: ${{ env.DEV_RELEASE_NAME }} SVN_PASSWORD: ${{ inputs.SVN_PASSWORD }} SVN_USERNAME: ${{ inputs.SVN_USERNAME }} run: | - if [[ ${{ inputs.PRE_RELEASE }} == "true" ]]; then - exit 1 - fi - - if [[ ${{ inputs.REPOSITORY_OWNER }} == "elementor" ]]; then - bash "${GITHUB_WORKSPACE}/.github/scripts/publish-to-wordpress-org.sh" - fi + bash "${GITHUB_WORKSPACE}/.github/scripts/publish-to-wordpress-org.sh" diff --git a/.github/workflows/remove-git-tag/action.yml b/.github/workflows/remove-git-tag/action.yml deleted file mode 100644 index 8494250857d4..000000000000 --- a/.github/workflows/remove-git-tag/action.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Remove Git Tag -description: Remove a Git tag from the remote repository. - -inputs: - TAG_NAME: - description: 'The tag name to remove (e.g. v3.11.2-cloud-rc)' - required: true - -runs: - using: "composite" - steps: - - shell: bash - run: | - git push origin :refs/tags/${{ inputs.TAG_NAME }} diff --git a/.github/workflows/test-revert-pr.yml b/.github/workflows/test-revert-pr.yml new file mode 100644 index 000000000000..640555387d32 --- /dev/null +++ b/.github/workflows/test-revert-pr.yml @@ -0,0 +1,176 @@ +name: Test Revert PR Functionality + +on: + workflow_dispatch: + inputs: + test_scenario: + description: 'Test scenario to run' + required: true + type: choice + options: + - 'test-revert-creation' + - 'test-duplicate-prevention' + - 'test-action-only' + default: 'test-revert-creation' + target_branch: + description: 'Branch to test against (should be a test branch)' + required: true + default: 'test-revert-functionality' + simulate_commit: + description: 'Commit SHA to simulate (leave empty to use current)' + required: false + +permissions: + contents: write + pull-requests: write + actions: read + +jobs: + create-test-commit: + name: Create Test Commit + runs-on: ubuntu-22.04 + if: ${{ inputs.test_scenario == 'test-revert-creation' }} + outputs: + test_commit_sha: ${{ steps.create_commit.outputs.commit_sha }} + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Create test branch if not exists + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + # Check if branch exists + if git show-ref --verify --quiet refs/remotes/origin/${{ inputs.target_branch }}; then + echo "Branch ${{ inputs.target_branch }} exists, checking out" + git checkout ${{ inputs.target_branch }} + else + echo "Creating new branch ${{ inputs.target_branch }}" + git checkout -b ${{ inputs.target_branch }} + git push -u origin ${{ inputs.target_branch }} + fi + + - name: Create test commit + id: create_commit + run: | + # Create a test file with timestamp + echo "Test commit for revert PR functionality - $(date)" > test-revert-file.txt + git add test-revert-file.txt + git commit -m "Test commit: Add test file for revert PR testing + + This commit is created to test the automatic revert PR functionality. + It should be reverted automatically when the test workflow fails." + + COMMIT_SHA=$(git rev-parse HEAD) + echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT + echo "Created test commit: $COMMIT_SHA" + + git push origin ${{ inputs.target_branch }} + + test-revert-action: + name: Test Revert Action + runs-on: ubuntu-22.04 + needs: [create-test-commit] + if: always() && (inputs.test_scenario == 'test-revert-creation' || inputs.test_scenario == 'test-action-only') + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_branch }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Determine commit SHA + id: get_commit + run: | + if [ -n "${{ inputs.simulate_commit }}" ]; then + COMMIT_SHA="${{ inputs.simulate_commit }}" + elif [ -n "${{ needs.create-test-commit.outputs.test_commit_sha }}" ]; then + COMMIT_SHA="${{ needs.create-test-commit.outputs.test_commit_sha }}" + else + COMMIT_SHA=$(git rev-parse HEAD) + fi + echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT + echo "Using commit SHA: $COMMIT_SHA" + + - name: Test revert PR creation + uses: ./.github/workflows/create-revert-pr + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT_SHA: ${{ steps.get_commit.outputs.commit_sha }} + BRANCH_NAME: ${{ inputs.target_branch }} + WORKFLOW_NAME: "Test Revert PR Functionality" + WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + test-duplicate-prevention: + name: Test Duplicate Prevention + runs-on: ubuntu-22.04 + if: ${{ inputs.test_scenario == 'test-duplicate-prevention' }} + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_branch }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Get latest commit + id: get_commit + run: | + COMMIT_SHA=$(git rev-parse HEAD) + echo "commit_sha=$COMMIT_SHA" >> $GITHUB_OUTPUT + echo "Using commit SHA: $COMMIT_SHA" + + - name: First revert PR creation + uses: ./.github/workflows/create-revert-pr + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT_SHA: ${{ steps.get_commit.outputs.commit_sha }} + BRANCH_NAME: ${{ inputs.target_branch }} + WORKFLOW_NAME: "Test Duplicate Prevention - First" + WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + - name: Second revert PR creation (should be prevented) + uses: ./.github/workflows/create-revert-pr + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + COMMIT_SHA: ${{ steps.get_commit.outputs.commit_sha }} + BRANCH_NAME: ${{ inputs.target_branch }} + WORKFLOW_NAME: "Test Duplicate Prevention - Second" + WORKFLOW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + cleanup: + name: Cleanup Test Data + runs-on: ubuntu-22.04 + needs: [test-revert-action, test-duplicate-prevention] + if: always() && inputs.test_scenario == 'test-revert-creation' + steps: + - name: Checkout source code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Remove test file + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git show-ref --verify --quiet refs/remotes/origin/${{ inputs.target_branch }}; then + git checkout ${{ inputs.target_branch }} + + if [ -f test-revert-file.txt ]; then + git rm test-revert-file.txt + git commit -m "Cleanup: Remove test file after revert PR testing" + git push origin ${{ inputs.target_branch }} + echo "Cleaned up test file" + else + echo "Test file not found, nothing to cleanup" + fi + else + echo "Test branch not found, nothing to cleanup" + fi \ No newline at end of file diff --git a/.github/workflows/validate-run-checks/action.yml b/.github/workflows/validate-run-checks/action.yml deleted file mode 100644 index 7bea2e49efa5..000000000000 --- a/.github/workflows/validate-run-checks/action.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Validate Run Checks -description: Validate run checks for the current version. - -inputs: - TOKEN: - required: true - REPOSITORY: - required: true - CURRENT_SHA: - required: true - IGNORE_CHECKS_LIST: - required: false - -runs: - using: "composite" - steps: - - shell: bash - env: - TOKEN: ${{ inputs.TOKEN }} - REPOSITORY: ${{ inputs.REPOSITORY }} - CURRENT_SHA: ${{ inputs.CURRENT_SHA }} - IGNORE_CHECKS_LIST: ${{ inputs.IGNORE_CHECKS_LIST }} - run: | - npm install --no-package-lock --no-save @octokit/core@3.4.0 - node .github/scripts/validate-run-checks.js diff --git a/.gitignore b/.gitignore index 47b2d473f783..7e215532dbe0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ log/ vendor/ local-site/ tmp/ +8888/ +8889/ # build outputs build/ @@ -15,6 +17,7 @@ assets/lib/swiper/css/swiper*.css *.log *.map /templates/ +/hello-elementor/ # build tools lock yarn.lock @@ -22,6 +25,7 @@ yarn.lock # IDE's .project/ .vscode/ +!vscode/extensions.json .idea/ # other @@ -30,6 +34,7 @@ Thumbs.db coverage/ .zimud.json .phpunit.result.cache +.npmrc # playwright test-results/ diff --git a/.grunt-config/copy.js b/.grunt-config/copy.js index 4fb3f67f7b18..1c9464ba913f 100644 --- a/.grunt-config/copy.js +++ b/.grunt-config/copy.js @@ -11,10 +11,6 @@ const getBuildFiles = [ '!.gitmodules', '!.jscsrc', '!karma.conf.js', - '!.jshintignore', - '!.jshintrc', - '!.travis.yml', - '!run-on-linux.js', '!phpcs.xml', '!tsconfig.json', '!app/**/assets/**', @@ -46,12 +42,15 @@ const getBuildFiles = [ '!test-results/', '!tmp/**', '!vendor/**', + '!php-scoper/**', '!yarn.lock', '!*~', '!commitlint.config.js', // Conflict with above rule. 'core/files/assets/**', + 'vendor/autoload.php', + 'vendor/composer/**', ]; /** * @type {{main: {src: string[], expand: boolean, dest: string}, secondary: {src: string[], expand: boolean, dest: string}}} diff --git a/.grunt-config/postcss.js b/.grunt-config/postcss.js index b92f8ab589b9..e96fb855d3d8 100644 --- a/.grunt-config/postcss.js +++ b/.grunt-config/postcss.js @@ -41,7 +41,9 @@ module.exports = { 'assets/css/modules/**/*.css', '!assets/css/modules/**/*.min.css', 'assets/lib/swiper/css/*.css', - '!assets/lib/swiper/css/*.min.css' + '!assets/lib/swiper/css/*.min.css', + 'assets/css/templates/*.css', + '!assets/css/templates/*.min.css' ], ext: '.min.css' } ] diff --git a/.grunt-config/sass.js b/.grunt-config/sass.js index d7adcc421c51..aabaada741ba 100644 --- a/.grunt-config/sass.js +++ b/.grunt-config/sass.js @@ -52,10 +52,17 @@ const sass = { { expand: true, cwd: 'modules/styleguide/assets/scss', - src: '*.scss', + src: 'editor.scss', dest: 'assets/css/modules/styleguide', ext: '.css', }, + { + expand: true, + cwd: 'modules/atomic-opt-in/assets/scss', + src: '*.scss', + dest: 'assets/css/modules/editor-v4-opt-in', + ext: '.css', + }, { expand: true, cwd: 'modules/ai/assets/scss', @@ -84,6 +91,20 @@ const sass = { dest: 'assets/css/conditionals', ext: '.css', }, + { + expand: true, + cwd: 'assets/dev/scss/frontend/conditionals/with-breakpoints', + src: '*.scss', + dest: 'assets/css/conditionals', + ext: '.css', + }, + { + expand: true, + cwd: 'assets/dev/scss/frontend/conditionals/with-breakpoints', + src: '*.scss', + dest: 'assets/css/templates', + ext: '.css', + }, ] } }; diff --git a/.grunt-config/webpack.alias.js b/.grunt-config/webpack.alias.js index f2ff54f90ab4..7becde5d9062 100644 --- a/.grunt-config/webpack.alias.js +++ b/.grunt-config/webpack.alias.js @@ -31,6 +31,7 @@ module.exports = { 'e-components': path.resolve( __dirname, '../packages/elementor-ui/components' ), 'e-utils': path.resolve( __dirname, '../packages/elementor-ui/components/utils' ), 'elementor-frontend-utils': path.resolve( __dirname, '../assets/dev/js/frontend/utils' ), + 'elementor-modules': path.resolve( __dirname, '../modules' ), }, }, }; diff --git a/.grunt-config/webpack.js b/.grunt-config/webpack.js index ce9018c3090a..79c1f6a5c4bf 100644 --- a/.grunt-config/webpack.js +++ b/.grunt-config/webpack.js @@ -68,14 +68,15 @@ const frontendRulesPresets = [ [ { targets: { browsers: [ - 'last 1 Android versions', - 'last 1 ChromeAndroid versions', - 'last 2 Chrome versions', - 'last 2 Firefox versions', - 'Safari >= 14', - 'iOS >= 14', - 'last 2 Edge versions', - 'last 2 Opera versions', + 'last 3 versions', + 'Chrome >= 100', + 'Firefox >= 100', + 'Edge >= 100', + 'Safari >= 15.5', + 'iOS >= 15.5', + 'Android >= 100', + 'ChromeAndroid >= 100', + 'not dead', ], }, "useBuiltIns": "usage", @@ -131,18 +132,25 @@ const entry = { 'element-manager-admin': path.resolve( __dirname, '../modules/element-manager/assets/js/admin.js' ), 'media-hints': path.resolve( __dirname, '../assets/dev/js/admin/hints/media.js' ), 'ai-media-library': path.resolve( __dirname, '../modules/ai/assets/js/media-library/index.js' ), + 'ai-unify-product-images': path.resolve( __dirname, '../modules/ai/assets/js/woocommerce/index.js' ), // Temporary solution for the AI App in the Admin. 'ai-admin': path.resolve( __dirname, '../modules/ai/assets/js/admin/index.js' ), 'styleguide': path.resolve( __dirname, '../modules/styleguide/assets/js/styleguide.js' ), 'styleguide-app-initiator': path.resolve( __dirname, '../modules/styleguide/assets/js/styleguide-app-initiator.js' ), 'e-home-screen': path.resolve( __dirname, '../modules/home/assets/js/app.js' ), + 'editor-v4-opt-in': path.resolve( __dirname, '../modules/atomic-opt-in/assets/js/opt-in-page/app.js'), + 'editor-v4-welcome-opt-in': path.resolve( __dirname, '../modules/atomic-opt-in/assets/js/welcome-screen/app.js'), + 'editor-v4-opt-in-alphachip': path.resolve( __dirname, '../modules/atomic-opt-in/assets/js/panel-chip/panel-chip.js' ), + 'e-react-promotions': path.resolve( __dirname, '../modules/promotions/assets/js/react/index.js' ), 'e-wc-product-editor': path.resolve( __dirname, '../modules/wc-product-editor/assets/js/e-wc-product-editor.js' ), 'floating-elements-modal': path.resolve( __dirname, '../assets/dev/js/admin/floating-elements/new-floating-elements.js' ), + 'cloud-library-screenshot': path.resolve( __dirname, '../modules/cloud-library/assets/js/preview/screenshot.js' ), }; const frontendEntries = { 'frontend-modules': path.resolve( __dirname, '../assets/dev/js/frontend/modules.js' ), 'frontend': { import: path.resolve( __dirname, '../assets/dev/js/frontend/frontend.js' ), dependOn: 'frontend-modules' }, + 'youtube-handler': path.resolve( __dirname, '../modules/atomic-widgets/elements/atomic-youtube/youtube-handler.js' ), }; const externals = [ @@ -159,6 +167,8 @@ const externals = [ '@elementor/icons': 'elementorV2.icons', '@elementor/editor-app-bar': 'elementorV2.editorAppBar', '@elementor/editor-v1-adapters': 'elementorV2.editorV1Adapters', + '@elementor/frontend-handlers': 'elementorV2.frontendHandlers', + '@elementor/query': 'elementorV2.query', '@wordpress/dom-ready': 'wp.domReady', '@wordpress/components': 'wp.components', '@wordpress/core-data': 'wp.coreData', @@ -189,6 +199,10 @@ const plugins = [ new WatchTimePlugin(), ]; +// Prevents the collision of chunk names between base and frontend bundles. +const baseOutputUniqueName = 'elementor'; +const frontendOutputUniqueName = 'elementorFrontend'; + const baseConfig = { target: 'web', context: __dirname, @@ -205,8 +219,6 @@ const devSharedConfig = { chunkFilename: ( chunkData ) => getChunkName( chunkData, 'development' ), filename: '[name].js', devtoolModuleFilenameTemplate: '../[resource]', - // Prevents the collision of chunk names between different bundles. - uniqueName: 'elementor', }, watch: true, }; @@ -214,6 +226,10 @@ const devSharedConfig = { const webpackConfig = [ { ...devSharedConfig, + output: { + ...devSharedConfig.output, + uniqueName: baseOutputUniqueName, + }, module: moduleRules, plugins: [ ...plugins, @@ -223,6 +239,10 @@ const webpackConfig = [ }, { ...devSharedConfig, + output: { + ...devSharedConfig.output, + uniqueName: frontendOutputUniqueName, + }, module: frontendModuleRules, plugins: [ new RemoveChunksPlugin( '.bundle.js' ), @@ -261,8 +281,6 @@ const prodSharedConfig = { path: path.resolve( __dirname, '../assets/js' ), chunkFilename: ( chunkData ) => getChunkName( chunkData, 'production' ), filename: '[name].js', - // Prevents the collision of chunk names between different bundles. - uniqueName: 'elementor', }, performance: { hints: false }, }; @@ -270,6 +288,10 @@ const prodSharedConfig = { const webpackProductionConfig = [ { ...prodSharedConfig, + output: { + ...prodSharedConfig.output, + uniqueName: baseOutputUniqueName, + }, module: moduleRules, plugins: [ ...plugins, @@ -285,6 +307,10 @@ const webpackProductionConfig = [ }, { ...prodSharedConfig, + output: { + ...prodSharedConfig.output, + uniqueName: frontendOutputUniqueName, + }, module: frontendModuleRules, plugins: [ new RemoveChunksPlugin( '.bundle.min.js' ), @@ -340,7 +366,6 @@ const gruntWebpackConfig = { developmentNoWatch: developmentNoWatchConfig, production: webpackProductionConfig, productionWatch: productionWatchConfig, - packages: packagesConfigs.dev, }; module.exports = gruntWebpackConfig; diff --git a/.grunt-config/webpack.packages.js b/.grunt-config/webpack.packages.js index fb123104a0c7..9a6271355917 100644 --- a/.grunt-config/webpack.packages.js +++ b/.grunt-config/webpack.packages.js @@ -1,10 +1,24 @@ const path = require( 'path' ); const fs = require( 'fs' ); -const { GenerateWordPressAssetFileWebpackPlugin } = require( '@elementor/generate-wordpress-asset-file-webpack-plugin' ); -const { ExtractI18nWordpressExpressionsWebpackPlugin } = require( '@elementor/extract-i18n-wordpress-expressions-webpack-plugin' ); -const { ExternalizeWordPressAssetsWebpackPlugin } = require( '@elementor/externalize-wordpress-assets-webpack-plugin' ); +const { GenerateWordPressAssetFileWebpackPlugin } = require( path.resolve( __dirname, '../packages/packages/tools/generate-wordpress-asset-file-webpack-plugin' ) ); +const { ExtractI18nWordpressExpressionsWebpackPlugin } = require( path.resolve( __dirname, '../packages/packages/tools/extract-i18n-wordpress-expressions-webpack-plugin' ) ); +const { ExternalizeWordPressAssetsWebpackPlugin } = require( path.resolve( __dirname, '../packages/packages/tools/externalize-wordpress-assets-webpack-plugin' ) ); +const { EntryInitializationWebpackPlugin } = require( path.resolve( __dirname, '../packages/packages/tools/entry-initialization-webpack-plugin' ) ); +const TerserPlugin = require('terser-webpack-plugin'); -const packages = process.env.ELEMENTOR_PACKAGES_USE_LOCAL ? getLocalRepoPackagesEntries() : getNodeModulesPackagesEntries() +const packages = getLocalRepoPackagesEntries(); + +const REGEXES = { + // @elementor/ui/SvgIcon. Used inside @elementor/icons + elementorPathImports: /^@elementor\/(ui|icons)\/(.+)$/, + + // @elementor/editor + // We want to bundle `@elementor/design-tokens` inside the UI package since it's an internal thing. + elementorPackages: /^@elementor\/(?!design-tokens)(.+)$/, + + // @wordpress/components + wordpressPackages: /^@wordpress\/(.+)$/, +}; const common = { name: 'packages', @@ -35,9 +49,9 @@ const common = { new GenerateWordPressAssetFileWebpackPlugin( { handle: ( entryName ) => `elementor-v2-${entryName}`, map: [ - { request: /^@elementor\/(ui|icons)(\/.+)?$/, handle: 'elementor-v2-$1' }, - { request: /^@elementor\/(.+)$/, handle: 'elementor-v2-$1' }, - { request: /^@wordpress\/(.+)$/, handle: 'wp-$1' }, + { request: REGEXES.elementorPathImports, handle: 'elementor-v2-$1' }, + { request: REGEXES.elementorPackages, handle: 'elementor-v2-$1' }, + { request: REGEXES.wordpressPackages, handle: 'wp-$1' }, { request: 'react', handle: 'react' }, { request: 'react-dom', handle: 'react-dom' }, ] @@ -45,13 +59,18 @@ const common = { new ExternalizeWordPressAssetsWebpackPlugin( { global: ( entryName ) => [ 'elementorV2', entryName ], map: [ - { request: /^@elementor\/(ui|icons)\/(.+)$/, global: [ 'elementorV2', '$1', '$2' ] }, - { request: /^@elementor\/(.+)$/, global: [ 'elementorV2', '$1' ] }, - { request: /^@wordpress\/(.+)$/, global: [ 'wp', '$1' ] }, + { request: REGEXES.elementorPathImports, global: [ 'elementorV2', '$1', '$2' ] }, + { request: REGEXES.elementorPackages, global: [ 'elementorV2', '$1' ] }, + { request: REGEXES.wordpressPackages, global: [ 'wp', '$1' ] }, { request: 'react', global: 'React' }, { request: 'react-dom', global: 'ReactDOM' }, ] } ), + new EntryInitializationWebpackPlugin( { + initializer: ( { entryName } ) => { + return `window.elementorV2.${ entryName }?.init?.();`; + }, + } ), ], output: { path: path.resolve( __dirname, '../assets/js/packages/' ), @@ -61,17 +80,25 @@ const common = { const devConfig = { ...common, mode: 'development', - devtool: false, // TODO: Need to check what to do with source maps. + devtool: 'source-map', watch: true, // All the webpack config in the plugin that are dev, should have this property. optimization: { ...( common.optimization || {} ), // Intentionally minimizing the dev assets to reduce the bundle size. minimize: true, + minimizer: [ + new TerserPlugin({ + terserOptions: { + keep_classnames: true, + keep_fnames: true, + } + }) + ] }, output: { ...( common.output || {} ), filename: '[name]/[name].js', - } + }, } const prodConfig = { @@ -99,40 +126,10 @@ module.exports = { prod: prodConfig, }; -function getNodeModulesPackagesEntries() { - const { dependencies } = require( '../package.json' ); - - return Object.keys( dependencies ) - .filter( ( packageName ) => packageName.startsWith( '@elementor/' ) ) - .map( ( packageName ) => { - const pkgJSON = fs.readFileSync( path.resolve( __dirname, `../node_modules/${packageName}/package.json` ) ); - - const { main, module } = JSON.parse( pkgJSON ); - - return { - mainFile: module || main, - packageName, - } - } ) - .filter( ( { mainFile } ) => !! mainFile ) - .map( ( { mainFile, packageName } ) => ( { - name: packageName.replace( '@elementor/', '' ), - path: path.resolve( __dirname, `../node_modules/${ packageName }`, mainFile ), - } ) ); -} - function getLocalRepoPackagesEntries() { - const repoPath = process.env.ELEMENTOR_PACKAGES_PATH; + const repoPath = path.resolve( __dirname, '../packages' ); const relevantDirs = [ 'packages/core', 'packages/libs' ] - if ( ! repoPath ) { - throw new Error( 'ELEMENTOR_PACKAGES_PATH is not defined, define it in your operating system environment variables.' ); - } - - if ( ! fs.existsSync( repoPath ) ) { - throw new Error( `ELEMENTOR_PACKAGES_PATH is defined but the path ${repoPath} does not exist.` ); - } - const packages = relevantDirs.flatMap( ( dir ) => fs.readdirSync( path.resolve( repoPath, dir ) ) .map( ( name ) => ( { diff --git a/.grunt-config/widgets-css.js b/.grunt-config/widgets-css.js index c8ed0283685a..b80ad2c4daec 100644 --- a/.grunt-config/widgets-css.js +++ b/.grunt-config/widgets-css.js @@ -44,8 +44,6 @@ class WidgetsCss { getWidgetScssContent( importPath, direction ) { return `$direction: ${ direction }; -@import "../helpers/direction"; - @import "../../../../assets/dev/scss/helpers/variables"; @import "../../../../assets/dev/scss/helpers/mixins"; @import "../../../../assets/dev/scss/frontend/breakpoints/proxy"; @@ -102,6 +100,7 @@ class WidgetsCss { rtlFilename: this.cssFilePrefix + filename.replace( '.scss', '-rtl.scss' ), importPath: `../frontend/widgets/${ widgetName }`, filePath: this.sourceScssFolder + '/' + filename, + cssFileName: `${ this.cssFilePrefix }${ filename.replace( '.scss', '' ) }`, } ); } ); @@ -113,7 +112,8 @@ class WidgetsCss { moduleWidgetsList = this.getModulesFrontendScssFiles( this.sourceModulesScssFolder ); moduleWidgetsList.forEach( ( filePath ) => { - const widgetData = this.getWidgetDataFromPath( this.sourceModulesScssFolder, filePath ); + const isFrontendScssFile = filePath.indexOf( 'frontend.scss' ) > -1; + const widgetData = this.getWidgetDataFromPath( this.sourceModulesScssFolder, filePath, isFrontendScssFile ); moduleWidgetData.push( { widgetName: widgetData.name, @@ -121,6 +121,7 @@ class WidgetsCss { rtlFilename: this.cssFilePrefix + widgetData.name + '-rtl.scss', importPath: `../../../../modules/${ widgetData.path }`, filePath, + cssFileName: `${ this.cssFilePrefix }${ widgetData.name }`, } ); } ); @@ -130,10 +131,12 @@ class WidgetsCss { getModulesFrontendScssFiles( filePath, frontendScssFiles = [] ) { fs.readdirSync( filePath ).forEach( ( fileName ) => { const fileFullPath = path.join( filePath, fileName ); + const isFrontendScssFile = fileName.indexOf( 'frontend.scss' ) > -1; + const isWidgetsScssFile = fileName.indexOf( '.scss' ) > -1 && filePath.indexOf( '/widgets' ) > -1; if ( fs.lstatSync( fileFullPath ).isDirectory() ) { this.getModulesFrontendScssFiles( fileFullPath, frontendScssFiles ); - } else if ( fileName.indexOf( 'frontend.scss' ) > -1 ) { + } else if ( isFrontendScssFile || isWidgetsScssFile ) { frontendScssFiles.push( fileFullPath ); } } ); @@ -141,13 +144,22 @@ class WidgetsCss { return frontendScssFiles; } - getWidgetDataFromPath( baseFolder, filePath ) { + getWidgetDataFromPath( baseFolder, filePath, isFrontendScssFile = false ) { // Removing base-folder and first slash so that the module name will be the first value in the path. filePath = filePath.replace( baseFolder, '' ).substring(1); + const getFileExtension = ( filePath ) => { + const match = filePath.match( /\.min\.css$/ ); + return match ? '.min.css' : path.extname( filePath ); + }; + + const widgetName = isFrontendScssFile + ? filePath.split( path.sep )[ 0 ] + : path.basename( filePath, getFileExtension( filePath ) ); + return { path: filePath.replace( /\\/g, '/' ), - name: filePath.split( path.sep )[ 0 ], + name: widgetName, }; } @@ -161,8 +173,9 @@ class WidgetsCss { const widgetsCssFilesList = this.getWidgetsCssFilesList(); widgetsCssFilesList.forEach( ( item ) => { - const widgetSourceFilePath = item.filePath, - fileContent = fs.readFileSync( widgetSourceFilePath ).toString(); + const cssFolder = path.resolve( __dirname, '../assets/css' ); + const widgetSourceFilePath = path.join( cssFolder, `${ item.cssFileName }.min.css` ); + const fileContent = fs.readFileSync( widgetSourceFilePath ).toString(); // Collecting all widgets .scss files that has @media queries in order to create templates files for custom breakpoints. if ( fileContent.indexOf( '@media' ) > -1 ) { diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000000..c8ac5a416219 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20.19 \ No newline at end of file diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000000..e8b2e6c751c1 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,13 @@ +{ + "recommendations": [ + "bmewburn.vscode-intelephense-client", + "wordpresstools.wordpress", + "ms-vscode.vscode-eslint", + "esbenp.prettier-vscode", + "orta.vscode-jest", + "eamodio.gitlens", + "streetsidesoftware.code-spell-checker", + "gruntfuggly.todo-tree", + "file-icons.file-icons" + ] +} \ No newline at end of file diff --git a/.wporg-assets/banner-1544x500.gif b/.wporg-assets/banner-1544x500.gif deleted file mode 100644 index 1db28e30668d..000000000000 Binary files a/.wporg-assets/banner-1544x500.gif and /dev/null differ diff --git a/.wporg-assets/banner-1544x500.png b/.wporg-assets/banner-1544x500.png new file mode 100644 index 000000000000..22c089bc0e3a Binary files /dev/null and b/.wporg-assets/banner-1544x500.png differ diff --git a/.wporg-assets/banner-772x250.gif b/.wporg-assets/banner-772x250.gif deleted file mode 100644 index 85de4c3e7ddd..000000000000 Binary files a/.wporg-assets/banner-772x250.gif and /dev/null differ diff --git a/.wporg-assets/banner-772x250.png b/.wporg-assets/banner-772x250.png new file mode 100644 index 000000000000..799810bab80b Binary files /dev/null and b/.wporg-assets/banner-772x250.png differ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000000..0786cc501b29 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,206 @@ +# Contributing + +A guide on how to get started contributing code to the Elementor plugin. + +Before diving into this repository, make sure you have a basic understanding of Elementor and its architecture. + +## Architecture +If you are interested in learning more about the architecture of Elementor, please refer to the documentation in the `docs/` directory. + +## Repository structure + +The repository is structured as follows: + +``` +@/elementor +├── app/ +│ ├── admin-menu-items/ +│ ├── assets/ +│ │ ├── js/ +│ │ └── styles/ +│ ├── modules/ +│ │ ├── import-export/ +│ │ ├── kit-library/ +│ │ ├── onboarding/ +│ │ └── site-editor/ +│ ├── app.php +│ └── view.php +├── core/ +│ ├── admin/ +│ ├── base/ +│ ├── editor/ +│ ├── frontend/ +│ ├── settings/ +│ ├── utils/ +│ └── ... +├── includes/ +│ ├── controls/ +│ ├── widgets/ +│ ├── managers/ +│ └── ... +├── modules/ (Feature modules) +│ ├── ai/ +│ ├── atomic-widgets/ +│ ├── floating-buttons/ +│ ├── global-classes/ +│ ├── nested-elements/ +│ └── ... +├── assets/ (Static assets) +│ ├── css/ +│ ├── js/ +│ ├── images/ +│ └── lib/ +├── packages/ (V4 packages) +│ ├── packages/ +│ ├── tests/ +│ └── package.json +├── tests/ (Test suites) +│ ├── playwright/ +│ ├── phpunit/ +│ ├── jest/ +│ └── qunit/ +├── docs/ (Documentation) +├── elementor.php (Main plugin file) +├── package.json +└── composer.json +``` + +## Development Setup + +To get started with development: + +1. Clone the repository +2. Install dependencies: +```bash +npm run prepare-environment +``` + +3. Start development: +```bash +npm run watch +``` + +This will start the development environment with file watching enabled. + +## Test, Lint & Build + +### Testing + +To run PHP tests: +```bash +npm run test:php +``` + +To run JavaScript tests: +```bash +npm run test:jest +``` + +To run Playwright end-to-end tests: +```bash +npm run start-local-server +npm run test:playwright +or +npm run test:playwright:* +``` + +### Linting + +You can run the linter by executing: +```bash +npm run lint +``` + +This command uses ESLint for JavaScript/TypeScript files and includes package linting. + +### Building + +To build the project for production: +```bash +npm run build +``` + +For development builds: +```bash +npm run start +``` + +To build packages: +```bash +npm run build:packages +``` + +## Development Commands + +- `npm run start` - Full build and setup (dev mode) +- `npm run watch` - Start development with file watching +- `npm run scripts` - Build JavaScript assets +- `npm run scripts:watch` - Watch JavaScript files +- `npm run styles` - Build CSS assets +- `npm run styles:watch` - Watch CSS files +- `npm run build:packages` - Build frontend packages +- `npm run build:tools` - Build development tools + +## Testing Environment Setup + +To set up the testing environment: +```bash +npm run setup:testing +``` + +To restart the testing environment: +```bash +npm run restart:testing +``` + +## Commit message conventions + +This repository uses [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), so please make sure to follow this convention to keep consistency in the repository. + +## Pull requests + +Maintainers merge pull requests by squashing all commits and editing the commit message if necessary using the GitHub user interface. + +Ensure you choose an appropriate commit message, and exercise caution when dealing with changes that may disrupt existing functionality. + +Additionally, remember to include tests for your modifications to ensure comprehensive coverage and maintain code quality. + +## Working with Packages + +The `packages/` directory contains frontend packages that can be developed separately: + +1. Navigate to the packages directory: +```bash +cd packages +``` + +2. Install dependencies: +```bash +npm ci +``` + +3. Start development: +```bash +npm run dev +``` + +When working on the main plugin with packages, use: +```bash +npm run watch +``` + +This will automatically handle package building and watching. + +## Code Quality + +- Follow WordPress coding standards +- Use meaningful commit messages +- Write tests for new features +- Update documentation when needed +- Ensure backward compatibility when possible + +## Getting Help + +- Check the `docs/` directory for detailed documentation +- Review existing code for patterns and conventions +- Ask questions in pull requests for clarification diff --git a/Gruntfile.js b/Gruntfile.js index f6c553320a33..596e7a94e335 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -70,10 +70,6 @@ module.exports = function( grunt ) { grunt.task.run( 'webpack:productionWatch' ); } ); - grunt.registerTask( 'scripts:packages', () => { - grunt.task.run( 'webpack:packages' ); - } ); - grunt.registerTask( 'styles', ( isDevMode = false ) => { if ( ! isDevMode ) { grunt.task.run( 'create_widgets_temp_scss_files' ); @@ -90,6 +86,7 @@ module.exports = function( grunt ) { } ); grunt.registerTask( 'watch_styles', () => { + grunt.task.run( 'styles' ); grunt.task.run( 'watch:styles' ); } ); diff --git a/app/app.php b/app/app.php index 4a186a88fbf0..bfced6bcf8c2 100644 --- a/app/app.php +++ b/app/app.php @@ -3,6 +3,7 @@ use Elementor\App\AdminMenuItems\Theme_Builder_Menu_Item; use Elementor\Core\Admin\Menu\Admin_Menu_Manager; +use Elementor\Core\Experiments\Manager as ExperimentsManager; use Elementor\Modules\WebCli\Module as WebCLIModule; use Elementor\Core\Base\App as BaseApp; use Elementor\Core\Settings\Manager as SettingsManager; @@ -11,6 +12,14 @@ use Elementor\User; use Elementor\Utils; use Elementor\Core\Utils\Promotions\Filtered_Promotions_Manager; +use Elementor\Core\Utils\Assets_Config_Provider; +use Elementor\Core\Utils\Collection; + +use Elementor\App\Modules\ImportExport\Module as ImportExportModule; +use Elementor\App\Modules\KitLibrary\Module as KitLibraryModule; +use Elementor\App\Modules\ImportExportCustomization\Module as ImportExportCustomizationModule; +use Elementor\App\Modules\SiteEditor\Module as SiteEditorModule; +use Elementor\App\Modules\Onboarding\Module as OnboardingModule; if ( ! defined( 'ABSPATH' ) ) { exit; // Exit if accessed directly. @@ -81,6 +90,8 @@ public function admin_init() { $this->enqueue_assets(); + remove_action( 'wp_print_styles', 'print_emoji_styles' ); + // Setup default heartbeat options // TODO: Enable heartbeat. add_filter( 'heartbeat_settings', function( $settings ) { @@ -148,9 +159,36 @@ private function enqueue_dark_theme_detection_script() { } } + private function register_packages() { + $assets_config_provider = ( new Assets_Config_Provider() ) + ->set_path_resolver( function ( $name ) { + return ELEMENTOR_ASSETS_PATH . "js/packages/{$name}/{$name}.asset.php"; + } ); + + Collection::make( [ 'ui', 'icons' ] ) + ->each( function( $package ) use ( $assets_config_provider ) { + $suffix = Utils::is_script_debug() ? '' : '.min'; + $config = $assets_config_provider->load( $package )->get( $package ); + + if ( ! $config ) { + return; + } + + wp_register_script( + $config['handle'], + ELEMENTOR_ASSETS_URL . "js/packages/{$package}/{$package}{$suffix}.js", + $config['deps'], + ELEMENTOR_VERSION, + true + ); + } ); + } + private function enqueue_assets() { Plugin::$instance->init_common(); + $this->register_packages(); + /** @var WebCLIModule $web_cli */ $web_cli = Plugin::$instance->modules_manager->get_modules( 'web-cli' ); $web_cli->register_scripts(); @@ -212,6 +250,8 @@ private function enqueue_assets() { [ 'wp-url', 'wp-i18n', + 'elementor-v2-ui', + 'elementor-v2-icons', 'react', 'react-dom', 'select2', @@ -242,17 +282,34 @@ public function enqueue_app_loader() { $this->print_config( 'elementor-app-loader' ); } + private function register_import_export_customization_experiment() { + Plugin::$instance->experiments->add_feature( [ + 'name' => 'import-export-customization', + 'title' => esc_html__( 'Import/Export Customization', 'elementor' ), + 'description' => esc_html__( 'Enable advanced customization options for import/export functionality.', 'elementor' ), + 'hidden' => true, + 'release_status' => ExperimentsManager::RELEASE_STATUS_ALPHA, + 'default' => ExperimentsManager::STATE_INACTIVE, + ] ); + } + public function __construct() { - $this->add_component( 'site-editor', new Modules\SiteEditor\Module() ); + $this->register_import_export_customization_experiment(); + + $this->add_component( 'site-editor', new SiteEditorModule() ); if ( current_user_can( 'manage_options' ) || Utils::is_wp_cli() ) { - $this->add_component( 'import-export', new Modules\ImportExport\Module() ); + $this->add_component( 'import-export', new ImportExportModule() ); + + if ( Plugin::$instance->experiments->is_feature_active( 'import-export-customization' ) ) { + $this->add_component( 'import-export-customization', new ImportExportCustomizationModule() ); + } // Kit library is depended on import-export - $this->add_component( 'kit-library', new Modules\KitLibrary\Module() ); + $this->add_component( 'kit-library', new KitLibraryModule() ); } - $this->add_component( 'onboarding', new Modules\Onboarding\Module() ); + $this->add_component( 'onboarding', new OnboardingModule() ); add_action( 'elementor/admin/menu/register', function ( Admin_Menu_Manager $admin_menu ) { $this->register_admin_menu( $admin_menu ); diff --git a/app/assets/js/app.scss b/app/assets/js/app.scss index 8abcef7b538e..a4de7e8370ba 100644 --- a/app/assets/js/app.scss +++ b/app/assets/js/app.scss @@ -22,10 +22,10 @@ position: fixed; height: 100%; width: 100%; + inset-inline-start: 0; background-color: var(--app-lightbox-background-color); z-index: $app-lightbox-z-index; bottom: 0; - @include start(0); } &__header { diff --git a/app/assets/js/hooks/use-cloud-kits-eligibility.js b/app/assets/js/hooks/use-cloud-kits-eligibility.js new file mode 100644 index 000000000000..52ef893433f9 --- /dev/null +++ b/app/assets/js/hooks/use-cloud-kits-eligibility.js @@ -0,0 +1,18 @@ +import { useQuery } from 'react-query'; +import { fetchCloudKitsEligibility } from '../utils/cloud-kits.js'; + +export const KEY = 'cloud-kits-availability'; + +/** + * Hook to check if cloud kits should be available + * + * @param {Object} options - React Query options + * @return {Object} Query result with availability status + */ +export default function useCloudKitsEligibility( options = {} ) { + return useQuery( + [ KEY ], + fetchCloudKitsEligibility, + options, + ); +} diff --git a/app/assets/js/index.js b/app/assets/js/index.js index 5a2d57658a02..0e0e91ff15b2 100644 --- a/app/assets/js/index.js +++ b/app/assets/js/index.js @@ -1,6 +1,7 @@ import ReactUtils from 'elementor-utils/react'; import App from './app'; import ImportExport from '../../modules/import-export/assets/js/module'; +import ImportExportCustomization from '../../modules/import-export-customization/assets/js/module'; import KitLibrary from '../../modules/kit-library/assets/js/module'; import Onboarding from '../../modules/onboarding/assets/js/module'; import { Module as SiteEditor } from '@elementor/site-editor'; @@ -12,6 +13,10 @@ new KitLibrary(); new SiteEditor(); new Onboarding(); +if ( elementorCommon?.config?.experimentalFeatures?.[ 'import-export-customization' ] ) { + new ImportExportCustomization(); +} + const AppWrapper = React.Fragment; ReactUtils.render( ( diff --git a/app/assets/js/layout/header.js b/app/assets/js/layout/header.js index 3ff9b7a3e0fd..8b53396cee50 100644 --- a/app/assets/js/layout/header.js +++ b/app/assets/js/layout/header.js @@ -22,7 +22,7 @@ export default function Header( props ) {

{ props.title }

- + ); } diff --git a/app/assets/js/layout/page.js b/app/assets/js/layout/page.js index a58f90d71947..23c6fcc275c1 100644 --- a/app/assets/js/layout/page.js +++ b/app/assets/js/layout/page.js @@ -28,7 +28,7 @@ export default function Page( props ) { return (
-
props.onClose?.() } /> +
{ AppSidebar() } diff --git a/app/assets/js/molecules/collapse.scss b/app/assets/js/molecules/collapse.scss index 13e241ae4771..bf98d5e959cc 100644 --- a/app/assets/js/molecules/collapse.scss +++ b/app/assets/js/molecules/collapse.scss @@ -20,8 +20,8 @@ $root: e-app-collapse; justify-content: center; font-size: type(size, "14"); position: absolute; - top: 50%; - @include end(var(--e-app-collapse-toggle-icon-spacing)); + inset-block-start: 50%; + inset-inline-end: var(--e-app-collapse-toggle-icon-spacing); transform: translateY(-50%); &:before { @@ -53,8 +53,8 @@ $root: e-app-collapse; .#{$root} { &-toggle { &__icon { - @include end(initial); - @include start(var(--e-app-collapse-toggle-icon-spacing)); + inset-inline-start: var(--e-app-collapse-toggle-icon-spacing); + inset-inline-end: initial; } } } diff --git a/app/assets/js/ui/atoms/text-field.js b/app/assets/js/ui/atoms/text-field.js index 02fd30fac5f8..7ea3c73c45cb 100644 --- a/app/assets/js/ui/atoms/text-field.js +++ b/app/assets/js/ui/atoms/text-field.js @@ -3,9 +3,14 @@ import { arrayToClassName } from '../../utils/utils'; import './text-field.scss'; export default function TextField( props ) { - const classNameBase = 'eps-text-field', - classes = [ classNameBase, props.className, { [ classNameBase + '--outlined' ]: 'outlined' === props.variant } ], - validProps = { ...props, className: arrayToClassName( classes ) }; + const classNameBase = 'eps-text-field'; + const classes = [ + classNameBase, + props.className, + { [ classNameBase + '--outlined' ]: 'outlined' === props.variant }, + { [ classNameBase + '--standard' ]: 'standard' === props.variant }, + ]; + const validProps = { ...props, className: arrayToClassName( classes ) }; if ( validProps.multiline ) { delete validProps.multiline; diff --git a/app/assets/js/ui/atoms/text-field.scss b/app/assets/js/ui/atoms/text-field.scss index ffff8dc5f568..2787c89ad00f 100644 --- a/app/assets/js/ui/atoms/text-field.scss +++ b/app/assets/js/ui/atoms/text-field.scss @@ -1,14 +1,25 @@ + +$eps-text-field-border-color: tints(300); +$eps-text-field-dark-border-color: dark-tints(700); +$eps-text-field-focus-border-color: tints(600); +$eps-text-field-focus-dark-border-color: dark-tints(300); + $eps-text-field-color: tints(600); $eps-text-field-dark-color: dark-tints(200); $eps-text-field-background-color: transparent; $eps-text-field-dark-background-color: transparent; -$eps-text-field-outlined-border-color: tints(300); -$eps-text-field-outlined-dark-border-color: dark-tints(700); -$eps-text-field-outlined-focus-border-color: tints(600); -$eps-text-field-outlined-focus-dark-border-color: dark-tints(300); +$eps-text-field-outlined-border-color: $eps-text-field-border-color; +$eps-text-field-outlined-dark-border-color: $eps-text-field-dark-border-color; +$eps-text-field-outlined-focus-border-color: $eps-text-field-focus-border-color; +$eps-text-field-outlined-focus-dark-border-color: $eps-text-field-focus-dark-border-color; $eps-text-field-placeholder-color: tints(500); $eps-text-field-placeholder-dark-color: dark-tints(300); +$eps-text-field-standard-border-color: $eps-text-field-border-color; +$eps-text-field-standard-dark-border-color: $eps-text-field-dark-border-color; +$eps-text-field-standard-focus-border-color: tints(600); +$eps-text-field-standard-focus-dark-border-color: $eps-text-field-focus-dark-border-color; + @mixin font-style { font-family: $eps-font-family; font-size: $eps-body-font-size; @@ -24,6 +35,9 @@ $root: eps-text-field; --eps-text-field-placeholder-color: #{$eps-text-field-placeholder-color}; --eps-text-field-outlined-border-color: #{$eps-text-field-outlined-border-color}; --eps-text-field-outlined-focus-border-color: #{$eps-text-field-outlined-focus-border-color}; + --eps-text-field-standard-border-color: #{$eps-text-field-standard-border-color}; + --eps-text-field-standard-focus-border-color: #{$eps-text-field-standard-focus-border-color}; + --border: #{$eps-border-width $eps-border-style var(--eps-text-field-outlined-border-color)}; width: 100%; color: var(--eps-text-field-color); @@ -35,7 +49,7 @@ $root: eps-text-field; &--outlined { border-radius: $eps-radius; - border: $eps-border-width $eps-border-style var(--eps-text-field-outlined-border-color); + border: var(--border); padding: spacing(10); &:focus { @@ -43,6 +57,16 @@ $root: eps-text-field; } } + &--standard { + padding-top: spacing(10); + padding-bottom: spacing(10); + border-bottom: var(--border); + + &:focus { + border-color: var(--eps-text-field-standard-focus-border-color); + } + } + &::placeholder { color: var(--eps-text-field-placeholder-color); @include font-style; @@ -65,6 +89,14 @@ $root: eps-text-field; border-color: var(--eps-text-field-outlined-focus-border-color); } } + + &--standard { + border-color: var(--eps-text-field-standard-border-color); + + &:focus { + border-color: var(--eps-text-field-standard-focus-border-color); + } + } } diff --git a/app/assets/js/ui/card/card.scss b/app/assets/js/ui/card/card.scss index bd6945d5500f..6b252e484919 100644 --- a/app/assets/js/ui/card/card.scss +++ b/app/assets/js/ui/card/card.scss @@ -55,13 +55,13 @@ object-fit: contain; object-position: top; position: absolute; - top: 0; - @include start(0); + inset-block-start: 0; + inset-inline-start: 0; } &__image-overlay { position: absolute; - top: 0; + inset-block-start: 0; background-color: var(--card-image-overlay-background-color); z-index: 1; width: 100%; diff --git a/app/assets/js/ui/dialog/dialog.scss b/app/assets/js/ui/dialog/dialog.scss index 97d29705287e..d0df98fe60a8 100644 --- a/app/assets/js/ui/dialog/dialog.scss +++ b/app/assets/js/ui/dialog/dialog.scss @@ -4,8 +4,8 @@ &__close-button { position: absolute; - top: spacing(44) * -1; - @include end(spacing(44) * -1); + inset-block-start: calc( spacing(44) * -1 ); + inset-inline-end: calc( spacing(44) * -1 ); margin-block-start: spacing(10); margin-inline-end: spacing(10); z-index: $eps-zindex-modal; diff --git a/app/assets/js/ui/menu/menu-item.scss b/app/assets/js/ui/menu/menu-item.scss index ae711b249983..78de61c50f87 100644 --- a/app/assets/js/ui/menu/menu-item.scss +++ b/app/assets/js/ui/menu/menu-item.scss @@ -57,8 +57,8 @@ $menu-item-action-button-dark-color: dark-tints(400); content: ''; display: block; position: absolute; - top: 0; - @include start(0); + inset-block-start: 0; + inset-inline-start: 0; height: 100%; width: var(--menu-item-pointer-width); background-color: $menu-item-pointer-color; diff --git a/app/assets/js/ui/modal/modal.scss b/app/assets/js/ui/modal/modal.scss index c28a16e39c8e..9b5bf4d0515f 100644 --- a/app/assets/js/ui/modal/modal.scss +++ b/app/assets/js/ui/modal/modal.scss @@ -53,10 +53,7 @@ $eps-dark-tip-background-color: dark-theme-colors(info-bg); background: $eps-modal-overlay; position: fixed; display: flex; - top: 0; - @include start(0); - width: 100%; - height: 100%; + inset: 0; align-items: center; justify-content: center; z-index: $eps-modal-zindex; diff --git a/app/assets/js/ui/molecules/inline-link.scss b/app/assets/js/ui/molecules/inline-link.scss index f755aef2bc7e..73e591ab35ee 100644 --- a/app/assets/js/ui/molecules/inline-link.scss +++ b/app/assets/js/ui/molecules/inline-link.scss @@ -8,6 +8,11 @@ $eps-inline-link-color-disabled: theme-colors(disabled); .eps-inline-link { color: var(--eps-inline-link-color); + // Reset style for button tag. + background-color: initial; + border: 0; + padding: 0; + &--color { &-primary { --eps-inline-link-color: #{$eps-inline-link-color-primary}; @@ -46,11 +51,6 @@ $eps-inline-link-color-disabled: theme-colors(disabled); font-style: italic; } - // Reset style for button tag. - background-color: initial; - border: 0; - padding: 0; - &, &:focus { outline: none; diff --git a/app/assets/js/ui/molecules/popover.js b/app/assets/js/ui/molecules/popover.js index a60f5d093de1..0714e5d8c6cd 100644 --- a/app/assets/js/ui/molecules/popover.js +++ b/app/assets/js/ui/molecules/popover.js @@ -4,10 +4,23 @@ import './popover.scss'; export default function Popover( props ) { + const getArrowPositionClass = () => { + switch ( props.arrowPosition ) { + case 'start': + return 'eps-popover--arrow-start'; + case 'end': + return 'eps-popover--arrow-end'; + case 'none': + return 'eps-popover--arrow-none'; + default: + return 'eps-popover--arrow-center'; + } + }; + return ( <>
-
    +
      { props.children }
    @@ -18,8 +31,10 @@ Popover.propTypes = { children: PropTypes.any.isRequired, className: PropTypes.string, closeFunction: PropTypes.func, + arrowPosition: PropTypes.oneOf( [ 'start', 'center', 'end', 'none' ] ), }; Popover.defaultProps = { className: '', + arrowPosition: 'center', }; diff --git a/app/assets/js/ui/molecules/popover.scss b/app/assets/js/ui/molecules/popover.scss index b222d3102576..17e3c0ae0f26 100644 --- a/app/assets/js/ui/molecules/popover.scss +++ b/app/assets/js/ui/molecules/popover.scss @@ -14,14 +14,11 @@ z-index: $eps-zindex-popover; margin-block-start: $eps-popover-arrow-height; transform: translateX(-50%); - @include start(px-to-rem(4)); + inset-inline-start: px-to-rem(4); &__background { position: fixed; - top: 0; - bottom: 0; - @include start(0); - @include end(0); + inset: 0; z-index: $eps-zindex-modal-backdrop; } @@ -36,8 +33,8 @@ width: $eps-popover-arrow-width; height: $eps-popover-arrow-height; margin: 0 $eps-popover-border-radius $eps-popover-arrow-height; - top: -$eps-popover-arrow-height; - @include start(50%); + inset-block-start: -$eps-popover-arrow-height; + inset-inline-start: 50%; transform: translateX(-50%); border-color: transparent; border-style: solid; @@ -45,6 +42,27 @@ border-block-end-color: var(--popover-arrow-color); } + &--arrow-start { + &::before { + inset-inline-start: #{$eps-popover-border-radius}; + transform: none; + } + } + + &--arrow-end { + &::before { + inset-inline-start: auto; + inset-inline-end: #{$eps-popover-border-radius}; + transform: none; + } + } + + &--arrow-none { + &::before { + display: none; + } + } + &__item { padding: $eps-popover-item-spacing-y $eps-popover-item-spacing-x; background-color: var(--popover-item-background-color); diff --git a/app/assets/js/ui/popover-dialog/popover-dialog.scss b/app/assets/js/ui/popover-dialog/popover-dialog.scss index b2400ddb771f..5d8ee4cc0344 100644 --- a/app/assets/js/ui/popover-dialog/popover-dialog.scss +++ b/app/assets/js/ui/popover-dialog/popover-dialog.scss @@ -13,8 +13,8 @@ &:before { content: ''; position: absolute; - top: -$triangle-size * 2; - @include end(var(--popover-arrow-offset-end, 22px)); + inset-block-start: -$triangle-size * 2; + inset-inline-end: var(--popover-arrow-offset-end, 22px); border: $triangle-size solid transparent; border-block-end-color: #fff; } diff --git a/app/assets/js/utils/cloud-kits.js b/app/assets/js/utils/cloud-kits.js new file mode 100644 index 000000000000..0bd0ac14650a --- /dev/null +++ b/app/assets/js/utils/cloud-kits.js @@ -0,0 +1,15 @@ +/** + * Fetch cloud kits availability from WordPress backend + * + * @return {Promise} Whether cloud kits should be available + */ +export async function fetchCloudKitsEligibility() { + const isCloudKitsExperimentActive = elementorCommon?.config?.experimentalFeatures?.[ 'cloud-library' ]; + if ( ! isCloudKitsExperimentActive ) { + return false; + } + + const response = await $e.data.get( 'cloud-kits/eligibility', {}, { refresh: true } ); + + return response?.data; +} diff --git a/app/assets/styles/_shame.scss b/app/assets/styles/_shame.scss index 58af65b5d6bc..a2125e121d23 100644 --- a/app/assets/styles/_shame.scss +++ b/app/assets/styles/_shame.scss @@ -11,8 +11,8 @@ iframe { position: absolute; - top: 0; - @include start(0); + inset-block-start: 0; + inset-inline-start: 0; width: 100%; height: 100%; } diff --git a/app/assets/styles/app-imports.scss b/app/assets/styles/app-imports.scss index f605baf691cd..39fb70f7f591 100644 --- a/app/assets/styles/app-imports.scss +++ b/app/assets/styles/app-imports.scss @@ -72,6 +72,7 @@ @import "../../modules/import-export/assets/js/ui/message-banner/message-banner.scss"; @import "../../modules/import-export/assets/js/pages/import/import-complete/components/connect-pro-notice/connect-pro-notice.scss"; @import "../../modules/import-export/assets/js/pages/import/import-complete/components/failed-plugins-notice/failed-plugins-notice.scss"; +@import "../../modules/import-export/assets/js/pages/export/export-plugins/components/export-plugins-footer/export-plugins-footer.scss"; // Onboarding @import "../../modules/onboarding/assets/scss/onboarding"; diff --git a/app/modules/import-export-customization/assets/js/export/components/export-complete-download-link.js b/app/modules/import-export-customization/assets/js/export/components/export-complete-download-link.js new file mode 100644 index 000000000000..c23ceeb2c157 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/components/export-complete-download-link.js @@ -0,0 +1,18 @@ +import { Typography, Link } from '@elementor/ui'; +import PropTypes from 'prop-types'; + +export default function ExportCompleteDownloadLink( { onDownloadClick } ) { + return ( + + { __( 'Is the automatic download not starting?', 'elementor' ) }{ ' ' } + + { __( 'Download manually', 'elementor' ) } + + { '. ' } + + ); +} + +ExportCompleteDownloadLink.propTypes = { + onDownloadClick: PropTypes.func.isRequired, +}; diff --git a/app/modules/import-export-customization/assets/js/export/components/export-complete-heading.js b/app/modules/import-export-customization/assets/js/export/components/export-complete-heading.js new file mode 100644 index 000000000000..1b7f6acc7491 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/components/export-complete-heading.js @@ -0,0 +1,36 @@ +import { Typography, Link, Box } from '@elementor/ui'; +import PropTypes from 'prop-types'; + +export default function ExportCompleteHeading( { isCloudExport } ) { + return ( + + + { isCloudExport + ? __( 'Your website template is now saved to the library!', 'elementor' ) + : __( 'Your .zip file is ready', 'elementor' ) + } + + + + { isCloudExport + ? ( + <> + { __( 'You can find it in the My Website Templates tab.', 'elementor' ) }{ ' ' } + + { __( 'Take me there', 'elementor' ) } + + + ) + : __( 'Once the download is complete, you can upload it to be used for other sites.', 'elementor' ) + } + + + ); +} + +ExportCompleteHeading.propTypes = { + isCloudExport: PropTypes.bool.isRequired, +}; diff --git a/app/modules/import-export-customization/assets/js/export/components/export-complete-icon.js b/app/modules/import-export-customization/assets/js/export/components/export-complete-icon.js new file mode 100644 index 000000000000..7e2c5ef90060 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/components/export-complete-icon.js @@ -0,0 +1,13 @@ +import { Box } from '@elementor/ui'; + +export default function ExportCompleteIcon() { + return ( + + + + ); +} diff --git a/app/modules/import-export-customization/assets/js/export/components/export-complete-summary.js b/app/modules/import-export-customization/assets/js/export/components/export-complete-summary.js new file mode 100644 index 000000000000..2b22bb1cf742 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/components/export-complete-summary.js @@ -0,0 +1,41 @@ +import { Box, Typography } from '@elementor/ui'; +import PropTypes from 'prop-types'; + +export default function ExportCompleteSummary( { kitInfo, includes } ) { + return ( + + + { kitInfo.title } + + + { kitInfo.description && ( + + { kitInfo.description } + + ) } + + + { __( 'Exported items:', 'elementor' ) } + + + { includes.map( ( item ) => { + const itemLabels = { + content: __( 'Content', 'elementor' ), + templates: __( 'Templates', 'elementor' ), + settings: __( 'Settings & configurations', 'elementor' ), + plugins: __( 'Plugins', 'elementor' ), + }; + return itemLabels[ item ] || item; + } ).join( ', ' ) } + + + ); +} + +ExportCompleteSummary.propTypes = { + kitInfo: PropTypes.shape( { + title: PropTypes.string, + description: PropTypes.string, + } ).isRequired, + includes: PropTypes.arrayOf( PropTypes.string ).isRequired, +}; diff --git a/app/modules/import-export-customization/assets/js/export/components/export-error.js b/app/modules/import-export-customization/assets/js/export/components/export-error.js new file mode 100644 index 000000000000..1a11f2997e9a --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/components/export-error.js @@ -0,0 +1,53 @@ +import { Box, Typography, Stack, Button } from '@elementor/ui'; +import PropTypes from 'prop-types'; +import { XIcon } from '../../shared/components/icons'; + +const HELP_URL = 'https://go.elementor.com/app-import-download-failed'; + +export default function ExportError( { statusText } ) { + const handleTryAgain = () => { + window.location.href = elementorAppConfig.base_url + '#/export-customization/'; + }; + + const handleLearnMore = () => { + window.open( HELP_URL, '_blank' ); + }; + + return ( + <> + + + + + + { statusText } + + + + { __( 'We couldn\'t complete the export. Please try again, and if the problem persists, check our help guide for troubleshooting steps.', 'elementor' ) } + + + + + + + + ); +} + +ExportError.propTypes = { + statusText: PropTypes.string.isRequired, +}; diff --git a/app/modules/import-export-customization/assets/js/export/components/export-intro.js b/app/modules/import-export-customization/assets/js/export/components/export-intro.js new file mode 100644 index 000000000000..5fe4e228da43 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/components/export-intro.js @@ -0,0 +1,17 @@ +import { Box, Typography, Link } from '@elementor/ui'; + +export default function ExportIntro() { + return ( + + + { __( 'Export a Website template?', 'elementor' ) } + + + { __( 'Choose which Elementor components - templates, content and site settings - to include in your website templates file. By default, all of your components will be exported.', 'elementor' ) }{ ' ' } + + { __( 'Learn more', 'elementor' ) } + + + + ); +} diff --git a/app/modules/import-export-customization/assets/js/export/components/export-kit-footer.js b/app/modules/import-export-customization/assets/js/export/components/export-kit-footer.js new file mode 100644 index 000000000000..7f272fdee79a --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/components/export-kit-footer.js @@ -0,0 +1,152 @@ +import { useRef, useEffect } from 'react'; +import { Button, Stack, CircularProgress } from '@elementor/ui'; + +import useCloudKitsEligibility from 'elementor-app/hooks/use-cloud-kits-eligibility'; +import useConnectState from '../../shared/hooks/use-connect-state'; +import { useExportContext, EXPORT_STATUS } from '../context/export-context'; + +export default function ExportKitFooter() { + const connectButtonRef = useRef(); + const { isConnected, isConnecting, setConnecting, handleConnectSuccess, handleConnectError } = useConnectState(); + const { dispatch, isTemplateNameValid } = useExportContext(); + + const { data: cloudKitsData, isLoading: isCheckingEligibility, refetch: refetchEligibility } = useCloudKitsEligibility( { + enabled: isConnected, + } ); + + const isCloudKitsEligible = cloudKitsData?.is_eligible || false; + + useEffect( () => { + if ( ! connectButtonRef.current ) { + return; + } + + jQuery( connectButtonRef.current ).elementorConnect( { + popup: { + width: 600, + height: 700, + }, + success: () => { + handleConnectSuccess(); + setConnecting( true ); + refetchEligibility(); + }, + error: () => { + handleConnectError(); + }, + } ); + }, [ handleConnectSuccess, handleConnectError, setConnecting, refetchEligibility ] ); + + useEffect( () => { + if ( ! isConnecting || isCheckingEligibility ) { + return; + } + + if ( ! isCloudKitsEligible ) { + window.location.href = elementorAppConfig.base_url + '#/kit-library/cloud'; + } else { + dispatch( { type: 'SET_KIT_SAVE_SOURCE', payload: 'cloud' } ); + dispatch( { type: 'SET_EXPORT_STATUS', payload: EXPORT_STATUS.EXPORTING } ); + window.location.href = elementorAppConfig.base_url + '#/export-customization/process'; + } + }, [ isConnecting, isCheckingEligibility, isCloudKitsEligible, dispatch ] ); + + useEffect( () => { + if ( isConnecting && ! isCheckingEligibility ) { + setConnecting( false ); + } + }, [ isConnecting, isCheckingEligibility, setConnecting ] ); + + const handleUpgradeClick = () => { + window.location.href = elementorAppConfig.base_url + '#/kit-library/cloud'; + }; + + const handleUploadClick = () => { + dispatch( { type: 'SET_KIT_SAVE_SOURCE', payload: 'cloud' } ); + dispatch( { type: 'SET_EXPORT_STATUS', payload: EXPORT_STATUS.EXPORTING } ); + window.location.href = elementorAppConfig.base_url + '#/export-customization/process'; + }; + + const handleExportAsZip = () => { + dispatch( { type: 'SET_KIT_SAVE_SOURCE', payload: 'file' } ); + dispatch( { type: 'SET_EXPORT_STATUS', payload: EXPORT_STATUS.EXPORTING } ); + window.location.href = elementorAppConfig.base_url + '#/export-customization/process'; + }; + + const renderSaveToLibraryButton = () => { + if ( ! isConnected ) { + return ( + + ); + } + + if ( isConnecting || isCheckingEligibility ) { + return ( + + ); + } + + if ( ! isCloudKitsEligible ) { + return ( + + ); + } + + return ( + + ); + }; + + return ( + + { renderSaveToLibraryButton() } + + + ); +} diff --git a/app/modules/import-export-customization/assets/js/export/components/export-processing.js b/app/modules/import-export-customization/assets/js/export/components/export-processing.js new file mode 100644 index 000000000000..d62504eaa574 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/components/export-processing.js @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types'; +import { Typography, CircularProgress } from '@elementor/ui'; + +export default function ExportProcessing( { statusText } ) { + return ( + <> + + + { statusText } + + + { __( 'This usually takes a few moments.', 'elementor' ) } +
    + { __( 'Don\'t close this window until the process is finished.', 'elementor' ) } +
    + + ); +} + +ExportProcessing.propTypes = { + statusText: PropTypes.string.isRequired, +}; diff --git a/app/modules/import-export-customization/assets/js/export/components/kit-content.js b/app/modules/import-export-customization/assets/js/export/components/kit-content.js new file mode 100644 index 000000000000..6806968791cd --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/components/kit-content.js @@ -0,0 +1,49 @@ +import { useState } from 'react'; +import { Box, Typography, Stack, Checkbox, FormControlLabel, Button } from '@elementor/ui'; + +import kitContentData from '../../shared/kit-content-data'; +import { useExportContext } from '../context/export-context'; + +export default function KitContent() { + const { data, dispatch } = useExportContext(); + const [ activeDialog, setActiveDialog ] = useState( null ); + + const handleCheckboxChange = ( itemType ) => { + const isChecked = data.includes.includes( itemType ); + const actionType = isChecked ? 'REMOVE_INCLUDE' : 'ADD_INCLUDE'; + dispatch( { type: actionType, payload: itemType } ); + }; + + return ( + + { kitContentData.map( ( item ) => ( + <> + + + + handleCheckboxChange( item.type ) } + sx={ { py: 0 } } + /> + } + label={ { item.data.title } } + /> + + { item.data.features.open.join( ', ' ) } + + + + + + { item.dialog && setActiveDialog( null ) } /> } + + ) ) } + + ); +} diff --git a/app/modules/import-export-customization/assets/js/export/components/kit-info.js b/app/modules/import-export-customization/assets/js/export/components/kit-info.js new file mode 100644 index 000000000000..24e9b40ebbd4 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/components/kit-info.js @@ -0,0 +1,40 @@ +import { Typography, Input, Box } from '@elementor/ui'; + +import { useExportContext } from '../context/export-context'; + +export default function KitInfo() { + const { data, dispatch } = useExportContext(); + + const { templateName, description } = { + templateName: data.kitInfo.title || '', + description: data.kitInfo.description || '', + }; + + return ( + + + { __( 'Website template name', 'elementor' ) } * + + dispatch( { type: 'SET_KIT_TITLE', payload: e.target.value || '' } ) } + placeholder={ __( 'Type name here...', 'elementor' ) } + inputProps={ { maxLength: 75 } } + sx={ { mb: 2 } } + /> + + + { __( 'Description (Optional)', 'elementor' ) } + + dispatch( { type: 'SET_KIT_DESCRIPTION', payload: e.target.value || '' } ) } + placeholder={ __( 'Type description here...', 'elementor' ) } + /> + + ); +} diff --git a/app/modules/import-export-customization/assets/js/export/components/kit-settings-customization-dialog.js b/app/modules/import-export-customization/assets/js/export/components/kit-settings-customization-dialog.js new file mode 100644 index 000000000000..7166b42aa40d --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/components/kit-settings-customization-dialog.js @@ -0,0 +1,226 @@ +import { + Dialog, + DialogHeader, + DialogTitle, + DialogContent, + DialogActions, + Button, + Box, + Typography, + Switch, + Stack, +} from '@elementor/ui'; +import { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { useExportContext } from '../context/export-context'; + +export default function KitSettingsCustomizationDialog( { open, handleClose } ) { + const { data, dispatch } = useExportContext(); + + const initialState = data.includes.includes( 'settings' ); + + const [ settings, setSettings ] = useState( () => { + if ( data.customization.settings ) { + return data.customization.settings; + } + + return { + theme: initialState, + globalColors: initialState, + globalFonts: initialState, + themeStyleSettings: initialState, + generalSettings: initialState, + experiments: initialState, + }; + } ); + + useEffect( () => { + if ( open ) { + if ( data.customization.settings ) { + setSettings( data.customization.settings ); + } else { + setSettings( { + theme: initialState, + globalColors: initialState, + globalFonts: initialState, + themeStyleSettings: initialState, + generalSettings: initialState, + experiments: initialState, + } ); + } + } + }, [ open, data.customization.settings, initialState ] ); + + const handleToggleChange = ( settingKey ) => { + setSettings( ( prev ) => ( { + ...prev, + [ settingKey ]: ! prev[ settingKey ], + } ) ); + }; + + const handleSaveChanges = () => { + const hasEnabledSettings = Object.values( settings ).some( ( value ) => value ); + + dispatch( { + type: 'SET_CUSTOMIZATION', + payload: { + key: 'settings', + value: settings, + }, + } ); + + if ( hasEnabledSettings ) { + dispatch( { type: 'ADD_INCLUDE', payload: 'settings' } ); + } else { + dispatch( { type: 'REMOVE_INCLUDE', payload: 'settings' } ); + } + + handleClose(); + }; + + const SettingSection = ( { title, description, children, hasToggle = true, settingKey } ) => ( + + + + + { title } + + { description && ( + + { description } + + ) } + + { hasToggle && ( + handleToggleChange( settingKey ) } + color="info" + size="medium" + sx={ { alignSelf: 'center' } } + /> + ) } + + { children && ( + + { children } + + ) } + + ); + + SettingSection.propTypes = { + title: PropTypes.string.isRequired, + description: PropTypes.string, + children: PropTypes.node, + hasToggle: PropTypes.bool, + settingKey: PropTypes.string, + }; + + const SubSetting = ( { label, settingKey } ) => ( + + + { label } + + handleToggleChange( settingKey ) } + color="info" + size="medium" + /> + + ); + + SubSetting.propTypes = { + label: PropTypes.string.isRequired, + settingKey: PropTypes.string.isRequired, + }; + + return ( + + + + { __( 'Edit settings & configurations', 'elementor' ) } + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +KitSettingsCustomizationDialog.propTypes = { + open: PropTypes.bool.isRequired, + handleClose: PropTypes.func.isRequired, +}; diff --git a/app/modules/import-export-customization/assets/js/export/context/export-context.js b/app/modules/import-export-customization/assets/js/export/context/export-context.js new file mode 100644 index 000000000000..27c9eae25704 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/context/export-context.js @@ -0,0 +1,108 @@ +import { createContext, useContext, useReducer } from 'react'; +import PropTypes from 'prop-types'; + +export const ExportContext = createContext(); + +export const EXPORT_STATUS = { + PENDING: 'PENDING', + EXPORTING: 'EXPORTING', + COMPLETED: 'COMPLETED', +}; + +const initialState = { + downloadUrl: '', + exportedData: null, + exportStatus: EXPORT_STATUS.PENDING, + plugins: [], + customization: { + settings: null, + templates: null, + content: null, + plugins: null, + }, + includes: [ 'content', 'templates', 'settings', 'plugins' ], // All items selected by default + kitInfo: { + title: null, + description: null, + source: null, + }, +}; + +function exportReducer( state, { type, payload } ) { + switch ( type ) { + case 'SET_DOWNLOAD_URL': + return { ...state, downloadUrl: payload }; + case 'SET_EXPORTED_DATA': + return { ...state, exportedData: payload }; + case 'SET_PLUGINS': + return { ...state, plugins: payload }; + case 'SET_EXPORT_STATUS': + return { ...state, exportStatus: payload }; + case 'SET_KIT_TITLE': + return { ...state, kitInfo: { ...state.kitInfo, title: payload } }; + case 'SET_KIT_DESCRIPTION': + return { ...state, kitInfo: { ...state.kitInfo, description: payload } }; + case 'SET_KIT_SAVE_SOURCE': + return { ...state, kitInfo: { ...state.kitInfo, source: payload } }; + case 'ADD_INCLUDE': + return { + ...state, + includes: state.includes.includes( payload ) + ? state.includes + : [ ...state.includes, payload ], + }; + case 'REMOVE_INCLUDE': + return { + ...state, + includes: state.includes.filter( ( item ) => item !== payload ), + // Clear customization when removing from includes + customization: { + ...state.customization, + [ payload ]: null, + }, + }; + case 'SET_CUSTOMIZATION': + return { + ...state, + customization: { + ...state.customization, + [ payload.key ]: payload.value, + }, + }; + default: + return state; + } +} + +export function ExportContextProvider( { children } ) { + const [ data, dispatch ] = useReducer( exportReducer, initialState ); + + const value = { + data, + dispatch, + isTemplateNameValid: ( data.kitInfo.title?.trim() || '' ).length > 0, + isExporting: data.exportStatus === EXPORT_STATUS.EXPORTING, + isCompleted: data.exportStatus === EXPORT_STATUS.COMPLETED, + isPending: data.exportStatus === EXPORT_STATUS.PENDING, + }; + + return ( + + { children } + + ); +} + +ExportContextProvider.propTypes = { + children: PropTypes.node.isRequired, +}; + +export function useExportContext() { + const context = useContext( ExportContext ); + + if ( ! context ) { + throw new Error( 'useExportContext must be used within an ExportContextProvider' ); + } + + return context; +} diff --git a/app/modules/import-export-customization/assets/js/export/export.js b/app/modules/import-export-customization/assets/js/export/export.js new file mode 100644 index 000000000000..026ae344bba5 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/export.js @@ -0,0 +1,33 @@ +import { LocationProvider, Router } from '@reach/router'; +import router from '@elementor/router'; +import { ExportContextProvider } from './context/export-context'; +import { QueryClientProvider, QueryClient } from 'react-query'; +import ExportKit from './pages/export-kit'; +import ExportProcess from './pages/export-process'; +import ExportComplete from './pages/export-complete'; + +const queryClient = new QueryClient( { + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + staleTime: 1000 * 60 * 30, // 30 minutes + }, + }, +} ); + +export default function Export() { + return ( + + + + + + + + + + + + ); +} diff --git a/app/modules/import-export-customization/assets/js/export/hooks/use-export-kit.js b/app/modules/import-export-customization/assets/js/export/hooks/use-export-kit.js new file mode 100644 index 000000000000..940490e03cd1 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/hooks/use-export-kit.js @@ -0,0 +1,90 @@ +import { useState, useEffect, useCallback } from 'react'; +import { generateScreenshot } from '../utils/screenshot'; +import { EXPORT_STATUS } from '../context/export-context'; + +const STATUS_PROCESSING = 'processing'; +const STATUS_ERROR = 'error'; + +export const useExportKit = ( { includes, kitInfo, customization, isExporting, dispatch } ) => { + const [ status, setStatus ] = useState( STATUS_PROCESSING ); + + const exportKit = useCallback( async () => { + try { + setStatus( STATUS_PROCESSING ); + + const exportData = { + kitInfo: { + title: kitInfo.title?.trim() || null, + description: kitInfo.description?.trim() || null, + source: kitInfo.source, + }, + include: includes, + customization, + }; + + const isCloudKitFeatureActive = elementorCommon?.config?.experimentalFeatures?.[ 'cloud-library' ]; + const isCloudExport = 'cloud' === kitInfo.source; + + if ( isCloudKitFeatureActive && isCloudExport ) { + const screenshot = await generateScreenshot(); + exportData.screenShotBlob = screenshot; + } + + const baseUrl = elementorAppConfig[ 'import-export-customization' ].restApiBaseUrl; + const exportUrl = `${ baseUrl }/export`; + + const response = await fetch( exportUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': window.wpApiSettings?.nonce || '', + }, + body: JSON.stringify( exportData ), + } ); + + const result = await response.json(); + + if ( ! response.ok ) { + const errorMessage = result?.data?.message || `HTTP error! with the following code: ${ result?.data?.code }`; + throw new Error( errorMessage ); + } + + const isExportLocal = 'file' === kitInfo.source && result.data && result.data.file; + const isExportToCloud = 'cloud' === kitInfo.source && result.data && result.data.kit; + + if ( isExportLocal ) { + const exportedData = { + file: result.data.file, // This is base64 encoded file data + manifest: result.data.manifest, + }; + + dispatch( { type: 'SET_EXPORTED_DATA', payload: exportedData } ); + } else if ( isExportToCloud ) { + const exportedData = { + kit: result.data.kit, + }; + + dispatch( { type: 'SET_EXPORTED_DATA', payload: exportedData } ); + } else { + throw new Error( 'Invalid response format from server' ); + } + + dispatch( { type: 'SET_EXPORT_STATUS', payload: EXPORT_STATUS.COMPLETED } ); + window.location.href = elementorAppConfig.base_url + '#/export-customization/complete'; + } catch ( error ) { + setStatus( STATUS_ERROR ); + } + }, [ includes, kitInfo, customization, dispatch ] ); + + useEffect( () => { + if ( isExporting ) { + exportKit(); + } + }, [ isExporting, exportKit ] ); + + return { + status, + STATUS_PROCESSING, + STATUS_ERROR, + }; +}; diff --git a/app/modules/import-export-customization/assets/js/export/index.js b/app/modules/import-export-customization/assets/js/export/index.js new file mode 100644 index 000000000000..eacc207a7244 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/index.js @@ -0,0 +1 @@ +export { default } from './export'; diff --git a/app/modules/import-export-customization/assets/js/export/pages/export-complete.js b/app/modules/import-export-customization/assets/js/export/pages/export-complete.js new file mode 100644 index 000000000000..ae8d08860d3a --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/pages/export-complete.js @@ -0,0 +1,112 @@ +import { useEffect, useRef } from 'react'; +import { Redirect } from '@reach/router'; +import { Button, Stack } from '@elementor/ui'; +import { BaseLayout, TopBar, Footer, PageHeader, CenteredContent } from '../../shared/components'; +import { useExportContext } from '../context/export-context'; +import ExportCompleteSummary from '../components/export-complete-summary'; +import ExportCompleteIcon from '../components/export-complete-icon'; +import ExportCompleteHeading from '../components/export-complete-heading'; +import ExportCompleteDownloadLink from '../components/export-complete-download-link'; + +const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g; + +export default function ExportComplete() { + const { data, isCompleted } = useExportContext(); + const { exportedData, kitInfo } = data; + const downloadLink = useRef( null ); + + const downloadFile = ( event ) => { + event?.preventDefault(); + + if ( ! downloadLink.current ) { + const link = document.createElement( 'a' ); + + const defaultKitName = 'elementor-kit'; + const kitName = kitInfo.title || defaultKitName; + const sanitizedKitName = kitName + .replace( INVALID_FILENAME_CHARS, '' ) + .trim(); + + const fileName = sanitizedKitName || defaultKitName; + + link.href = 'data:application/zip;base64,' + exportedData.file; + link.download = fileName + '.zip'; + + downloadLink.current = link; + } + + downloadLink.current.click(); + }; + + useEffect( () => { + if ( 'cloud' !== kitInfo.source && exportedData?.file ) { + downloadFile(); + } + }, [ exportedData, kitInfo.source, downloadFile ] ); + + const handleDone = () => { + window.top.location = elementorAppConfig.admin_url; + }; + + if ( ! isCompleted ) { + return ; + } + + const isCloudExport = 'cloud' === kitInfo.source; + + const footerContent = ( + + { isCloudExport ? ( + + ) : ( + + ) } + + ); + + const headerContent = ( + + ); + + return ( + { headerContent } } + footer={
    { footerContent }
    } + > + + + + + + + + + { ! isCloudExport && ( + + ) } + + +
    + ); +} diff --git a/app/modules/import-export-customization/assets/js/export/pages/export-kit.js b/app/modules/import-export-customization/assets/js/export/pages/export-kit.js new file mode 100644 index 000000000000..a574c232d9a0 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/pages/export-kit.js @@ -0,0 +1,28 @@ +import { Box } from '@elementor/ui'; + +import { BaseLayout, TopBar, Footer, PageHeader } from '../../shared/components'; +import ExportIntro from '../components/export-intro'; +import ExportKitFooter from '../components/export-kit-footer'; +import KitContent from '../components/kit-content'; +import KitInfo from '../components/kit-info'; + +export default function ExportKit() { + const footerContent = ; + + const headerContent = ( + + ); + + return ( + { headerContent } } + footer={
    { footerContent }
    } + > + + + + + +
    + ); +} diff --git a/app/modules/import-export-customization/assets/js/export/pages/export-process.js b/app/modules/import-export-customization/assets/js/export/pages/export-process.js new file mode 100644 index 000000000000..0bb20b8c4aa7 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/pages/export-process.js @@ -0,0 +1,52 @@ +import { Redirect } from '@reach/router'; +import { Stack } from '@elementor/ui'; +import { BaseLayout, TopBar, PageHeader, CenteredContent } from '../../shared/components'; +import { useExportContext } from '../context/export-context'; +import { useExportKit } from '../hooks/use-export-kit'; +import ExportProcessing from '../components/export-processing'; +import ExportError from '../components/export-error'; + +export default function ExportProcess() { + const { data, dispatch, isExporting, isPending } = useExportContext(); + const { kitInfo, includes, customization } = data; + + const { status, STATUS_PROCESSING, STATUS_ERROR } = useExportKit( { + includes, + kitInfo, + customization, + isExporting, + dispatch, + } ); + + if ( isPending ) { + return ; + } + + const getStatusText = () => { + if ( status === STATUS_PROCESSING ) { + return __( 'Setting up your website template...', 'elementor' ); + } + + return __( 'Export failed', 'elementor' ); + }; + + const headerContent = ( + + ); + + return ( + { headerContent } }> + + + { status === STATUS_PROCESSING && ( + + ) } + + { status === STATUS_ERROR && ( + + ) } + + + + ); +} diff --git a/app/modules/import-export-customization/assets/js/export/utils/screenshot.js b/app/modules/import-export-customization/assets/js/export/utils/screenshot.js new file mode 100644 index 000000000000..ab4463326a31 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/export/utils/screenshot.js @@ -0,0 +1,25 @@ +export const generateScreenshot = () => { + return new Promise( ( resolve ) => { + const iframe = document.createElement( 'iframe' ); + iframe.style = 'visibility: hidden;'; + iframe.width = '1200'; + iframe.height = '1000'; + + const messageHandler = ( event ) => { + if ( 'kit-screenshot-done' === event.data.name ) { + window.removeEventListener( 'message', messageHandler ); + document.body.removeChild( iframe ); + resolve( event.data.imageUrl || null ); + } + }; + + window.addEventListener( 'message', messageHandler ); + + const previewUrl = new URL( window.location.origin ); + previewUrl.searchParams.set( 'kit_thumbnail', '1' ); + previewUrl.searchParams.set( 'nonce', elementorAppConfig[ 'import-export-customization' ].kitPreviewNonce ); + + document.body.appendChild( iframe ); + iframe.src = previewUrl.toString(); + } ); +}; diff --git a/app/modules/import-export-customization/assets/js/import/components/drop-zone.js b/app/modules/import-export-customization/assets/js/import/components/drop-zone.js new file mode 100644 index 000000000000..0b72188daddd --- /dev/null +++ b/app/modules/import-export-customization/assets/js/import/components/drop-zone.js @@ -0,0 +1,151 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Box, Typography, CircularProgress, Stack, Link, styled } from '@elementor/ui'; +import { UploadIcon } from '@elementor/icons'; +import { __ } from '@wordpress/i18n'; +import useDropZone from '../hooks/use-drop-zone'; +import { getAcceptedFileTypes } from '../utils/file-validation'; + +const StyledDropZoneBox = styled( Box, { + shouldForwardProp: ( prop ) => ! [ 'isDragOver', 'isLoading' ].includes( prop ), +} )( ( { theme, isDragOver, isLoading } ) => ( { + border: 2, + borderColor: isDragOver ? theme.palette.primary.main : theme.palette.divider, + borderStyle: 'dashed', + backgroundColor: isDragOver ? theme.palette.action.hover : theme.palette.background.default, + transition: 'all 0.2s ease-in-out', + cursor: isLoading ? 'not-allowed' : 'normal', + position: 'relative', + borderRadius: theme.spacing( 0.5 ), +} ) ); + +const StyledContentStack = styled( Stack, { + shouldForwardProp: ( prop ) => prop !== 'isLoading', +} )( ( { isLoading } ) => ( { + opacity: isLoading ? 0.5 : 1, + transition: 'opacity 0.2s ease-in-out', +} ) ); + +const DropZone = ( { + onFileSelect, + onError = () => {}, + filetypes = [ 'application/zip' ], + className = '', + isLoading = false, + error, + onButtonClick, + onFileChoose, + helperText = __( 'Upload a .zip file', 'elementor' ), + ...props +} ) => { + const { + isDragOver, + fileInputRef, + fileInputId, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleFileInputChange, + handleUploadClick, + } = useDropZone( { + onFileSelect, + onError, + filetypes, + isLoading, + onButtonClick, + onFileChoose, + } ); + + return ( + + + + + { isLoading ? ( + + ) : ( + + ) } + + + + + + { __( 'Click to upload', 'elementor' ) } + + + { __( 'or drag and drop', 'elementor' ) } + + + + { error ? error.message : helperText } + + + + + + + + ); +}; + +DropZone.propTypes = { + className: PropTypes.string, + onFileSelect: PropTypes.func.isRequired, + onError: PropTypes.func, + filetypes: PropTypes.array, + isLoading: PropTypes.bool, + error: PropTypes.shape( { + message: PropTypes.string.isRequired, + } ), + onButtonClick: PropTypes.func, + onFileChoose: PropTypes.func, + helperText: PropTypes.string, +}; + +export default DropZone; diff --git a/app/modules/import-export-customization/assets/js/import/hooks/use-drop-zone.js b/app/modules/import-export-customization/assets/js/import/hooks/use-drop-zone.js new file mode 100644 index 000000000000..0419604dcca2 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/import/hooks/use-drop-zone.js @@ -0,0 +1,115 @@ +import { useState, useRef, useId } from 'react'; +import { __ } from '@wordpress/i18n'; +import { isValidFileType } from '../utils/file-validation'; + +const useDropZone = ( { onFileSelect, onError, filetypes, isLoading, onButtonClick, onFileChoose } ) => { + const [ isDragOver, setIsDragOver ] = useState( false ); + const [ , setDragCounter ] = useState( 0 ); + const fileInputRef = useRef( null ); + const fileInputId = useId(); + + const handleDragEnter = ( e ) => { + e.preventDefault(); + e.stopPropagation(); + + setDragCounter( ( prev ) => prev + 1 ); + + if ( e.dataTransfer.items && e.dataTransfer.items.length > 0 ) { + setIsDragOver( true ); + } + }; + + const handleDragLeave = ( e ) => { + e.preventDefault(); + e.stopPropagation(); + + setDragCounter( ( prev ) => { + const newCounter = prev - 1; + if ( newCounter <= 0 ) { + setIsDragOver( false ); + } + return newCounter; + } ); + }; + + const handleDragOver = ( e ) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = ( e ) => { + e.preventDefault(); + e.stopPropagation(); + + setIsDragOver( false ); + setDragCounter( 0 ); + + if ( isLoading ) { + return; + } + + if ( ! e.dataTransfer.files || 0 === e.dataTransfer.files.length ) { + return; + } + + const file = e.dataTransfer.files[ 0 ]; + + if ( file && isValidFileType( file.type, file.name, filetypes ) ) { + onFileSelect( file, e, 'drop' ); + } else { + onError( { + id: 'file_not_allowed', + message: __( 'This file type is not allowed', 'elementor' ), + } ); + } + }; + + const handleFileInputChange = ( e ) => { + if ( ! e.target.files || 0 === e.target.files.length ) { + return; + } + + const file = e.target.files[ 0 ]; + if ( ! file ) { + return; + } + + if ( ! isValidFileType( file.type, file.name, filetypes ) ) { + onError( { + id: 'file_not_allowed', + message: __( 'This file type is not allowed', 'elementor' ), + } ); + return; + } + + onFileSelect( file, e, 'browse' ); + + if ( onFileChoose ) { + onFileChoose( file ); + } + }; + + const handleUploadClick = ( e ) => { + if ( onButtonClick ) { + onButtonClick( e ); + } + + if ( fileInputRef.current ) { + fileInputRef.current.click(); + } + }; + + return { + isDragOver, + fileInputRef, + fileInputId, + handleDragEnter, + handleDragLeave, + handleDragOver, + handleDrop, + handleFileInputChange, + handleUploadClick, + }; +}; + +export default useDropZone; diff --git a/app/modules/import-export-customization/assets/js/import/utils/file-validation.js b/app/modules/import-export-customization/assets/js/import/utils/file-validation.js new file mode 100644 index 000000000000..3f909c4e1a29 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/import/utils/file-validation.js @@ -0,0 +1,60 @@ +const getMimeTypeToExtensionMap = () => { + return { + 'application/zip': [ 'zip' ], + 'application/json': [ 'json' ], + 'application/pdf': [ 'pdf' ], + 'text/plain': [ 'txt' ], + 'image/jpeg': [ 'jpg', 'jpeg' ], + 'image/png': [ 'png' ], + 'image/gif': [ 'gif' ], + 'text/csv': [ 'csv' ], + 'application/xml': [ 'xml' ], + 'text/xml': [ 'xml' ], + }; +}; + +const getValidExtensions = ( filetypes ) => { + const mimeToExtMap = getMimeTypeToExtensionMap(); + const validExtensions = []; + + filetypes.forEach( ( mimeType ) => { + if ( mimeToExtMap[ mimeType ] ) { + validExtensions.push( ...mimeToExtMap[ mimeType ] ); + } + } ); + + return validExtensions; +}; + +const isValidFileType = ( fileType, fileName = '', filetypes = [] ) => { + if ( 0 === filetypes.length ) { + return true; + } + + if ( filetypes.includes( fileType ) ) { + return true; + } + + const extension = fileName.toLowerCase().split( '.' ).pop(); + const validExtensions = getValidExtensions( filetypes ); + + return validExtensions.includes( extension ); +}; + +const getAcceptedFileTypes = ( filetypes ) => { + const acceptTypes = [ ...filetypes ]; + const validExtensions = getValidExtensions( filetypes ); + + validExtensions.forEach( ( ext ) => { + acceptTypes.push( `.${ ext }` ); + } ); + + return acceptTypes.join( ',' ); +}; + +export { + getMimeTypeToExtensionMap, + getValidExtensions, + isValidFileType, + getAcceptedFileTypes, +}; diff --git a/app/modules/import-export-customization/assets/js/module.js b/app/modules/import-export-customization/assets/js/module.js new file mode 100644 index 000000000000..955a587e8feb --- /dev/null +++ b/app/modules/import-export-customization/assets/js/module.js @@ -0,0 +1,18 @@ +import router from '@elementor/router'; + +import Export from './export'; + +export default class ImportExportCustomization { + routes = [ + { + path: '/export-customization/*', + component: Export, + }, + ]; + + constructor() { + for ( const route of this.routes ) { + router.addRoute( route ); + } + } +} diff --git a/app/modules/import-export-customization/assets/js/package.js b/app/modules/import-export-customization/assets/js/package.js new file mode 100644 index 000000000000..1a0340e8fd4c --- /dev/null +++ b/app/modules/import-export-customization/assets/js/package.js @@ -0,0 +1,6 @@ +// Alphabetical order. +import Module from './module'; + +export default { + Module, +}; diff --git a/app/modules/import-export-customization/assets/js/shared/components/icons/index.js b/app/modules/import-export-customization/assets/js/shared/components/icons/index.js new file mode 100644 index 000000000000..1ce9ecc151db --- /dev/null +++ b/app/modules/import-export-customization/assets/js/shared/components/icons/index.js @@ -0,0 +1 @@ +export { XIcon } from './x-icon'; diff --git a/app/modules/import-export-customization/assets/js/shared/components/icons/x-icon.js b/app/modules/import-export-customization/assets/js/shared/components/icons/x-icon.js new file mode 100644 index 000000000000..c263f33574e8 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/shared/components/icons/x-icon.js @@ -0,0 +1,19 @@ +import { forwardRef } from 'react'; +import { SvgIcon } from '@elementor/ui'; + +export const XIcon = forwardRef( ( props, ref ) => { + return ( + + + + + ); +} ); diff --git a/app/modules/import-export-customization/assets/js/shared/components/index.js b/app/modules/import-export-customization/assets/js/shared/components/index.js new file mode 100644 index 000000000000..b2f2f067904e --- /dev/null +++ b/app/modules/import-export-customization/assets/js/shared/components/index.js @@ -0,0 +1,2 @@ +export * from './layout'; +export * from './icons'; diff --git a/app/modules/import-export-customization/assets/js/shared/components/layout/base-layout.js b/app/modules/import-export-customization/assets/js/shared/components/layout/base-layout.js new file mode 100644 index 000000000000..7cd403e44ef2 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/shared/components/layout/base-layout.js @@ -0,0 +1,62 @@ +import { ThemeProvider, DirectionProvider, Box } from '@elementor/ui'; +import PropTypes from 'prop-types'; + +export default function BaseLayout( props ) { + const { + children, + topBar, + footer, + sx = {}, + ...rest + } = props; + + const uiTheme = elementorAppConfig[ 'import-export-customization' ]?.uiTheme || 'auto'; + const isDarkMode = 'dark' === uiTheme || ( 'auto' === uiTheme && window.matchMedia && window.matchMedia( `(prefers-color-scheme: dark)` ).matches ); + + const colorScheme = isDarkMode ? 'dark' : 'light'; + const isRTL = elementorCommon?.config?.isRTL || false; + + return ( + + + + + { topBar } + + + + { children } + + + + { footer } + + + + + ); +} + +BaseLayout.propTypes = { + children: PropTypes.node.isRequired, + topBar: PropTypes.node, + footer: PropTypes.node, + sx: PropTypes.object, +}; diff --git a/app/modules/import-export-customization/assets/js/shared/components/layout/centered-content.js b/app/modules/import-export-customization/assets/js/shared/components/layout/centered-content.js new file mode 100644 index 000000000000..3775972dda6d --- /dev/null +++ b/app/modules/import-export-customization/assets/js/shared/components/layout/centered-content.js @@ -0,0 +1,39 @@ +import { Box } from '@elementor/ui'; +import PropTypes from 'prop-types'; + +const DEFAULT_OFFSET_WITHOUT_FOOTER = 120; +const DEFAULT_OFFSET_WITH_FOOTER = 180; + +export default function CenteredContent( { + children, + hasFooter = false, + offsetHeight, + maxWidth = '600px', +} ) { + const calculatedOffset = offsetHeight || ( hasFooter ? DEFAULT_OFFSET_WITH_FOOTER : DEFAULT_OFFSET_WITHOUT_FOOTER ); + + return ( + + + { children } + + + ); +} + +CenteredContent.propTypes = { + children: PropTypes.node.isRequired, + hasFooter: PropTypes.bool, + offsetHeight: PropTypes.number, + maxWidth: PropTypes.string, +}; diff --git a/app/modules/import-export-customization/assets/js/shared/components/layout/footer.js b/app/modules/import-export-customization/assets/js/shared/components/layout/footer.js new file mode 100644 index 000000000000..4a4ae01edb7a --- /dev/null +++ b/app/modules/import-export-customization/assets/js/shared/components/layout/footer.js @@ -0,0 +1,35 @@ +import { Box } from '@elementor/ui'; +import PropTypes from 'prop-types'; + +export default function Footer( props ) { + const { + children, + sx = {}, + ...rest + } = props; + + return ( + + { children } + + ); +} + +Footer.propTypes = { + children: PropTypes.node, + sx: PropTypes.object, +}; diff --git a/app/modules/import-export-customization/assets/js/shared/components/layout/index.js b/app/modules/import-export-customization/assets/js/shared/components/layout/index.js new file mode 100644 index 000000000000..c4c8bd12ea55 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/shared/components/layout/index.js @@ -0,0 +1,5 @@ +export { default as BaseLayout } from './base-layout'; +export { default as CenteredContent } from './centered-content'; +export { default as Footer } from './footer'; +export { default as PageHeader } from './page-header'; +export { default as TopBar } from './top-bar'; diff --git a/app/modules/import-export-customization/assets/js/shared/components/layout/page-header.js b/app/modules/import-export-customization/assets/js/shared/components/layout/page-header.js new file mode 100644 index 000000000000..70dce1d0872f --- /dev/null +++ b/app/modules/import-export-customization/assets/js/shared/components/layout/page-header.js @@ -0,0 +1,122 @@ +import { Button, Box, Typography, Stack, IconButton, Dialog, DialogTitle, DialogContent, DialogActions, Link, styled, SvgIcon } from '@elementor/ui'; +import { useState } from 'react'; +import PropTypes from 'prop-types'; + +import { XIcon } from '../icons'; + +const ElementorLogo = ( props ) => { + return ( + + + + ); +}; + +const StyledElementorLogo = styled( ElementorLogo )( ( { theme } ) => ( { + width: theme.spacing( 4 ), + height: theme.spacing( 4 ), + '& path': { + fill: theme.palette.text.primary, + }, +} ) ); + +export default function PageHeader( { title = __( 'Export', 'elementor' ) } ) { + const [ isHelpModalOpen, setIsHelpModalOpen ] = useState( false ); + + const handleClose = () => { + window.top.location = elementorAppConfig.admin_url + 'admin.php?page=elementor-tools'; + }; + + const handleHelpClick = () => { + setIsHelpModalOpen( true ); + }; + + const handleHelpModalClose = () => { + setIsHelpModalOpen( false ); + }; + + return ( + <> + + + + { title } + + + + + + + + + + + + + + + { __( 'Export a Website Template', 'elementor' ) } + + + + + { __( "What's a Website Template?", 'elementor' ) } + + + { __( 'A Website Template is a .zip file that contains all the parts of a complete site. It\'s an easy way to get a site up and running quickly.', 'elementor' ) } + + + { __( 'Learn more about Website Templates', 'elementor' ) } + + + + + + { __( 'How does exporting work?', 'elementor' ) } + + + { __( 'To turn your site into a Website Template, select the templates, content, settings and plugins you want to include. Once it\'s ready, you\'ll get a .zip file that you can import to other sites.', 'elementor' ) } + + + { __( 'Learn More', 'elementor' ) } + + + + + + + + + ); +} + +PageHeader.propTypes = { + title: PropTypes.string, +}; diff --git a/app/modules/import-export-customization/assets/js/shared/components/layout/top-bar.js b/app/modules/import-export-customization/assets/js/shared/components/layout/top-bar.js new file mode 100644 index 000000000000..8092a8d854e6 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/shared/components/layout/top-bar.js @@ -0,0 +1,40 @@ +import { AppBar, Toolbar, Divider } from '@elementor/ui'; +import PropTypes from 'prop-types'; + +export default function TopBar( props ) { + const { + children, + sx = {}, + ...rest + } = props; + + return ( + <> + + + { children } + + + + + ); +} + +TopBar.propTypes = { + children: PropTypes.node, + sx: PropTypes.object, +}; diff --git a/app/modules/import-export-customization/assets/js/shared/hooks/use-connect-state.js b/app/modules/import-export-customization/assets/js/shared/hooks/use-connect-state.js new file mode 100644 index 000000000000..5b19946184b4 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/shared/hooks/use-connect-state.js @@ -0,0 +1,40 @@ +import { useState, useCallback } from 'react'; + +export default function useConnectState() { + const [ isConnected, setIsConnected ] = useState( elementorCommon.config.library_connect.is_connected ); + const [ isConnecting, setIsConnecting ] = useState( false ); + + const handleConnectSuccess = useCallback( ( callback ) => { + setIsConnecting( true ); + setIsConnected( true ); + + elementorCommon.config.library_connect.is_connected = true; + + if ( callback ) { + callback(); + } + }, [] ); + + const handleConnectError = useCallback( ( callback ) => { + setIsConnected( false ); + setIsConnecting( false ); + + elementorCommon.config.library_connect.is_connected = false; + + if ( callback ) { + callback(); + } + }, [] ); + + const setConnecting = useCallback( ( connecting ) => { + setIsConnecting( connecting ); + }, [] ); + + return { + isConnected, + isConnecting, + setConnecting, + handleConnectSuccess, + handleConnectError, + }; +} diff --git a/app/modules/import-export-customization/assets/js/shared/kit-content-data.js b/app/modules/import-export-customization/assets/js/shared/kit-content-data.js new file mode 100644 index 000000000000..55642235a0a1 --- /dev/null +++ b/app/modules/import-export-customization/assets/js/shared/kit-content-data.js @@ -0,0 +1,78 @@ +import KitSettingsCustomizationDialog + from '../export/components/kit-settings-customization-dialog'; + +const kitContentData = [ + { + type: 'content', + data: { + title: __( 'Content', 'elementor' ), + features: { + open: [ + __( 'Elementor Pages', 'elementor' ), + __( 'Landing Pages', 'elementor' ), + __( 'Elementor Posts', 'elementor' ), + __( 'WP Pages', 'elementor' ), + __( 'WP Posts', 'elementor' ), + __( 'WP Menus', 'elementor' ), + __( 'Custom Post Types', 'elementor' ), + ], + }, + }, + dialog: null, + }, + { + type: 'templates', + data: { + title: __( 'Templates', 'elementor' ), + features: { + open: [ + __( 'Saved Templates', 'elementor' ), + __( 'Headers', 'elementor' ), + __( 'Footers', 'elementor' ), + __( 'Archives', 'elementor' ), + __( 'Single Posts', 'elementor' ), + __( 'Single Pages', 'elementor' ), + __( 'Search Results', 'elementor' ), + __( '404 Error Page', 'elementor' ), + __( 'Popups', 'elementor' ), + __( 'Global widgets', 'elementor' ), + ], + }, + }, + dialog: null, + }, + { + type: 'settings', + data: { + title: __( 'Settings & configurations', 'elementor' ), + features: { + open: [ + __( 'Global Colors', 'elementor' ), + __( 'Global Fonts', 'elementor' ), + __( 'Theme Style Settings', 'elementor' ), + __( 'Layout Settings', 'elementor' ), + __( 'Lightbox Settings', 'elementor' ), + __( 'Background Settings', 'elementor' ), + __( 'Custom Fonts', 'elementor' ), + __( 'Icons', 'elementor' ), + __( 'Code', 'elementor' ), + ], + }, + }, + dialog: KitSettingsCustomizationDialog, + }, + { + type: 'plugins', + data: { + title: __( 'Plugins', 'elementor' ), + features: { + open: [ + __( 'All plugins are required for this website templates work', 'elementor' ), + ], + }, + }, + dialog: null, + }, +]; + +export default kitContentData; diff --git a/app/modules/import-export-customization/compatibility/base-adapter.php b/app/modules/import-export-customization/compatibility/base-adapter.php new file mode 100644 index 000000000000..b2fe3e698159 --- /dev/null +++ b/app/modules/import-export-customization/compatibility/base-adapter.php @@ -0,0 +1,34 @@ + false, + 'globalColors' => false, + 'globalFonts' => false, + 'themeStyleSettings' => false, + 'generalSettings' => false, + 'experiments' => false, + ]; + + // Map old tab keys to new setting types + $tab_mapping = [ + 'settings-global-colors' => 'globalColors', + 'settings-global-typography' => 'globalFonts', + 'theme-style-typography' => 'themeStyleSettings', + 'settings-general' => 'generalSettings', + ]; + + // If we have tab keys, assume all were exported (true) + if ( ! empty( $old_site_settings ) ) { + // In the old format, if site-settings was included, all settings were exported + $new_site_settings = [ + 'theme' => true, + 'globalColors' => true, + 'globalFonts' => true, + 'themeStyleSettings' => true, + 'generalSettings' => true, + 'experiments' => true, + ]; + } + + $manifest_data['site-settings'] = $new_site_settings; + } + + // Update version to current + $manifest_data['version'] = Module::FORMAT_VERSION; + + return $manifest_data; + } +} diff --git a/app/modules/import-export-customization/compatibility/envato.php b/app/modules/import-export-customization/compatibility/envato.php new file mode 100644 index 000000000000..0f77056b4cbe --- /dev/null +++ b/app/modules/import-export-customization/compatibility/envato.php @@ -0,0 +1,82 @@ +kits_manager->get_active_kit(); + $kit_tabs = $kit->get_tabs(); + unset( $kit_tabs['settings-site-identity'] ); + $manifest_data['site-settings'] = array_keys( $kit_tabs ); + + continue; + } + + // Evanto uses "type" instead of "doc_type" + $template['doc_type'] = $template['type']; + + // Evanto uses for "name" instead of "title" + $template['title'] = $template['name']; + + // Envato specifying an exact path to the template rather than using its "ID" as an index. + // This extracts the "file name" part out of our exact source list and we treat that as an ID. + $file_name_without_extension = str_replace( '.json', '', basename( $template['source'] ) ); + + // Append the template to the global list: + $manifest_data['templates'][ $file_name_without_extension ] = $template; + } + + $manifest_data['name'] = $manifest_data['title']; + + return $manifest_data; + } + + public function adapt_site_settings( array $site_settings, array $manifest_data, $path ) { + if ( empty( $manifest_data['path-to-envto-site-settings'] ) ) { + return $site_settings; + } + + $global_file_path = $path . $manifest_data['path-to-envto-site-settings']; + $global_file_data = ImportExportUtils::read_json_file( $global_file_path ); + + return [ + 'settings' => $global_file_data['page_settings'], + ]; + } + + public function adapt_template( array $template_data, array $template_settings ) { + if ( ! empty( $template_data['metadata']['elementor_pro_conditions'] ) ) { + foreach ( $template_data['metadata']['elementor_pro_conditions'] as $condition ) { + list ( $type, $name, $sub_name, $sub_id ) = array_pad( explode( '/', $condition ), 4, '' ); + + $template_data['import_settings']['conditions'][] = compact( 'type', 'name', 'sub_name', 'sub_id' ); + } + } + + return $template_data; + } +} diff --git a/app/modules/import-export-customization/compatibility/kit-library.php b/app/modules/import-export-customization/compatibility/kit-library.php new file mode 100644 index 000000000000..9e510be43114 --- /dev/null +++ b/app/modules/import-export-customization/compatibility/kit-library.php @@ -0,0 +1,29 @@ + self::register_routes() ); + } + + public static function get_base_url() { + return get_rest_url() . self::API_NAMESPACE . '/' . self::API_BASE; + } + + private static function register_routes() { + ( new Export() )->register_route( self::API_NAMESPACE, self::API_BASE ); + } +} diff --git a/app/modules/import-export-customization/data/response.php b/app/modules/import-export-customization/data/response.php new file mode 100644 index 000000000000..54745715c27e --- /dev/null +++ b/app/modules/import-export-customization/data/response.php @@ -0,0 +1,38 @@ +data = $data; + $this->meta = $meta; + } + + public static function success( array $data, array $meta = [] ): \WP_REST_Response { + $response = new self( $data, $meta ); + return $response->to_wp_rest_response( 200 ); + } + + public static function error( string $code, string $message, array $meta = [] ): \WP_REST_Response { + $response = new self([ + 'code' => $code, + 'message' => $message, + ], $meta); + + return $response->to_wp_rest_response( 500 ); + } + + private function to_array(): array { + return [ + 'data' => $this->data, + 'meta' => $this->meta, + ]; + } + + private function to_wp_rest_response( int $status_code = 200 ): \WP_REST_Response { + return new \WP_REST_Response( $this->to_array(), $status_code ); + } +} diff --git a/app/modules/import-export-customization/data/routes/base-route.php b/app/modules/import-export-customization/data/routes/base-route.php new file mode 100644 index 000000000000..b27e7426e6bc --- /dev/null +++ b/app/modules/import-export-customization/data/routes/base-route.php @@ -0,0 +1,30 @@ +get_route(), [ + [ + 'methods' => $this->get_method(), + 'callback' => fn( $request ) => $this->callback( $request ), + 'permission_callback' => $this->permission_callback(), + 'args' => $this->get_args(), + ], + ] ); + } + + abstract protected function get_route(): string; + + abstract protected function get_method(): string; + + abstract protected function callback( $request ): \WP_REST_Response; + + protected function permission_callback(): callable { + return fn() => current_user_can( 'manage_options' ); + } + + abstract protected function get_args(): array; +} diff --git a/app/modules/import-export-customization/data/routes/export.php b/app/modules/import-export-customization/data/routes/export.php new file mode 100644 index 000000000000..49533043dc12 --- /dev/null +++ b/app/modules/import-export-customization/data/routes/export.php @@ -0,0 +1,142 @@ + $request->get_param( 'include' ), + 'kitInfo' => $request->get_param( 'kitInfo' ), + 'screenShotBlob' => $request->get_param( 'screenShotBlob' ), + 'customization' => $request->get_param( 'customization' ), + 'plugins' => $request->get_param( 'plugins' ), + 'selectedCustomPostTypes' => $request->get_param( 'selectedCustomPostTypes' ), + ]; + + $settings = array_filter( $settings ); + + $source = $settings['kitInfo']['source']; + + $module = Plugin::$instance->app->get_component( 'import-export-customization' ); + + $export = $module->export_kit( $settings ); + + $file_name = $export['file_name']; + $file = ElementorUtils::file_get_contents( $file_name ); + + if ( ! $file ) { + throw new \Error( 'Could not read the exported file.' ); + } + + Plugin::$instance->uploads_manager->remove_file_or_dir( dirname( $file_name ) ); + + $result = apply_filters( + 'elementor/export/kit/export-result', + [ + 'manifest' => $export['manifest'], + 'file' => base64_encode( $file ), + ], + $source, + $export, + $settings, + $file, + ); + + if ( is_wp_error( $result ) ) { + throw new \Error( $result->get_error_message() ); + } + + return Response::success( $result ); + + } catch ( \Error | \Exception $e ) { + Plugin::$instance->logger->get_logger()->error( $e->getMessage(), [ + 'meta' => [ + 'trace' => $e->getTraceAsString(), + ], + ] ); + + return Response::error( 'export_error', $e->getMessage() ); + } + } + + protected function get_args(): array { + return [ + 'include' => [ + 'type' => 'array', + 'description' => 'Content types to include in export', + 'required' => false, + 'default' => [ 'templates', 'content', 'settings', 'plugins' ], + ], + 'kitInfo' => [ + 'type' => 'object', + 'description' => 'Kit information', + 'required' => false, + 'default' => [ + 'title' => 'Elementor Website Template', + 'description' => '', + 'source' => 'local', + ], + ], + 'screenShotBlob' => [ + 'type' => 'string', + 'description' => 'Base64 encoded screenshot for cloud exports', + 'required' => false, + 'default' => null, + ], + 'customization' => [ + 'type' => 'object', + 'description' => 'Customization settings for selective export', + 'required' => false, + 'default' => null, + 'properties' => [ + 'settings' => [ + 'type' => [ 'object', 'null' ], + 'description' => 'Site settings customization', + ], + 'templates' => [ + 'type' => [ 'object', 'null' ], + 'description' => 'Templates customization', + ], + 'content' => [ + 'type' => [ 'object', 'null' ], + 'description' => 'Content customization', + ], + 'plugins' => [ + 'type' => [ 'object', 'null' ], + 'description' => 'Plugins customization', + ], + ], + ], + 'plugins' => [ + 'type' => 'array', + 'description' => 'Selected plugins to export', + 'required' => false, + 'default' => [], + ], + 'selectedCustomPostTypes' => [ + 'type' => 'array', + 'description' => 'Selected custom post types', + 'required' => false, + 'default' => [], + ], + ]; + } +} diff --git a/app/modules/import-export-customization/data/routes/import-runner.php b/app/modules/import-export-customization/data/routes/import-runner.php new file mode 100644 index 000000000000..9002b60601d3 --- /dev/null +++ b/app/modules/import-export-customization/data/routes/import-runner.php @@ -0,0 +1,74 @@ +get_param( 'session' ); + $runner = $request->get_param( 'runner' ); + $module = Plugin::$instance->app->get_component( 'import-export-customization' ); + + if ( empty( $session_id ) ) { + return Response::error( 'Session ID is required.', 'missing_session_id' ); + } + + if ( empty( $runner ) ) { + return Response::error( 'Runner name is required.', 'missing_runner_name' ); + } + + $import = $module->import_kit_by_runner( $session_id, $runner ); + + if ( ! empty( $import['status'] ) ) { + Plugin::$instance->logger->get_logger()->info( + sprintf( 'Import runner completed via REST API: %1$s %2$s', + $import['runner'] ?? $runner, + ( 'success' === $import['status'] ? '✓' : '✗' ) + ) + ); + } + + do_action( 'elementor/import-export-customization/import-kit/runner/after-run', $import ); + + return Response::success( $import ); + + } catch ( \Error $e ) { + Plugin::$instance->logger->get_logger()->error( $e->getMessage(), [ + 'meta' => [ + 'trace' => $e->getTraceAsString(), + ], + ] ); + + return Response::error( $e->getMessage(), 'import_runner_error' ); + } + } + + protected function get_args(): array { + return [ + 'session' => [ + 'type' => 'string', + 'description' => 'Session ID for import operations', + 'required' => true, + ], + 'runner' => [ + 'type' => 'string', + 'description' => 'Runner name for import_runner action', + 'required' => true, + ], + ]; + } +} diff --git a/app/modules/import-export-customization/data/routes/import.php b/app/modules/import-export-customization/data/routes/import.php new file mode 100644 index 000000000000..ed7c411f3647 --- /dev/null +++ b/app/modules/import-export-customization/data/routes/import.php @@ -0,0 +1,66 @@ +get_param( 'session' ); + $settings = $request->get_param( 'settings' ); + $module = Plugin::$instance->app->get_component( 'import-export-customization' ); + + if ( empty( $session ) ) { + return Response::error( 'Session ID is required.', 'missing_session_id' ); + } + + $import = $module->import_kit( $session, $settings, true ); + + Plugin::$instance->logger->get_logger()->info( + sprintf( 'Selected import runners via REST API: %1$s', + implode( ', ', $import['runners'] ?? [] ) + ) + ); + + return Response::success( $import ); + + } catch ( \Error $e ) { + Plugin::$instance->logger->get_logger()->error( $e->getMessage(), [ + 'meta' => [ + 'trace' => $e->getTraceAsString(), + ], + ] ); + + return Response::error( $e->getMessage(), 'import_error' ); + } + } + + protected function get_args(): array { + return [ + 'session' => [ + 'type' => 'string', + 'description' => 'Session ID for import operations', + 'required' => true, + ], + 'settings' => [ + 'type' => 'object', + 'description' => 'Import settings', + 'required' => false, + 'default' => [], + ], + ]; + } +} diff --git a/app/modules/import-export-customization/data/routes/upload.php b/app/modules/import-export-customization/data/routes/upload.php new file mode 100644 index 000000000000..c091d87765a5 --- /dev/null +++ b/app/modules/import-export-customization/data/routes/upload.php @@ -0,0 +1,126 @@ +get_param( 'file_url' ); + $kit_id = $request->get_param( 'kit_id' ); + $source = $request->get_param( 'source' ); + $module = Plugin::$instance->app->get_component( 'import-export-customization' ); + + $is_import_from_library = ! empty( $file_url ); + + if ( $is_import_from_library ) { + if ( ! filter_var( $file_url, FILTER_VALIDATE_URL ) || 0 !== strpos( $file_url, 'http' ) ) { + return Response::error( 'Invalid kit library URL.', 'invalid_kit_library_url' ); + } + + $import_result = apply_filters( 'elementor/import/kit/result', [ 'file_url' => $file_url ] ); + } elseif ( ! empty( $source ) ) { + $import_result = apply_filters( 'elementor/import/kit/result/' . $source, [ + 'kit_id' => $kit_id, + 'source' => $source, + ] ); + } else { + $files = $request->get_file_params(); + $file = $files['e_import_file'] ?? null; + + if ( empty( $file ) || empty( $file['tmp_name'] ) ) { + return Response::error( 'No file uploaded or upload error occurred.', 'no_file_uploaded' ); + } + + $import_result = [ + 'file_name' => $file['tmp_name'], + 'referrer' => $module::REFERRER_LOCAL, + ]; + } + + Plugin::$instance->logger->get_logger()->info( 'Uploading Kit via REST API: ', [ + 'meta' => [ + 'kit_id' => $kit_id, + 'referrer' => $import_result['referrer'] ?? 'unknown', + ], + ] ); + + if ( is_wp_error( $import_result ) ) { + return Response::error( $import_result->get_error_message(), 'upload_error' ); + } + + $uploaded_kit = $module->upload_kit( $import_result['file_name'], $import_result['referrer'], $kit_id ); + + $result = [ + 'session' => $uploaded_kit['session'], + 'manifest' => $uploaded_kit['manifest'], + ]; + + if ( ! empty( $import_result['file_url'] ) ) { + $result['file_url'] = $import_result['file_url']; + } + + if ( ! empty( $import_result['kit'] ) ) { + $result['uploaded_kit'] = $import_result['kit']; + } + + if ( ! empty( $uploaded_kit['conflicts'] ) ) { + $result['conflicts'] = $uploaded_kit['conflicts']; + } + + // Clean up temporary files + if ( $is_import_from_library || ! empty( $source ) ) { + Plugin::$instance->uploads_manager->remove_file_or_dir( dirname( $import_result['file_name'] ) ); + } + + return Response::success( $result ); + + } catch ( \Error $e ) { + Plugin::$instance->logger->get_logger()->error( $e->getMessage(), [ + 'meta' => [ + 'trace' => $e->getTraceAsString(), + ], + ] ); + + return Response::error( $e->getMessage(), 'upload_error' ); + } + } + + protected function get_args(): array { + return [ + 'file_url' => [ + 'type' => 'string', + 'description' => 'File URL for upload action', + 'required' => false, + 'validate_callback' => function ( $value ) { + if ( empty( $value ) ) { + return true; + } + return filter_var( $value, FILTER_VALIDATE_URL ); + }, + ], + 'kit_id' => [ + 'type' => 'string', + 'description' => 'Kit ID for upload action', + 'required' => false, + ], + 'source' => [ + 'type' => 'string', + 'description' => 'Source for upload action', + 'required' => false, + ], + ]; + } +} diff --git a/app/modules/import-export-customization/module.php b/app/modules/import-export-customization/module.php new file mode 100644 index 000000000000..f3812aa253f9 --- /dev/null +++ b/app/modules/import-export-customization/module.php @@ -0,0 +1,771 @@ +register_actions(); + + Controller::register_hooks(); + + if ( ElementorUtils::is_wp_cli() ) { + \WP_CLI::add_command( 'elementor kit', WP_CLI::class ); + } + + ( new Usage() )->register(); + + $this->revert = new Revert(); + } + + public function get_init_settings() { + if ( ! Plugin::$instance->app->is_current() ) { + return []; + } + + return $this->get_config_data(); + } + + /** + * Register the import/export tab in elementor tools. + */ + public function register_settings_tab( Tools $tools ) { + $tools->add_tab( 'import-export-kit', [ + 'label' => esc_html__( 'Website Templates', 'elementor' ), + 'sections' => [ + 'intro' => [ + 'label' => esc_html__( 'Website Templates', 'elementor' ), + 'callback' => function() { + $this->render_import_export_tab_content(); + }, + 'fields' => [], + ], + ], + ] ); + } + + /** + * Render the import/export tab content. + */ + private function render_import_export_tab_content() { + $is_cloud_kits_available = Plugin::$instance->experiments->is_feature_active( 'cloud-library' ) && CloudKitLibrary::get_app()->is_eligible(); + + $content_data = [ + 'export' => [ + 'title' => esc_html__( 'Export this website', 'elementor' ), + 'button' => [ + 'url' => Plugin::$instance->app->get_base_url() . '#/export', + 'text' => esc_html__( 'Export', 'elementor' ), + ], + 'description' => esc_html__( 'You can download this website as a .zip file, or upload it to the library.', 'elementor' ), + ], + 'import' => [ + 'title' => esc_html__( 'Apply a Website Template', 'elementor' ), + 'button' => [ + 'url' => Plugin::$instance->app->get_base_url() . '#/import', + 'text' => $is_cloud_kits_available ? esc_html__( 'Upload .zip file', 'elementor' ) : esc_html__( 'Import', 'elementor' ), + ], + 'description' => esc_html__( 'You can import design and settings from a .zip file or choose from the library.', 'elementor' ), + ], + ]; + + if ( $is_cloud_kits_available ) { + $content_data['import']['button_secondary'] = [ + 'url' => Plugin::$instance->app->get_base_url() . '#/kit-library/cloud', + 'text' => esc_html__( 'Open the Library', 'elementor' ), + ]; + } + + $last_imported_kit = $this->revert->get_last_import_session(); + $penultimate_imported_kit = $this->revert->get_penultimate_import_session(); + + $user_date_format = get_option( 'date_format' ); + $user_time_format = get_option( 'time_format' ); + $date_format = $user_date_format . ' ' . $user_time_format; + + $should_show_revert_section = $this->should_show_revert_section( $last_imported_kit ); + + if ( $should_show_revert_section ) { + if ( ! empty( $penultimate_imported_kit ) ) { + $revert_text = sprintf( + esc_html__( 'Remove all the content and site settings that came with "%1$s" on %2$s %3$s and revert to the site setting that came with "%4$s" on %5$s.', 'elementor' ), + ! empty( $last_imported_kit['kit_title'] ) ? $last_imported_kit['kit_title'] : esc_html__( 'imported kit', 'elementor' ), + gmdate( $date_format, $last_imported_kit['start_timestamp'] ), + '
    ', + ! empty( $penultimate_imported_kit['kit_title'] ) ? $penultimate_imported_kit['kit_title'] : esc_html__( 'imported kit', 'elementor' ), + gmdate( $date_format, $penultimate_imported_kit['start_timestamp'] ) + ); + } else { + $revert_text = sprintf( + esc_html__( 'Remove all the content and site settings that came with "%1$s" on %2$s.%3$s Your original site settings will be restored.', 'elementor' ), + ! empty( $last_imported_kit['kit_title'] ) ? $last_imported_kit['kit_title'] : esc_html__( 'imported kit', 'elementor' ), + gmdate( $date_format, $last_imported_kit['start_timestamp'] ), + '
    ' + ); + } + } + ?> + +
    +

    + %2$s', + esc_html__( 'Here’s where you can export this website as a .zip file, upload it to the cloud, or start the process of applying an existing template to your site.', 'elementor' ), + esc_html__( 'Learn more', 'elementor' ), + ); + ?> +

    + +
    + print_item_content( $data ); + } ?> +
    + + $this->get_revert_href(), + 'id' => 'elementor-import-export__revert_kit', + 'class' => 'button', + ]; + ?> +
    +

    + +

    +

    + +

    + render_last_kit_thumbnail( $last_imported_kit ); ?> + > + + +
    + +
    + experiments->is_feature_active( 'cloud-library' ); + + if ( $is_cloud_kits_feature_active ) { ?> +
    +
    +

    +
    +

    + + + + +
    + + + + + + + + +
    +
    + +
    +
    +

    + + + +
    +

    + +
    + maybe_add_referrer_param( $nonced_admin_post_url ); + } + + /** + * Checks if referred by a kit and adds the referrer ID to the href + * + * @param string $href + * + * @return string + */ + private function maybe_add_referrer_param( string $href ): string { + $param_name = 'referrer_kit'; + + if ( empty( $_GET[ $param_name ] ) ) { + return $href; + } + + return add_query_arg( $param_name, sanitize_key( $_GET[ $param_name ] ), $href ); + } + + /** + * Render the last kit thumbnail if exists + * + * @param $last_imported_kit + * + * @return void + */ + private function render_last_kit_thumbnail( $last_imported_kit ) { + if ( empty( $last_imported_kit['kit_thumbnail'] ) ) { + return; + } + + ?> +
    +
    +
    +

    + +

    +
    + <?php echo esc_attr( $last_imported_kit['kit_title'] ); ?> +
    +
    + ensure_writing_permissions(); + + $this->import = new Import( $file, [ + 'referrer' => $referrer, + 'id' => $kit_id, + ] ); + + return [ + 'session' => $this->import->get_session_id(), + 'manifest' => $this->import->get_manifest(), + 'conflicts' => $this->import->get_settings_conflicts(), + ]; + } + + /** + * Import a kit by session_id. + * Upload and import a kit by kit zip file. + * + * If the split_to_chunks flag is true, the process won't start + * It will initialize the import process and return the session_id and the runners. + * + * Assigning the Import process to the 'import' property, + * so it will be available to use in different places such as: WP_Cli, Pro, etc. + * + * @param string $path Path to the file or session_id. + * @param array $settings Settings the import use to determine which content to import. + * (e.g: include, selected_plugins, selected_cpt, selected_override_conditions, etc.) + * @param bool $split_to_chunks Determine if the import process should be split into chunks. + * @return array + * @throws \Exception + */ + public function import_kit( string $path, array $settings, bool $split_to_chunks = false ): array { + $this->ensure_writing_permissions(); + $this->ensure_DOMDocument_exists(); + + $this->import = new Import( $path, $settings ); + $this->import->register_default_runners(); + + remove_filter( 'elementor/document/save/data', [ Plugin::$instance->modules_manager->get_modules( 'content-sanitizer' ), 'sanitize_content' ] ); + do_action( 'elementor/import-export-customization/import-kit', $this->import ); + + if ( $split_to_chunks ) { + $this->import->init_import_session( true ); + + return [ + 'session' => $this->import->get_session_id(), + 'runners' => $this->import->get_runners_name(), + ]; + } + + return $this->import->run(); + } + + /** + * Resuming import process by re-creating the import instance and running the specific runner. + * + * @param string $session_id The id off the import session. + * @param string $runner_name The specific runner that we want to run. + * + * @return array Two types of response. + * 1. The status and the runner name. + * 2. The imported data. (Only if the runner is the last one in the import process) + * @throws \Exception + */ + public function import_kit_by_runner( string $session_id, string $runner_name ): array { + // Check session_id + $this->import = Import::from_session( $session_id ); + $runners = $this->import->get_runners_name(); + + $run = $this->import->run_runner( $runner_name ); + + if ( end( $runners ) === $run['runner'] ) { + return $this->import->get_imported_data(); + } + + return $run; + } + + /** + * Export a kit. + * + * Assigning the Export process to the 'export' property, + * so it will be available to use in different places such as: WP_Cli, Pro, etc. + * + * @param array $settings Settings the export use to determine which content to export. + * (e.g: include, kit_info, selected_plugins, selected_cpt, etc.) + * @return array + * @throws \Exception + */ + public function export_kit( array $settings ) { + $this->ensure_writing_permissions(); + + $this->export = new Export( $settings ); + $this->export->register_default_runners(); + + do_action( 'elementor/import-export-customization/export-kit', $this->export ); + + return $this->export->run(); + } + + /** + * Handle revert kit ajax request. + */ + public function revert_last_imported_kit() { + $this->revert = new Revert(); + $this->revert->register_default_runners(); + + do_action( 'elementor/import-export-customization/revert-kit', $this->revert ); + + $this->revert->run(); + } + + + /** + * Handle revert last imported kit ajax request. + */ + public function handle_revert_last_imported_kit() { + check_admin_referer( 'elementor_revert_kit' ); + + $this->revert_last_imported_kit(); + + wp_safe_redirect( admin_url( 'admin.php?page=' . Tools::PAGE_ID . '#tab-import-export-kit' ) ); + die; + } + + /** + * Register appropriate actions. + */ + private function register_actions() { + add_action( 'admin_post_elementor_revert_kit', [ $this, 'handle_revert_last_imported_kit' ] ); + + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); + + $page_id = Tools::PAGE_ID; + + add_action( "elementor/admin/after_create_settings/{$page_id}", [ $this, 'register_settings_tab' ] ); + + // TODO 18/04/2023 : This needs to be moved to the runner itself after https://elementor.atlassian.net/browse/HTS-434 is done. + if ( self::IMPORT_PLUGINS_ACTION === ElementorUtils::get_super_global_value( $_SERVER, 'HTTP_X_ELEMENTOR_ACTION' ) ) { + add_filter( 'woocommerce_create_pages', [ $this, 'empty_pages' ], 10, 0 ); + } + // TODO ^^^ + + add_filter( 'elementor/import/kit/result', function( $result ) { + if ( ! empty( $result['file_url'] ) ) { + return [ + 'file_name' => $this->get_remote_kit_zip( $result['file_url'] ), + 'referrer' => static::REFERRER_KIT_LIBRARY, + 'file_url' => $result['file_url'], + ]; + } + + return $result; + } ); + } + + /** + * Prevent the creation of the default WooCommerce pages (Cart, Checkout, etc.) + * + * TODO 18/04/2023 : This needs to be moved to the runner itself after https://elementor.atlassian.net/browse/HTS-434 is done. + * + * @return array + */ + public function empty_pages(): array { + return []; + } + + private function ensure_writing_permissions() { + $server = new Server(); + + $paths_to_check = [ + Server::KEY_PATH_WP_CONTENT_DIR => $server->get_system_path( Server::KEY_PATH_WP_CONTENT_DIR ), + Server::KEY_PATH_UPLOADS_DIR => $server->get_system_path( Server::KEY_PATH_UPLOADS_DIR ), + Server::KEY_PATH_ELEMENTOR_UPLOADS_DIR => $server->get_system_path( Server::KEY_PATH_ELEMENTOR_UPLOADS_DIR ), + ]; + + $permissions = $server->get_paths_permissions( $paths_to_check ); + + // WP Content dir has to be exists and writable. + if ( ! $permissions[ Server::KEY_PATH_WP_CONTENT_DIR ]['write'] ) { + throw new \Error( self::NO_WRITE_PERMISSIONS_KEY ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + // WP Uploads dir has to be exists and writable. + if ( ! $permissions[ Server::KEY_PATH_UPLOADS_DIR ]['write'] ) { + throw new \Error( self::NO_WRITE_PERMISSIONS_KEY ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + // Elementor uploads dir permissions is divided to 2 cases: + // 1. If the dir exists, it has to be writable. + // 2. If the dir doesn't exist, the parent dir has to be writable (wp uploads dir), so we can create it. + if ( $permissions[ Server::KEY_PATH_ELEMENTOR_UPLOADS_DIR ]['exists'] && ! $permissions[ Server::KEY_PATH_ELEMENTOR_UPLOADS_DIR ]['write'] ) { + throw new \Error( self::NO_WRITE_PERMISSIONS_KEY ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + } + + private function ensure_DOMDocument_exists() { + if ( ! class_exists( 'DOMDocument' ) ) { + throw new \Error( self::DOMDOCUMENT_MISSING ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + } + + /** + * Enqueue admin scripts + */ + public function enqueue_scripts() { + wp_enqueue_script( + 'elementor-import-export-admin', + $this->get_js_assets_url( 'import-export-admin' ), + [ 'elementor-common' ], + ELEMENTOR_VERSION, + true + ); + + wp_localize_script( + 'elementor-import-export-admin', + 'elementorImportExport', + [ + 'lastImportedSession' => $this->revert->get_last_import_session(), + 'appUrl' => Plugin::$instance->app->get_base_url() . '#/kit-library', + ] + ); + } + + protected function get_remote_kit_zip( $url ) { + $remote_zip_request = wp_safe_remote_get( $url ); + + if ( is_wp_error( $remote_zip_request ) ) { + Plugin::$instance->logger->get_logger()->error( $remote_zip_request->get_error_message() ); + throw new \Error( static::KIT_LIBRARY_ERROR_KEY ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + if ( 200 !== $remote_zip_request['response']['code'] ) { + Plugin::$instance->logger->get_logger()->error( $remote_zip_request['response']['message'] ); + throw new \Error( static::KIT_LIBRARY_ERROR_KEY ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + return Plugin::$instance->uploads_manager->create_temp_file( $remote_zip_request['body'], 'kit.zip' ); + } + + /** + * Get config data that will be exposed to the frontend. + */ + private function get_config_data() { + $export_nonce = wp_create_nonce( 'elementor_export' ); + $export_url = add_query_arg( [ '_nonce' => $export_nonce ], Plugin::$instance->app->get_base_url() ); + + return [ + 'exportURL' => $export_url, + 'summaryTitles' => $this->get_summary_titles(), + 'builtinWpPostTypes' => ImportExportUtils::get_builtin_wp_post_types(), + 'elementorPostTypes' => ImportExportUtils::get_elementor_post_types(), + 'isUnfilteredFilesEnabled' => Uploads_Manager::are_unfiltered_uploads_enabled(), + 'elementorHomePageUrl' => $this->get_elementor_home_page_url(), + 'recentlyEditedElementorPageUrl' => $this->get_recently_edited_elementor_page_url(), + 'tools_url' => Tools::get_url(), + 'importSessions' => Revert::get_import_sessions(), + 'lastImportedSession' => $this->revert->get_last_import_session(), + 'kitPreviewNonce' => wp_create_nonce( 'kit_thumbnail' ), + 'restApiBaseUrl' => Controller::get_base_url(), + 'uiTheme' => $this->get_elementor_ui_theme_preference(), + ]; + } + + private function get_elementor_ui_theme_preference() { + $editor_preferences = SettingsManager::get_settings_managers( 'editorPreferences' ); + + return $editor_preferences->get_model()->get_settings( 'ui_theme' ); + } + + /** + * Get labels of Elementor document types, Elementor Post types, WordPress Post types and Custom Post types. + */ + private function get_summary_titles() { + $summary_titles = []; + + $document_types = Plugin::$instance->documents->get_document_types(); + + foreach ( $document_types as $name => $document_type ) { + $summary_titles['templates'][ $name ] = [ + 'single' => $document_type::get_title(), + 'plural' => $document_type::get_plural_title(), + ]; + } + + $elementor_post_types = ImportExportUtils::get_elementor_post_types(); + $wp_builtin_post_types = ImportExportUtils::get_builtin_wp_post_types(); + $post_types = array_merge( $elementor_post_types, $wp_builtin_post_types ); + + foreach ( $post_types as $post_type ) { + $post_type_object = get_post_type_object( $post_type ); + + $summary_titles['content'][ $post_type ] = [ + 'single' => $post_type_object->labels->singular_name ?? '', + 'plural' => $post_type_object->label ?? '', + ]; + } + + $custom_post_types = ImportExportUtils::get_registered_cpt_names(); + if ( ! empty( $custom_post_types ) ) { + foreach ( $custom_post_types as $custom_post_type ) { + + $custom_post_types_object = get_post_type_object( $custom_post_type ); + // CPT data appears in two arrays: + // 1. content object: in order to show the export summary when completed in getLabel function + $summary_titles['content'][ $custom_post_type ] = [ + 'single' => $custom_post_types_object->labels->singular_name ?? '', + 'plural' => $custom_post_types_object->label ?? '', + ]; + + // 2. customPostTypes object: in order to actually export the data + $summary_titles['content']['customPostTypes'][ $custom_post_type ] = [ + 'single' => $custom_post_types_object->labels->singular_name ?? '', + 'plural' => $custom_post_types_object->label ?? '', + ]; + } + } + + $active_kit = Plugin::$instance->kits_manager->get_active_kit(); + + foreach ( $active_kit->get_tabs() as $key => $tab ) { + $summary_titles['site-settings'][ $key ] = $tab->get_title(); + } + + return $summary_titles; + } + + public function should_show_revert_section( $last_imported_kit ) { + if ( empty( $last_imported_kit ) ) { + return false; + } + + // TODO: BC - remove in the future + // The 'templates' runner was in core and moved to the Pro plugin. (Part of it still exits in the Core for BC) + // The runner that is in the core version is missing the revert functionality, + // therefore we shouldn't display the revert section if the import process done with the core version. + $is_import_templates_ran = isset( $last_imported_kit['runners']['templates'] ); + if ( $this->has_pro() && $is_import_templates_ran ) { + $has_imported_templates = ! empty( $last_imported_kit['runners']['templates'] ); + + return $has_imported_templates; + } + + return true; + } + + public function has_pro(): bool { + return ElementorUtils::has_pro(); + } + + private function get_elementor_editor_home_page_url() { + if ( 'page' !== get_option( 'show_on_front' ) ) { + return ''; + } + + $frontpage_id = get_option( 'page_on_front' ); + + return $this->get_elementor_editor_page_url( $frontpage_id ); + } + + private function get_elementor_home_page_url() { + if ( 'page' !== get_option( 'show_on_front' ) ) { + return ''; + } + + $frontpage_id = get_option( 'page_on_front' ); + + return $this->get_elementor_page_url( $frontpage_id ); + } + + private function get_recently_edited_elementor_page_url() { + $query = ElementorUtils::get_recently_edited_posts_query( [ 'posts_per_page' => 1 ] ); + + if ( ! isset( $query->post ) ) { + return ''; + } + + return $this->get_elementor_page_url( $query->post->ID ); + } + + private function get_recently_edited_elementor_editor_page_url() { + $query = ElementorUtils::get_recently_edited_posts_query( [ 'posts_per_page' => 1 ] ); + + if ( ! isset( $query->post ) ) { + return ''; + } + + return $this->get_elementor_editor_page_url( $query->post->ID ); + } + + private function get_elementor_document( $page_id ) { + $document = Plugin::$instance->documents->get( $page_id ); + + if ( ! $document || ! $document->is_built_with_elementor() ) { + return false; + } + + return $document; + } + + private function get_elementor_page_url( $page_id ) { + $document = $this->get_elementor_document( $page_id ); + + return $document ? $document->get_preview_url() : ''; + } + + private function get_elementor_editor_page_url( $page_id ) { + $document = $this->get_elementor_document( $page_id ); + + return $document ? $document->get_edit_url() : ''; + } + + /** + * @param string $class + * + * @return bool + */ + public function is_third_party_class( $class ) { + $allowed_classes = [ + 'Elementor\\', + 'ElementorPro\\', + 'WP_', + 'wp_', + ]; + + foreach ( $allowed_classes as $allowed_class ) { + if ( str_starts_with( $class, $allowed_class ) ) { + return false; + } + } + + return true; + } +} diff --git a/app/modules/import-export-customization/processes/export.php b/app/modules/import-export-customization/processes/export.php new file mode 100644 index 000000000000..5f2250cb9e4c --- /dev/null +++ b/app/modules/import-export-customization/processes/export.php @@ -0,0 +1,364 @@ +settings_include = ! empty( $settings['include'] ) ? $settings['include'] : null; + $this->settings_kit_info = ! empty( $settings['kitInfo'] ) ? $settings['kitInfo'] : null; + $this->settings_customization = isset( $settings['customization'] ) ? $settings['customization'] : null; + $this->settings_selected_plugins = isset( $settings['plugins'] ) ? $settings['plugins'] : null; + $this->settings_selected_custom_post_types = isset( $settings['selectedCustomPostTypes'] ) ? $settings['selectedCustomPostTypes'] : null; + } + + /** + * Register a runner. + * + * @param Export_Runner_Base $runner_instance + */ + public function register( Export_Runner_Base $runner_instance ) { + $this->runners[ $runner_instance::get_name() ] = $runner_instance; + } + + public function register_default_runners() { + $this->register( new Site_Settings() ); + $this->register( new Plugins() ); + $this->register( new Templates() ); + $this->register( new Taxonomies() ); + $this->register( new Elementor_Content() ); + $this->register( new Wp_Content() ); + } + + /** + * Execute the export process. + * + * @return array The export data output. + * + * @throws \Exception If no export runners have been specified. + */ + public function run() { + if ( empty( $this->runners ) ) { + throw new \Exception( 'Couldn’t execute the export process because no export runners have been specified. Try again by specifying export runners.' ); + } + + $this->set_default_settings(); + + $this->init_zip_archive(); + $this->init_manifest_data(); + + $data = [ + 'include' => $this->settings_include, + 'customization' => $this->settings_customization, + 'selected_plugins' => $this->settings_selected_plugins, + 'selected_custom_post_types' => $this->settings_selected_custom_post_types, + ]; + + foreach ( $this->runners as $runner ) { + if ( $runner->should_export( $data ) ) { + $export_result = $runner->export( $data ); + $this->handle_export_result( $export_result ); + } + } + + $this->add_json_file( 'manifest', $this->manifest_data ); + + $zip_file_name = $this->zip->filename; + $this->zip->close(); + + return [ + 'manifest' => $this->manifest_data, + 'file_name' => $zip_file_name, + ]; + } + + /** + * Set default settings for the export. + */ + private function set_default_settings() { + if ( ! is_array( $this->get_settings_include() ) ) { + $this->settings_include( $this->get_default_settings_include() ); + } + + if ( ! is_array( $this->get_settings_kit_info() ) ) { + $this->settings_kit_info( $this->get_default_settings_kit_info() ); + } + + if ( ! is_array( $this->get_settings_selected_custom_post_types() ) && in_array( 'content', $this->settings_include, true ) ) { + $this->settings_selected_custom_post_types( $this->get_default_settings_custom_post_types() ); + } + + if ( ! is_array( $this->get_settings_selected_plugins() ) && in_array( 'plugins', $this->settings_include, true ) ) { + $this->settings_selected_plugins( $this->get_default_settings_selected_plugins() ); + } + + if ( ! is_array( $this->get_settings_customization() ) ) { + $this->settings_customization( $this->get_default_settings_customization() ); + } + } + + public function settings_include( $include ) { + $this->settings_include = $include; + } + + public function get_settings_include() { + return $this->settings_include; + } + + private function settings_kit_info( $kit_info ) { + $this->settings_kit_info = $kit_info; + } + + private function get_settings_kit_info() { + return $this->settings_kit_info; + } + + public function settings_customization( $customization ) { + $this->settings_customization = $customization; + } + + public function get_settings_customization() { + return $this->settings_customization; + } + + public function settings_selected_custom_post_types( $selected_custom_post_types ) { + $this->settings_selected_custom_post_types = $selected_custom_post_types; + } + + public function get_settings_selected_custom_post_types() { + return $this->settings_selected_custom_post_types; + } + + public function settings_selected_plugins( $plugins ) { + $this->settings_selected_plugins = $plugins; + } + + public function get_settings_selected_plugins() { + return $this->settings_selected_plugins; + } + + /** + * Get the default settings of which content types should be exported. + * + * @return array + */ + private function get_default_settings_include() { + return [ 'templates', 'content', 'settings', 'plugins' ]; + } + + /** + * Get the default settings of the kit info. + * + * @return array + */ + private function get_default_settings_kit_info() { + return [ + 'title' => 'kit', + 'description' => '', + ]; + } + + /** + * Get the default settings of the plugins that should be exported. + * + * @return array{name: string, plugin:string, pluginUri: string, version: string} + */ + private function get_default_settings_selected_plugins() { + $installed_plugins = Plugin::$instance->wp->get_plugins(); + + return $installed_plugins->map( function ( $item, $key ) { + return [ + 'name' => $item['Name'], + 'plugin' => $key, + 'pluginUri' => $item['PluginURI'], + 'version' => $item['Version'], + ]; + } )->all(); + } + + private function get_default_settings_customization() { + return [ + 'settings' => null, + 'templates' => null, + 'content' => null, + 'plugins' => null, + ]; + } + + /** + * Get the default settings of all the custom post types that should be exported. + * Should be all the custom post types that are not built in to WordPress and not part of Elementor. + * + * @return array + */ + private function get_default_settings_custom_post_types() { + return Utils::get_registered_cpt_names(); + } + + /** + * Init the zip archive. + */ + private function init_zip_archive() { + if ( ! class_exists( '\ZipArchive' ) ) { + throw new \Error( static::ZIP_ARCHIVE_MODULE_MISSING ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + $zip = new \ZipArchive(); + + $temp_dir = Plugin::$instance->uploads_manager->create_unique_dir(); + + $zip_file_name = $temp_dir . sanitize_title( $this->settings_kit_info['title'] ) . '.zip'; + + $zip->open( $zip_file_name, \ZipArchive::CREATE | \ZipArchive::OVERWRITE ); + + $this->zip = $zip; + } + + /** + * Init the manifest data and add some basic info to it. + */ + private function init_manifest_data() { + $kit_post = Plugin::$instance->kits_manager->get_active_kit()->get_post(); + + $manifest_data = [ + 'name' => sanitize_title( $this->settings_kit_info['title'] ), + 'title' => $this->settings_kit_info['title'], + 'description' => $this->settings_kit_info['description'], + 'author' => get_the_author_meta( 'display_name', $kit_post->post_author ), + 'version' => Module::FORMAT_VERSION, + 'elementor_version' => ELEMENTOR_VERSION, + 'created' => gmdate( 'Y-m-d H:i:s' ), + 'thumbnail' => get_the_post_thumbnail_url( $kit_post ), + 'site' => get_site_url(), + ]; + + $this->manifest_data = $manifest_data; + } + + /** + * Handle the export process output. + * Add the manifest data from the runner to the manifest.json file. + * Create files according to the files array that should be exported by the runner. + * + * @param array $export_result + */ + private function handle_export_result( $export_result ) { + foreach ( $export_result['manifest'] as $data ) { + $this->manifest_data += $data; + } + + if ( isset( $export_result['files']['path'] ) ) { + $export_result['files'] = [ $export_result['files'] ]; + } + + foreach ( $export_result['files'] as $file ) { + $file_extension = pathinfo( $file['path'], PATHINFO_EXTENSION ); + if ( empty( $file_extension ) ) { + $this->add_json_file( + $file['path'], + $file['data'] + ); + } else { + $this->add_file( + $file['path'], + $file['data'] + ); + } + } + } + + /** + * Add json file to the zip archive. + * + * @param string $path The relative path to the file. + * @param array $content The content of the file. + * @param int $json_flags + */ + private function add_json_file( $path, array $content, $json_flags = 0 ) { + if ( ! Str::ends_with( $path, '.json' ) ) { + $path .= '.json'; + } + + $this->add_file( $path, wp_json_encode( $content, $json_flags ) ); + } + + /** + * Add file to the zip archive. + * + * @param string $file + * @param string $content The content of the file. + */ + private function add_file( $file, $content ) { + $this->zip->addFromString( $file, $content ); + } +} diff --git a/app/modules/import-export-customization/processes/import.php b/app/modules/import-export-customization/processes/import.php new file mode 100644 index 000000000000..1a66831ed901 --- /dev/null +++ b/app/modules/import-export-customization/processes/import.php @@ -0,0 +1,796 @@ + { "elements": array , "settings": array } } + */ + private $documents_data = []; + + /** + * Path to the extracted kit files. + * + * @var string + */ + private $extracted_directory_path; + + /** + * Imported kit manifest. + * + * @var array + */ + private $manifest; + + /** + * Imported kit site settings. (e.g: custom_colors, custom_typography, etc.) + * + * @var array + */ + private $site_settings; + + /** + * Selected content types to import. + * + * @var array + */ + private $settings_include; + + /** + * Referer of the import. (e.g: kit-library, local, etc.) + * + * @var string + */ + private $settings_referrer; + + /** + * All the conflict between the exited templates and the kit templates. + * + * @var array + */ + private $settings_conflicts; + + /** + * Selected elementor templates conditions to override. + * + * @var array + */ + private $settings_selected_override_conditions; + + /** + * Selected custom post types to import. + * + * @var array + */ + private $settings_selected_custom_post_types; + + /** + * Selected plugins to import. + * + * @var array + */ + private $settings_selected_plugins; + + /** + * The imported data output. + * + * @var array + */ + private $imported_data = []; + + /** + * The metadata output of the import runners. + * Will be saved in the import_session and will be used to revert the import process. + * + * @var array + */ + private $runners_import_metadata = []; + + /** + * @param string $path session_id | zip_file_path + * @param array $settings Use to determine which content to import. + * (e.g: include, selected_plugins, selected_cpt, selected_override_conditions, etc.) + * @param array|null $old_instance An array of old instance parameters that will be used for creating new instance. + * We are using it for quick creation of the instance when the import process is being split into chunks. + * @throws \Exception If the import session does not exist. + */ + public function __construct( string $path, array $settings = [], array $old_instance = null ) { + if ( ! empty( $old_instance ) ) { + $this->set_import_object( $old_instance ); + } else { + if ( is_file( $path ) ) { + $this->extracted_directory_path = $this->extract_zip( $path ); + } else { + $elementor_tmp_directory = Plugin::$instance->uploads_manager->get_temp_dir(); + $path = $elementor_tmp_directory . basename( $path ); + + if ( ! is_dir( $path ) ) { + throw new \Exception( 'Couldn’t execute the import process because the import session does not exist.' ); + } + + $this->extracted_directory_path = $path . '/'; + } + + $this->session_id = basename( $this->extracted_directory_path ); + $this->kit_id = $settings['id'] ?? ''; + $this->settings_referrer = ! empty( $settings['referrer'] ) ? $settings['referrer'] : 'local'; + $this->settings_include = ! empty( $settings['include'] ) ? $settings['include'] : null; + + // Using isset and not empty is important since empty array is valid option. + $this->settings_selected_override_conditions = $settings['overrideConditions'] ?? null; + $this->settings_selected_custom_post_types = $settings['selectedCustomPostTypes'] ?? null; + $this->settings_selected_plugins = $settings['plugins'] ?? null; + + $this->manifest = $this->read_manifest_json(); + $this->site_settings = $this->read_site_settings_json(); + + $this->set_default_settings(); + } + + add_filter( 'wp_php_error_args', function ( $args, $error ) { + return $this->filter_php_error_args( $args, $error ); + }, 10, 2 ); + } + + /** + * Set the import object parameters. + * + * @param array $instance + * @return void + */ + private function set_import_object( array $instance ) { + $this->session_id = $instance['session_id']; + + $instance_data = $instance['instance_data']; + + $this->extracted_directory_path = $instance_data['extracted_directory_path']; + $this->runners = $instance_data['runners']; + $this->adapters = $instance_data['adapters']; + + $this->manifest = $instance_data['manifest']; + $this->site_settings = $instance_data['site_settings']; + + $this->settings_include = $instance_data['settings_include']; + $this->settings_referrer = $instance_data['settings_referrer']; + $this->settings_conflicts = $instance_data['settings_conflicts']; + $this->settings_selected_override_conditions = $instance_data['settings_selected_override_conditions']; + $this->settings_selected_custom_post_types = $instance_data['settings_selected_custom_post_types']; + $this->settings_selected_plugins = $instance_data['settings_selected_plugins']; + + $this->documents_data = $instance_data['documents_data']; + $this->imported_data = $instance_data['imported_data']; + $this->runners_import_metadata = $instance_data['runners_import_metadata']; + } + + /** + * Creating a new instance of the import process by the id of the old import session. + * + * @param string $session_id + * + * @return Import + * @throws \Exception If the import session does not exist. + */ + public static function from_session( string $session_id ): Import { + $import_sessions = Utils::get_import_sessions(); + + if ( ! $import_sessions || ! isset( $import_sessions[ $session_id ] ) ) { + throw new \Exception( 'Couldn’t execute the import process because the import session does not exist.' ); + } + + $import_session = $import_sessions[ $session_id ]; + + return new self( $session_id, [], $import_session ); + } + + /** + * Register a runner. + * Be aware that the runner will be executed in the order of registration, the order is crucial for the import process. + * + * @param Import_Runner_Base $runner_instance + */ + public function register( Import_Runner_Base $runner_instance ) { + $this->runners[ $runner_instance::get_name() ] = $runner_instance; + } + + public function register_default_runners() { + $this->register( new Site_Settings() ); + $this->register( new Plugins() ); + $this->register( new Templates() ); + $this->register( new Taxonomies() ); + $this->register( new Elementor_Content() ); + $this->register( new Wp_Content() ); + } + + /** + * Set default settings for the import. + */ + private function set_default_settings() { + if ( ! is_array( $this->get_settings_include() ) ) { + $this->settings_include( $this->get_default_settings_include() ); + } + + if ( ! is_array( $this->get_settings_conflicts() ) ) { + $this->settings_conflicts( $this->get_default_settings_conflicts() ); + } + + if ( ! is_array( $this->get_settings_selected_override_conditions() ) ) { + $this->settings_selected_override_conditions( $this->get_default_settings_override_conditions() ); + } + + if ( ! is_array( $this->get_settings_selected_custom_post_types() ) ) { + $this->settings_selected_custom_post_types( $this->get_default_settings_custom_post_types() ); + } + + if ( ! is_array( $this->get_settings_selected_plugins() ) ) { + $this->settings_selected_plugins( $this->get_default_settings_plugins() ); + } + } + + /** + * Execute the import process. + * + * @return array The imported data output. + * + * @throws \Exception If no import runners have been specified. + */ + public function run() { + if ( empty( $this->runners ) ) { + throw new \Exception( 'Couldn’t execute the import process because no import runners have been specified. Try again by specifying import runners.' ); + } + + $data = [ + 'session_id' => $this->session_id, + 'include' => $this->settings_include, + 'manifest' => $this->manifest, + 'site_settings' => $this->site_settings, + 'selected_plugins' => $this->settings_selected_plugins, + 'extracted_directory_path' => $this->extracted_directory_path, + 'selected_custom_post_types' => $this->settings_selected_custom_post_types, + ]; + + $this->init_import_session(); + + remove_filter( 'elementor/document/save/data', [ Plugin::$instance->modules_manager->get_modules( 'content-sanitizer' ), 'sanitize_content' ] ); + add_filter( 'elementor/document/save/data', [ $this, 'prevent_saving_elements_on_post_creation' ], 10, 2 ); + + // Set the Request's state as an Elementor upload request, in order to support unfiltered file uploads. + Plugin::$instance->uploads_manager->set_elementor_upload_state( true ); + + foreach ( $this->runners as $runner ) { + if ( $runner->should_import( $data ) ) { + $import = $runner->import( $data, $this->imported_data ); + $this->imported_data = array_merge_recursive( $this->imported_data, $import ); + + $this->runners_import_metadata[ $runner::get_name() ] = $runner->get_import_session_metadata(); + } + } + + // After the upload complete, set the elementor upload state back to false. + Plugin::$instance->uploads_manager->set_elementor_upload_state( false ); + + remove_filter( 'elementor/document/save/data', [ $this, 'prevent_saving_elements_on_post_creation' ], 10 ); + + $this->finalize_import_session_option(); + + $this->save_elements_of_imported_posts(); + + Plugin::$instance->uploads_manager->remove_file_or_dir( $this->extracted_directory_path ); + return $this->imported_data; + } + + /** + * Run specific runner by runner_name + * + * @param string $runner_name + * + * @return array + * + * @throws \Exception If no export runners have been specified. + */ + public function run_runner( string $runner_name ): array { + if ( empty( $this->runners ) ) { + throw new \Exception( 'Couldn’t execute the import process because no import runners have been specified. Try again by specifying import runners.' ); + } + + $data = [ + 'session_id' => $this->session_id, + 'include' => $this->settings_include, + 'manifest' => $this->manifest, + 'site_settings' => $this->site_settings, + 'selected_plugins' => $this->settings_selected_plugins, + 'extracted_directory_path' => $this->extracted_directory_path, + 'selected_custom_post_types' => $this->settings_selected_custom_post_types, + ]; + + add_filter( 'elementor/document/save/data', [ $this, 'prevent_saving_elements_on_post_creation' ], 10, 2 ); + + // Set the Request's state as an Elementor upload request, in order to support unfiltered file uploads. + Plugin::$instance->uploads_manager->set_elementor_upload_state( true ); + + $runner = $this->runners[ $runner_name ]; + + if ( empty( $runner ) ) { + throw new \Exception( 'Couldn’t execute the import process because the import runner was not found. Try again by specifying an import runner.' ); + } + + if ( $runner->should_import( $data ) ) { + $import = $runner->import( $data, $this->imported_data ); + $this->imported_data = array_merge_recursive( $this->imported_data, $import ); + + $this->runners_import_metadata[ $runner::get_name() ] = $runner->get_import_session_metadata(); + } + + // After the upload complete, set the elementor upload state back to false. + Plugin::$instance->uploads_manager->set_elementor_upload_state( false ); + + remove_filter( 'elementor/document/save/data', [ $this, 'prevent_saving_elements_on_post_creation' ], 10 ); + + $is_last_runner = key( array_slice( $this->runners, -1, 1, true ) ) === $runner_name; + if ( $is_last_runner ) { + $this->finalize_import_session_option(); + $this->save_elements_of_imported_posts(); + } else { + $this->update_instance_data_in_import_session_option(); + } + + return [ + 'status' => 'success', + 'runner' => $runner_name, + ]; + } + + /** + * Create and save all the instance data to the import sessions option. + * + * @return void + */ + public function init_import_session( $save_instance_data = false ) { + $import_sessions = Utils::get_import_sessions( true ); + + $import_sessions[ $this->session_id ] = [ + 'session_id' => $this->session_id, + 'kit_title' => $this->manifest['title'] ?? '', + 'kit_name' => $this->manifest['name'] ?? '', + 'kit_thumbnail' => $this->get_kit_thumbnail(), + 'kit_source' => $this->settings_referrer, + 'user_id' => get_current_user_id(), + 'start_timestamp' => current_time( 'timestamp' ), + ]; + + if ( $save_instance_data ) { + $import_sessions[ $this->session_id ]['instance_data'] = [ + 'extracted_directory_path' => $this->extracted_directory_path, + 'runners' => $this->runners, + 'adapters' => $this->adapters, + + 'manifest' => $this->manifest, + 'site_settings' => $this->site_settings, + + 'settings_include' => $this->settings_include, + 'settings_referrer' => $this->settings_referrer, + 'settings_conflicts' => $this->settings_conflicts, + 'settings_selected_override_conditions' => $this->settings_selected_override_conditions, + 'settings_selected_custom_post_types' => $this->settings_selected_custom_post_types, + 'settings_selected_plugins' => $this->settings_selected_plugins, + + 'documents_data' => $this->documents_data, + 'imported_data' => $this->imported_data, + 'runners_import_metadata' => $this->runners_import_metadata, + ]; + } + + update_option( Module::OPTION_KEY_ELEMENTOR_IMPORT_SESSIONS, $import_sessions, false ); + } + + /** + * Get the Kit thumbnail, goes to the home page thumbnail if main doesn't exist + * + * @return string + */ + private function get_kit_thumbnail(): string { + if ( ! empty( $this->manifest['thumbnail'] ) ) { + return $this->manifest['thumbnail']; + } + + return apply_filters( 'elementor/import/kit_thumbnail', '', $this->kit_id, $this->settings_referrer ); + } + + public function get_runners_name(): array { + return array_keys( $this->runners ); + } + + public function get_manifest() { + return $this->manifest; + } + + public function get_extracted_directory_path() { + return $this->extracted_directory_path; + } + + public function get_session_id() { + return $this->session_id; + } + + public function get_adapters() { + return $this->adapters; + } + + public function get_imported_data() { + return $this->imported_data; + } + + /** + * Get settings by key. + * Used for backward compatibility. + * + * @param string $key The key of the setting. + */ + public function get_settings( $key ) { + switch ( $key ) { + case 'include': + return $this->get_settings_include(); + + case 'overrideConditions': + return $this->get_settings_selected_override_conditions(); + + case 'selectedCustomPostTypes': + return $this->get_settings_selected_custom_post_types(); + + case 'plugins': + return $this->get_settings_selected_plugins(); + + default: + return []; + } + } + + public function settings_include( array $settings_include ) { + $this->settings_include = $settings_include; + + return $this; + } + + public function get_settings_include() { + return $this->settings_include; + } + + public function settings_referrer( $settings_referrer ) { + $this->settings_referrer = $settings_referrer; + + return $this; + } + + public function get_settings_referrer() { + return $this->settings_referrer; + } + + public function settings_conflicts( array $settings_conflicts ) { + $this->settings_conflicts = $settings_conflicts; + + return $this; + } + + public function get_settings_conflicts() { + return $this->settings_conflicts; + } + + public function settings_selected_override_conditions( array $settings_selected_override_conditions ) { + $this->settings_selected_override_conditions = $settings_selected_override_conditions; + + return $this; + } + + public function get_settings_selected_override_conditions() { + return $this->settings_selected_override_conditions; + } + + public function settings_selected_custom_post_types( array $settings_selected_custom_post_types ) { + $this->settings_selected_custom_post_types = $settings_selected_custom_post_types; + + return $this; + } + + public function get_settings_selected_custom_post_types() { + return $this->settings_selected_custom_post_types; + } + + public function settings_selected_plugins( array $settings_selected_plugins ) { + $this->settings_selected_plugins = $settings_selected_plugins; + + return $this; + } + + public function get_settings_selected_plugins() { + return $this->settings_selected_plugins; + } + + /** + * Prevent saving elements on elementor post creation. + * + * @param array $data + * @param Document $document + * + * @return array + */ + public function prevent_saving_elements_on_post_creation( array $data, Document $document ) { + if ( isset( $data['elements'] ) ) { + $this->documents_data[ $document->get_main_id() ] = [ 'elements' => $data['elements'] ]; + + $data['elements'] = []; + } + + if ( isset( $data['settings'] ) ) { + $this->documents_data[ $document->get_main_id() ]['settings'] = $data['settings']; + + } + + return $data; + } + + /** + * Extract the zip file. + * + * @param string $zip_path The path to the zip file. + * @return string The extracted directory path. + */ + private function extract_zip( $zip_path ) { + $extraction_result = Plugin::$instance->uploads_manager->extract_and_validate_zip( $zip_path, [ 'json', 'xml' ] ); + + if ( is_wp_error( $extraction_result ) ) { + if ( isset( $extraction_result->errors['zip_error'] ) ) { + throw new \Error( static::ZIP_ARCHIVE_ERROR_KEY ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + throw new \Error( static::ZIP_FILE_ERROR_KEY ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + return $extraction_result['extraction_directory']; + } + + /** + * Get the manifest file from the extracted directory and adapt it if needed. + * + * @return string The manifest file content. + */ + private function read_manifest_json() { + $manifest = Utils::read_json_file( $this->extracted_directory_path . 'manifest' ); + + if ( ! $manifest ) { + Plugin::$instance->logger->get_logger()->error( static::MANIFEST_ERROR_KEY ); + throw new \Error( static::ZIP_FILE_ERROR_KEY ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + $this->init_adapters( $manifest ); + + foreach ( $this->adapters as $adapter ) { + $manifest = $adapter->adapt_manifest( $manifest ); + } + + return $manifest; + } + + /** + * Init the adapters and determine which ones to use. + * + * @param array $manifest_data The manifest file content. + */ + private function init_adapters( array $manifest_data ) { + $this->adapters = []; + + /** @var Base_Adapter[] $adapter_types */ + $adapter_types = [ Customization::class, Envato::class, Kit_Library::class ]; + + foreach ( $adapter_types as $adapter_type ) { + if ( $adapter_type::is_compatibility_needed( $manifest_data, [ 'referrer' => $this->get_settings_referrer() ] ) ) { + $this->adapters[] = new $adapter_type( $this ); + } + } + } + + /** + * Get the site settings file from the extracted directory and adapt it if needed. + * + * @return string The site settings file content. + */ + private function read_site_settings_json() { + $site_settings = Utils::read_json_file( $this->extracted_directory_path . 'site-settings' ); + + foreach ( $this->adapters as $adapter ) { + $site_settings = $adapter->adapt_site_settings( $site_settings, $this->manifest, $this->extracted_directory_path ); + } + + return $site_settings; + } + + /** + * Get all the custom post types in the kit. + * + * @return array Custom post types names. + */ + private function get_default_settings_custom_post_types() { + if ( empty( $this->manifest['custom-post-type-title'] ) ) { + return []; + } + + $manifest_post_types = array_keys( $this->manifest['custom-post-type-title'] ); + + return array_diff( $manifest_post_types, Utils::get_builtin_wp_post_types() ); + } + + /** + * Get the default settings of elementor templates conditions to override. + * + * @return array + */ + private function get_default_settings_conflicts() { + if ( empty( $this->manifest['templates'] ) ) { + return []; + } + + return apply_filters( 'elementor/import/get_default_settings_conflicts', [], $this->manifest['templates'] ); + } + + /** + * Get the default settings of elementor templates conditions to override. + * + * @return array + */ + private function get_default_settings_override_conditions() { + if ( empty( $this->settings_conflicts ) ) { + return []; + } + + return array_keys( $this->settings_conflicts ); + } + + /** + * Get the default settings of the plugins that should be imported. + * + * @return array + */ + private function get_default_settings_plugins() { + return ! empty( $this->manifest['plugins'] ) ? $this->manifest['plugins'] : []; + } + + /** + * Get the default settings of which content types should be imported. + * + * @return array + */ + private function get_default_settings_include() { + return [ 'templates', 'plugins', 'content', 'settings' ]; + } + + /** + * Get the data that requires updating/replacement when imported. + * + * @return array{post_ids: array, term_ids: array} + */ + private function get_imported_data_replacements(): array { + return [ + 'post_ids' => Utils::map_old_new_post_ids( $this->imported_data ), + 'term_ids' => Utils::map_old_new_term_ids( $this->imported_data ), + ]; + } + + /** + * Save the prevented elements on elementor post creation elements. + * Handle the replacement of all the dynamic content of the elements that probably have been changed during the import. + */ + private function save_elements_of_imported_posts() { + $imported_data_replacements = $this->get_imported_data_replacements(); + + foreach ( $this->documents_data as $new_id => $data ) { + $document = Plugin::$instance->documents->get( $new_id ); + + if ( isset( $data['elements'] ) ) { + $data['elements'] = $document->on_import_update_dynamic_content( $data['elements'], $imported_data_replacements ); + } + + if ( isset( $data['settings'] ) ) { + + if ( $document instanceof Kit ) { + // Without post_status certain tabs in the Kit will not save properly. + $data['settings']['post_status'] = get_post_status( $new_id ); + } + + $data['settings'] = $document->on_import_update_settings( $data['settings'], $imported_data_replacements ); + } + + $document->save( $data ); + } + } + + private function update_instance_data_in_import_session_option() { + $import_sessions = Utils::get_import_sessions(); + + $import_sessions[ $this->session_id ]['instance_data']['documents_data'] = $this->documents_data; + $import_sessions[ $this->session_id ]['instance_data']['imported_data'] = $this->imported_data; + $import_sessions[ $this->session_id ]['instance_data']['runners_import_metadata'] = $this->runners_import_metadata; + + update_option( Module::OPTION_KEY_ELEMENTOR_IMPORT_SESSIONS, $import_sessions, false ); + } + + public function finalize_import_session_option() { + $import_sessions = Utils::get_import_sessions(); + + if ( ! isset( $import_sessions[ $this->session_id ] ) ) { + return; + } + + unset( $import_sessions[ $this->session_id ]['instance_data'] ); + + $import_sessions[ $this->session_id ]['end_timestamp'] = current_time( 'timestamp' ); + $import_sessions[ $this->session_id ]['runners'] = $this->runners_import_metadata; + + update_option( Module::OPTION_KEY_ELEMENTOR_IMPORT_SESSIONS, $import_sessions, false ); + } + + /** + * Filter the php error args and return 408 status code if the error is a timeout. + * + * @param array $args + * @param array $error + * @return array + */ + private function filter_php_error_args( $args, $error ) { + if ( strpos( $error['message'], 'Maximum execution time' ) !== false ) { + $args['response'] = 408; + } + + return $args; + } +} diff --git a/app/modules/import-export-customization/processes/revert.php b/app/modules/import-export-customization/processes/revert.php new file mode 100644 index 000000000000..0e929558cf72 --- /dev/null +++ b/app/modules/import-export-customization/processes/revert.php @@ -0,0 +1,176 @@ +import_sessions = self::get_import_sessions(); + $this->revert_sessions = self::get_revert_sessions(); + } + + /** + * Register a runner. + * + * @param Revert_Runner_Base $runner_instance + */ + public function register( Revert_Runner_Base $runner_instance ) { + $this->runners[ $runner_instance::get_name() ] = $runner_instance; + } + + public function register_default_runners() { + $this->register( new Site_Settings() ); + $this->register( new Plugins() ); + $this->register( new Templates() ); + $this->register( new Taxonomies() ); + $this->register( new Elementor_Content() ); + $this->register( new Wp_Content() ); + } + + /** + * Execute the revert process. + * + * @throws \Exception If no revert runners have been specified. + */ + public function run() { + if ( empty( $this->runners ) ) { + throw new \Exception( 'Couldn’t execute the revert process because no revert runners have been specified. Try again by specifying revert runners.' ); + } + + $import_session = $this->get_last_import_session(); + + if ( empty( $import_session ) ) { + throw new \Exception( 'Couldn’t execute the revert process because there are no import sessions to revert.' ); + } + + // fallback if the import session failed and doesn't have the runners metadata + if ( ! isset( $import_session['runners'] ) && isset( $import_session['instance_data'] ) ) { + $import_session['runners'] = $import_session['instance_data']['runners_import_metadata'] ?? []; + } + + foreach ( $this->runners as $runner ) { + if ( $runner->should_revert( $import_session ) ) { + $runner->revert( $import_session ); + } + } + + $this->revert_attachments( $import_session ); + + $this->delete_last_import_data(); + } + + public static function get_import_sessions() { + $import_sessions = Utils::get_import_sessions(); + + if ( ! $import_sessions ) { + return []; + } + + usort( $import_sessions, function( $a, $b ) { + return strcmp( $a['start_timestamp'], $b['start_timestamp'] ); + } ); + + return $import_sessions; + } + + public static function get_revert_sessions() { + $revert_sessions = get_option( Module::OPTION_KEY_ELEMENTOR_REVERT_SESSIONS ); + + if ( ! $revert_sessions ) { + return []; + } + + return $revert_sessions; + } + + public function get_last_import_session() { + $import_sessions = $this->import_sessions; + + if ( empty( $import_sessions ) ) { + return []; + } + + return end( $import_sessions ); + } + + public function get_penultimate_import_session() { + $sessions_data = $this->import_sessions; + $penultimate_element_value = []; + + if ( empty( $sessions_data ) ) { + return []; + } + + end( $sessions_data ); + + prev( $sessions_data ); + + if ( ! is_null( key( $sessions_data ) ) ) { + $penultimate_element_value = current( $sessions_data ); + } + + return $penultimate_element_value; + } + + private function delete_last_import_data() { + $import_sessions = $this->import_sessions; + $revert_sessions = $this->revert_sessions; + + $reverted_session = array_pop( $import_sessions ); + + $revert_sessions[] = [ + 'session_id' => $reverted_session['session_id'], + 'kit_title' => $reverted_session['kit_title'], + 'kit_name' => $reverted_session['kit_name'], + 'kit_thumbnail' => $reverted_session['kit_thumbnail'], + 'source' => $reverted_session['kit_source'], + 'user_id' => get_current_user_id(), + 'import_timestamp' => $reverted_session['start_timestamp'], + 'revert_timestamp' => current_time( 'timestamp' ), + ]; + + update_option( Module::OPTION_KEY_ELEMENTOR_IMPORT_SESSIONS, $import_sessions, false ); + update_option( Module::OPTION_KEY_ELEMENTOR_REVERT_SESSIONS, $revert_sessions, false ); + + $this->import_sessions = $import_sessions; + $this->revert_sessions = $revert_sessions; + } + + private function revert_attachments( $data ) { + $query_args = [ + 'post_type' => 'attachment', + 'post_status' => 'any', + 'posts_per_page' => -1, + 'meta_query' => [ + [ + 'key' => Module::META_KEY_ELEMENTOR_IMPORT_SESSION_ID, + 'value' => $data['session_id'], + ], + ], + ]; + + $query = new \WP_Query( $query_args ); + + foreach ( $query->posts as $post ) { + wp_delete_attachment( $post->ID, true ); + } + } +} diff --git a/app/modules/import-export-customization/runners/export/elementor-content.php b/app/modules/import-export-customization/runners/export/elementor-content.php new file mode 100644 index 000000000000..1d06d969a40a --- /dev/null +++ b/app/modules/import-export-customization/runners/export/elementor-content.php @@ -0,0 +1,144 @@ +init_page_on_front_data(); + } + + public static function get_name(): string { + return 'elementor-content'; + } + + public function should_export( array $data ) { + return ( + isset( $data['include'] ) && + in_array( 'content', $data['include'], true ) + ); + } + + public function export( array $data ) { + $elementor_post_types = ImportExportUtils::get_elementor_post_types(); + + $files = []; + $manifest = []; + + foreach ( $elementor_post_types as $post_type ) { + $export = $this->export_elementor_post_type( $post_type ); + $files = array_merge( $files, $export['files'] ); + + $manifest[ $post_type ] = $export['manifest_data']; + } + + $manifest_data['content'] = $manifest; + + return [ + 'files' => $files, + 'manifest' => [ + $manifest_data, + ], + ]; + } + + private function export_elementor_post_type( $post_type ) { + $query_args = [ + 'post_type' => $post_type, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => [ + [ + 'key' => static::META_KEY_ELEMENTOR_EDIT_MODE, + 'compare' => 'EXISTS', + ], + [ + 'key' => '_elementor_data', + 'compare' => 'EXISTS', + ], + [ + 'key' => '_elementor_data', + 'compare' => '!=', + 'value' => '[]', + ], + ], + ]; + + $query = new \WP_Query( $query_args ); + + if ( empty( $query ) ) { + return [ + 'files' => [], + 'manifest_data' => [], + ]; + } + + $post_type_taxonomies = $this->get_post_type_taxonomies( $post_type ); + + $manifest_data = []; + $files = []; + + foreach ( $query->posts as $post ) { + $document = Plugin::$instance->documents->get( $post->ID ); + + $terms = ! empty( $post_type_taxonomies ) ? $this->get_post_terms( $post->ID, $post_type_taxonomies ) : []; + + $post_manifest_data = [ + 'title' => $post->post_title, + 'excerpt' => $post->post_excerpt, + 'doc_type' => $document->get_name(), + 'thumbnail' => get_the_post_thumbnail_url( $post ), + 'url' => get_permalink( $post ), + 'terms' => $terms, + ]; + + if ( $post->ID === $this->page_on_front_id ) { + $post_manifest_data['show_on_front'] = true; + } + + $manifest_data[ $post->ID ] = $post_manifest_data; + + $files[] = [ + 'path' => 'content/' . $post_type . '/' . $post->ID, + 'data' => $document->get_export_data(), + ]; + } + + return [ + 'files' => $files, + 'manifest_data' => $manifest_data, + ]; + } + + private function get_post_type_taxonomies( $post_type ) { + return get_object_taxonomies( $post_type ); + } + + private function get_post_terms( $post_id, array $taxonomies ) { + $terms = wp_get_object_terms( $post_id, $taxonomies ); + + $result = []; + + foreach ( $terms as $term ) { + $result[] = [ + 'term_id' => $term->term_id, + 'taxonomy' => $term->taxonomy, + 'slug' => $term->slug, + ]; + } + + return $result; + } + + private function init_page_on_front_data() { + $show_page_on_front = 'page' === get_option( 'show_on_front' ); + + if ( $show_page_on_front ) { + $this->page_on_front_id = (int) get_option( 'page_on_front' ); + } + } +} diff --git a/app/modules/import-export-customization/runners/export/export-runner-base.php b/app/modules/import-export-customization/runners/export/export-runner-base.php new file mode 100644 index 000000000000..957142c5c4af --- /dev/null +++ b/app/modules/import-export-customization/runners/export/export-runner-base.php @@ -0,0 +1,27 @@ + [ + $manifest_data, + ], + 'files' => [], + ]; + } +} diff --git a/app/modules/import-export-customization/runners/export/site-settings.php b/app/modules/import-export-customization/runners/export/site-settings.php new file mode 100644 index 000000000000..bfa144aeb5d7 --- /dev/null +++ b/app/modules/import-export-customization/runners/export/site-settings.php @@ -0,0 +1,231 @@ +export_customization( $data, $customization ); + } + + return $this->export_all( $data ); + } + + private function export_all( $data ) { + $kit = Plugin::$instance->kits_manager->get_active_kit(); + $kit_data = $kit->get_export_data(); + + $excluded_kit_settings_keys = [ + 'site_name', + 'site_description', + 'site_logo', + 'site_favicon', + ]; + + foreach ( $excluded_kit_settings_keys as $setting_key ) { + unset( $kit_data['settings'][ $setting_key ] ); + } + + $theme_data = $this->export_theme(); + + if ( $theme_data ) { + $kit_data['theme'] = $theme_data; + } + + $experiments_data = $this->export_experiments(); + + if ( $experiments_data ) { + $kit_data['experiments'] = $experiments_data; + } + + $manifest_data['site-settings'] = array_fill_keys( self::ALLOWED_SETTINGS, true ); + + return [ + 'files' => [ + 'path' => 'site-settings', + 'data' => $kit_data, + ], + 'manifest' => [ + $manifest_data, + ], + ]; + } + + private function export_customization( $data, $customization ) { + $kit = Plugin::$instance->kits_manager->get_active_kit(); + $kit_data = $kit->get_export_data(); + + foreach ( $customization as $key => $value ) { + if ( ! in_array( $key, self::ALLOWED_SETTINGS ) ) { + unset( $customization[ $key ] ); + } + } + + if ( ! $customization['globalColors'] ) { + $kit_data = $this->remove_global_colors( $kit_data ); + } + + if ( ! $customization['globalFonts'] ) { + $kit_data = $this->remove_global_fonts( $kit_data ); + } + + if ( ! $customization['themeStyleSettings'] ) { + $kit_data = $this->remove_theme_style( $kit_data ); + } + + if ( ! $customization['generalSettings'] ) { + $kit_data = $this->remove_other_settings( $kit_data ); + } + + if ( $customization['theme'] ) { + $theme_data = $this->export_theme(); + + if ( $theme_data ) { + $kit_data['theme'] = $theme_data; + } + } + + if ( $customization['experiments'] ) { + $experiments_data = $this->export_experiments(); + + if ( $experiments_data ) { + $kit_data['experiments'] = $experiments_data; + } + } + + return [ + 'files' => [ + 'path' => 'site-settings', + 'data' => $kit_data, + ], + 'manifest' => [ + [ 'site-settings' => $customization ], + ], + ]; + } + + public function export_theme() { + $theme = wp_get_theme(); + + if ( empty( $theme ) || empty( $theme->get( 'ThemeURI' ) ) ) { + return null; + } + + $theme_data['name'] = $theme->get( 'Name' ); + $theme_data['theme_uri'] = $theme->get( 'ThemeURI' ); + $theme_data['version'] = $theme->get( 'Version' ); + $theme_data['slug'] = $theme->get_stylesheet(); + + return $theme_data; + } + + private function export_experiments() { + $features = Plugin::$instance->experiments->get_features(); + + if ( empty( $features ) ) { + return null; + } + + $experiments_data = []; + + foreach ( $features as $feature_name => $feature ) { + $experiments_data[ $feature_name ] = [ + 'name' => $feature_name, + 'title' => $feature['title'], + 'state' => $feature['state'], + 'default' => $feature['default'], + 'release_status' => $feature['release_status'], + ]; + } + + return empty( $experiments_data ) ? null : $experiments_data; + } + + private function remove_global_colors( $kit_data ) { + $color_keys = [ 'system_colors', 'custom_colors' ]; + + foreach ( $color_keys as $key ) { + if ( isset( $kit_data['settings'][ $key ] ) ) { + unset( $kit_data['settings'][ $key ] ); + } + } + + return $kit_data; + } + + private function remove_global_fonts( $kit_data ) { + $typography_keys = [ 'system_typography', 'custom_typography', 'default_generic_fonts' ]; + + foreach ( $typography_keys as $key ) { + if ( isset( $kit_data['settings'][ $key ] ) ) { + unset( $kit_data['settings'][ $key ] ); + } + } + + return $kit_data; + } + + private function remove_theme_style( $kit_data ) { + $theme_style_patterns = [ + '/^body_/', + '/^h[1-6]_/', + '/^button_/', + '/^link_/', + '/^form_field_/', + ]; + + foreach ( $kit_data['settings'] as $key => $value ) { + foreach ( $theme_style_patterns as $pattern ) { + if ( preg_match( $pattern, $key ) ) { + unset( $kit_data['settings'][ $key ] ); + break; + } + } + } + + return $kit_data; + } + + private function remove_other_settings( $kit_data ) { + $settings_keys = [ + 'template', + 'container_width', + 'container_padding', + 'space_between_widgets', + 'viewport_md', + 'viewport_lg', + 'page_title_selector', + 'activeItemIndex', + ]; + + foreach ( $settings_keys as $key ) { + if ( isset( $kit_data['settings'][ $key ] ) ) { + unset( $kit_data['settings'][ $key ] ); + } + } + + return $kit_data; + } +} diff --git a/app/modules/import-export-customization/runners/export/taxonomies.php b/app/modules/import-export-customization/runners/export/taxonomies.php new file mode 100644 index 000000000000..e7af953c9ced --- /dev/null +++ b/app/modules/import-export-customization/runners/export/taxonomies.php @@ -0,0 +1,119 @@ +export_taxonomies( $post_types ); + + $manifest_data['taxonomies'] = $export['manifest']; + + return [ + 'files' => $export['files'], + 'manifest' => [ + $manifest_data, + ], + ]; + } + + private function export_taxonomies( array $post_types ) { + $files = []; + $manifest = []; + + $taxonomies = get_taxonomies(); + + foreach ( $taxonomies as $taxonomy ) { + $taxonomy_post_types = get_taxonomy( $taxonomy )->object_type; + $intersected_post_types = array_intersect( $taxonomy_post_types, $post_types ); + + if ( empty( $intersected_post_types ) ) { + continue; + } + + $data = $this->export_terms( $taxonomy ); + + if ( empty( $data ) ) { + continue; + } + + foreach ( $intersected_post_types as $post_type ) { + $manifest[ $post_type ][] = $taxonomy; + } + + $files[] = [ + 'path' => 'taxonomies/' . $taxonomy, + 'data' => $data, + ]; + } + + return [ + 'files' => $files, + 'manifest' => $manifest, + ]; + } + + private function export_terms( $taxonomy ) { + $terms = get_terms( [ + 'taxonomy' => (array) $taxonomy, + 'hide_empty' => true, + 'get' => 'all', + ] ); + + $ordered_terms = $this->order_terms( $terms ); + + if ( empty( $ordered_terms ) ) { + return []; + } + + $data = []; + + foreach ( $ordered_terms as $term ) { + $data[] = [ + 'term_id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + 'taxonomy' => $term->taxonomy, + 'description' => $term->description, + 'parent' => $term->parent, + ]; + } + + return $data; + } + /** + * Put terms in order with no child going before its parent. + */ + private function order_terms( array $terms ) { + $ordered_terms = []; + + while ( $term = array_shift( $terms ) ) { + $is_top_level = 0 === $term->parent; + $is_parent_exits = isset( $ordered_terms[ $term->parent ] ); + + if ( $is_top_level || $is_parent_exits ) { + $ordered_terms[ $term->term_id ] = $term; + } else { + $terms[] = $term; + } + } + + return $ordered_terms; + } +} diff --git a/app/modules/import-export-customization/runners/export/templates.php b/app/modules/import-export-customization/runners/export/templates.php new file mode 100644 index 000000000000..4a71f192328c --- /dev/null +++ b/app/modules/import-export-customization/runners/export/templates.php @@ -0,0 +1,66 @@ + Source_Local::CPT, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'meta_query' => [ + [ + 'key' => Document::TYPE_META_KEY, + 'value' => $template_types, + ], + ], + ]; + + $templates_query = new \WP_Query( $query_args ); + + $templates_manifest_data = []; + $files = []; + + foreach ( $templates_query->posts as $template_post ) { + $template_id = $template_post->ID; + + $template_document = Plugin::$instance->documents->get( $template_id ); + + $templates_manifest_data[ $template_id ] = $template_document->get_export_summary(); + + $files[] = [ + 'path' => 'templates/' . $template_id, + 'data' => $template_document->get_export_data(), + ]; + } + + $manifest_data['templates'] = $templates_manifest_data; + + return [ + 'files' => $files, + 'manifest' => [ + $manifest_data, + ], + ]; + } +} diff --git a/app/modules/import-export-customization/runners/export/wp-content.php b/app/modules/import-export-customization/runners/export/wp-content.php new file mode 100644 index 000000000000..6c3ed69c31d3 --- /dev/null +++ b/app/modules/import-export-customization/runners/export/wp-content.php @@ -0,0 +1,79 @@ +export_wp_post_type( $post_type ); + $files[] = $export['file']; + $manifest_data['wp-content'][ $post_type ] = $export['manifest_data']; + } + + foreach ( $custom_post_types as $post_type ) { + $export = $this->export_wp_post_type( $post_type ); + $files[] = $export['file']; + $manifest_data['wp-content'][ $post_type ] = $export['manifest_data']; + + $post_type_object = get_post_type_object( $post_type ); + + $manifest_data['custom-post-type-title'][ $post_type ] = [ + 'name' => $post_type_object->name, + 'label' => $post_type_object->label, + ]; + } + + return [ + 'files' => $files, + 'manifest' => [ + $manifest_data, + ], + ]; + } + + private function export_wp_post_type( $post_type ) { + $wp_exporter = new WP_Exporter( [ + 'content' => $post_type, + 'status' => 'publish', + 'limit' => 20, + 'meta_query' => [ + [ + 'key' => static::META_KEY_ELEMENTOR_EDIT_MODE, + 'compare' => 'NOT EXISTS', + ], + ], + 'include_post_featured_image_as_attachment' => true, + ] ); + + $export_result = $wp_exporter->run(); + + return [ + 'file' => [ + 'path' => 'wp-content/' . $post_type . '/' . $post_type . '.xml', + 'data' => $export_result['xml'], + ], + 'manifest_data' => $export_result['ids'], + ]; + } +} diff --git a/app/modules/import-export-customization/runners/import/elementor-content.php b/app/modules/import-export-customization/runners/import/elementor-content.php new file mode 100644 index 000000000000..11969c84b758 --- /dev/null +++ b/app/modules/import-export-customization/runners/import/elementor-content.php @@ -0,0 +1,158 @@ +init_page_on_front_data(); + } + + public static function get_name(): string { + return 'elementor-content'; + } + + public function should_import( array $data ) { + return ( + isset( $data['include'] ) && + in_array( 'content', $data['include'], true ) && + ! empty( $data['manifest']['content'] ) && + ! empty( $data['extracted_directory_path'] ) + ); + } + + public function import( array $data, array $imported_data ) { + $result['content'] = []; + $this->import_session_id = $data['session_id']; + + $elementor_post_types = ImportExportUtils::get_elementor_post_types(); + + foreach ( $elementor_post_types as $post_type ) { + if ( empty( $data['manifest']['content'][ $post_type ] ) ) { + continue; + } + + $posts_settings = $data['manifest']['content'][ $post_type ]; + $path = $data['extracted_directory_path'] . 'content/' . $post_type . '/'; + $imported_terms = ! empty( $imported_data['taxonomies'] ) + ? ImportExportUtils::map_old_new_term_ids( $imported_data ) + : []; + + $result['content'][ $post_type ] = $this->import_elementor_post_type( $posts_settings, $path, $post_type, $imported_terms ); + } + + return $result; + } + + private function import_elementor_post_type( array $posts_settings, $path, $post_type, array $imported_terms ) { + $result = [ + 'succeed' => [], + 'failed' => [], + ]; + + foreach ( $posts_settings as $id => $post_settings ) { + try { + $post_data = ImportExportUtils::read_json_file( $path . $id ); + $import = $this->import_post( $post_settings, $post_data, $post_type, $imported_terms ); + + if ( is_wp_error( $import ) ) { + $result['failed'][ $id ] = $import->get_error_message(); + continue; + } + + $result['succeed'][ $id ] = $import; + } catch ( \Exception $error ) { + $result['failed'][ $id ] = $error->getMessage(); + } + } + + return $result; + } + + private function import_post( array $post_settings, array $post_data, $post_type, array $imported_terms ) { + $post_attributes = [ + 'post_title' => $post_settings['title'], + 'post_type' => $post_type, + 'post_status' => 'publish', + ]; + + if ( ! empty( $post_settings['excerpt'] ) ) { + $post_attributes['post_excerpt'] = $post_settings['excerpt']; + } + + $new_document = Plugin::$instance->documents->create( + $post_settings['doc_type'], + $post_attributes + ); + + if ( is_wp_error( $new_document ) ) { + throw new \Exception( esc_html( $new_document->get_error_message() ) ); + } + + $post_data['import_settings'] = $post_settings; + + $new_attachment_callback = function( $attachment_id ) { + $this->set_session_post_meta( $attachment_id, $this->import_session_id ); + }; + + add_filter( 'elementor/template_library/import_images/new_attachment', $new_attachment_callback ); + + $new_document->import( $post_data ); + + remove_filter( 'elementor/template_library/import_images/new_attachment', $new_attachment_callback ); + + $new_post_id = $new_document->get_main_id(); + + if ( ! empty( $post_settings['terms'] ) ) { + $this->set_post_terms( $new_post_id, $post_settings['terms'], $imported_terms ); + } + + if ( ! empty( $post_settings['show_on_front'] ) ) { + $this->set_page_on_front( $new_post_id ); + } + + $this->set_session_post_meta( $new_post_id, $this->import_session_id ); + + return $new_post_id; + } + + private function set_post_terms( $post_id, array $terms, array $imported_terms ) { + foreach ( $terms as $term ) { + if ( ! isset( $imported_terms[ $term['term_id'] ] ) ) { + continue; + } + + wp_set_post_terms( $post_id, [ $imported_terms[ $term['term_id'] ] ], $term['taxonomy'], false ); + } + } + + private function init_page_on_front_data() { + $this->show_page_on_front = 'page' === get_option( 'show_on_front' ); + + if ( $this->show_page_on_front ) { + $this->page_on_front_id = (int) get_option( 'page_on_front' ); + } + } + + private function set_page_on_front( $page_id ) { + update_option( 'page_on_front', $page_id ); + + if ( ! $this->show_page_on_front ) { + update_option( 'show_on_front', 'page' ); + } + } + + public function get_import_session_metadata(): array { + return [ + 'page_on_front' => $this->page_on_front_id ?? 0, + ]; + } +} diff --git a/app/modules/import-export-customization/runners/import/import-runner-base.php b/app/modules/import-export-customization/runners/import/import-runner-base.php new file mode 100644 index 000000000000..68c7dd6bb138 --- /dev/null +++ b/app/modules/import-export-customization/runners/import/import-runner-base.php @@ -0,0 +1,39 @@ +plugins_manager = $plugins_manager; + } else { + $this->plugins_manager = new Plugins_Manager(); + } + } + + public static function get_name(): string { + return 'plugins'; + } + + public function should_import( array $data ) { + return ( + isset( $data['include'] ) && + in_array( 'plugins', $data['include'], true ) && + ! empty( $data['manifest']['plugins'] ) && + ! empty( $data['selected_plugins'] ) + ); + } + + public function import( array $data, array $imported_data ) { + $plugins = $data['selected_plugins']; + + $plugins_collection = ( new Collection( $plugins ) ) + ->map( function ( $item ) { + if ( ! Str::ends_with( $item['plugin'], '.php' ) ) { + $item['plugin'] .= '.php'; + } + return $item; + } ); + + $slugs = $plugins_collection + ->map( function ( $item ) { + return $item['plugin']; + } ) + ->all(); + + $installed = $this->plugins_manager->install( $slugs ); + $activated = $this->plugins_manager->activate( $installed['succeeded'] ); + + $ordered_activated_plugins = $plugins_collection + ->filter( function ( $item ) use ( $activated ) { + return in_array( $item['plugin'], $activated['succeeded'], true ); + } ) + ->map( function ( $item ) { + return $item['name']; + } ) + ->all(); + + $result['plugins'] = $ordered_activated_plugins; + + return $result; + } +} diff --git a/app/modules/import-export-customization/runners/import/site-settings.php b/app/modules/import-export-customization/runners/import/site-settings.php new file mode 100644 index 000000000000..aa8a41a6b677 --- /dev/null +++ b/app/modules/import-export-customization/runners/import/site-settings.php @@ -0,0 +1,256 @@ +kits_manager->get_active_kit(); + + $this->active_kit_id = (int) $active_kit->get_id(); + $this->previous_kit_id = (int) Plugin::$instance->kits_manager->get_previous_id(); + + $result = []; + + $old_settings = $active_kit->get_meta( PageManager::META_KEY ); + + if ( ! $old_settings ) { + $old_settings = []; + } + + if ( ! empty( $old_settings['custom_colors'] ) ) { + $new_site_settings['custom_colors'] = array_merge( $old_settings['custom_colors'], $new_site_settings['custom_colors'] ); + } + + if ( ! empty( $old_settings['custom_typography'] ) ) { + $new_site_settings['custom_typography'] = array_merge( $old_settings['custom_typography'], $new_site_settings['custom_typography'] ); + } + + if ( ! empty( $new_site_settings['space_between_widgets'] ) ) { + $new_site_settings['space_between_widgets'] = Utils::update_space_between_widgets_values( $new_site_settings['space_between_widgets'] ); + } + + $new_site_settings = array_replace_recursive( $old_settings, $new_site_settings ); + + $new_kit = Plugin::$instance->kits_manager->create_new_kit( $title, $new_site_settings ); + + $this->imported_kit_id = (int) $new_kit; + + $result['site-settings'] = (bool) $new_kit; + + $import_theme_result = $this->import_theme( $data ); + + if ( ! empty( $import_theme_result ) ) { + $result['theme'] = $import_theme_result; + } + + $this->import_experiments( $data ); + + if ( ! empty( $this->imported_experiments ) ) { + $result['experiments'] = $this->imported_experiments; + } + + return $result; + } + + protected function install_theme( $slug, $version ) { + $download_url = "https://downloads.wordpress.org/theme/{$slug}.{$version}.zip"; + + return $this->get_theme_upgrader()->install( $download_url ); + } + + protected function activate_theme( $slug ) { + switch_theme( $slug ); + } + + public function import_theme( array $data ) { + if ( empty( $data['site_settings']['theme'] ) ) { + return null; + } + + $theme = $data['site_settings']['theme']; + $theme_slug = $theme['slug']; + $theme_name = $theme['name']; + + $current_theme = wp_get_theme(); + $this->previous_active_theme = []; + $this->previous_active_theme['slug'] = $current_theme->get_stylesheet(); + $this->previous_active_theme['version'] = $current_theme->get( 'Version' ); + + if ( $current_theme->get_stylesheet() === $theme_slug ) { + $result['succeed'][ $theme_slug ] = sprintf( __( 'Theme: %1$s is already used', 'elementor' ), $theme_name ); + return $result; + } + + try { + if ( wp_get_theme( $theme_slug )->exists() ) { + $this->activate_theme( $theme_slug ); + $this->activated_theme = $theme_slug; + $result['succeed'][ $theme_slug ] = sprintf( __( 'Theme: %1$s has already been installed and activated', 'elementor' ), $theme_name ); + return $result; + } + + $import = $this->install_theme( $theme_slug, $theme['version'] ); + + if ( is_wp_error( $import ) ) { + $result['failed'][ $theme_slug ] = sprintf( __( 'Failed to install theme: %1$s', 'elementor' ), $theme_name ); + return $result; + } + + $result['succeed'][ $theme_slug ] = sprintf( __( 'Theme: %1$s has been successfully installed', 'elementor' ), $theme_name ); + $this->installed_theme = $theme_slug; + $this->activate_theme( $theme_slug ); + } catch ( \Exception $error ) { + $result['failed'][ $theme_slug ] = $error->getMessage(); + } + + return $result; + } + + private function import_experiments( array $data ) { + if ( empty( $data['site_settings']['experiments'] ) ) { + return null; + } + + $experiments_data = $data['site_settings']['experiments']; + $experiments_manager = Plugin::$instance->experiments; + $current_features = $experiments_manager->get_features(); + + $this->save_previous_experiments_state( $current_features ); + + foreach ( $experiments_data as $feature_name => $feature_data ) { + if ( ! isset( $current_features[ $feature_name ] ) ) { + continue; + } + + $current_feature = $current_features[ $feature_name ]; + + $current_feature_state = $current_feature['state']; + $new_state = $feature_data['state']; + + if ( $current_feature_state === $new_state ) { + continue; + } + + if ( ! in_array( $new_state, [ ExperimentsManager::STATE_DEFAULT, ExperimentsManager::STATE_ACTIVE, ExperimentsManager::STATE_ACTIVE ], true ) ) { + continue; + } + + $option_key = $experiments_manager->get_feature_option_key( $feature_name ); + + if ( 'default' === $new_state ) { + delete_option( $option_key ); + } else { + update_option( $option_key, $new_state ); + } + + $this->imported_experiments[ $feature_name ] = $feature_data; + } + } + + private function save_previous_experiments_state( array $current_features ) { + $experiments_manager = Plugin::$instance->experiments; + + foreach ( $current_features as $feature_name => $feature ) { + if ( ! $feature['mutable'] ) { + continue; + } + + $option_key = $experiments_manager->get_feature_option_key( $feature_name ); + $saved_state = get_option( $option_key ); + + $this->previous_experiments[ $feature_name ] = [ + 'name' => $feature_name, + 'title' => $feature['title'], + 'state' => empty( $saved_state ) ? 'default' : $saved_state, + 'default' => $feature['default'], + 'release_status' => $feature['release_status'], + ]; + } + } + + public function get_import_session_metadata(): array { + return [ + 'previous_kit_id' => $this->previous_kit_id, + 'active_kit_id' => $this->active_kit_id, + 'imported_kit_id' => $this->imported_kit_id, + 'installed_theme' => $this->installed_theme, + 'activated_theme' => $this->activated_theme, + 'previous_active_theme' => $this->previous_active_theme, + 'previous_experiments' => $this->previous_experiments, + 'imported_experiments' => $this->imported_experiments, + ]; + } +} diff --git a/app/modules/import-export-customization/runners/import/taxonomies.php b/app/modules/import-export-customization/runners/import/taxonomies.php new file mode 100644 index 000000000000..1a1e28a45b34 --- /dev/null +++ b/app/modules/import-export-customization/runners/import/taxonomies.php @@ -0,0 +1,143 @@ +import_session_id = $data['session_id']; + + $wp_builtin_post_types = ImportExportUtils::get_builtin_wp_post_types(); + $selected_custom_post_types = isset( $data['selected_custom_post_types'] ) ? $data['selected_custom_post_types'] : []; + $post_types = array_merge( $wp_builtin_post_types, $selected_custom_post_types ); + + $result = []; + + foreach ( $post_types as $post_type ) { + if ( empty( $data['manifest']['taxonomies'][ $post_type ] ) ) { + continue; + } + + $result['taxonomies'][ $post_type ] = $this->import_taxonomies( $data['manifest']['taxonomies'][ $post_type ], $path ); + } + + return $result; + } + + private function import_taxonomies( array $taxonomies, $path ) { + $result = []; + $imported_taxonomies = []; + + foreach ( $taxonomies as $taxonomy ) { + if ( ! taxonomy_exists( $taxonomy ) ) { + continue; + } + + if ( ! empty( $imported_taxonomies[ $taxonomy ] ) ) { + $result[ $taxonomy ] = $imported_taxonomies[ $taxonomy ]; + continue; + } + + $taxonomy_data = ImportExportUtils::read_json_file( $path . $taxonomy ); + if ( empty( $taxonomy_data ) ) { + continue; + } + + $import = $this->import_taxonomy( $taxonomy_data ); + $result[ $taxonomy ] = $import; + $imported_taxonomies[ $taxonomy ] = $import; + } + + return $result; + } + + private function import_taxonomy( array $taxonomy_data ) { + $terms = []; + + foreach ( $taxonomy_data as $term ) { + $old_slug = $term['slug']; + + $existing_term = term_exists( $term['slug'], $term['taxonomy'] ); + if ( $existing_term ) { + if ( 'nav_menu' === $term['taxonomy'] ) { + $term = $this->handle_duplicated_nav_menu_term( $term ); + } else { + $terms[] = [ + 'old_id' => (int) $term['term_id'], + 'new_id' => (int) $existing_term['term_id'], + 'old_slug' => $old_slug, + 'new_slug' => $term['slug'], + ]; + continue; + } + } + + $parent = $this->get_term_parent( $term, $terms ); + + $args = [ + 'slug' => $term['slug'], + 'description' => wp_slash( $term['description'] ), + 'parent' => (int) $parent, + ]; + + $new_term = wp_insert_term( wp_slash( $term['name'] ), $term['taxonomy'], $args ); + if ( ! is_wp_error( $new_term ) ) { + $this->set_session_term_meta( (int) $new_term['term_id'], $this->import_session_id ); + + $terms[] = [ + 'old_id' => $term['term_id'], + 'new_id' => (int) $new_term['term_id'], + 'old_slug' => $old_slug, + 'new_slug' => $term['slug'], + ]; + } + } + + return $terms; + } + + private function handle_duplicated_nav_menu_term( $term ) { + do { + $term['slug'] = $term['slug'] . '-duplicate'; + $term['name'] = $term['name'] . ' duplicate'; + } while ( term_exists( $term['slug'], 'nav_menu' ) ); + + return $term; + } + + private function get_term_parent( $term, array $imported_terms ) { + $parent = $term['parent']; + if ( 0 !== $parent && ! empty( $imported_terms ) ) { + foreach ( $imported_terms as $imported_term ) { + if ( $parent === $imported_term['old_id'] ) { + $parent_term = term_exists( $imported_term['new_id'], $term['taxonomy'] ); + break; + } + } + + if ( isset( $parent_term['term_id'] ) ) { + return $parent_term['term_id']; + } + } + + return 0; + } +} diff --git a/app/modules/import-export-customization/runners/import/templates.php b/app/modules/import-export-customization/runners/import/templates.php new file mode 100644 index 000000000000..43ba697fe2e6 --- /dev/null +++ b/app/modules/import-export-customization/runners/import/templates.php @@ -0,0 +1,86 @@ +import_session_id = $data['session_id']; + + $path = $data['extracted_directory_path'] . 'templates/'; + $templates = $data['manifest']['templates']; + + $result['templates'] = [ + 'succeed' => [], + 'failed' => [], + ]; + + foreach ( $templates as $id => $template_settings ) { + try { + $template_data = ImportExportUtils::read_json_file( $path . $id ); + $import = $this->import_template( $id, $template_settings, $template_data ); + + $result['templates']['succeed'][ $id ] = $import; + } catch ( \Exception $error ) { + $result['templates']['failed'][ $id ] = $error->getMessage(); + } + } + + return $result; + } + + private function import_template( $id, array $template_settings, array $template_data ) { + $doc_type = $template_settings['doc_type']; + + $new_document = Plugin::$instance->documents->create( + $doc_type, + [ + 'post_title' => $template_settings['title'], + 'post_type' => Source_Local::CPT, + 'post_status' => 'publish', + ] + ); + + if ( is_wp_error( $new_document ) ) { + throw new \Exception( esc_html( $new_document->get_error_message() ) ); + } + + $template_data['import_settings'] = $template_settings; + $template_data['id'] = $id; + + $new_attachment_callback = function( $attachment_id ) { + $this->set_session_post_meta( $attachment_id, $this->import_session_id ); + }; + + add_filter( 'elementor/template_library/import_images/new_attachment', $new_attachment_callback ); + + $new_document->import( $template_data ); + + remove_filter( 'elementor/template_library/import_images/new_attachment', $new_attachment_callback ); + + $document_id = $new_document->get_main_id(); + + $this->set_session_post_meta( $document_id, $this->import_session_id ); + + return $document_id; + } +} diff --git a/app/modules/import-export-customization/runners/import/wp-content.php b/app/modules/import-export-customization/runners/import/wp-content.php new file mode 100644 index 000000000000..dff6a324dd30 --- /dev/null +++ b/app/modules/import-export-customization/runners/import/wp-content.php @@ -0,0 +1,124 @@ +import_session_id = $data['session_id']; + + $path = $data['extracted_directory_path'] . 'wp-content/'; + + $post_types = $this->filter_post_types( $data['selected_custom_post_types'] ); + + $taxonomies = $imported_data['taxonomies'] ?? []; + $imported_terms = ImportExportUtils::map_old_new_term_ids( $imported_data ); + + $result['wp-content'] = []; + + foreach ( $post_types as $post_type ) { + $import = $this->import_wp_post_type( + $path, + $post_type, + $imported_data, + $taxonomies, + $imported_terms + ); + + if ( empty( $import ) ) { + continue; + } + + $result['wp-content'][ $post_type ] = $import; + $imported_data = array_merge( $imported_data, $result ); + } + + return $result; + } + + private function import_wp_post_type( $path, $post_type, array $imported_data, array $taxonomies, array $imported_terms ) { + $args = [ + 'fetch_attachments' => true, + 'posts' => ImportExportUtils::map_old_new_post_ids( $imported_data ), + 'terms' => $imported_terms, + 'taxonomies' => ! empty( $taxonomies[ $post_type ] ) ? $taxonomies[ $post_type ] : [], + 'posts_meta' => [ + static::META_KEY_ELEMENTOR_IMPORT_SESSION_ID => $this->import_session_id, + ], + 'terms_meta' => [ + static::META_KEY_ELEMENTOR_IMPORT_SESSION_ID => $this->import_session_id, + ], + ]; + + $file = $path . $post_type . '/' . $post_type . '.xml'; + + if ( ! file_exists( $file ) ) { + return []; + } + + $wp_importer = new WP_Import( $file, $args ); + $result = $wp_importer->run(); + + return $result['summary']['posts']; + } + + private function filter_post_types( $selected_custom_post_types = [] ) { + $wp_builtin_post_types = ImportExportUtils::get_builtin_wp_post_types(); + + foreach ( $selected_custom_post_types as $custom_post_type ) { + if ( post_type_exists( $custom_post_type ) ) { + $this->selected_custom_post_types[] = $custom_post_type; + } + } + + $post_types = array_merge( $wp_builtin_post_types, $this->selected_custom_post_types ); + $post_types = $this->force_element_to_be_last_by_value( $post_types, 'nav_menu_item' ); + + return $post_types; + } + + public function get_import_session_metadata(): array { + return [ + 'custom_post_types' => $this->selected_custom_post_types, + ]; + } + + /** + * @param $array array The array we want to relocate his element. + * @param $element mixed The value of the element in the array we want to shift to end of the array. + * @return mixed + */ + private function force_element_to_be_last_by_value( array $array, $element ) { + $index = array_search( $element, $array, true ); + + if ( false !== $index ) { + unset( $array[ $index ] ); + $array[] = $element; + } + + return $array; + } +} diff --git a/app/modules/import-export-customization/runners/revert/elementor-content.php b/app/modules/import-export-customization/runners/revert/elementor-content.php new file mode 100644 index 000000000000..9f4d7965875e --- /dev/null +++ b/app/modules/import-export-customization/runners/revert/elementor-content.php @@ -0,0 +1,94 @@ +init_page_on_front_data(); + } + + public static function get_name(): string { + return 'elementor-content'; + } + + public function should_revert( array $data ): bool { + return ( + isset( $data['runners'] ) && + array_key_exists( static::get_name(), $data['runners'] ) + ); + } + + public function revert( array $data ) { + $elementor_post_types = ImportExportUtils::get_elementor_post_types(); + + $query_args = [ + 'post_type' => $elementor_post_types, + 'post_status' => 'any', + 'posts_per_page' => -1, + 'meta_query' => [ + [ + 'key' => static::META_KEY_ELEMENTOR_EDIT_MODE, + 'compare' => 'EXISTS', + ], + [ + 'key' => static::META_KEY_ELEMENTOR_IMPORT_SESSION_ID, + 'value' => $data['session_id'], + ], + ], + ]; + + $query = new \WP_Query( $query_args ); + + foreach ( $query->posts as $post ) { + $post_type_document = Plugin::$instance->documents->get( $post->ID ); + $post_type_document->delete(); + + // Deleting the post will reset the show_on_front option. We need to set it to false, + // so we can set it back to what it was. + if ( $post->ID === $this->page_on_front_id ) { + $this->show_page_on_front = false; + } + } + + $this->restore_page_on_front( $data ); + } + + private function init_page_on_front_data() { + $this->show_page_on_front = 'page' === get_option( 'show_on_front' ); + + if ( $this->show_page_on_front ) { + $this->page_on_front_id = (int) get_option( 'page_on_front' ); + } + } + + private function restore_page_on_front( $data ) { + if ( empty( $data['runners'][ static::get_name() ]['page_on_front'] ) ) { + return; + } + + $page_on_front = $data['runners'][ static::get_name() ]['page_on_front']; + + $document = Plugin::$instance->documents->get( $page_on_front ); + + if ( ! $document ) { + return; + } + + $this->set_page_on_front( $document->get_main_id() ); + } + + private function set_page_on_front( $page_id ) { + update_option( 'page_on_front', $page_id ); + + if ( ! $this->show_page_on_front ) { + update_option( 'show_on_front', 'page' ); + } + } +} diff --git a/app/modules/import-export-customization/runners/revert/plugins.php b/app/modules/import-export-customization/runners/revert/plugins.php new file mode 100644 index 000000000000..851eee4d277e --- /dev/null +++ b/app/modules/import-export-customization/runners/revert/plugins.php @@ -0,0 +1,16 @@ +kits_manager->revert( + $data['runners'][ static::get_name() ]['imported_kit_id'], + $data['runners'][ static::get_name() ]['active_kit_id'], + $data['runners'][ static::get_name() ]['previous_kit_id'] + ); + + $this->revert_theme( $data ); + $this->revert_experiments( $data ); + } + + public function get_theme_upgrader(): \Theme_Upgrader { + if ( ! class_exists( '\Theme_Upgrader' ) ) { + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php'; + } + + if ( ! class_exists( '\WP_Ajax_Upgrader_Skin' ) ) { + require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php'; + } + + return new \Theme_Upgrader( new \WP_Ajax_Upgrader_Skin() ); + } + + protected function revert_theme( $data ) { + $installed_theme = $data['runners'][ static::get_name() ]['installed_theme']; + $activated_theme = $data['runners'][ static::get_name() ]['activated_theme']; + $previous_active_theme = $data['runners'][ static::get_name() ]['previous_active_theme']; + + if ( empty( $installed_theme ) && empty( $activated_theme ) ) { + // no need to remove a theme as it was used before import + return; + } + + if ( ! empty( $activated_theme ) ) { + $previous_theme = wp_get_theme( $previous_active_theme['slug'] ); + + // no need to remove imported theme as it existed before import + $this->activate_previous_theme( $previous_active_theme ); + return; + } + + if ( ! empty( $installed_theme ) ) { + $this->activate_previous_theme( $previous_active_theme ); + $this->delete_theme( $installed_theme ); + } + } + + protected function should_delete_theme( $theme_slug ): bool { + $current_theme = wp_get_theme(); + + return $theme_slug !== $current_theme->get_stylesheet() && wp_get_theme( $theme_slug )->exists(); + } + + protected function delete_theme( $theme_slug ): bool { + return delete_theme( $theme_slug ); + } + + protected function activate_previous_theme( $previous_active_theme ) { + if ( ! $previous_active_theme ) { + return; + } + + $theme = wp_get_theme( $previous_active_theme['slug'] ); + + if ( $theme->exists() ) { + switch_theme( $theme->get_stylesheet() ); + return; + } + + $download_url = "https://downloads.wordpress.org/theme/{$previous_active_theme['slug']}.{$previous_active_theme['version']}.zip"; + $install = $this->get_theme_upgrader()->install( $download_url ); + + if ( ! $install || is_wp_error( $install ) ) { + return; + } + + switch_theme( $previous_active_theme['slug'] ); + } + + protected function revert_experiments( array $data ) { + $runner_data = $data['runners'][ static::get_name() ]; + $previous_experiments = $runner_data['previous_experiments'] ?? []; + + if ( empty( $previous_experiments ) ) { + return; + } + + $experiments_manager = Plugin::$instance->experiments; + $current_features = $experiments_manager->get_features(); + + foreach ( $previous_experiments as $feature_name => $feature_data ) { + if ( ! isset( $current_features[ $feature_name ] ) ) { + continue; + } + + if ( ! array_key_exists( $feature_name, $previous_experiments ) ) { + continue; + } + + $option_key = $experiments_manager->get_feature_option_key( $feature_name ); + $previous_state = $feature_data['state']; + + if ( ExperimentsManager::STATE_DEFAULT === $previous_state ) { + delete_option( $option_key ); + } else { + update_option( $option_key, $previous_state ); + } + } + } +} diff --git a/app/modules/import-export-customization/runners/revert/taxonomies.php b/app/modules/import-export-customization/runners/revert/taxonomies.php new file mode 100644 index 000000000000..6e6aa78d8d40 --- /dev/null +++ b/app/modules/import-export-customization/runners/revert/taxonomies.php @@ -0,0 +1,37 @@ + $taxonomies, + 'hide_empty' => false, + 'get' => 'all', + 'meta_query' => [ + [ + 'key' => static::META_KEY_ELEMENTOR_IMPORT_SESSION_ID, + 'value' => $data['session_id'], + ], + ], + ] ); + + foreach ( $terms as $term ) { + wp_delete_term( $term->term_id, $term->taxonomy ); + } + } +} diff --git a/app/modules/import-export-customization/runners/revert/templates.php b/app/modules/import-export-customization/runners/revert/templates.php new file mode 100644 index 000000000000..1255c6d0ffa1 --- /dev/null +++ b/app/modules/import-export-customization/runners/revert/templates.php @@ -0,0 +1,18 @@ + $post_types, + 'post_status' => 'any', + 'posts_per_page' => -1, + 'meta_query' => [ + [ + 'key' => static::META_KEY_ELEMENTOR_EDIT_MODE, + 'compare' => 'NOT EXISTS', + ], + [ + 'key' => static::META_KEY_ELEMENTOR_IMPORT_SESSION_ID, + 'value' => $data['session_id'], + ], + ], + ]; + + $query = new \WP_Query( $query_args ); + + foreach ( $query->posts as $post ) { + wp_delete_post( $post->ID, true ); + } + + /** + * Revert the nav menu terms. + * BC: The nav menu in new kits will be imported as part of the taxonomies, but old kits + * importing the nav menu terms as part from the wp-content import. + */ + $this->revert_nav_menus( $data ); + } + + private function revert_nav_menus( array $data ) { + $terms = get_terms( [ + 'taxonomy' => 'nav_menu', + 'hide_empty' => false, + 'get' => 'all', + 'meta_query' => [ + [ + 'key' => static::META_KEY_ELEMENTOR_IMPORT_SESSION_ID, + 'value' => $data['session_id'], + ], + ], + ] ); + + foreach ( $terms as $term ) { + wp_delete_term( $term->term_id, $term->taxonomy ); + } + } +} diff --git a/app/modules/import-export-customization/runners/runner-interface.php b/app/modules/import-export-customization/runners/runner-interface.php new file mode 100644 index 000000000000..062cbd56d480 --- /dev/null +++ b/app/modules/import-export-customization/runners/runner-interface.php @@ -0,0 +1,20 @@ +get_revert_usage_data(); + + return $params; + } ); + } + + /** + * Get the Revert usage data. + * + * @return array + */ + private function get_revert_usage_data() { + $revert_sessions = ( new Revert() )->get_revert_sessions(); + + $data = []; + + foreach ( $revert_sessions as $revert_session ) { + $data[] = [ + 'kit_name' => $revert_session['kit_name'], + 'source' => $revert_session['source'], + 'revert_timestamp' => (int) $revert_session['revert_timestamp'], + 'total_time' => ( (int) $revert_session['revert_timestamp'] - (int) $revert_session['import_timestamp'] ), + ]; + } + + return $data; + } +} diff --git a/app/modules/import-export-customization/utils.php b/app/modules/import-export-customization/utils.php new file mode 100644 index 000000000000..19ef6e1454c3 --- /dev/null +++ b/app/modules/import-export-customization/utils.php @@ -0,0 +1,140 @@ + true, + 'can_export' => true, + '_builtin' => false, + ] ); + + unset( + $post_types[ Landing_Pages_Module::CPT ], + $post_types[ Source_Local::CPT ], + $post_types[ Floating_Buttons_Module::CPT_FLOATING_BUTTONS ] + ); + + return array_keys( $post_types ); + } + + /** + * Transform a string name to title format. + * + * @param $name + * + * @return string + */ + public static function transform_name_to_title( $name ): string { + if ( empty( $name ) ) { + return ''; + } + + $title = str_replace( [ '-', '_' ], ' ', $name ); + + return ucwords( $title ); + } + + public static function get_import_sessions( $should_run_cleanup = false ) { + $import_sessions = get_option( Module::OPTION_KEY_ELEMENTOR_IMPORT_SESSIONS, [] ); + + if ( $should_run_cleanup ) { + foreach ( $import_sessions as $session_id => $import_session ) { + if ( ! isset( $import_session['runners'] ) && isset( $import_session['instance_data'] ) ) { + $import_sessions[ $session_id ]['runners'] = $import_session['instance_data']['runners_import_metadata'] ?? []; + + unset( $import_sessions[ $session_id ]['instance_data'] ); + } + } + + update_option( Module::OPTION_KEY_ELEMENTOR_IMPORT_SESSIONS, $import_sessions ); + } + + return $import_sessions; + } + + public static function update_space_between_widgets_values( $space_between_widgets ) { + $setting_exist = isset( $space_between_widgets['size'] ); + $already_processed = isset( $space_between_widgets['column'] ); + + if ( ! $setting_exist || $already_processed ) { + return $space_between_widgets; + } + + $size = strval( $space_between_widgets['size'] ); + $space_between_widgets['column'] = $size; + $space_between_widgets['row'] = $size; + $space_between_widgets['isLinked'] = true; + + return $space_between_widgets; + } +} diff --git a/app/modules/import-export-customization/wp-cli.php b/app/modules/import-export-customization/wp-cli.php new file mode 100644 index 000000000000..bffa2c3f56cb --- /dev/null +++ b/app/modules/import-export-customization/wp-cli.php @@ -0,0 +1,280 @@ + $value ) { + if ( ! in_array( $key, static::AVAILABLE_SETTINGS, true ) ) { + continue; + } + + $export_settings[ $key ] = explode( ',', $value ); + } + + try { + /** + * Running the export process through the import-export module so the export property in the module will be available to use. + * + * @type Module $import_export_module + */ + $import_export_module = Plugin::$instance->app->get_component( 'import-export' ); + $result = $import_export_module->export_kit( $export_settings ); + + rename( $result['file_name'], $args[0] ); + } catch ( \Error $error ) { + \WP_CLI::error( $error->getMessage() ); + } + + \WP_CLI::success( 'Kit exported successfully.' ); + } + + /** + * Import a Kit + * + * [--include] + * Which type of content to include. Possible values are 'content', 'templates', 'site-settings'. + * if this parameter won't be specified, All data types will be included. + * + * [--overrideConditions] + * Templates ids to override conditions for. + * + * [--sourceType] + * Which source type is used in the current session. Available values are 'local', 'remote', 'library'. + * The default value is 'local' + * + * ## EXAMPLES + * + * 1. wp elementor kit import path/to/elementor-kit.zip + * - This will import the whole kit file content. + * + * 2. wp elementor kit import path/to/elementor-kit.zip --include=site-settings,content + * - This will import only site settings and content. + * + * 3. wp elementor kit import path/to/elementor-kit.zip --overrideConditions=3478,4520 + * - This will import all content and will override conditions for the given template ids. + * + * 4. wp elementor kit import path/to/elementor-kit.zip --unfilteredFilesUpload=enable + * - This will allow the import process to import unfiltered files. + * + * @param array $args + * @param array $assoc_args + */ + public function import( array $args, array $assoc_args ) { + if ( ! current_user_can( 'manage_options' ) ) { + \WP_CLI::error( 'You must run this command as an admin user' ); + } + + if ( empty( $args[0] ) ) { + \WP_CLI::error( 'Please specify a file to import' ); + } + + \WP_CLI::line( 'Kit import started' ); + + $assoc_args = wp_parse_args( $assoc_args, [ + 'sourceType' => 'local', + ] ); + + $url = null; + $file_path = $args[0]; + $import_settings = []; + $import_settings['referrer'] = Module::REFERRER_LOCAL; + + switch ( $assoc_args['sourceType'] ) { + case 'library': + $url = $this->get_url_from_library( $file_path ); + $zip_path = $this->create_temp_file_from_url( $url ); + $import_settings['referrer'] = Module::REFERRER_KIT_LIBRARY; + break; + + case 'remote': + $zip_path = $this->create_temp_file_from_url( $file_path ); + break; + + case 'local': + $zip_path = $file_path; + break; + + default: + \WP_CLI::error( 'Unknown source type.' ); + break; + } + + if ( 'enable' === $assoc_args['unfilteredFilesUpload'] ) { + Plugin::$instance->uploads_manager->enable_unfiltered_files_upload(); + } + + foreach ( $assoc_args as $key => $value ) { + if ( ! in_array( $key, static::AVAILABLE_SETTINGS, true ) ) { + continue; + } + + $import_settings[ $key ] = explode( ',', $value ); + } + + try { + \WP_CLI::line( 'Importing data...' ); + + /** + * Running the import process through the import-export module so the import property in the module will be available to use. + * + * @type Module $import_export_module + */ + $import_export_module = Plugin::$instance->app->get_component( 'import-export' ); + + if ( ! $import_export_module ) { + \WP_CLI::error( 'Import Export module is not available.' ); + } + + $import = $import_export_module->import_kit( $zip_path, $import_settings ); + + $manifest_data = $import_export_module->import->get_manifest(); + + /** + * Import Export Manifest Data + * + * Allows 3rd parties to read and edit the kit's manifest before it is used. + * + * @since 3.7.0 + * + * @param array $manifest_data The Kit's Manifest data + */ + $manifest_data = apply_filters( 'elementor/import-export/wp-cli/manifest_data', $manifest_data ); + + \WP_CLI::line( 'Removing temp files...' ); + + // The file was created from remote or library request, it also should be removed. + if ( $url ) { + Plugin::$instance->uploads_manager->remove_file_or_dir( dirname( $zip_path ) ); + } + + \WP_CLI::success( 'Kit imported successfully' ); + } catch ( \Error $error ) { + Plugin::$instance->logger->get_logger()->error( $error->getMessage(), [ + 'meta' => [ + 'trace' => $error->getTraceAsString(), + ], + ] ); + + if ( $url ) { + Plugin::$instance->uploads_manager->remove_file_or_dir( dirname( $zip_path ) ); + } + + \WP_CLI::error( $error->getMessage() ); + } + } + + /** + * Revert last imported kit. + */ + public function revert() { + \WP_CLI::line( 'Kit revert started.' ); + + try { + /** + * Running the revert process through the import-export module so the revert property in the module will be available to use. + * + * @type Module $import_export_module + */ + $import_export_module = Plugin::$instance->app->get_component( 'import-export' ); + $import_export_module->revert_last_imported_kit(); + + } catch ( \Error $error ) { + \WP_CLI::error( $error->getMessage() ); + } + + \WP_CLI::success( 'Kit reverted successfully.' ); + } + + /** + * Helper to get kit url by the kit id + * TODO: Maybe extract it. + * + * @param $kit_id + * + * @return string + */ + private function get_url_from_library( $kit_id ) { + /** @var Kit_Library $app */ + $app = Plugin::$instance->common->get_component( 'connect' )->get_app( 'kit-library' ); + + if ( ! $app ) { + \WP_CLI::error( 'Kit library app not found' ); + } + + $response = $app->download_link( $kit_id ); + + if ( is_wp_error( $response ) ) { + \WP_CLI::error( "Library Response: {$response->get_error_message()}" ); + } + + return $response->download_link; + } + + /** + * Helper to get kit zip file path by the kit url + * TODO: Maybe extract it. + * + * @param $url + * + * @return string + */ + private function create_temp_file_from_url( $url ) { + \WP_CLI::line( 'Extracting zip archive...' ); + $response = wp_remote_get( $url ); + + if ( is_wp_error( $response ) ) { + \WP_CLI::error( "Download file url: {$response->get_error_message()}" ); + } + + if ( 200 !== $response['response']['code'] ) { + \WP_CLI::error( "Download file url: {$response['response']['message']}" ); + } + + // Set the Request's state as an Elementor upload request, in order to support unfiltered file uploads. + Plugin::$instance->uploads_manager->set_elementor_upload_state( true ); + + $file = Plugin::$instance->uploads_manager->create_temp_file( $response['body'], 'kit.zip' ); + + // After the upload complete, set the elementor upload state back to false. + Plugin::$instance->uploads_manager->set_elementor_upload_state( false ); + + return $file; + } +} diff --git a/app/modules/import-export/assets/js/admin.js b/app/modules/import-export/assets/js/admin.js index 333bf507e09d..1cb935972c52 100644 --- a/app/modules/import-export/assets/js/admin.js +++ b/app/modules/import-export/assets/js/admin.js @@ -92,7 +92,7 @@ class Admin { elementorCommon.dialogsManager.createWidget( 'confirm', { headerMessage: __( 'Are you sure?', 'elementor' ), // Translators: %s is the name of the active Kit - message: __( 'Removing %s will permanently delete changes made to the Kit\'s content and site settings', 'elementor' ).replace( '%s', this.activeKitName ), + message: __( 'Removing %s will permanently delete changes made to the Websites Template\'s content and site settings', 'elementor' ).replace( '%s', this.activeKitName ), strings: { confirm: __( 'Delete', 'elementor' ), cancel: __( 'Cancel', 'elementor' ), @@ -117,10 +117,10 @@ class Admin { if ( 0 === referrerKitId.length ) { this.createKitDeletedWidget( { - message: __( 'Try a different Kit or build your site from scratch.', 'elementor' ), + message: __( 'Try a different Website Template or build your site from scratch.', 'elementor' ), strings: { confirm: __( 'OK', 'elementor' ), - cancel: __( 'Kit Library', 'elementor' ), + cancel: __( 'Library', 'elementor' ), }, onCancel: () => { location.href = elementorImportExport.appUrl; diff --git a/app/modules/import-export/assets/js/context/connect-state-context.js b/app/modules/import-export/assets/js/context/connect-state-context.js new file mode 100644 index 000000000000..b78ca9fe8fdc --- /dev/null +++ b/app/modules/import-export/assets/js/context/connect-state-context.js @@ -0,0 +1,53 @@ +import { createContext, useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; + +export const ConnectStateContext = createContext(); + +export function ConnectStateProvider( { children } ) { + const [ isConnected, setIsConnected ] = useState( elementorCommon.config.library_connect.is_connected ); + const [ isConnecting, setIsConnecting ] = useState( false ); + + const handleConnectSuccess = useCallback( ( callback ) => { + setIsConnecting( true ); + setIsConnected( true ); + + elementorCommon.config.library_connect.is_connected = true; + + if ( callback ) { + callback(); + } + }, [] ); + + const handleConnectError = useCallback( ( callback ) => { + setIsConnected( false ); + setIsConnecting( false ); + + elementorCommon.config.library_connect.is_connected = false; + + if ( callback ) { + callback(); + } + }, [] ); + + const setConnecting = useCallback( ( connecting ) => { + setIsConnecting( connecting ); + }, [] ); + + const value = { + isConnected, + isConnecting, + setConnecting, + handleConnectSuccess, + handleConnectError, + }; + + return ( + + { children } + + ); +} + +ConnectStateProvider.propTypes = { + children: PropTypes.node.isRequired, +}; diff --git a/app/modules/import-export/assets/js/context/export-context/export-context-provider.js b/app/modules/import-export/assets/js/context/export-context/export-context-provider.js index be0fdb6f8cf9..c1b2f22ac233 100644 --- a/app/modules/import-export/assets/js/context/export-context/export-context-provider.js +++ b/app/modules/import-export/assets/js/context/export-context/export-context-provider.js @@ -13,6 +13,7 @@ export default function ExportContextProvider( props ) { kitInfo: { title: null, description: null, + source: null, }, }, [ data, dispatch ] = useReducer( reducer, initialState ); diff --git a/app/modules/import-export/assets/js/context/export-context/export-context-reducer.js b/app/modules/import-export/assets/js/context/export-context/export-context-reducer.js index 5e468b3e6461..4d75958554bd 100644 --- a/app/modules/import-export/assets/js/context/export-context/export-context-reducer.js +++ b/app/modules/import-export/assets/js/context/export-context/export-context-reducer.js @@ -12,6 +12,8 @@ export const reducer = ( state, { type, payload } ) => { return { ...state, kitInfo: { ...state.kitInfo, title: payload } }; case 'SET_KIT_DESCRIPTION': return { ...state, kitInfo: { ...state.kitInfo, description: payload } }; + case 'SET_KIT_SAVE_SOURCE': + return { ...state, kitInfo: { ...state.kitInfo, source: payload } }; default: return state; } diff --git a/app/modules/import-export/assets/js/context/import-context/import-context-provider.js b/app/modules/import-export/assets/js/context/import-context/import-context-provider.js index 624b116da227..6006f9782643 100644 --- a/app/modules/import-export/assets/js/context/import-context/import-context-provider.js +++ b/app/modules/import-export/assets/js/context/import-context/import-context-provider.js @@ -10,6 +10,7 @@ export default function ImportContextProvider( props ) { file: null, uploadedData: null, importedData: null, + source: '', plugins: [], requiredPlugins: [], importedPlugins: [], diff --git a/app/modules/import-export/assets/js/context/import-context/import-context-reducer.js b/app/modules/import-export/assets/js/context/import-context/import-context-reducer.js index 8bdb7f76b04c..e1cb58bac98a 100644 --- a/app/modules/import-export/assets/js/context/import-context/import-context-reducer.js +++ b/app/modules/import-export/assets/js/context/import-context/import-context-reducer.js @@ -6,6 +6,8 @@ export const reducer = ( state, { type, payload } ) => { return { ...state, id: payload }; case 'SET_FILE': return { ...state, file: payload }; + case 'SET_KIT_SOURCE': + return { ...state, source: payload }; case 'ADD_OVERRIDE_CONDITION': return ReducerUtils.updateArray( state, 'overrideConditions', payload, 'add' ); case 'REMOVE_OVERRIDE_CONDITION': diff --git a/app/modules/import-export/assets/js/export.js b/app/modules/import-export/assets/js/export.js index 402493df74d7..c48762d417d6 100644 --- a/app/modules/import-export/assets/js/export.js +++ b/app/modules/import-export/assets/js/export.js @@ -1,5 +1,7 @@ import SharedContextProvider from './context/shared-context/shared-context-provider'; import ExportContextProvider from './context/export-context/export-context-provider'; +import { ConnectStateProvider } from './context/connect-state-context'; +import { QueryClientProvider, QueryClient } from 'react-query'; import { LocationProvider, Router } from '@reach/router'; import router from '@elementor/router'; @@ -9,19 +11,33 @@ import ExportComplete from './pages/export/export-complete/export-complete'; import ExportPlugins from './pages/export/export-plugins/export-plugins'; import ExportProcess from './pages/export/export-process/export-process'; +const queryClient = new QueryClient( { + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: false, + staleTime: 1000 * 60 * 30, // 30 minutes + }, + }, +} ); + export default function Export() { return ( - - - - - - - - - - - - + + + + + + + + + + + + + + + + ); } diff --git a/app/modules/import-export/assets/js/hooks/use-connect-state.js b/app/modules/import-export/assets/js/hooks/use-connect-state.js new file mode 100644 index 000000000000..48d8da0618e6 --- /dev/null +++ b/app/modules/import-export/assets/js/hooks/use-connect-state.js @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { ConnectStateContext } from '../context/connect-state-context'; + +export default function useConnectState() { + const context = useContext( ConnectStateContext ); + + if ( ! context ) { + throw new Error( 'useConnectState must be used within a ConnectStateProvider' ); + } + + return context; +} diff --git a/app/modules/import-export/assets/js/hooks/use-kit.js b/app/modules/import-export/assets/js/hooks/use-kit.js index 1e9ac2be7e98..b921e849e928 100644 --- a/app/modules/import-export/assets/js/hooks/use-kit.js +++ b/app/modules/import-export/assets/js/hooks/use-kit.js @@ -2,6 +2,11 @@ import { useState, useEffect } from 'react'; import useAjax from 'elementor-app/hooks/use-ajax'; +export const KIT_SOURCE_MAP = { + CLOUD: 'cloud', + FILE: 'file', +}; + const KIT_STATUS_MAP = Object.freeze( { INITIAL: 'initial', UPLOADED: 'uploaded', @@ -21,11 +26,12 @@ export default function useKit() { data: null, }, [ kitState, setKitState ] = useState( kitStateInitialState ), - uploadKit = ( { kitId, file, kitLibraryNonce } ) => { + uploadKit = ( { kitId, file, kitLibraryNonce, source = '' } ) => { setAjax( { data: { action: UPLOAD_KIT_KEY, - e_import_file: file, + source, + ...( file ? { e_import_file: file } : null ), kit_id: kitId, ...( kitLibraryNonce ? { e_kit_library_nonce: kitLibraryNonce } : {} ), }, @@ -121,7 +127,7 @@ export default function useKit() { await runImportRunners( importSession.data.session, importSession.data.runners ); }, - exportKit = ( { include, kitInfo, plugins, selectedCustomPostTypes } ) => { + exportKit = ( { include, kitInfo, plugins, selectedCustomPostTypes, screenShotBlob } ) => { setAjax( { data: { action: EXPORT_KIT_KEY, @@ -130,6 +136,7 @@ export default function useKit() { kitInfo, plugins, selectedCustomPostTypes, + screenShotBlob, } ), }, } ); @@ -141,7 +148,7 @@ export default function useKit() { const newState = {}; if ( 'success' === ajaxState.status ) { - if ( ajaxState.response?.file ) { + if ( ajaxState.response?.file || ajaxState.response?.kit ) { newState.status = KIT_STATUS_MAP.EXPORTED; } else { newState.status = ajaxState.response?.manifest ? KIT_STATUS_MAP.UPLOADED : KIT_STATUS_MAP.IMPORTED; diff --git a/app/modules/import-export/assets/js/pages/export/export-complete/export-complete.js b/app/modules/import-export/assets/js/pages/export/export-complete/export-complete.js index b8967c8c9607..7201e45a76cc 100644 --- a/app/modules/import-export/assets/js/pages/export/export-complete/export-complete.js +++ b/app/modules/import-export/assets/js/pages/export/export-complete/export-complete.js @@ -1,63 +1,127 @@ -import { useContext, useEffect, useRef } from 'react'; +import { useContext, useEffect, useMemo, useRef } from 'react'; import { useNavigate } from '@reach/router'; import { ExportContext } from '../../../context/export-context/export-context-provider'; +import { KIT_SOURCE_MAP } from '../../../hooks/use-kit'; import Layout from '../../../templates/layout'; import ActionsFooter from '../../../shared/actions-footer/actions-footer'; import WizardStep from '../../../ui/wizard-step/wizard-step'; import KitData from '../../../shared/kit-data/kit-data'; import InlineLink from 'elementor-app/ui/molecules/inline-link'; +import Button from 'elementor-app/ui/molecules/button'; import DashboardButton from 'elementor-app/molecules/dashboard-button'; import './export-complete.scss'; +const INVALID_FILENAME_CHARS = /[<>:"/\\|?*]/g; + export default function ExportComplete() { - const exportContext = useContext( ExportContext ), - navigate = useNavigate(), + const exportContext = useContext( ExportContext ); + const isSavedToCloud = KIT_SOURCE_MAP.CLOUD === exportContext.data.kitInfo.source; + const navigate = useNavigate(), downloadLink = useRef( null ), getFooter = () => ( - + { isSavedToCloud + ? ( +
+ + ); +} + +ConnectScreen.propTypes = { + onConnectSuccess: PropTypes.func, + onConnectError: PropTypes.func, + menuItems: PropTypes.array.isRequired, + forceRefetch: PropTypes.func.isRequired, + isFetching: PropTypes.bool.isRequired, +}; diff --git a/app/modules/kit-library/assets/js/pages/cloud/full-page-loader.js b/app/modules/kit-library/assets/js/pages/cloud/full-page-loader.js new file mode 100644 index 000000000000..240bb1ef201d --- /dev/null +++ b/app/modules/kit-library/assets/js/pages/cloud/full-page-loader.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import Content from '../../../../../../assets/js/layout/content'; +import IndexHeader from '../index/index-header'; +import IndexSidebar from '../index/index-sidebar'; +import Layout from '../../components/layout'; +import PageLoader from '../../components/page-loader'; + +export default function FullPageLoader( { + menuItems, + forceRefetch, + isFetching, +} ) { + return ( + + } + header={ + { + forceRefetch(); + } } + isFetching={ isFetching } + /> + } + > +
+ + + +
+
+ ); +} + +FullPageLoader.propTypes = { + menuItems: PropTypes.array.isRequired, + forceRefetch: PropTypes.func.isRequired, + isFetching: PropTypes.bool.isRequired, +}; diff --git a/app/modules/kit-library/assets/js/pages/cloud/upgrade-screen.js b/app/modules/kit-library/assets/js/pages/cloud/upgrade-screen.js new file mode 100644 index 000000000000..043c426b1244 --- /dev/null +++ b/app/modules/kit-library/assets/js/pages/cloud/upgrade-screen.js @@ -0,0 +1,63 @@ +import { Heading, Text, Grid, Button } from '@elementor/app-ui'; +import PropTypes from 'prop-types'; +import Content from '../../../../../../assets/js/layout/content'; +import IndexHeader from '../index/index-header'; +import IndexSidebar from '../index/index-sidebar'; +import Layout from '../../components/layout'; + +export default function UpgradeScreen( { + menuItems, + forceRefetch, + isFetching, + cloudKitsData, +} ) { + const hasSubscription = '' !== cloudKitsData?.subscription_id; + const url = hasSubscription ? 'https://go.elementor.com/go-pro-cloud-website-templates-library-advanced/' : 'https://go.elementor.com/go-pro-cloud-website-templates-library/'; + + return ( + + } + header={ + { + forceRefetch(); + } } + isFetching={ isFetching } + /> + } + > +
+ + + + + { __( 'It\'s time to level up', 'elementor' ) } + + + { __( 'Upgrade to Elementor Pro to import your own website template and save templates that you can reuse on any of your connected websites.', 'elementor' ) } + +
+
+ ); +} + +UpgradeScreen.propTypes = { + menuItems: PropTypes.array.isRequired, + forceRefetch: PropTypes.func.isRequired, + isFetching: PropTypes.bool.isRequired, + cloudKitsData: PropTypes.object.isRequired, +}; diff --git a/app/modules/kit-library/assets/js/pages/favorites/favorites.js b/app/modules/kit-library/assets/js/pages/favorites/favorites.js index b8395b761327..3be8b928f429 100644 --- a/app/modules/kit-library/assets/js/pages/favorites/favorites.js +++ b/app/modules/kit-library/assets/js/pages/favorites/favorites.js @@ -1,6 +1,7 @@ import Index from '../index/index'; import ErrorScreen from '../../components/error-screen'; import { useNavigate } from '@reach/router'; +import PropTypes from 'prop-types'; export default function Favorites( props ) { const navigate = useNavigate(); @@ -8,7 +9,7 @@ export default function Favorites( props ) { const indexNotResultsFavorites = navigate( '/kit-library' ), diff --git a/app/modules/kit-library/assets/js/pages/index/index-header.js b/app/modules/kit-library/assets/js/pages/index/index-header.js index 1244b50f682d..4b639cd74a69 100644 --- a/app/modules/kit-library/assets/js/pages/index/index-header.js +++ b/app/modules/kit-library/assets/js/pages/index/index-header.js @@ -69,7 +69,7 @@ export default function IndexHeader( props ) { targetRef={ importRef } wrapperClass="e-kit-library__tooltip" > - { __( 'Import Kit', 'elementor' ) } + { __( 'Import Website Template', 'elementor' ) } onClose( e ) } >
- { __( 'What\'s a Website Kit?', 'elementor' ) } - { __( 'A Website Kit is full, ready-made design that you can apply to your site. It includes all the pages, parts, settings and content that you\'d expect in a fully functional website.', 'elementor' ) } + { __( 'What\'s a Website Template?', 'elementor' ) } + { __( 'A Website Template is full, ready-made design that you can apply to your site. It includes all the pages, parts, settings and content that you\'d expect in a fully functional website.', 'elementor' ) }
- { __( 'What\'s going on in the Kit Library?', 'elementor' ) } + { __( 'What\'s going on in the Website Templates Library?', 'elementor' ) } - { __( 'Search & filter for kits by category and tags, or browse through individual kits to see what\'s inside.', 'elementor' ) } + { __( 'Search & filter for website templates by category and tags, or browse through individual website templates to see what\'s inside.', 'elementor' ) }
{ __( 'Once you\'ve picked a winner, apply it to your site!', 'elementor' ) }
diff --git a/app/modules/kit-library/assets/js/pages/index/index.js b/app/modules/kit-library/assets/js/pages/index/index.js index 3596deaeae76..005fb688ee0a 100644 --- a/app/modules/kit-library/assets/js/pages/index/index.js +++ b/app/modules/kit-library/assets/js/pages/index/index.js @@ -11,10 +11,11 @@ import SearchInput from '../../components/search-input'; import SortSelect from '../../components/sort-select'; import TaxonomiesFilter from '../../components/taxonomies-filter'; import useKits, { defaultQueryParams } from '../../hooks/use-kits'; +import useMenuItems from '../../hooks/use-menu-items'; import usePageTitle from 'elementor-app/hooks/use-page-title'; import useTaxonomies from '../../hooks/use-taxonomies'; import { Grid } from '@elementor/app-ui'; -import { useCallback, useMemo, useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useLastFilterContext } from '../../context/last-filter-context'; import { useLocation } from '@reach/router'; import { appsEventTrackingDispatch } from 'elementor-app/event-track/apps-event-tracking'; @@ -51,35 +52,6 @@ function useTaxonomiesSelection( setQueryParams ) { return [ selectTaxonomy, unselectTaxonomy ]; } -/** - * Generate the menu items for the index page. - * - * @param {string} path - * @return {Array} menu items - */ -function useMenuItems( path ) { - return useMemo( () => { - const page = path.replace( '/', '' ); - - return [ - { - label: __( 'All Website Kits', 'elementor' ), - icon: 'eicon-filter', - isActive: ! page, - url: '/kit-library', - trackEventData: { command: 'kit-library/select-organizing-category', category: 'all' }, - }, - { - label: __( 'Favorites', 'elementor' ), - icon: 'eicon-heart-o', - isActive: 'favorites' === page, - url: '/kit-library/favorites', - trackEventData: { command: 'kit-library/select-organizing-category', category: 'favorites' }, - }, - ]; - }, [ path ] ); -} - /** * Update and read the query param from the url * @@ -137,7 +109,7 @@ function useRouterQueryParams( queryParams, setQueryParams, exclude = [] ) { export default function Index( props ) { usePageTitle( { - title: __( 'Kit Library', 'elementor' ), + title: __( 'Website Templates', 'elementor' ), } ); const menuItems = useMenuItems( props.path ); @@ -208,7 +180,7 @@ export default function Index( props ) { { setQueryParams( ( prev ) => ( { ...prev, search: value } ) ); diff --git a/app/modules/kit-library/assets/js/pages/index/index.scss b/app/modules/kit-library/assets/js/pages/index/index.scss index ce6c8f9cf80d..693ecbc034f0 100644 --- a/app/modules/kit-library/assets/js/pages/index/index.scss +++ b/app/modules/kit-library/assets/js/pages/index/index.scss @@ -35,5 +35,50 @@ $e-kit-library-sticky-search-z-index: 2; display: flex; flex-direction: column; justify-content: space-between; + + &.e-kit-library__connect-container { + justify-content: center; + align-items: center; + } + } + + &__connect-button { + background-color: var(--e-a-btn-bg-primary); + color: var(--e-a-btn-color); + border-radius: 4px; + padding: 8px; + + &:hover { + opacity: 0.8; + } + } + + &__upgrade-button { + background-color: var(--e-a-color-accent); + color: var(--e-a-color-txt-invert); + border-radius: 4px; + padding: 8px; + + &:hover { + opacity: 0.8; + } + } + + .eicon-library-cloud-connect, + .eicon-library-subscription-upgrade { + font-size: 65px; + } + + .connect-badge { + color: $e-pink-900; + margin-inline-start: 10px; + } + + .upgrade-badge { + color: $e-accent; + border: 1px solid $e-accent; + border-radius: 10px; + padding: 2px 6px; + margin-inline-start: 10px; } } diff --git a/app/modules/kit-library/assets/js/pages/preview/preview.scss b/app/modules/kit-library/assets/js/pages/preview/preview.scss index 846e31d6e851..8becf64a0b60 100644 --- a/app/modules/kit-library/assets/js/pages/preview/preview.scss +++ b/app/modules/kit-library/assets/js/pages/preview/preview.scss @@ -1,8 +1,8 @@ .e-kit-library__preview { &-loader { position: absolute; - top: 0; - @include start(0); + inset-block-start: 0; + inset-inline-start: 0; z-index: $ground-layer; } diff --git a/app/modules/kit-library/assets/js/utils.js b/app/modules/kit-library/assets/js/utils.js new file mode 100644 index 000000000000..a7a5d384857c --- /dev/null +++ b/app/modules/kit-library/assets/js/utils.js @@ -0,0 +1,13 @@ +/** + * A util function to transform data throw transform functions + * + * @param {Array} functions + * @return {function(*=, ...[*]): *} function + */ +export function pipe( ...functions ) { + return ( value, ...args ) => + functions.reduce( + ( currentValue, currentFunction ) => currentFunction( currentValue, ...args ), + value, + ); +} diff --git a/app/modules/kit-library/connect/kit-library.php b/app/modules/kit-library/connect/kit-library.php index 6a02f6a321e5..825c93a220d5 100644 --- a/app/modules/kit-library/connect/kit-library.php +++ b/app/modules/kit-library/connect/kit-library.php @@ -5,7 +5,7 @@ use Elementor\Core\Common\Modules\Connect\Apps\Library; if ( ! defined( 'ABSPATH' ) ) { - exit; // Exit if accessed directly + exit; // Exit if accessed directly. } class Kit_Library extends Library { diff --git a/app/modules/kit-library/data/kits/controller.php b/app/modules/kit-library/data/kits/controller.php index b0692a1816d4..c0f330750dfb 100644 --- a/app/modules/kit-library/data/kits/controller.php +++ b/app/modules/kit-library/data/kits/controller.php @@ -60,6 +60,6 @@ public function register_endpoints() { } public function get_permission_callback( $request ) { - return current_user_can( 'administrator' ); + return current_user_can( 'manage_options' ); } } diff --git a/app/modules/kit-library/data/repository.php b/app/modules/kit-library/data/repository.php index 165d53f07c66..1af41580d9db 100644 --- a/app/modules/kit-library/data/repository.php +++ b/app/modules/kit-library/data/repository.php @@ -85,7 +85,7 @@ public function find( $id, $options = [] ) { $manifest = $this->api->get_manifest( $id ); if ( is_wp_error( $manifest ) ) { - throw new WP_Error_Exception( $manifest ); + throw new WP_Error_Exception( esc_html( $manifest ) ); } } @@ -128,7 +128,7 @@ public function get_download_link( $id ) { $response = $this->api->download_link( $id ); if ( is_wp_error( $response ) ) { - throw new WP_Error_Exception( $response ); + throw new WP_Error_Exception( esc_html( $response ) ); } return [ 'download_link' => $response->download_link ]; @@ -204,7 +204,7 @@ private function get_kits_data( $force_api_request = false ) { $data = $this->api->get_all( $args ); if ( is_wp_error( $data ) ) { - throw new WP_Error_Exception( $data ); + throw new WP_Error_Exception( esc_html( $data ) ); } set_transient( static::KITS_CACHE_KEY, $data, static::KITS_CACHE_TTL_HOURS * HOUR_IN_SECONDS ); @@ -225,7 +225,7 @@ private function get_taxonomies_data( $force_api_request = false ) { $data = $this->api->get_taxonomies(); if ( is_wp_error( $data ) ) { - throw new WP_Error_Exception( $data ); + throw new WP_Error_Exception( esc_html( $data ) ); } set_transient( static::KITS_TAXONOMIES_CACHE_KEY, $data, static::KITS_TAXONOMIES_CACHE_TTL_HOURS * HOUR_IN_SECONDS ); @@ -276,7 +276,6 @@ private function transform_kit_api_response( $kit, $manifest = null ) { 'popularity_index' => isset( $kit->popularity_index ) ? $kit->popularity_index : 0, 'created_at' => isset( $kit->created_at ) ? $kit->created_at : null, 'updated_at' => isset( $kit->updated_at ) ? $kit->updated_at : null, - // ], $manifest ? $this->transform_manifest_api_response( $manifest ) : [] ); diff --git a/app/modules/kit-library/data/taxonomies/controller.php b/app/modules/kit-library/data/taxonomies/controller.php index bcf1c0f20941..3b5388f1d1ba 100644 --- a/app/modules/kit-library/data/taxonomies/controller.php +++ b/app/modules/kit-library/data/taxonomies/controller.php @@ -33,6 +33,6 @@ public function get_items( $request ) { } public function get_permission_callback( $request ) { - return current_user_can( 'administrator' ); + return current_user_can( 'manage_options' ); } } diff --git a/app/modules/kit-library/kit-library-menu-item.php b/app/modules/kit-library/kit-library-menu-item.php index 8c3dfc5a9766..ff0849d851fa 100644 --- a/app/modules/kit-library/kit-library-menu-item.php +++ b/app/modules/kit-library/kit-library-menu-item.php @@ -19,7 +19,7 @@ public function get_parent_slug() { } public function get_label() { - return esc_html__( 'Kit Library', 'elementor' ); + return esc_html__( 'Website Templates', 'elementor' ); } public function get_capability() { diff --git a/app/modules/kit-library/module.php b/app/modules/kit-library/module.php index 1e2dde161f93..8e1e54d558d8 100644 --- a/app/modules/kit-library/module.php +++ b/app/modules/kit-library/module.php @@ -12,9 +12,10 @@ use Elementor\App\Modules\KitLibrary\Data\Kits\Controller as Kits_Controller; use Elementor\App\Modules\KitLibrary\Data\Taxonomies\Controller as Taxonomies_Controller; use Elementor\Core\Utils\Promotions\Filtered_Promotions_Manager; +use Elementor\Utils as ElementorUtils; if ( ! defined( 'ABSPATH' ) ) { - exit; // Exit if accessed directly + exit; // Exit if accessed directly. } class Module extends BaseModule { @@ -31,8 +32,8 @@ public function get_name() { private function register_admin_menu( MainMenu $menu ) { $menu->add_submenu( [ - 'page_title' => esc_html__( 'Kit Library', 'elementor' ), - 'menu_title' => '' . esc_html__( 'Kit Library', 'elementor' ) . '', + 'page_title' => esc_html__( 'Website Templates', 'elementor' ), + 'menu_title' => '' . esc_html__( 'Website Templates', 'elementor' ) . '', 'menu_slug' => Plugin::$instance->app->get_base_url() . '#/kit-library', 'index' => 40, ] ); @@ -60,7 +61,7 @@ private function set_kit_library_settings() { $kit_library = $connect->get_app( 'kit-library' ); Plugin::$instance->app->set_settings( 'kit-library', [ - 'has_access_to_module' => current_user_can( 'administrator' ), + 'has_access_to_module' => current_user_can( 'manage_options' ), 'subscription_plans' => $this->apply_filter_subscription_plans( $connect->get_subscription_plans( 'kit-library' ) ), 'is_pro' => false, 'is_library_connected' => $kit_library->is_connected(), @@ -99,6 +100,12 @@ public function __construct() { Plugin::$instance->data_manager_v2->register_controller( new Kits_Controller() ); Plugin::$instance->data_manager_v2->register_controller( new Taxonomies_Controller() ); + $this->register_actions(); + + do_action( 'elementor/kit_library/registered', $this ); + } + + public function register_actions() { // Assigning this action here since the repository is being loaded by demand. add_action( 'elementor/experiments/feature-state-change/container', [ Repository::class, 'clear_cache' ], 10, 0 ); @@ -112,6 +119,57 @@ public function __construct() { add_action( 'elementor/init', function () { $this->set_kit_library_settings(); - }, 12 /** after the initiation of the connect kit library */ ); + }, 12 /** After the initiation of the connect kit library */ ); + + if ( Plugin::$instance->experiments->is_feature_active( 'cloud-library' ) ) { + add_action( 'template_redirect', [ $this, 'handle_kit_screenshot_generation' ] ); + } + } + + public function handle_kit_screenshot_generation() { + $is_kit_preview = ElementorUtils::get_super_global_value( $_GET, 'kit_thumbnail' ); + $nonce = ElementorUtils::get_super_global_value( $_GET, 'nonce' ); + + if ( $is_kit_preview ) { + if ( ! wp_verify_nonce( $nonce, 'kit_thumbnail' ) ) { + wp_die( esc_html__( 'Not Authorized', 'elementor' ), esc_html__( 'Error', 'elementor' ), 403 ); + } + + $suffix = ( ElementorUtils::is_script_debug() || ElementorUtils::is_elementor_tests() ) ? '' : '.min'; + + show_admin_bar( false ); + + wp_enqueue_script( + 'dom-to-image', + ELEMENTOR_ASSETS_URL . "/lib/dom-to-image/js/dom-to-image{$suffix}.js", + [], + '2.6.0', + true + ); + + wp_enqueue_script( + 'html2canvas', + ELEMENTOR_ASSETS_URL . "/lib/html2canvas/js/html2canvas{$suffix}.js", + [], + '1.4.1', + true + ); + + wp_enqueue_script( + 'cloud-library-screenshot', + ELEMENTOR_ASSETS_URL . "/js/cloud-library-screenshot{$suffix}.js", + [ 'dom-to-image', 'html2canvas', 'elementor-common', 'elementor-common-modules' ], + ELEMENTOR_VERSION, + true + ); + + $config = [ + 'home_url' => home_url(), + 'kit_id' => uniqid(), + 'selector' => 'body', + ]; + + wp_add_inline_script( 'cloud-library-screenshot', 'var ElementorScreenshotConfig = ' . wp_json_encode( $config ) . ';' ); + } } } diff --git a/app/modules/onboarding/assets/js/components/button.scss b/app/modules/onboarding/assets/js/components/button.scss index c97fdfba412c..882064573f93 100644 --- a/app/modules/onboarding/assets/js/components/button.scss +++ b/app/modules/onboarding/assets/js/components/button.scss @@ -23,6 +23,12 @@ &:hover { background-color: $e-gray-25; } + + &--disabled { + pointer-events: none; + cursor: none; + color: $e-gray-100; + } } &--disabled { diff --git a/app/modules/onboarding/assets/js/components/card.js b/app/modules/onboarding/assets/js/components/card.js index f90a36770da0..33ba097e1bc2 100644 --- a/app/modules/onboarding/assets/js/components/card.js +++ b/app/modules/onboarding/assets/js/components/card.js @@ -1,4 +1,4 @@ -export default function Card( { image, imageAlt, text, link, name, clickAction } ) { +export default function Card( { image, imageAlt, text, link, name, clickAction, target = '_self' } ) { const onClick = () => { elementorCommon.events.dispatchEvent( { event: 'starting canvas click', @@ -15,7 +15,7 @@ export default function Card( { image, imageAlt, text, link, name, clickAction } }; return ( - + {
{ text }
@@ -29,4 +29,5 @@ Card.propTypes = { link: PropTypes.string.isRequired, name: PropTypes.string.isRequired, clickAction: PropTypes.func, + target: PropTypes.string, }; diff --git a/app/modules/onboarding/assets/js/components/progress-bar/progress-bar.js b/app/modules/onboarding/assets/js/components/progress-bar/progress-bar.js index e46a7116d239..3cee4bc7f8fe 100644 --- a/app/modules/onboarding/assets/js/components/progress-bar/progress-bar.js +++ b/app/modules/onboarding/assets/js/components/progress-bar/progress-bar.js @@ -19,7 +19,7 @@ export default function ProgressBar() { if ( ! elementorAppConfig.onboarding.helloActivated ) { progressBarItemsConfig.push( { id: 'hello', - title: __( 'Hello Theme', 'elementor' ), + title: __( 'Hello Biz Theme', 'elementor' ), route: 'hello', } ); } diff --git a/app/modules/onboarding/assets/js/pages/choose-features.js b/app/modules/onboarding/assets/js/pages/choose-features.js index 77aa4693360c..d3d98a0b9cf6 100644 --- a/app/modules/onboarding/assets/js/pages/choose-features.js +++ b/app/modules/onboarding/assets/js/pages/choose-features.js @@ -1,19 +1,19 @@ -import { useContext, useEffect, useState } from 'react'; +import { useEffect, useState } from 'react'; import useAjax from 'elementor-app/hooks/use-ajax'; -import { OnboardingContext } from '../context/context'; import Message from '../components/message'; import { options, setSelectedFeatureList } from '../utils/utils'; import Layout from '../components/layout/layout'; import PageContentLayout from '../components/layout/page-content-layout'; +import useButtonAction from '../utils/use-button-action'; export default function ChooseFeatures() { - const { state } = useContext( OnboardingContext ), - { setAjax } = useAjax(), + const { setAjax } = useAjax(), tiers = { advanced: __( 'Advanced', 'elementor' ), essential: __( 'Essential', 'elementor' ) }, [ selectedFeatures, setSelectedFeatures ] = useState( { essential: [], advanced: [] } ), [ tierName, setTierName ] = useState( tiers.essential ), pageId = 'chooseFeatures', nextStep = 'goodToGo', + { state, handleAction } = useButtonAction( pageId, nextStep ), actionButton = { text: __( 'Upgrade Now', 'elementor' ), href: elementorAppConfig.onboarding.urls.upgrade, @@ -36,6 +36,8 @@ export default function ChooseFeatures() { } ), }, } ); + + handleAction( 'completed' ); }, }; @@ -53,6 +55,8 @@ export default function ChooseFeatures() { } ), }, } ); + + handleAction( 'skipped' ); }, }; } diff --git a/app/modules/onboarding/assets/js/pages/good-to-go.js b/app/modules/onboarding/assets/js/pages/good-to-go.js index 9b5dcf42209e..83f04da71b02 100644 --- a/app/modules/onboarding/assets/js/pages/good-to-go.js +++ b/app/modules/onboarding/assets/js/pages/good-to-go.js @@ -19,7 +19,7 @@ export default function GoodToGo() { : __( 'That\'s a wrap! What\'s next?', 'elementor' ) }
- { __( 'There are two ways to get started with Elementor:', 'elementor' ) } + { __( 'There are three ways to get started with Elementor:', 'elementor' ) }
{ @@ -41,6 +41,14 @@ export default function GoodToGo() { location.reload(); } } /> + diff --git a/app/modules/onboarding/assets/js/pages/good-to-go.scss b/app/modules/onboarding/assets/js/pages/good-to-go.scss index a16a910bfd0d..89a6b24c50cd 100644 --- a/app/modules/onboarding/assets/js/pages/good-to-go.scss +++ b/app/modules/onboarding/assets/js/pages/good-to-go.scss @@ -1,13 +1,5 @@ .e-onboarding { - &__page-goodToGo { - - .e-onboarding__page-content-section-title, - .e-onboarding__page-content-section-text { - text-align: center; - } - } - &__cards-grid { // Specificity. @@ -25,6 +17,23 @@ } } + &__page-goodToGo { + + .e-onboarding__page-content-section-title, + .e-onboarding__page-content-section-text { + text-align: center; + } + + .e-onboarding__cards-grid { + justify-content: center; + flex-wrap: nowrap; + } + + .e-onboarding__card { + max-width: 388px; + } + } + &__good-to-go-footer { justify-content: end; } diff --git a/app/modules/onboarding/assets/js/pages/hello-theme.js b/app/modules/onboarding/assets/js/pages/hello-theme.js index cefcb3d406f9..fe7915a57ec6 100644 --- a/app/modules/onboarding/assets/js/pages/hello-theme.js +++ b/app/modules/onboarding/assets/js/pages/hello-theme.js @@ -19,7 +19,7 @@ export default function HelloTheme() { }, [ noticeState, setNoticeState ] = useState( state.isHelloThemeActivated ? noticeStateSuccess : null ), [ activeTimeouts, setActiveTimeouts ] = useState( [] ), - continueWithHelloThemeText = state.isHelloThemeActivated ? __( 'Next', 'elementor' ) : __( 'Continue with Hello Theme', 'elementor' ), + continueWithHelloThemeText = state.isHelloThemeActivated ? __( 'Next', 'elementor' ) : __( 'Continue with Hello Biz Theme', 'elementor' ), [ actionButtonText, setActionButtonText ] = useState( continueWithHelloThemeText ), navigate = useNavigate(), pageId = 'hello', @@ -100,7 +100,7 @@ export default function HelloTheme() { setNoticeState( { type: 'error', icon: 'eicon-warning', - message: __( 'There was a problem installing Hello Theme.', 'elementor' ), + message: __( 'There was a problem installing Hello Biz Theme.', 'elementor' ), } ); resetScreenContent(); @@ -122,7 +122,7 @@ export default function HelloTheme() { } wp.updates.ajax( 'install-theme', { - slug: 'hello-elementor', + slug: 'hello-biz', success: () => activateHelloTheme(), error: () => onErrorInstallHelloTheme(), } ); @@ -174,12 +174,14 @@ export default function HelloTheme() { /** * Skip Button */ - let skipButton; + const skipButton = {}; + + if ( isInstalling ) { + skipButton.className = 'e-onboarding__button-skip--disabled'; + } if ( 'completed' !== state.steps[ pageId ] ) { - skipButton = { - text: __( 'Skip', 'elementor' ), - }; + skipButton.text = __( 'Skip', 'elementor' ); } /** @@ -248,7 +250,7 @@ export default function HelloTheme() { setNoticeState( { type: 'error', icon: 'eicon-warning', - message: __( 'There was a problem activating Hello Theme.', 'elementor' ), + message: __( 'There was a problem activating Hello Biz Theme.', 'elementor' ), } ); // Clear any active timeouts for changing the action button text during installation. @@ -260,22 +262,22 @@ export default function HelloTheme() { return (

- { __( 'Hello is Elementor\'s official blank canvas theme optimized to build your website exactly the way you want.', 'elementor' ) } + { __( 'Hello Biz by Elementor helps you launch your professional business website - fast.', 'elementor' ) }

{ ! elementorAppConfig.onboarding.experiment &&

{ __( 'Here\'s why:', 'elementor' ) }

}
    -
  • { __( 'Light-weight and fast loading', 'elementor' ) }
  • +
  • { __( 'Get online faster', 'elementor' ) }
  • +
  • { __( 'Lightweight and fast loading', 'elementor' ) }
  • { __( 'Great for SEO', 'elementor' ) }
  • -
  • { __( 'Already being used by 1M+ web creators', 'elementor' ) }
diff --git a/app/modules/onboarding/assets/js/pages/site-logo.scss b/app/modules/onboarding/assets/js/pages/site-logo.scss index fd707fe6da63..cb9d366e4b06 100644 --- a/app/modules/onboarding/assets/js/pages/site-logo.scss +++ b/app/modules/onboarding/assets/js/pages/site-logo.scss @@ -52,8 +52,8 @@ &-remove { position: absolute; - @include end(0); - top: 5px; + inset-block-start: 5px; + inset-inline-end: 0; i { font-size: 16px; diff --git a/app/modules/onboarding/assets/js/utils/use-button-action.js b/app/modules/onboarding/assets/js/utils/use-button-action.js new file mode 100644 index 000000000000..028c7f7b8574 --- /dev/null +++ b/app/modules/onboarding/assets/js/utils/use-button-action.js @@ -0,0 +1,19 @@ +import { useContext } from 'react'; +import { OnboardingContext } from '../context/context'; +import { useNavigate } from '@reach/router'; + +export default function useButtonAction( pageId, nextPage ) { + const { state, updateState, getStateObjectToUpdate } = useContext( OnboardingContext ); + const navigate = useNavigate(); + + const handleAction = ( action ) => { + const stateToUpdate = getStateObjectToUpdate( state, 'steps', pageId, action ); + updateState( stateToUpdate ); + navigate( 'onboarding/' + nextPage ); + }; + + return { + state, + handleAction, + }; +} diff --git a/app/modules/onboarding/features-usage.php b/app/modules/onboarding/features-usage.php index 697c7044592f..e89b3f4a657d 100644 --- a/app/modules/onboarding/features-usage.php +++ b/app/modules/onboarding/features-usage.php @@ -5,7 +5,7 @@ use Elementor\Tracker; if ( ! defined( 'ABSPATH' ) ) { - exit; // Exit if accessed directly + exit; // Exit if accessed directly. } class Features_Usage { diff --git a/app/modules/onboarding/module.php b/app/modules/onboarding/module.php index 375f8e1279e3..945ab6516bad 100644 --- a/app/modules/onboarding/module.php +++ b/app/modules/onboarding/module.php @@ -11,7 +11,7 @@ use Plugin_Upgrader; if ( ! defined( 'ABSPATH' ) ) { - exit; // Exit if accessed directly + exit; // Exit if accessed directly. } /** @@ -83,6 +83,13 @@ private function set_onboarding_settings() { 'isUnfilteredFilesEnabled' => Uploads_Manager::are_unfiltered_uploads_enabled(), 'urls' => [ 'kitLibrary' => Plugin::$instance->app->get_base_url() . '#/kit-library?order[direction]=desc&order[by]=featuredIndex', + 'sitePlanner' => add_query_arg( [ + 'type' => 'editor', + 'siteUrl' => esc_url( home_url() ), + 'siteName' => esc_html( $site_name ), + 'siteDescription' => esc_html( get_bloginfo( 'description' ) ), + 'siteLanguage' => get_locale(), + ], 'https://planner.elementor.com/onboarding.html' ), 'createNewPage' => Plugin::$instance->documents->get_create_new_post_url(), 'connect' => $library->get_admin_url( 'authorize', [ 'utm_source' => 'onboarding-wizard', @@ -113,7 +120,7 @@ private function set_onboarding_settings() { 'downloadPro' => '?utm_source=onboarding-wizard&utm_campaign=my-account-subscriptions&utm_medium=wp-dash&utm_content=import-pro-plugin&utm_term=' . self::VERSION, ], 'nonce' => wp_create_nonce( 'onboarding' ), - 'experiment' => Plugin::$instance->experiments->is_feature_active( 'e_onboarding' ), + 'experiment' => true, ] ); } @@ -257,7 +264,7 @@ private function maybe_update_site_logo() { private function maybe_upload_logo_image() { $error_message = esc_html__( 'There was a problem uploading your file.', 'elementor' ); - $file = Utils::get_super_global_value( $_FILES, 'fileToUpload' ); + $file = Utils::get_super_global_value( $_FILES, 'fileToUpload' ); // phpcs:ignore WordPress.Security.NonceVerification.Missing // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! is_array( $file ) || empty( $file['type'] ) ) { @@ -318,7 +325,7 @@ private function maybe_activate_hello_theme() { return $this->get_permission_error_response(); } - switch_theme( 'hello-elementor' ); + switch_theme( 'hello-biz' ); return [ 'status' => 'success', @@ -342,7 +349,7 @@ private function upload_and_install_pro() { $error_message = esc_html__( 'There was a problem uploading your file.', 'elementor' ); - $file = Utils::get_super_global_value( $_FILES, 'fileToUpload' ) ?? []; + $file = Utils::get_super_global_value( $_FILES, 'fileToUpload' ) ?? []; // phpcs:ignore WordPress.Security.NonceVerification.Missing // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( ! is_array( $file ) || empty( $file['type'] ) ) { @@ -412,9 +419,8 @@ private function maybe_update_onboarding_db_option() { * Maybe Handle Ajax * * This method checks if there are any AJAX actions being - * @since 3.6.0 * - * @return array|null + * @since 3.6.0 */ private function maybe_handle_ajax() { $result = []; diff --git a/app/modules/site-editor/assets/scss/loading.scss b/app/modules/site-editor/assets/scss/loading.scss index 6f94e94bb2d7..35043ff99fe2 100644 --- a/app/modules/site-editor/assets/scss/loading.scss +++ b/app/modules/site-editor/assets/scss/loading.scss @@ -57,12 +57,12 @@ $e-elementor-loader-dark-box-color: tints(700); &:nth-of-type(1) { width: 20%; height: 100%; - @include start(0); - top: 0; + inset-block-start: 0; + inset-inline-start: 0; } &:not(:nth-of-type(1)) { - @include end(0); + inset-inline-end: 0; height: 20%; width: 60%; } diff --git a/app/modules/site-editor/module.php b/app/modules/site-editor/module.php index d303c3bf8d01..d1405c44cc7f 100644 --- a/app/modules/site-editor/module.php +++ b/app/modules/site-editor/module.php @@ -5,7 +5,7 @@ use Elementor\Plugin; if ( ! defined( 'ABSPATH' ) ) { - exit; // Exit if accessed directly + exit; // Exit if accessed directly. } /** diff --git a/assets/data/responsive-widgets.json b/assets/data/responsive-widgets.json index c4aa51ce49df..937bbe5d6e15 100644 --- a/assets/data/responsive-widgets.json +++ b/assets/data/responsive-widgets.json @@ -1 +1 @@ -{"accordion":true,"alert":true,"icon-box":true,"icon-list":true,"image-box":true,"image-gallery":true,"progress":true,"star-rating":true,"tabs":true,"toggle":true,"nested-tabs":true} +{"accordion":true,"alert":true,"icon-box":true,"icon-list":true,"image-box":true,"image-gallery":true,"progress":true,"star-rating":true,"tabs":true,"toggle":true,"floating-bars-base":true,"floating-bars-var-3":true,"contact-buttons-base":true,"contact-buttons-var-10":true,"contact-buttons-var-7":true,"contact-buttons-var-9":true,"floating-buttons":true,"link-in-bio":true,"link-in-bio-base":true,"nested-tabs":true} diff --git a/assets/dev/js/admin/admin.js b/assets/dev/js/admin/admin.js index 2afb5212e891..7c306b6ce597 100644 --- a/assets/dev/js/admin/admin.js +++ b/assets/dev/js/admin/admin.js @@ -109,6 +109,7 @@ import FloatingButtonsHandler from 'elementor/modules/floating-buttons/assets/js $.post( ajaxurl, { action: 'elementor_set_admin_notice_viewed', notice_id: $wrapperElm.data( 'notice_id' ), + _wpnonce: $wrapperElm.data( 'nonce' ), } ); $wrapperElm.fadeTo( 100, 0, function() { @@ -134,6 +135,15 @@ import FloatingButtonsHandler from 'elementor/modules/floating-buttons/assets/js } ); } ); + $( '.e-notice--cta.e-notice--dismissible[data-notice_id="site_mailer_promotion"] a.e-button--cta' ).on( 'click', function() { + const isWcNotice = $( this ).closest( '.e-notice' ).hasClass( 'sm-notice-wc' ); + elementorCommon.ajax.addRequest( 'elementor_core_site_mailer_campaign', { + data: { + source: isWcNotice ? 'sm-core-woo-install' : 'sm-core-form-install', + }, + } ); + } ); + $( '#elementor-clear-cache-button' ).on( 'click', function( event ) { event.preventDefault(); var $thisButton = $( this ); @@ -405,6 +415,10 @@ import FloatingButtonsHandler from 'elementor/modules/floating-buttons/assets/js return; } + if ( 0 === this.elements.$importNowButton.length ) { + return; + } + const self = this, $importForm = self.elements.$importForm, $importButton = self.elements.$importButton, diff --git a/assets/dev/js/admin/hints/media.js b/assets/dev/js/admin/hints/media.js index 74261481f332..4cd77a7ccea7 100644 --- a/assets/dev/js/admin/hints/media.js +++ b/assets/dev/js/admin/hints/media.js @@ -2,6 +2,7 @@ if ( ! elementorAdminHints?.mediaHint ) { return; } + if ( ! wp?.media?.view?.Attachment?.Details ) { return; } @@ -24,9 +25,8 @@ <% } %>
-
`, diff --git a/assets/dev/js/editor/components/browser-import/session.js b/assets/dev/js/editor/components/browser-import/session.js index 13fc1e069bdf..c08617eeae3c 100644 --- a/assets/dev/js/editor/components/browser-import/session.js +++ b/assets/dev/js/editor/components/browser-import/session.js @@ -109,6 +109,7 @@ export default class Session { case 'container': case 'section': case 'column': + case 'e-div-block': case 'widget': return this.target.view.createElementFromModel( element.model, diff --git a/assets/dev/js/editor/components/documents/commands/close.js b/assets/dev/js/editor/components/documents/commands/close.js index 34503e1fe572..404d02eeb138 100644 --- a/assets/dev/js/editor/components/documents/commands/close.js +++ b/assets/dev/js/editor/components/documents/commands/close.js @@ -31,6 +31,8 @@ export class Close extends $e.modules.CommandBase { break; } + $e.run( 'document/elements/deselect-all' ); + await $e.internal( 'editor/documents/unload', { document } ); if ( onClose ) { @@ -47,15 +49,15 @@ export class Close extends $e.modules.CommandBase { this.confirmDialog = elementorCommon.dialogsManager.createWidget( 'confirm', { id: 'elementor-document-save-on-close', - headerMessage: __( 'Save Changes', 'elementor' ), - message: __( 'Would you like to save the changes you\'ve made?', 'elementor' ), + headerMessage: __( 'You are leaving to a separate site part.', 'elementor' ), + message: __( 'Save your changes before moving on because the current document and the one you’re moving to are separate site parts.', 'elementor' ), position: { my: 'center center', at: 'center center', }, strings: { - confirm: __( 'Save', 'elementor' ), - cancel: __( 'Discard', 'elementor' ), + confirm: __( 'Save & leave', 'elementor' ), + cancel: __( 'Don\'t leave', 'elementor' ), }, onHide: () => { // If still not action chosen. use `defer` because onHide is called before onConfirm/onCancel. @@ -65,18 +67,13 @@ export class Close extends $e.modules.CommandBase { } } ); }, + onCancel: () => { + window.top.$e.internal( 'panel/state-ready' ); + deferred.reject( 'Close document has been canceled.' ); + }, onConfirm: () => { this.args.mode = 'save'; - // Re-run with same args. - $e.run( 'editor/documents/close', this.args ) - .then( () => { - deferred.resolve(); - } ); - }, - onCancel: () => { - this.args.mode = 'discard'; - // Re-run with same args. $e.run( 'editor/documents/close', this.args ) .then( () => { diff --git a/assets/dev/js/editor/components/documents/commands/internal/attach-preview.js b/assets/dev/js/editor/components/documents/commands/internal/attach-preview.js index 8a6b57999344..50e642b0dd38 100644 --- a/assets/dev/js/editor/components/documents/commands/internal/attach-preview.js +++ b/assets/dev/js/editor/components/documents/commands/internal/attach-preview.js @@ -9,7 +9,7 @@ export class AttachPreview extends $e.modules.CommandInternalBase { } } - apply( args ) { + apply( { shouldNavigateToDefaultRoute = true, ...args } = {} ) { const document = elementor.documents.getCurrent(); return $e.data.get( 'globals/index' ) @@ -27,9 +27,11 @@ export class AttachPreview extends $e.modules.CommandInternalBase { elementor.trigger( 'document:loaded', document ); - return $e.internal( 'panel/open-default', { - refresh: true, - } ); + if ( shouldNavigateToDefaultRoute ) { + return $e.internal( 'panel/open-default', { + refresh: true, + } ); + } } ); } diff --git a/assets/dev/js/editor/components/documents/commands/internal/load.js b/assets/dev/js/editor/components/documents/commands/internal/load.js index 9d9b62906ca8..c00c59ac3b39 100644 --- a/assets/dev/js/editor/components/documents/commands/internal/load.js +++ b/assets/dev/js/editor/components/documents/commands/internal/load.js @@ -7,7 +7,7 @@ export class Load extends $e.modules.CommandInternalBase { } apply( args ) { - const { config, setAsInitial = false, shouldScroll = true } = args; + const { config, setAsInitial = false, shouldScroll = true, shouldNavigateToDefaultRoute = true } = args; if ( elementorCommon.config.experimentalFeatures.additional_custom_breakpoints ) { // When the Responsive Optimization experiment is active, the responsive controls are generated on the @@ -64,6 +64,7 @@ export class Load extends $e.modules.CommandInternalBase { return $e.internal( 'editor/documents/attach-preview', { shouldScroll, + shouldNavigateToDefaultRoute, selector: args.selector, } ); } ); diff --git a/assets/dev/js/editor/components/documents/commands/open.js b/assets/dev/js/editor/components/documents/commands/open.js index 928c38c4ee9c..7ead5bec3c68 100644 --- a/assets/dev/js/editor/components/documents/commands/open.js +++ b/assets/dev/js/editor/components/documents/commands/open.js @@ -4,7 +4,7 @@ export class Open extends $e.modules.CommandBase { } apply( args ) { - const { id, selector, shouldScroll = true, setAsInitial = false } = args, + const { id, selector, shouldScroll = true, shouldNavigateToDefaultRoute = true, setAsInitial = false } = args, currentDocument = elementor.documents.getCurrent(); // Already opened. @@ -31,7 +31,7 @@ export class Open extends $e.modules.CommandBase { elementorCommon.elements.$body.addClass( `elementor-editor-${ config.type }` ); // Tell the editor to load the document. - return $e.internal( 'editor/documents/load', { config, selector, setAsInitial, shouldScroll } ); + return $e.internal( 'editor/documents/load', { config, selector, setAsInitial, shouldScroll, shouldNavigateToDefaultRoute } ); } ) .always( () => { // TODO: move to $e.hooks.ui. diff --git a/assets/dev/js/editor/components/documents/commands/switch.js b/assets/dev/js/editor/components/documents/commands/switch.js index bc97b01861f9..65337573732b 100644 --- a/assets/dev/js/editor/components/documents/commands/switch.js +++ b/assets/dev/js/editor/components/documents/commands/switch.js @@ -4,7 +4,7 @@ export class Switch extends $e.modules.CommandBase { } apply( args ) { - const { id, mode, onClose, shouldScroll = true, setAsInitial = false } = args; + const { id, mode, onClose, shouldScroll = true, shouldNavigateToDefaultRoute = true, setAsInitial = false } = args; if ( setAsInitial ) { // Will be removed by the attach-preview after the iframe has loaded. @@ -18,7 +18,7 @@ export class Switch extends $e.modules.CommandBase { selector: args.selector, } ) .then( () => { - return $e.run( 'editor/documents/open', { id, shouldScroll, selector: args.selector, setAsInitial } ); + return $e.run( 'editor/documents/open', { id, shouldScroll, shouldNavigateToDefaultRoute, selector: args.selector, setAsInitial } ); } ) .then( () => { elementor.getPanelView().getPages( 'menu' ).view.addExitItem(); diff --git a/assets/dev/js/editor/components/icons-manager/classes/icon-library.js b/assets/dev/js/editor/components/icons-manager/classes/icon-library.js index fda4cdfbce22..4719471d008a 100644 --- a/assets/dev/js/editor/components/icons-manager/classes/icon-library.js +++ b/assets/dev/js/editor/components/icons-manager/classes/icon-library.js @@ -65,12 +65,14 @@ export default class { // Enqueue CSS if ( libraryConfig.enqueue ) { libraryConfig.enqueue.forEach( ( assetURL ) => { - elementor.helpers.enqueueEditorStylesheet( assetURL ); + const versionAddedURL = `${ assetURL }${ libraryConfig?.ver ? '?ver=' + libraryConfig.ver : '' }`; + elementor.helpers.enqueueEditorStylesheet( versionAddedURL ); } ); } if ( libraryConfig.url ) { - elementor.helpers.enqueueEditorStylesheet( libraryConfig.url ); + const versionAddedURL = `${ libraryConfig.url }${ libraryConfig?.ver ? '?ver=' + libraryConfig.ver : '' }`; + elementor.helpers.enqueueEditorStylesheet( versionAddedURL ); } // Already saved an stored diff --git a/assets/dev/js/editor/components/preview/commands/drop.js b/assets/dev/js/editor/components/preview/commands/drop.js index ca924a3e65ba..c5d86207328c 100644 --- a/assets/dev/js/editor/components/preview/commands/drop.js +++ b/assets/dev/js/editor/components/preview/commands/drop.js @@ -1,4 +1,4 @@ -export class Drop extends $e.modules.CommandBase { +export class Drop extends $e.modules.editor.CommandContainerBase { validateArgs( args = {} ) { this.requireContainer( args ); diff --git a/assets/dev/js/editor/components/selection/manager.js b/assets/dev/js/editor/components/selection/manager.js index bdc1bd23ff70..5aaa11685155 100644 --- a/assets/dev/js/editor/components/selection/manager.js +++ b/assets/dev/js/editor/components/selection/manager.js @@ -35,6 +35,10 @@ export default class Manager extends elementorModules.editor.utils.Module { get( target, prop ) { if ( [ 'add', 'remove' ].includes( prop ) ) { return ( ...args ) => { + if ( ! target.isAllowed() ) { + return; + } + const result = target[ prop ]( ...args ); target.updateType(); @@ -80,6 +84,10 @@ export default class Manager extends elementorModules.editor.utils.Module { * @param {boolean} append */ add( containers, append = false ) { + if ( ! this.isAllowed() ) { + return; + } + containers = Array.isArray( containers ) ? containers : [ containers ]; // If command/ctrl+click not clicked, clear selected elements. @@ -104,6 +112,10 @@ export default class Manager extends elementorModules.editor.utils.Module { * @param {boolean} all */ remove( containers, all = false ) { + if ( ! this.isAllowed() ) { + return; + } + containers = Array.isArray( containers ) ? containers : [ containers ]; if ( all ) { @@ -216,4 +228,8 @@ export default class Manager extends elementorModules.editor.utils.Module { return ! this.getElements().length || Boolean( this.type ); } + + isAllowed() { + return 'edit' === elementor.channels.dataEditMode.request( 'activeMode' ); + } } diff --git a/assets/dev/js/editor/components/settings/editor-preferences/manager.js b/assets/dev/js/editor/components/settings/editor-preferences/manager.js index e8ff8b6ec53a..ed25ed935497 100644 --- a/assets/dev/js/editor/components/settings/editor-preferences/manager.js +++ b/assets/dev/js/editor/components/settings/editor-preferences/manager.js @@ -22,6 +22,8 @@ export default class extends BaseManager { toggleChecklistIconVisibility( switcherValue ) { const shouldShow = 'yes' === switcherValue; + this.addMixpanelTrackingChecklist( shouldShow ); + $e.run( 'checklist/toggle-icon', shouldShow ); } @@ -59,4 +61,28 @@ export default class extends BaseManager { onShowHiddenElementsChange() { elementorFrontend.elements.$body.toggleClass( 'e-preview--show-hidden-elements' ); } + + addMixpanelTrackingChecklist( shouldShow ) { + const name = shouldShow ? 'checklistShow' : 'checklistHide'; + const postId = elementor.getPreviewContainer().document.config.id; + const postTitle = elementor.getPreviewContainer().model.attributes.settings.attributes.post_title; + const postTypeTitle = elementor.getPreviewContainer().document.config.post_type_title; + const documentType = elementor.getPreviewContainer().document.config.type; + + return ( + elementor.editorEvents.dispatchEvent( + elementor.editorEvents.config.names.elementorEditor.userPreferences[ name ], + { + location: elementor.editorEvents.config.locations.elementorEditor, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.userPreferences, + trigger: elementor.editorEvents.config.triggers.toggleClick, + element: elementor.editorEvents.config.elements.toggle, + postId, + postTitle, + postTypeTitle, + documentType, + }, + ) + ); + } } diff --git a/assets/dev/js/editor/components/template-library/behaviors/insert-template.js b/assets/dev/js/editor/components/template-library/behaviors/insert-template.js index e424c11ee840..9d33533336ac 100644 --- a/assets/dev/js/editor/components/template-library/behaviors/insert-template.js +++ b/assets/dev/js/editor/components/template-library/behaviors/insert-template.js @@ -9,14 +9,33 @@ InsertTemplateHandler = Marionette.Behavior.extend( { 'click @ui.insertButton': 'onInsertButtonClick', }, - onInsertButtonClick() { + onRender() { + this.ui.insertButton.toggleClass( 'disabled', this.view.model.isLocked() ); + }, + + onInsertButtonClick( e ) { + if ( 'locked' === this.view.model.get( 'status' ) ) { + e.preventDefault(); + e.stopPropagation(); + return; + } + const args = { model: this.view.model, }; this.ui.insertButton.addClass( 'elementor-disabled' ); + const activeSource = args.model.get( 'source' ); + + /** + * Filter template source. + * + * @param bool isRemote - If `true` the source is a remote source. + * @param string activeSource - The current template source. + */ + const isRemote = elementor.hooks.applyFilters( 'templates/source/is-remote', 'remote' === activeSource, activeSource ); - if ( 'remote' === args.model.get( 'source' ) && ! elementor.config.library_connect.is_connected ) { + if ( isRemote && ! elementor.config.library_connect.is_connected ) { $e.route( 'library/connect', args ); return; } diff --git a/assets/dev/js/editor/components/template-library/component.js b/assets/dev/js/editor/components/template-library/component.js index 7171669eefa2..6a819ec2c53b 100644 --- a/assets/dev/js/editor/components/template-library/component.js +++ b/assets/dev/js/editor/components/template-library/component.js @@ -1,6 +1,7 @@ import ComponentModalBase from 'elementor-api/modules/component-modal-base'; import * as commands from './commands/'; import * as commandsData from './commands-data/'; +import { SAVE_CONTEXTS } from './constants'; const TemplateLibraryLayoutView = require( 'elementor-templates/views/library-layout' ); @@ -13,6 +14,12 @@ export default class Component extends ComponentModalBase { // Remove whole component cache data. $e.data.deleteCache( this, 'library' ); + + if ( elementorCommon.config.experimentalFeatures?.[ 'cloud-library' ] ) { + elementor.channels.templates.on( 'quota:update', ( { force } = {} ) => { + $e.components.get( 'cloud-library' ).utils.setQuotaConfig( force ); + } ); + } } getNamespace() { @@ -37,21 +44,22 @@ export default class Component extends ComponentModalBase { }, }, 'templates/my-templates': { - title: __( 'My Templates', 'elementor' ), - filter: { - source: 'local', - }, + title: __( 'Templates', 'elementor' ), + getFilter: () => ( { + source: elementor.templates.getSourceSelection() ?? 'local', + view: elementor.templates.getViewSelection() ?? 'list', + } ), }, }; } defaultRoutes() { - return { + const defaultRoutes = { import: () => { this.manager.layout.showImportView(); }, 'save-template': ( args ) => { - this.manager.layout.showSaveTemplateView( args.model ); + this.manager.layout.showSaveTemplateView( args.model, args.context ?? SAVE_CONTEXTS.SAVE ); }, preview: ( args ) => { this.manager.layout.showPreviewView( args.model ); @@ -66,6 +74,14 @@ export default class Component extends ComponentModalBase { this.manager.layout.showConnectView( args ); }, }; + + if ( elementorCommon.config.experimentalFeatures?.[ 'cloud-library' ] ) { + defaultRoutes[ 'view-folder' ] = ( args ) => { + this.manager.layout.showFolderView( args ); + }; + } + + return defaultRoutes; } defaultCommands() { @@ -96,8 +112,10 @@ export default class Component extends ComponentModalBase { } renderTab( tab ) { - const currentTab = this.tabs[ tab ], - filter = currentTab.getFilter ? currentTab.getFilter() : currentTab.filter; + const currentTab = this.tabs[ tab ]; + const filter = currentTab.getFilter ? currentTab.getFilter() : currentTab.filter; + + this.currentTab = tab; this.manager.setScreen( filter ); } @@ -145,6 +163,11 @@ export default class Component extends ComponentModalBase { model: callbackParams.model, data, options: callbackParams.importOptions, + onAfter: () => { + this.manager.eventManager.sendTemplateInsertedEvent( { + library_type: callbackParams.model.get( 'source' ) ?? 'local', + } ); + }, } ); } ); } @@ -207,6 +230,12 @@ export default class Component extends ComponentModalBase { $e.run( 'library/insert-template', { model, withPageSettings: true, + onAfter: () => { + elementor.templates.eventManager.sendInsertApplySettingsEvent( { + apply_modal_result: 'apply', + library_type: model.get( 'source' ), + } ); + }, } ); }; @@ -214,6 +243,12 @@ export default class Component extends ComponentModalBase { $e.run( 'library/insert-template', { model, withPageSettings: false, + onAfter: () => { + elementor.templates.eventManager.sendInsertApplySettingsEvent( { + apply_modal_result: `don't apply`, + library_type: model.get( 'source' ), + } ); + }, } ); }; diff --git a/assets/dev/js/editor/components/template-library/constants.js b/assets/dev/js/editor/components/template-library/constants.js new file mode 100644 index 000000000000..ba46d508f802 --- /dev/null +++ b/assets/dev/js/editor/components/template-library/constants.js @@ -0,0 +1,20 @@ +export const SAVE_CONTEXTS = Object.freeze( { + SAVE: 'save', + MOVE: 'move', + COPY: 'copy', + BULK_MOVE: 'bulkMove', + BULK_COPY: 'bulkCopy', +} ); + +export const QUOTA_WARNINGS = Object.freeze( { + /* Translators: 1: Quota usage percentage */ + warning: __( 'You\'ve saved %1$d%% of the templates in your plan. To get more space ', 'elementor' ) + '' + __( 'Upgrade now', 'elementor' ) + '', + /* Translators: 1: Quota usage percentage */ + alert: __( 'You\'ve saved %1$d%% of the templates in your plan. To get more space ', 'elementor' ) + '' + __( 'Upgrade now', 'elementor' ) + '', +} ); + +export const QUOTA_BAR_STATES = Object.freeze( { + NORMAL: 'normal', + WARNING: 'warning', + ALERT: 'alert', +} ); diff --git a/assets/dev/js/editor/components/template-library/event-manager/index.js b/assets/dev/js/editor/components/template-library/event-manager/index.js new file mode 100644 index 000000000000..ce734ed43079 --- /dev/null +++ b/assets/dev/js/editor/components/template-library/event-manager/index.js @@ -0,0 +1,154 @@ + +const EVENTS_MAP = { + SAVE_TEMPLATE_CONTEXT_MENU_EXPOSURE: 'save_template_context_menu_exposure', + NEW_SAVE_TEMPLATE_CLICKED: 'new_save_template_clicked', + TEMPLATE_SAVED: 'template_saved', + TEMPLATE_TRANSFER: 'template_transfer', + ITEM_DELETED: 'item_deleted', + TEMPLATE_IMPORT: 'template_import', + TEMPLATE_RENAME: 'template_rename', + TEMPLATE_INSERTED: 'template_inserted', + BULK_ACTIONS_SUCCESS: 'bulk_actions', + BULK_ACTIONS_FAILED: 'bulk_actions', + FOLDER_CREATE: 'folder_create', + QUOTA_BAR_CAPACITY: 'quota_bar_capacity', + INSERT_APPLY_SETTINGS: 'insert_apply_settings', + UPGRADE_CLICKED: 'upgrade_clicked', + PAGE_VIEWED: 'page_viewed', + DELETION_UNDO: 'deletion_undo', +}; + +export class EventManager { + sendEvent( eventName, data ) { + return elementor.editorEvents.dispatchEvent( + eventName, + data, + ); + } + + sendContextMenuExposureEvent() { + return this.sendEvent( EVENTS_MAP.SAVE_TEMPLATE_CONTEXT_MENU_EXPOSURE, { + location: elementor.editorEvents.config.locations.elementorEditor, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.contextMenu, + trigger: elementor.editorEvents.config.triggers.visible, + } ); + } + + sendNewSaveTemplateClickedEvent() { + return this.sendEvent( EVENTS_MAP.NEW_SAVE_TEMPLATE_CLICKED, { + location: elementor.editorEvents.config.locations.templatesLibrary.library, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.saveModal, + trigger: elementor.editorEvents.config.triggers.click, + } ); + } + + sendTemplateSavedEvent( data ) { + return this.sendEvent( EVENTS_MAP.TEMPLATE_SAVED, { + location: elementor.editorEvents.config.locations.templatesLibrary.library, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.saveModal, + trigger: elementor.editorEvents.config.triggers.click, + ...data, + } ); + } + + sendTemplateTransferEvent( data ) { + return this.sendEvent( EVENTS_MAP.TEMPLATE_TRANSFER, { + location: elementor.editorEvents.config.locations.templatesLibrary.library, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.saveModal, + ...data, + } ); + } + + sendItemDeletedEvent( data ) { + return this.sendEvent( EVENTS_MAP.ITEM_DELETED, { + location: elementor.editorEvents.config.locations.templatesLibrary.library, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.deleteDialog, + ...data, + } ); + } + + sendTemplateImportEvent( data ) { + return this.sendEvent( EVENTS_MAP.TEMPLATE_IMPORT, { + location: elementor.editorEvents.config.locations.templatesLibrary.library, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.saveModal, + ...data, + } ); + } + + sendTemplateRenameEvent( data ) { + return this.sendEvent( EVENTS_MAP.TEMPLATE_RENAME, { + location: elementor.editorEvents.config.locations.templatesLibrary.library, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.renameDialog, + ...data, + } ); + } + + sendTemplateInsertedEvent( data ) { + return this.sendEvent( EVENTS_MAP.TEMPLATE_INSERTED, { + location: elementor.editorEvents.config.locations.templatesLibrary.library, + ...data, + } ); + } + + sendBulkActionsSuccessEvent( data ) { + return this.sendEvent( EVENTS_MAP.BULK_ACTIONS_SUCCESS, { + bulk_status: 'success', + location: elementor.editorEvents.config.locations.templatesLibrary.library, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.saveModal, + ...data, + } ); + } + + sendBulkActionsFailedEvent( data ) { + return this.sendEvent( EVENTS_MAP.BULK_ACTIONS_FAILED, { + bulk_status: 'fail', + location: elementor.editorEvents.config.locations.templatesLibrary.library, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.saveModal, + ...data, + } ); + } + + sendFolderCreateEvent() { + return this.sendEvent( EVENTS_MAP.FOLDER_CREATE, { + location: elementor.editorEvents.config.locations.templatesLibrary.library, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.createFolderDialog, + } ); + } + + sendQuotaBarCapacityEvent( data ) { + return this.sendEvent( EVENTS_MAP.QUOTA_BAR_CAPACITY, { + location: elementor.editorEvents.config.locations.templatesLibrary.library, + ...data, + } ); + } + + sendInsertApplySettingsEvent( data ) { + return this.sendEvent( EVENTS_MAP.INSERT_APPLY_SETTINGS, { + location: elementor.editorEvents.config.locations.templatesLibrary.library, + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.applySettingsDialog, + ...data, + } ); + } + + sendUpgradeClickedEvent( data ) { + return this.sendEvent( EVENTS_MAP.UPGRADE_CLICKED, { + location: elementor.editorEvents.config.locations.templatesLibrary.library, + current_sub: elementor?.config?.library_connect?.current_access_tier, + ...data, + } ); + } + + sendPageViewEvent( data ) { + return this.sendEvent( EVENTS_MAP.PAGE_VIEWED, { + page_loaded: data.location, + ...data, + } ); + } + + sendDeletionUndoEvent( data ) { + return this.sendEvent( EVENTS_MAP.DELETION_UNDO, { + ...data, + } ); + } +} + diff --git a/assets/dev/js/editor/components/template-library/manager.js b/assets/dev/js/editor/components/template-library/manager.js index 3587c61d123a..fd066f114881 100644 --- a/assets/dev/js/editor/components/template-library/manager.js +++ b/assets/dev/js/editor/components/template-library/manager.js @@ -1,61 +1,48 @@ import Component from './component'; +import LocalStorage from 'elementor-api/core/data/storages/local-storage'; +import { EventManager } from './event-manager'; +import { SAVE_CONTEXTS } from './constants'; -var TemplateLibraryCollection = require( 'elementor-templates/collections/templates' ), - TemplateLibraryManager; +const TemplateLibraryCollection = require( 'elementor-templates/collections/templates' ); -TemplateLibraryManager = function() { +const TemplateLibraryManager = function() { this.modalConfig = {}; + this.eventManager = new EventManager(); + const self = this, - templateTypes = {}; + templateTypes = {}, + storage = new LocalStorage(), + storageKeyPrefix = 'my_templates_', + sourceKey = 'source', + viewKey = 'view', + bulkSelectedItems = new Set(), + lastDeletedItems = new Set(); let deleteDialog, errorDialog, templatesCollection, config = {}, - filterTerms = {}; + filterTerms = {}, + isLoading = false, + total = 0, + toastConfig = { show: false, options: {} }; const registerDefaultTemplateTypes = function() { - var data = { - saveDialog: { - description: __( 'Your designs will be available for export and reuse on any page or website', 'elementor' ), - }, - ajaxParams: { - success( successData ) { - $e.route( 'library/templates/my-templates', { - onBefore: () => { - if ( templatesCollection ) { - const itemExist = templatesCollection.findWhere( { - template_id: successData.template_id, - } ); - - if ( ! itemExist ) { - templatesCollection.add( successData ); - } - } - }, - } ); - }, - error( errorData ) { - self.showErrorDialog( errorData ); - }, - }, - }; + var data = self.getDefaultTemplateTypeData(); const translationMap = { page: __( 'Page', 'elementor' ), section: __( 'Section', 'elementor' ), container: __( 'Container', 'elementor' ), + 'e-div-block': __( 'Div Block', 'elementor' ), + 'e-flexbox': __( 'Flexbox', 'elementor' ), + [ elementor.config.document.type ]: elementor.config.document.panel.title, }; jQuery.each( translationMap, function( type, title ) { - var safeData = jQuery.extend( true, {}, data, { - saveDialog: { - /* Translators: %s: Template type. */ - title: sprintf( __( 'Save Your %s to Library', 'elementor' ), title ), - }, - } ); + var safeData = jQuery.extend( true, {}, data, self.getDefaultTemplateTypeSafeData( title ) ); self.registerTemplateType( type, safeData ); } ); @@ -82,6 +69,16 @@ TemplateLibraryManager = function() { }; }; + this.isLoading = () => isLoading; + + this.canLoadMore = () => { + if ( ! templatesCollection ) { + return false; + } + + return templatesCollection.length < total; + }; + this.init = function() { registerDefaultTemplateTypes(); @@ -92,6 +89,160 @@ TemplateLibraryManager = function() { elementor.addBackgroundClickListener( 'libraryToggleMore', { element: '.elementor-template-library-template-more', } ); + + window.addEventListener( 'message', ( message ) => { + const { data } = message; + + if ( ! data.name || data.name !== 'library/capture-screenshot-done' ) { + return; + } + + const template = templatesCollection.models.find( ( templateModel ) => { + return templateModel.get( 'template_id' ) === parseInt( data.id ); + } ); + + if ( ! template ) { + return null; + } + + template.set( 'preview_url', data.imageUrl ); + } ); + + this.handleKeydown = ( event ) => { + if ( this.isSelectAllShortcut( event ) && this.isCloudGridView() && this.isClickedInLibrary( event ) ) { + event.preventDefault(); + this.selectAllTemplates(); + } + + if ( this.isUndoShortCut( event ) && lastDeletedItems.size ) { + this.restoreRemovedItems(); + } + }; + + document.addEventListener( 'keydown', this.handleKeydown ); + }; + + this.getDefaultTemplateTypeData = function() { + return { + saveDialog: { + icon: '', + canSaveToCloud: elementorCommon.config.experimentalFeatures?.[ 'cloud-library' ], + saveBtnText: __( 'Save', 'elementor' ), + }, + moveDialog: { + description: __( 'Alternatively, you can copy the template.', 'elementor' ), + icon: '', + canSaveToCloud: elementorCommon.config.experimentalFeatures?.[ 'cloud-library' ], + saveBtnText: __( 'Move', 'elementor' ), + }, + copyDialog: { + description: __( 'Alternatively, you can move the template.', 'elementor' ), + icon: '', + canSaveToCloud: elementorCommon.config.experimentalFeatures?.[ 'cloud-library' ], + saveBtnText: __( 'Copy', 'elementor' ), + }, + bulkMoveDialog: { + description: __( 'Alternatively, you can copy the templates.', 'elementor' ), + title: __( 'Move templates to a different location', 'elementor' ), + icon: '', + canSaveToCloud: elementorCommon.config.experimentalFeatures?.[ 'cloud-library' ], + saveBtnText: __( 'Move', 'elementor' ), + }, + bulkCopyDialog: { + description: __( 'Alternatively, you can move the templates.', 'elementor' ), + title: __( 'Copy templates to a different location', 'elementor' ), + icon: '', + canSaveToCloud: elementorCommon.config.experimentalFeatures?.[ 'cloud-library' ], + saveBtnText: __( 'Copy', 'elementor' ), + }, + }; + }; + + this.getDefaultTemplateTypeSafeData = function( title ) { + return { + saveDialog: { + description: elementorCommon.config.experimentalFeatures?.[ 'cloud-library' ] ? sprintf( + /* Translators: 1: Opening bold tag, 2: Closing bold tag. 2: Line break tag. 4: Opening bold tag, 5: Closing bold tag. */ + __( 'You can save it to %1$sCloud Templates%2$s to reuse across any of your Elementor sites at any time%3$sor to %4$sSite Templates%5$s so it’s always ready when editing this website.', 'elementor' ), + '', '', '
', '', '', + ) : __( 'Your designs will be available for export and reuse on any page or website', 'elementor' ), + /* Translators: %s: Template type. */ + title: sprintf( __( 'Save this %s to your library', 'elementor' ), title ), + }, + moveDialog: { + /* Translators: %s: Template type. */ + title: sprintf( __( 'Move your %s to a different location', 'elementor' ), title ), + }, + copyDialog: { + /* Translators: %s: Template type. */ + title: sprintf( __( 'Copy your %s to a different location', 'elementor' ), title ), + }, + }; + }; + + this.isSelectAllShortcut = function( event ) { + return ( event.metaKey || event.ctrlKey ) && 'a' === event.key; + }; + + this.isUndoShortCut = function( event ) { + return ( event.metaKey || event.ctrlKey ) && 'z' === event.key; + }; + + this.isCloudGridView = function() { + return 'cloud' === this.getFilter( 'source' ) && 'grid' === this.getViewSelection(); + }; + + this.isClickedInLibrary = function( event ) { + if ( event.target === document.body ) { + return true; // When the rename dialog is closed it sets the target to the body. + } + + const libraryElement = document.getElementById( 'elementor-template-library-modal' ); + + return libraryElement && event.target === libraryElement; + }; + + this.clearLastRemovedItems = function() { + lastDeletedItems.clear(); + }; + + this.addLastRemovedItems = function( ids ) { + if ( ! Array.isArray( ids ) && ! ids.length ) { + return; + } + + ids.forEach( ( id ) => lastDeletedItems.add( id ) ); + }; + + this.selectAllTemplates = function() { + document.querySelectorAll( '.elementor-template-library-template[data-template_id]' ).forEach( ( element ) => { + const templateId = element.getAttribute( 'data-template_id' ); + + element.classList.add( 'bulk-selected-item' ); + this.addBulkSelectionItem( templateId ); + } ); + + this.layout.handleBulkActionBar(); + }; + + this.restoreRemovedItems = function() { + this.onUndoDelete(); + }; + + this.getSourceSelection = function() { + return storage.getItem( storageKeyPrefix + sourceKey ); + }; + + this.setSourceSelection = function( value ) { + return storage.setItem( storageKeyPrefix + sourceKey, value ); + }; + + this.getViewSelection = function() { + return storage.getItem( storageKeyPrefix + viewKey ); + }; + + this.setViewSelection = function( value ) { + return storage.setItem( storageKeyPrefix + viewKey, value ); }; this.getTemplateTypes = function( type ) { @@ -103,28 +254,51 @@ TemplateLibraryManager = function() { }; this.registerTemplateType = function( type, data ) { + if ( templateTypes.hasOwnProperty( type ) ) { + return; + } + templateTypes[ type ] = data; }; this.deleteTemplate = function( templateModel, options ) { - var dialog = self.getDeleteDialog(); + this.clearLastRemovedItems(); + + var dialog = self.getDeleteDialog( templateModel ); dialog.onConfirm = function() { if ( options.onConfirm ) { options.onConfirm(); } + const templateId = templateModel.get( 'template_id' ); + const source = templateModel.get( 'source' ); + const itemType = templateModel.get( 'subType' ); + elementorCommon.ajax.addRequest( 'delete_template', { data: { - source: templateModel.get( 'source' ), - template_id: templateModel.get( 'template_id' ), + source, + template_id: templateId, }, success( response ) { - templatesCollection.remove( templateModel, { silent: true } ); + templatesCollection.remove( templateModel ); + + if ( 'cloud' === source ) { + self.addLastRemovedItems( [ templateId ] ); + } if ( options.onSuccess ) { options.onSuccess( response ); } + + self.layout.updateViewCollection( self.filterTemplates() ); + + self.triggerQuotaUpdate(); + self.resetBulkActionBar(); + self.eventManager.sendItemDeletedEvent( { + library_type: source, + item_type: itemType, + } ); }, } ); }; @@ -132,12 +306,335 @@ TemplateLibraryManager = function() { dialog.show(); }; + this.renameTemplate = ( templateModel, options ) => { + const originalTitle = templateModel.get( 'title' ); + + this.clearLastRemovedItems(); + + const dialog = this.getRenameDialog( templateModel ); + + return new Promise( ( resolve ) => { + dialog.onConfirm = () => { + if ( options.onConfirm ) { + options.onConfirm(); + } + + const source = templateModel.get( 'source' ); + + elementorCommon.ajax.addRequest( 'rename_template', { + data: { + source, + id: templateModel.get( 'template_id' ), + title: templateModel.get( 'title' ), + }, + success: ( response ) => { + templateModel.trigger( 'change:title' ); + this.eventManager.sendTemplateRenameEvent( { source } ); + resolve( response ); + }, + error: ( error ) => { + this.showErrorDialog( error ); + templateModel.set( 'title', originalTitle ); + resolve(); + }, + } ); + }; + dialog.show(); + } ); + }; + + this.getRenameDialog = function( templateModel ) { + const headerMessage = sprintf( + // Translators: %1$s: Folder name, %2$s: Number of templates. + __( 'Rename "%1$s"', 'elementor' ), + templateModel.get( 'title' ), + ); + + const originalTitle = templateModel.get( 'title' ); + + const $inputArea = jQuery( '', { + id: 'elementor-rename-template-dialog__input', + type: 'text', + value: templateModel.get( 'title' ), + } ) + .attr( 'autocomplete', 'off' ); + + const dialog = elementorCommon.dialogsManager.createWidget( 'confirm', { + id: 'elementor-template-library-rename-dialog', + headerMessage, + message: $inputArea, + strings: { + confirm: __( 'Rename', 'elementor' ), + }, + hide: { + ignore: '#elementor-template-library-modal', + }, + onCancel: () => { + templateModel.set( 'title', originalTitle ); + }, + onShow: () => { + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.renameDialog, + } ); + $inputArea.trigger( 'focus' ); + }, + } ); + + $inputArea.on( 'input', ( event ) => { + event.preventDefault(); + const title = event.target.value.trim(); + + templateModel.set( 'title', title, { silent: true } ); + + dialog.getElements( 'ok' ).prop( 'disabled', ! self.isTemplateTitleValid( title ) ); + } ); + + return dialog; + }; + + this.isTemplateTitleValid = ( title ) => { + return title.trim().length > 0 && title.trim().length <= 75; + }; + + this.getFolderTemplates = ( parentElement ) => { + this.clearLastRemovedItems(); + + const parentId = parentElement.model.get( 'template_id' ); + const parentTitle = parentElement.model.get( 'title' ); + + return new Promise( ( resolve ) => { + isLoading = true; + const ajaxOptions = { + data: { + source: 'cloud', + template_id: parentId, + }, + success: ( data ) => { + this.setFilter( 'orderby', '', true ); + this.setFilter( 'order', '', true ); + + this.setFilter( 'parent', { + id: parentId, + title: parentTitle, + } ); + + templatesCollection = new TemplateLibraryCollection( data.templates ); + + elementor.templates.layout.hideLoadingView(); + + self.layout.updateViewCollection( templatesCollection.models ); + self.layout.modalContent.currentView.ui.addNewFolder.remove(); + self.layout.modalContent.currentView.ui.addNewFolderDivider.remove(); + self.layout.resetSortingUI(); + + isLoading = false; + resolve(); + }, + error: ( error ) => { + isLoading = false; + this.showErrorDialog( error ); + }, + }; + + elementorCommon.ajax.addRequest( 'get_item_children', ajaxOptions ); + } ); + }; + + this.createFolder = function( folderData, options ) { + this.clearLastRemovedItems(); + + if ( null !== this.getFilter( 'parent' ) ) { + this.showErrorDialog( __( 'You can not create a folder inside another folder.', 'elementor' ) ); + + return; + } + + const dialog = this.getCreateFolderDialog( folderData ); + + return new Promise( ( resolve ) => { + dialog.onConfirm = async () => { + await elementorCommon.ajax.addRequest( 'create_folder', { + data: { + source: folderData.source, + title: folderData.title, + }, + success: ( response ) => { + resolve( response ); + + options?.onSuccess(); + + this.eventManager.sendFolderCreateEvent(); + }, + error: ( error ) => { + this.showErrorDialog( error ); + + resolve(); + }, + } ); + }; + + dialog.show(); + } ); + }; + + this.getCreateFolderDialog = function( folderData ) { + const paragraph = document.createElement( 'p' ); + paragraph.className = 'elementor-create-folder-template-dialog__p'; + paragraph.textContent = __( 'Save assets to reuse on any site in your account.', 'elementor' ); + + const inputArea = document.createElement( 'input' ); + inputArea.className = 'elementor-create-folder-template-dialog__input'; + inputArea.type = 'text'; + inputArea.value = ''; + inputArea.placeholder = __( 'Folder name', 'elementor' ); + inputArea.autocomplete = 'off'; + + const fragment = document.createDocumentFragment(); + fragment.appendChild( paragraph ); + fragment.appendChild( inputArea ); + + const dialog = elementorCommon.dialogsManager.createWidget( 'confirm', { + id: 'elementor-template-library-create-new-folder-dialog', + headerMessage: __( 'Create a new folder', 'elementor' ), + message: fragment, + strings: { + confirm: __( 'Create', 'elementor' ), + }, + hide: { + ignore: '#elementor-template-library-modal', + }, + onShow: () => { + inputArea.focus(); + + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.newFolderModal, + } ); + }, + } ); + + dialog.getElements( 'ok' ).prop( 'disabled', true ); + + inputArea.addEventListener( 'input', ( event ) => { + event.preventDefault(); + + const title = event.target.value.trim(); + + folderData.title = title; + + const isTitleValid = self.isTemplateTitleValid( title ); + + dialog.getElements( 'ok' ).prop( 'disabled', ! isTitleValid ); + } ); + + return dialog; + }; + + this.deleteFolder = function( templateModel, options ) { + this.clearLastRemovedItems(); + + const ajaxOptions = { + data: { + source: 'cloud', + template_id: templateModel.get( 'template_id' ), + }, + success: ( data ) => this.handleGetFolderDataSuccess( templateModel, options, data ), + }; + + elementorCommon.ajax.addRequest( 'get_item_children', ajaxOptions ); + }; + + this.handleGetFolderDataSuccess = function( templateModel, options, data ) { + const dialog = this.getDeleteFolderDialog( templateModel, data ); + + dialog.onConfirm = () => { + options.onConfirm?.(); + + this.sendDeleteRequest( templateModel, options ); + }; + + dialog.show(); + }; + + this.getDeleteFolderDialog = function( templateModel, data ) { + const deleteFolderDialog = elementorCommon.dialogsManager.createWidget( 'confirm', { + id: 'elementor-template-library-delete-dialog', + headerMessage: __( 'Delete this folder?', 'elementor' ), + message: sprintf( + // Translators: %1$s: Folder name, %2$s: Number of templates. + __( 'This will permanently delete "%1$s" that contains %2$d templates.', 'elementor' ), + templateModel.get( 'title' ), + data.total, + ), + strings: { + confirm: __( 'Delete', 'elementor' ), + }, + onShow: () => { + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.deleteFolderDialog, + } ); + }, + } ); + + deleteFolderDialog.getElements( 'ok' ).addClass( 'e-danger color-white' ); + + return deleteFolderDialog; + }; + + this.getBulkDeleteDialog = function() { + const bulkDeleteDialog = elementorCommon.dialogsManager.createWidget( 'confirm', { + id: 'elementor-template-library-bulk-delete-dialog', + headerMessage: __( 'Delete items?', 'elementor' ), + message: sprintf( + // Translators: %1$s: Number of selected items. + __( 'This will permanently remove %1$s selected items.', 'elementor' ), + bulkSelectedItems.size, + ), + strings: { + confirm: __( 'Delete', 'elementor' ), + }, + } ); + + bulkDeleteDialog.getElements( 'ok' ).addClass( 'e-danger color-white' ); + + return bulkDeleteDialog; + }; + + this.sendDeleteRequest = function( templateModel, options ) { + const templateId = templateModel.get( 'template_id' ); + const source = templateModel.get( 'source' ); + + elementorCommon.ajax.addRequest( 'delete_template', { + data: { + source, + template_id: templateId, + }, + success: ( response ) => { + self.addLastRemovedItems( [ templateId ] ); + templatesCollection.remove( templateModel, { silent: true } ); + options.onSuccess?.( response ); + + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.deleteFolderDialog, + } ); + + elementor.templates.eventManager.sendItemDeletedEvent( { + library_type: source, + item_type: 'folder', + } ); + + this.triggerQuotaUpdate(); + }, + } ); + }; + /** * @param {*} model - Template model. * @param {Object} args - Template arguments. * @deprecated since 2.8.0, use `$e.run( 'library/insert-template' )` instead. */ this.importTemplate = function( model, args = {} ) { + this.clearLastRemovedItems(); + elementorDevTools.deprecation.deprecated( 'importTemplate', '2.8.0', "$e.run( 'library/insert-template' )" ); @@ -147,10 +644,12 @@ TemplateLibraryManager = function() { }; this.saveTemplate = function( type, data ) { + this.clearLastRemovedItems(); + var templateType = templateTypes[ type ]; _.extend( data, { - source: 'local', + source: data.source ?? 'local', type, } ); @@ -160,16 +659,91 @@ TemplateLibraryManager = function() { data.content = JSON.stringify( data.content ); - var ajaxParams = { data }; + const defaultAjaxParams = { + data, + success( successData ) { + $e.route( 'library/templates/my-templates', { + onBefore: () => { + self.triggerQuotaUpdate(); + if ( templatesCollection ) { + const itemExist = templatesCollection.findWhere( { + template_id: successData.template_id, + } ); + + if ( ! itemExist ) { + templatesCollection.add( successData ); + } + } + + self.sendOnSavedTemplateSuccessEvent( data ); + }, + } ); + }, + error( errorData ) { + self.showErrorDialog( errorData ); + self.clearToastConfig(); + self.sendOnSavedTemplateFailedEvent( data ); + }, + }; + + const ajaxParams = _.extend( defaultAjaxParams, templateType.ajaxParams ); + + elementorCommon.ajax.addRequest( this.getSaveAjaxAction( data.save_context ), ajaxParams ); + }; - if ( templateType.ajaxParams ) { - _.extend( ajaxParams, templateType.ajaxParams ); + this.sendOnSavedTemplateSuccessEvent = ( formData ) => { + if ( SAVE_CONTEXTS.SAVE === formData.save_context ) { + self.eventManager.sendTemplateSavedEvent( { + library_type: formData.source, + template_type: formData.type, + } ); + } else if ( [ SAVE_CONTEXTS.COPY, SAVE_CONTEXTS.MOVE ].includes( formData.save_context ) ) { + self.eventManager.sendTemplateTransferEvent( { + transfer_method: formData.save_context, + template_type: formData.type, + template_origin: formData.from_source, + template_destination: formData.source, + } ); + } else if ( [ SAVE_CONTEXTS.BULK_MOVE, SAVE_CONTEXTS.BULK_COPY ].includes( formData.save_context ) ) { + self.eventManager.sendBulkActionsSuccessEvent( { + bulk_action: SAVE_CONTEXTS.BULK_MOVE === formData.save_context ? 'move' : 'copy', + library_type: formData.source, + bulk_count: formData.from_template_id.length, + template_origin: formData.from_source, + template_destination: formData.source, + } ); } + }; - elementorCommon.ajax.addRequest( 'save_template', ajaxParams ); + this.sendOnSavedTemplateFailedEvent = ( formData ) => { + if ( [ SAVE_CONTEXTS.BULK_MOVE, SAVE_CONTEXTS.BULK_COPY ].includes( formData.save_context ) ) { + self.eventManager.sendBulkActionsFailedEvent( { + bulk_action: SAVE_CONTEXTS.BULK_MOVE === formData.save_context ? 'move' : 'copy', + library_type: formData.source, + bulk_count: formData.from_template_id.length, + template_origin: formData.from_source, + template_destination: formData.source, + } ); + } + }; + + this.getSaveAjaxAction = function( saveContext ) { + this.clearLastRemovedItems(); + + const saveActions = { + [ SAVE_CONTEXTS.SAVE ]: 'save_template', + [ SAVE_CONTEXTS.MOVE ]: 'move_template', + [ SAVE_CONTEXTS.COPY ]: 'copy_template', + [ SAVE_CONTEXTS.BULK_MOVE ]: 'bulk_move_templates', + [ SAVE_CONTEXTS.BULK_COPY ]: 'bulk_copy_templates', + }; + + return saveActions[ saveContext ] ?? 'save_template'; }; this.requestTemplateContent = function( source, id, ajaxOptions ) { + this.clearLastRemovedItems(); + var options = { unique_id: id, data: { @@ -188,6 +762,8 @@ TemplateLibraryManager = function() { }; this.markAsFavorite = function( templateModel, favorite ) { + this.clearLastRemovedItems(); + var options = { data: { source: templateModel.get( 'source' ), @@ -199,16 +775,27 @@ TemplateLibraryManager = function() { return elementorCommon.ajax.addRequest( 'mark_template_as_favorite', options ); }; - this.getDeleteDialog = function() { + this.getDeleteDialog = function( templateModel ) { if ( ! deleteDialog ) { deleteDialog = elementorCommon.dialogsManager.createWidget( 'confirm', { id: 'elementor-template-library-delete-dialog', - headerMessage: __( 'Delete Template', 'elementor' ), - message: __( 'Are you sure you want to delete this template?', 'elementor' ), + headerMessage: __( 'Delete this template?', 'elementor' ), + message: sprintf( + // Translators: %1$s: Template name. + __( 'This will permanently remove "%1$s".', 'elementor' ), + templateModel.get( 'title' ), + ), strings: { confirm: __( 'Delete', 'elementor' ), }, + onShow: () => { + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.deleteDialog, + } ); + }, } ); + + deleteDialog.getElements( 'ok' ).addClass( 'e-danger color-white' ); } return deleteDialog; @@ -277,6 +864,7 @@ TemplateLibraryManager = function() { }; this.setFilter = function( name, value, silent ) { + this.clearLastRemovedItems(); elementor.channels.templates.reply( 'filter:' + name, value ); if ( ! silent ) { @@ -293,6 +881,7 @@ TemplateLibraryManager = function() { }; this.setScreen = function( args ) { + this.clearLastRemovedItems(); elementor.channels.templates.stopReplying(); self.setFilter( 'source', args.source, true ); @@ -303,33 +892,164 @@ TemplateLibraryManager = function() { }; this.loadTemplates = function( onUpdate ) { + this.clearLastRemovedItems(); + + isLoading = true; + total = 0; + self.layout.showLoadingView(); const query = { source: this.getFilter( 'source' ) }, options = {}; // TODO: Remove - it when all the data commands is ready, manage the cache!. - if ( 'local' === query.source ) { + if ( 'local' === query.source || 'cloud' === query.source ) { options.refresh = true; } - $e.data.get( 'library/templates', query, options ).then( ( result ) => { - templatesCollection = new TemplateLibraryCollection( - result.data.templates, - ); + this.setFilter( 'parent', null, query ); - if ( result.data.config ) { - config = result.data.config; - } + const loadTemplatesData = () => { + return $e.data.get( 'library/templates', query, options ).then( ( result ) => { + const templates = 'cloud' === query.source ? result.data.templates.templates : result.data.templates; + + templatesCollection = new TemplateLibraryCollection( + templates, + ); + + if ( result.data?.templates?.total ) { + total = result.data?.templates?.total; + } + + if ( result.data.config ) { + config = result.data.config; + } + + self.layout.hideLoadingView(); + + if ( onUpdate ) { + onUpdate(); + } + } ).finally( ( ) => { + isLoading = false; + } ); + }; - self.layout.hideLoadingView(); + const handleCloudSource = () => { + if ( 'undefined' === typeof elementorAppConfig[ 'cloud-library' ]?.quota ) { + return $e.components.get( 'cloud-library' ).utils.getQuotaConfig( true ) + .then( () => { + if ( self.shouldShowCloudStateView() ) { + self.layout.showCloudStateView(); + return; + } + + return loadTemplatesData(); + } ) + .catch( () => { + self.layout.showCloudStateView(); + isLoading = false; + } ); + } - if ( onUpdate ) { - onUpdate(); + if ( self.shouldShowCloudStateView() ) { + self.layout.showCloudStateView(); + return; } + + return loadTemplatesData(); + }; + + if ( 'cloud' === query.source ) { + handleCloudSource(); + } else { + loadTemplatesData(); + } + }; + + this.searchTemplates = ( data ) => { + this.clearLastRemovedItems(); + + return new Promise( ( resolve ) => { + this.setFilter( 'parent', null ); + + isLoading = true; + + const ajaxOptions = { + data, + success: ( result ) => { + isLoading = false; + + templatesCollection = new TemplateLibraryCollection( result.templates ); + + total = result.total; + + self.layout.updateViewCollection( templatesCollection.models ); + + this.setFilter( 'text', data.search ); + + resolve( result ); + }, + error: ( error ) => { + isLoading = false; + + this.showErrorDialog( error ); + + resolve(); + }, + }; + + elementorCommon.ajax.addRequest( 'search_templates', ajaxOptions ); } ); }; + this.loadMore = ( { + onUpdate, + search = '', + refresh = false, + } = {} ) => { + isLoading = true; + + this.clearLastRemovedItems(); + + const source = this.getFilter( 'source' ); + + const parentId = this.getFilter( 'parent' )?.id; + + const ajaxOptions = { + data: { + source, + offset: refresh ? 0 : templatesCollection.length, + search, + parentId, + orderby: elementor.templates.getFilter( 'orderby' ) || null, + order: elementor.templates.getFilter( 'order' ) || null, + }, + success: ( result ) => { + const collection = new TemplateLibraryCollection( result.templates ); + + if ( refresh ) { + templatesCollection.reset( collection.models ); + self.layout.updateViewCollection( templatesCollection.models ); + } else { + templatesCollection.add( collection.models, { merge: true } ); + self.layout.addTemplates( collection.models ); + } + + if ( onUpdate ) { + onUpdate(); + } + + isLoading = false; + }, + error: () => { + isLoading = false; + }, + }; + + elementorCommon.ajax.addRequest( 'load_more_templates', ajaxOptions ); + }; + this.showTemplates = function() { // The tabs should exist in DOM on loading. self.layout.setHeaderDefaultParts(); @@ -338,11 +1058,35 @@ TemplateLibraryManager = function() { var templatesToShow = self.filterTemplates(); self.layout.showTemplatesView( new TemplateLibraryCollection( templatesToShow ) ); + + self.handleToast(); + } ); + }; + + this.handleToast = function() { + if ( ! toastConfig?.show ) { + return; + } + + elementor.notifications.showToast( toastConfig?.options ); + + this.clearToastConfig(); + }; + + this.setToastConfig = function( newConfig ) { + toastConfig = newConfig; + }; + + this.clearToastConfig = function() { + this.setToastConfig( { + show: false, + options: {}, } ); }; this.filterTemplates = function() { const activeSource = self.getFilter( 'source' ); + return templatesCollection.filter( function( model ) { if ( activeSource !== model.get( 'source' ) ) { return false; @@ -382,6 +1126,230 @@ TemplateLibraryManager = function() { .setMessage( errorMessage ) .show(); }; + + this.onSelectSourceFilterChange = function( event ) { + const templatesSource = event?.currentTarget?.dataset?.source ?? 'local', + alreadyActive = templatesSource === self.getFilter( 'source' ); + + if ( alreadyActive ) { + return; + } + + self.setSourceSelection( templatesSource ); + self.setFilter( 'source', templatesSource, true ); + self.clearBulkSelectionItems(); + + self.loadTemplates( function() { + const templatesToShow = self.filterTemplates(); + + self.layout.showTemplatesView( new TemplateLibraryCollection( templatesToShow ) ); + } ); + }; + + this.onSelectViewChange = function( selectedView ) { + self.setViewSelection( selectedView ); + self.setFilter( viewKey, selectedView, true ); + + self.layout.updateViewCollection( self.filterTemplates() ); + + self.resetBulkActionBar(); + }; + + this.resetBulkActionBar = () => { + this.clearBulkSelectionItems(); + this.layout.handleBulkActionBarUi(); + }; + + this.shouldShowCloudStateView = function() { + if ( ! elementor.config.library_connect.is_connected ) { + return true; + } + + return ! this.hasCloudLibraryQuota() || this.cloudLibraryIsDeactivated(); + }; + + this.cloudLibraryIsDeactivated = function() { + const quota = elementorAppConfig[ 'cloud-library' ]?.quota; + + if ( ! quota ) { + return false; + } + + const { + currentUsage = 0, + threshold = 0, + subscriptionId = '', + } = quota; + + const isOverThreshold = currentUsage > threshold; + const hasSubscription = '' !== subscriptionId; + + return isOverThreshold && ! hasSubscription; + }; + + this.hasCloudLibraryQuota = function() { + return 'undefined' !== typeof elementorAppConfig[ 'cloud-library' ]?.quota && + 0 < elementorAppConfig[ 'cloud-library' ].quota?.threshold; + }; + + this.addBulkSelectionItem = function( templateId ) { + bulkSelectedItems.add( parseInt( templateId ) ); + }; + + this.removeBulkSelectionItem = function( templateId ) { + bulkSelectedItems.delete( parseInt( templateId ) ); + }; + + this.clearBulkSelectionItems = function() { + bulkSelectedItems.clear(); + }; + + this.getBulkSelectionItems = function() { + return bulkSelectedItems; + }; + + this.onBulkDeleteClick = function() { + this.clearLastRemovedItems(); + + return new Promise( ( resolve ) => { + const selectedItems = this.getBulkSelectionItems(); + + if ( ! selectedItems.size ) { + return; + } + + const dialog = this.getBulkDeleteDialog(); + + const source = this.getFilter( 'source' ); + + const templateIds = Array.from( selectedItems ); + + dialog.onConfirm = () => { + isLoading = true; + + const ajaxOptions = { + data: { + source, + template_ids: templateIds, + }, + success: () => { + isLoading = false; + + const modelsToRemove = templatesCollection.models.filter( ( templateModel ) => { + return selectedItems.has( templateModel.get( 'template_id' ) ); + } ); + + if ( 'cloud' === source ) { + self.addLastRemovedItems( templateIds ); + } + + templatesCollection.remove( modelsToRemove ); + + self.layout.updateViewCollection( self.filterTemplates() ); + + self.clearBulkSelectionItems(); + + self.eventManager.sendBulkActionsSuccessEvent( { + library_type: source, + bulk_action: 'delete', + bulk_count: templateIds.length, + } ); + + const buttons = 'cloud' === source ? [ + { + name: 'undo_bulk_delete', + text: __( 'Undo', 'elementor' ), + callback: () => { + this.onUndoDelete( isBulk ); + }, + }, + ] : null; + + elementor.notifications.showToast( { + message: `${ templateIds.length } items deleted successfully`, + buttons, + } ); + + this.triggerQuotaUpdate(); + + resolve(); + }, + error: ( error ) => { + isLoading = false; + + this.showErrorDialog( error ); + + self.eventManager.sendBulkActionsFailedEvent( { + library_type: source, + bulk_action: 'delete', + bulk_count: templateIds.length, + } ); + + resolve(); + }, + }; + + elementorCommon.ajax.addRequest( 'bulk_delete_templates', ajaxOptions ); + }; + + dialog.onCancel = () => { + resolve(); + }; + + dialog.show(); + } ); + }; + + this.onUndoDelete = function( isBulk ) { + return new Promise( ( resolve ) => { + isLoading = true; + + if ( ! lastDeletedItems.size ) { + return resolve(); + } + + const source = this.getFilter( 'source' ); + + const templateIds = Array.from( lastDeletedItems ); + + const ajaxOptions = { + data: { + source, + template_ids: templateIds, + }, + success: () => { + isLoading = false; + + $e.routes.refreshContainer( 'library' ); + + this.clearLastRemovedItems(); + + this.triggerQuotaUpdate(); + + resolve(); + }, + error: ( error ) => { + isLoading = false; + + this.clearLastRemovedItems(); + + this.showErrorDialog( error ); + + resolve(); + }, + }; + + elementorCommon.ajax.addRequest( 'bulk_undo_delete_items', ajaxOptions ); + + self.eventManager.sendDeletionUndoEvent( { + is_bulk: isBulk, + } ); + } ); + }; + + this.triggerQuotaUpdate = function( force = true ) { + elementor.channels.templates.trigger( 'quota:update', { force } ); + }; }; module.exports = new TemplateLibraryManager(); diff --git a/assets/dev/js/editor/components/template-library/models/template.js b/assets/dev/js/editor/components/template-library/models/template.js index aa26991858b0..b570ab5df589 100644 --- a/assets/dev/js/editor/components/template-library/models/template.js +++ b/assets/dev/js/editor/components/template-library/models/template.js @@ -9,6 +9,13 @@ module.exports = Backbone.Model.extend( { thumbnail: '', url: '', export_link: '', + status: null, + preview_url: null, + generate_preview_url: null, tags: [], }, + + isLocked() { + return 'locked' === this.get( 'status' ); + }, } ); diff --git a/assets/dev/js/editor/components/template-library/views/library-layout.js b/assets/dev/js/editor/components/template-library/views/library-layout.js index e2a4288b6c99..b51708496fde 100644 --- a/assets/dev/js/editor/components/template-library/views/library-layout.js +++ b/assets/dev/js/editor/components/template-library/views/library-layout.js @@ -6,7 +6,11 @@ var TemplateLibraryHeaderActionsView = require( 'elementor-templates/views/parts TemplateLibrarySaveTemplateView = require( 'elementor-templates/views/parts/save-template' ), TemplateLibraryImportView = require( 'elementor-templates/views/parts/import' ), TemplateLibraryConnectView = require( 'elementor-templates/views/parts/connect' ), - TemplateLibraryPreviewView = require( 'elementor-templates/views/parts/preview' ); + TemplateLibraryCloudStateView = require( 'elementor-templates/views/parts/cloud-states' ), + TemplateLibraryPreviewView = require( 'elementor-templates/views/parts/preview' ), + TemplateLibraryNavigationContainerView = require( 'elementor-templates/views/parts/navigation-container' ); + +import { SAVE_CONTEXTS } from './../constants'; module.exports = elementorModules.common.views.modal.Layout.extend( { getModalOptions() { @@ -18,6 +22,7 @@ module.exports = elementorModules.common.views.modal.Layout.extend( { onOutsideClick: allowClosingModal, onBackgroundClick: allowClosingModal, onEscKeyPress: allowClosingModal, + ignore: '.dialog-widget-content, .dialog-buttons-undo_bulk_delete, .dialog-buttons-template_after_save, #elementor-library--infotip__dialog, #elementor-template-library-rename-dialog, #elementor-template-library-delete-dialog', }, }; }, @@ -82,6 +87,15 @@ module.exports = elementorModules.common.views.modal.Layout.extend( { } ) ); }, + updateViewCollection( models ) { + this.modalContent.currentView.collection.reset( models ); + this.modalContent.currentView.ui.navigationContainer.html( ( new TemplateLibraryNavigationContainerView() ).render()?.el ); + }, + + addTemplates( models ) { + this.modalContent.currentView.collection.add( models, { merge: true } ); + }, + showImportView() { const headerView = this.getHeaderView(); @@ -98,10 +112,21 @@ module.exports = elementorModules.common.views.modal.Layout.extend( { this.modalContent.show( new TemplateLibraryConnectView( args ) ); }, - showSaveTemplateView( elementModel ) { - this.getHeaderView().menuArea.reset(); + showCloudStateView() { + elementor.templates.layout.hideLoadingView(); + this.modalContent.show( new TemplateLibraryCloudStateView() ); + }, + + showSaveTemplateView( elementModel, context = SAVE_CONTEXTS.SAVE ) { + const headerView = this.getHeaderView(); + + headerView.menuArea.reset(); - this.modalContent.show( new TemplateLibrarySaveTemplateView( { model: elementModel } ) ); + if ( SAVE_CONTEXTS.SAVE !== context ) { + headerView.logoArea.show( new TemplateLibraryHeaderBackView() ); + } + + this.modalContent.show( new TemplateLibrarySaveTemplateView( { model: elementModel, context } ) ); }, showPreviewView( templateModel ) { @@ -119,4 +144,73 @@ module.exports = elementorModules.common.views.modal.Layout.extend( { headerView.logoArea.show( new TemplateLibraryHeaderBackView() ); }, + + async showFolderView( elementModel ) { + try { + elementor.templates.layout.showLoadingView(); + + await elementor.templates.getFolderTemplates( elementModel ); + } finally { + elementor.templates.layout.hideLoadingView(); + } + }, + + createScreenshotIframe( previewUrl ) { + const iframe = document.createElement( 'iframe' ); + + iframe.src = previewUrl; + iframe.width = '1200'; + iframe.height = '500'; + iframe.style = 'visibility: hidden;'; + + document.body.appendChild( iframe ); + + return iframe; + }, + + handleBulkActionBarUi() { + if ( 0 === this.modalContent.currentView.$( '.bulk-selection-item-checkbox:checked' ).length ) { + this.modalContent.currentView.$el.addClass( 'no-bulk-selections' ); + this.modalContent.currentView.$el.removeClass( 'has-bulk-selections' ); + } else { + this.modalContent.currentView.$el.addClass( 'has-bulk-selections' ); + this.modalContent.currentView.$el.removeClass( 'no-bulk-selections' ); + } + + this.handleBulkActionBar(); + }, + + handleBulkActionBar() { + const selectedCount = elementor.templates.getBulkSelectionItems().size ?? 0; + const display = 0 === selectedCount ? 'none' : 'flex'; + + this.modalContent.currentView.ui.bulkSelectedCount.html( `${ selectedCount } Selected` ); + this.modalContent.currentView.ui.bulkSelectionActionBar.css( 'display', display ); + + // TODO: Temporary fix until the bulk action bar will be as separate view. + const displayNavigationContainer = 0 === selectedCount ? 'flex' : 'none'; + this.modalContent.currentView.ui.navigationContainer.css( 'display', displayNavigationContainer ); + }, + + selectAllCheckboxMinus() { + if ( this.isListView() ) { + this.modalContent.currentView.ui.bulkSelectAllCheckbox.addClass( 'checkbox-minus' ); + } + }, + + selectAllCheckboxNormal() { + if ( this.isListView() ) { + this.modalContent.currentView.ui.bulkSelectAllCheckbox.removeClass( 'checkbox-minus' ); + } + }, + + isListView() { + return 'list' === elementor.templates.getViewSelection(); + }, + + resetSortingUI() { + Array.from( this.modalContent.currentView.ui?.orderInputs || [] ).forEach( function( input ) { + input.checked = false; + } ); + }, } ); diff --git a/assets/dev/js/editor/components/template-library/views/parts/cloud-states.js b/assets/dev/js/editor/components/template-library/views/parts/cloud-states.js new file mode 100644 index 000000000000..59a96b7c5d6f --- /dev/null +++ b/assets/dev/js/editor/components/template-library/views/parts/cloud-states.js @@ -0,0 +1,115 @@ +module.exports = Marionette.ItemView.extend( { + template: '#tmpl-elementor-template-library-connect-states', + + id: 'elementor-template-library-connect-states', + + ui: { + connect: '#elementor-template-library-connect__button', + selectSourceFilter: '.elementor-template-library-filter-select-source .source-option', + title: '.elementor-template-library-blank-title', + message: '.elementor-template-library-blank-message', + icon: '.elementor-template-library-blank-icon', + button: '.elementor-template-library-cloud-empty__button', + }, + + events: { + 'click @ui.selectSourceFilter': 'onSelectSourceFilterChange', + 'click @ui.button': 'onButtonClick', + }, + + modesStrings() { + const defaultIcon = this.getDefaultIcon(); + + return { + notConnected: { + title: elementorAppConfig?.[ 'cloud-library' ]?.library_connect_title_copy ?? __( 'Connect to your Elementor account', 'elementor' ), + message: elementorAppConfig?.[ 'cloud-library' ]?.library_connect_sub_title_copy ?? __( 'Then you can find all your templates in one convenient library.', 'elementor' ), + icon: defaultIcon, + button: `${ elementorAppConfig?.[ 'cloud-library' ]?.library_connect_button_copy ?? __( 'Connect', 'elementor' ) }`, + }, + connectedNoQuota: { + title: __( 'It’s time to level up', 'elementor' ), + message: __( 'Elementor Pro plans come with Cloud Templates.', 'elementor' ) + '
' + __( 'Upgrade now to re-use your templates on all the websites you’re working on.', 'elementor' ), + icon: ``, + button: `${ __( 'Upgrade now', 'elementor' ) }`, + }, + deactivated: { + title: __( 'Your library has been deactivated', 'elementor' ), + message: __( 'This is because you don’t have an active subscription.', 'elementor' ) + '
' + __( 'Your templates are saved for 90 days from the day your subscription expires,', 'elementor' ) + '
' + __( 'then they’ll be gone forever.', 'elementor' ), + icon: ``, + button: `${ __( 'Renew my subscription', 'elementor' ) }`, + }, + }; + }, + + getDefaultIcon() { + return ``; + }, + + getCurrentMode() { + if ( ! elementor.config.library_connect.is_connected ) { + return 'notConnected'; + } + + if ( elementor.templates.cloudLibraryIsDeactivated() ) { + return 'deactivated'; + } + + return 'connectedNoQuota'; + }, + + onRender() { + this.updateTemplateMarkup(); + + this.handleElementorConnect(); + + elementor.templates.layout.getHeaderView()?.tools?.$el[ 0 ]?.classList?.add( 'e-hidden-disabled' ); + + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.cloudTabUpgrade, + } ); + }, + + updateTemplateMarkup() { + const modeStrings = this.modesStrings()[ this.getCurrentMode() ]; + + this.ui.title.html( modeStrings.title ); + + this.ui.message.html( modeStrings.message ); + + this.ui.button.html( modeStrings.button ); + + this.ui.icon.html( modeStrings.icon ); + }, + + handleElementorConnect() { + this.ui.connect.elementorConnect( { + success: () => { + elementor.config.library_connect.is_connected = true; + + $e.run( 'library/close' ); + elementor.notifications.showToast( { + message: __( 'Connected successfully.', 'elementor' ), + } ); + }, + error: () => { + elementor.config.library_connect.is_connected = false; + }, + } ); + }, + + onSelectSourceFilterChange( event ) { + elementor.templates.onSelectSourceFilterChange( event ); + }, + + onButtonClick() { + elementor.templates.eventManager.sendUpgradeClickedEvent( { + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.cloudTab, + upgradePosition: elementor.editorEvents.config.secondaryLocations.templateLibrary.cloudTab, + } ); + }, + + onDestroy() { + elementor.templates.layout.getHeaderView()?.tools?.$el[ 0 ]?.classList?.remove( 'e-hidden-disabled' ); + }, +} ); diff --git a/assets/dev/js/editor/components/template-library/views/parts/connect.js b/assets/dev/js/editor/components/template-library/views/parts/connect.js index eab274ed6306..e7903cd91cb0 100644 --- a/assets/dev/js/editor/components/template-library/views/parts/connect.js +++ b/assets/dev/js/editor/components/template-library/views/parts/connect.js @@ -13,6 +13,10 @@ module.exports = Marionette.ItemView.extend( { }, onRender() { + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.cloudTabConnect, + } ); + this.ui.connect.elementorConnect( { parseUrl: ( url ) => url.replace( '%%template_type%%', this.model.get( 'type' ) ), success: () => { diff --git a/assets/dev/js/editor/components/template-library/views/parts/folders/folder-empty.js b/assets/dev/js/editor/components/template-library/views/parts/folders/folder-empty.js new file mode 100644 index 000000000000..149cc215d22a --- /dev/null +++ b/assets/dev/js/editor/components/template-library/views/parts/folders/folder-empty.js @@ -0,0 +1,10 @@ +module.exports = Marionette.ItemView.extend( { + tagName: 'li', + className: 'no-results', + template: _.template( sprintf( + /* Translators: 1: Empty message, 2: CTA. */ + '

%1$s
%2$s

', + __( 'Folders you create will appear here.', 'elementor' ), + __( 'To create a new one, go to Cloud Templates.', 'elementor' ), + ) ), +} ); diff --git a/assets/dev/js/editor/components/template-library/views/parts/folders/folder-item.js b/assets/dev/js/editor/components/template-library/views/parts/folders/folder-item.js new file mode 100644 index 000000000000..378a0af3919f --- /dev/null +++ b/assets/dev/js/editor/components/template-library/views/parts/folders/folder-item.js @@ -0,0 +1,20 @@ +module.exports = Marionette.ItemView.extend( { + tagName: 'li', + template: _.template( '<%= title %>' ), + className: 'folder-item', + + attributes() { + const data = this.model.toJSON(); + + return { + 'data-id': data.template_id, + 'data-value': data.title, + }; + }, + + render() { + this.$el.html( this.template( this.model.toJSON() ) ); + + return this; + }, +} ); diff --git a/assets/dev/js/editor/components/template-library/views/parts/folders/folders-list.js b/assets/dev/js/editor/components/template-library/views/parts/folders/folders-list.js new file mode 100644 index 000000000000..6fbcc1f58b0e --- /dev/null +++ b/assets/dev/js/editor/components/template-library/views/parts/folders/folders-list.js @@ -0,0 +1,9 @@ +const EmptyView = require( './folder-empty' ); +const FolderItemView = require( './folder-item' ); + +module.exports = Marionette.CollectionView.extend( { + tagName: 'ul', + className: 'folder-list', + childView: FolderItemView, + emptyView: EmptyView, +} ); diff --git a/assets/dev/js/editor/components/template-library/views/parts/header-parts/actions.js b/assets/dev/js/editor/components/template-library/views/parts/header-parts/actions.js index 941feeddb30a..3558630336df 100644 --- a/assets/dev/js/editor/components/template-library/views/parts/header-parts/actions.js +++ b/assets/dev/js/editor/components/template-library/views/parts/header-parts/actions.js @@ -19,6 +19,12 @@ module.exports = Marionette.ItemView.extend( { $e.route( 'library/import' ); }, + onRender() { + const currentTab = $e.components.get( 'library' ).currentTab ?? ''; + + this.ui.import.toggleClass( 'elementor-hidden', 'templates/my-templates' !== currentTab ); + }, + onSyncClick() { var self = this; diff --git a/assets/dev/js/editor/components/template-library/views/parts/import.js b/assets/dev/js/editor/components/template-library/views/parts/import.js index deea9761624e..c83b295bf41c 100644 --- a/assets/dev/js/editor/components/template-library/views/parts/import.js +++ b/assets/dev/js/editor/components/template-library/views/parts/import.js @@ -11,6 +11,7 @@ TemplateLibraryImportView = Marionette.ItemView.extend( { ui: { uploadForm: '#elementor-template-library-import-form', fileInput: '#elementor-template-library-import-form-input', + icon: '.elementor-template-library-blank-icon i', }, events: { @@ -41,16 +42,37 @@ TemplateLibraryImportView = Marionette.ItemView.extend( { async importTemplate( fileName, fileData ) { const layout = elementor.templates.layout; + const activeSource = elementor.templates.getFilter( 'source' ); this.options = { data: { fileName, fileData, + source: activeSource, }, success: ( successData ) => { + elementor.templates.clearLastRemovedItems(); elementor.templates.getTemplatesCollection().add( successData ); + elementor.templates.setToastConfig( { + show: true, + options: { + /* Translators: 1: Number of templates */ + message: sprintf( __( 'You successfully imported %1$d template(s).', 'elementor' ), successData.length ), + position: { + my: 'right bottom', + at: 'right-10 bottom-10', + of: '#elementor-template-library-modal .dialog-lightbox-widget-content', + }, + }, + } ); $e.route( 'library/templates/my-templates' ); + elementor.templates.triggerQuotaUpdate(); + elementor.templates.eventManager.sendTemplateImportEvent( { + library_type: activeSource, + file_type: fileName.split( '.' ).pop(), + template_count: successData.length, + } ); }, error: ( errorData ) => { elementor.templates.showErrorDialog( errorData ); @@ -89,6 +111,20 @@ TemplateLibraryImportView = Marionette.ItemView.extend( { 'dragleave drop': this.onFormDragLeave.bind( this ), drop: this.onFormDrop.bind( this ), } ); + + this.resolveIcon(); + + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.importModal, + } ); + }, + + resolveIcon() { + const activeSource = elementor.templates.getFilter( 'source' ) || 'local'; + + const className = 'local' === activeSource ? 'eicon-library-upload' : 'eicon-library-import'; + + this.ui.icon.removeClass().addClass( className ); }, onFormActions( event ) { diff --git a/assets/dev/js/editor/components/template-library/views/parts/navigation-container.js b/assets/dev/js/editor/components/template-library/views/parts/navigation-container.js new file mode 100644 index 000000000000..aa5195c705aa --- /dev/null +++ b/assets/dev/js/editor/components/template-library/views/parts/navigation-container.js @@ -0,0 +1,32 @@ +module.exports = Marionette.ItemView.extend( { + template: '#tmpl-elementor-template-library-navigation-container', + + className: 'elementor-template-library-navigation-container', + + ui: { + title: '.elementor-template-library-current-folder-title', + backButton: '.elementor-template-library-navigation-back-button', + }, + + events: { + 'click @ui.backButton': 'onBackButtonClick', + }, + + render() { + if ( null === elementor.templates.getFilter( 'parent' ) ) { + return this; + } + + return Marionette.ItemView.prototype.render.call( this ); + }, + + onRender() { + this.ui.title.text( elementor.templates.getFilter( 'parent' )?.title ); + }, + + onBackButtonClick() { + elementor.templates.setFilter( 'parent', null ); + + $e.route( 'library/templates/my-templates' ); + }, +} ); diff --git a/assets/dev/js/editor/components/template-library/views/parts/save-template.js b/assets/dev/js/editor/components/template-library/views/parts/save-template.js index 11cd2d926d97..cba8ce7cd8e6 100644 --- a/assets/dev/js/editor/components/template-library/views/parts/save-template.js +++ b/assets/dev/js/editor/components/template-library/views/parts/save-template.js @@ -1,6 +1,12 @@ -var TemplateLibrarySaveTemplateView; +const TemplateLibraryTemplateModel = require( 'elementor-templates/models/template' ); +const TemplateLibraryCollection = require( 'elementor-templates/collections/templates' ); +const FolderCollectionView = require( './folders/folders-list' ); -TemplateLibrarySaveTemplateView = Marionette.ItemView.extend( { +const LOAD_MORE_ID = 0; + +import { SAVE_CONTEXTS } from './../../constants'; + +const TemplateLibrarySaveTemplateView = Marionette.ItemView.extend( { id: 'elementor-template-library-save-template', template: '#tmpl-elementor-template-library-save-template', @@ -8,15 +14,166 @@ TemplateLibrarySaveTemplateView = Marionette.ItemView.extend( { ui: { form: '#elementor-template-library-save-template-form', submitButton: '#elementor-template-library-save-template-submit', + ellipsisIcon: '.cloud-library-form-inputs .ellipsis-container', + foldersList: '.cloud-folder-selection-dropdown ul', + foldersDropdown: '.cloud-folder-selection-dropdown', + foldersListContainer: '.cloud-folder-selection-dropdown-list', + removeFolderSelection: '.source-selections .selected-folder i', + selectedFolder: '.selected-folder', + selectedFolderText: '.selected-folder-text', + hiddenInputSelectedFolder: '#parentId', + templateNameInput: '#elementor-template-library-save-template-name', + localInput: '.source-selections-input.local', + cloudInput: '.source-selections-input.cloud', + sourceSelectionCheckboxes: '.source-selections-input input[type="checkbox"]', + infoIcon: '.source-selections-input.cloud .eicon-info', + connect: '#elementor-template-library-connect__badge', + connectBadge: '.source-selections-input.cloud .connect-badge', + cloudFormInputs: '.cloud-library-form-inputs', + upgradeBadge: '.source-selections-input.cloud upgrade-badge', }, events: { 'submit @ui.form': 'onFormSubmit', + 'click @ui.ellipsisIcon': 'onEllipsisIconClick', + 'click @ui.foldersList': 'onFoldersListClick', + 'click @ui.removeFolderSelection': 'onRemoveFolderSelectionClick', + 'click @ui.selectedFolderText': 'onSelectedFolderTextClick', + 'click @ui.upgradeBadge': 'onUpgradeBadgeClicked', + 'change @ui.sourceSelectionCheckboxes': 'handleSourceSelectionChange', + 'mouseenter @ui.infoIcon': 'showInfoTip', + 'mouseenter @ui.connect': 'showConnectInfoTip', + 'input @ui.templateNameInput': 'onTemplateNameInputChange', + }, + + onRender() { + if ( 'undefined' === typeof elementorAppConfig[ 'cloud-library' ]?.quota && this.templateHelpers()?.canSaveToCloud ) { + elementor.templates.layout.showLoadingView(); + + $e.components.get( 'cloud-library' ).utils.setQuotaConfig() + .then( ( data ) => { + elementorAppConfig[ 'cloud-library' ].quota = data; + } ) + .catch( () => { + delete elementorAppConfig[ 'cloud-library' ].quota; + } ) + .finally( () => { + this.handleOnRender(); + elementor.templates.layout.hideLoadingView(); + } ); + } else { + this.handleOnRender(); + } + }, + + handleOnRender() { + setTimeout( () => this.ui.templateNameInput.trigger( 'focus' ) ); + + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary[ `${ context }Modal` ], + } ); + + const context = this.getOption( 'context' ); + + if ( SAVE_CONTEXTS.SAVE === context ) { + this.handleSaveAction(); + } + + if ( SAVE_CONTEXTS.MOVE === context || SAVE_CONTEXTS.COPY === context ) { + this.handleSingleActionContextUiState(); + } + + if ( SAVE_CONTEXTS.BULK_MOVE === context || SAVE_CONTEXTS.BULK_COPY === context ) { + this.handleBulkActionContextUiState(); + } + + if ( ! elementor.templates.hasCloudLibraryQuota() ) { + this.handleCloudLibraryPromo(); + } + + if ( this.cloudMaxCapacityReached() ) { + this.handleCloudLibraryPromo( 'max-capacity' ); + } + + if ( ! elementor.config.library_connect.is_connected ) { + this.handleElementorConnect(); + } + }, + + cloudMaxCapacityReached() { + return 'undefined' !== typeof elementorAppConfig[ 'cloud-library' ]?.quota && + 0 < elementorAppConfig[ 'cloud-library' ].quota?.threshold && + elementorAppConfig[ 'cloud-library' ].quota?.currentUsage >= elementorAppConfig[ 'cloud-library' ].quota?.threshold; + }, + + handleSaveAction() { + this.maybeEnableSaveButton(); + }, + + handleSingleActionContextUiState() { + const title = this.model.get( 'title' ); + + this.ui.templateNameInput.val( title ); + + this.handleContextUiStateChecboxes(); + + this.maybeEnableSaveButton(); + }, + + maybeEnableSaveButton() { + if ( ! this.templateHelpers()?.canSaveToCloud ) { + return; + } + + const isAnyChecked = this.ui.sourceSelectionCheckboxes.is( ':checked' ); + + const title = this.ui.templateNameInput.val().trim(); + + const isTitleFilled = this.ui.templateNameInput.is( ':visible' ) + ? elementor.templates.isTemplateTitleValid( title ) + : true; + + this.updateSubmitButtonState( ! isAnyChecked || ! isTitleFilled ); + }, + + handleBulkActionContextUiState() { + this.ui.templateNameInput.remove(); + this.handleContextUiStateChecboxes(); + this.maybeEnableSaveButton(); + }, + + handleContextUiStateChecboxes() { + const fromSource = elementor.templates.getFilter( 'source' ); + + if ( 'local' === fromSource ) { + this.$( '.source-selections-input #cloud' ).prop( 'checked', true ); + this.ui.localInput.addClass( 'disabled' ); + } + }, + + handleCloudLibraryPromo( stateClass = 'promotion' ) { + if ( SAVE_CONTEXTS.SAVE === this.getOption( 'context' ) ) { + this.$( '.source-selections-input #local' ).prop( 'checked', true ); + } else { + this.$( '.source-selections-input #local, .source-selections-input.local label' ).css( 'pointer-events', 'none' ); + } + + this.$( '.source-selections-input #cloud' ).prop( 'checked', false ); + this.$( '.source-selections-input #cloud' ).prop( 'disabled', true ); + + this.ui.cloudFormInputs.addClass( stateClass ); + + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.saveModalSelectUpgrade, + } ); }, getSaveType() { let type; - if ( this.model ) { + + if ( SAVE_CONTEXTS.MOVE === this.getOption( 'context' ) || SAVE_CONTEXTS.COPY === this.getOption( 'context' ) ) { + type = this.model.get( 'type' ); + } else if ( this.model ) { type = this.model.get( 'elType' ); } else if ( elementor.config.document.library && elementor.config.document.library.save_as_same_type ) { type = elementor.config.document.type; @@ -28,24 +185,516 @@ TemplateLibrarySaveTemplateView = Marionette.ItemView.extend( { }, templateHelpers() { - var saveType = this.getSaveType(), - templateType = elementor.templates.getTemplateTypes( saveType ); + const saveType = this.getSaveType(), + templateType = elementor.templates.getTemplateTypes( saveType ), + saveContext = this.getOption( 'context' ); - return templateType.saveDialog; + return templateType[ `${ saveContext }Dialog` ]; }, onFormSubmit( event ) { event.preventDefault(); + elementor.templates.eventManager.sendNewSaveTemplateClickedEvent(); + var formData = this.ui.form.elementorSerializeObject(), - saveType = this.getSaveType(), JSONParams = { remove: [ 'default' ] }; + formData.parentTitle = formData.parentId ? this.ui.selectedFolderText.html() : ''; + formData.content = this.model ? [ this.model.toJSON( JSONParams ) ] : elementor.elements.toJSON( JSONParams ); + this.updateSourceSelections( formData ); + + if ( ! formData?.source && this.templateHelpers()?.canSaveToCloud ) { + this.showEmptySourceErrorDialog(); + + return; + } + this.ui.submitButton.addClass( 'elementor-button-state' ); - elementor.templates.saveTemplate( saveType, formData ); + this.updateSaveContext( formData ); + + this.updateToastConfig( formData ); + + this.updateSourceState( formData ); + + elementor.templates.saveTemplate( this.getSaveType(), formData ); + }, + + updateSourceSelections( formData ) { + const selectedSources = [ 'cloud', 'local' ].filter( ( type ) => formData[ type ] ); + + if ( ! selectedSources.length ) { + return; + } + + formData.source = selectedSources; + + [ 'cloud', 'local' ].forEach( ( type ) => delete formData[ type ] ); + }, + + showEmptySourceErrorDialog( ) { + elementorCommon.dialogsManager.createWidget( 'alert', { + id: 'elementor-template-library-error-dialog', + headerMessage: __( 'An error occured.', 'elementor' ), + message: __( 'Please select at least one location.', 'elementor' ), + } ).show(); + }, + + updateSaveContext( formData ) { + const saveContext = this.getOption( 'context' ) ?? SAVE_CONTEXTS.SAVE; + + formData.save_context = saveContext; + + if ( [ SAVE_CONTEXTS.MOVE, SAVE_CONTEXTS.BULK_MOVE, SAVE_CONTEXTS.COPY, SAVE_CONTEXTS.BULK_COPY ].includes( saveContext ) ) { + formData.from_source = elementor.templates.getFilter( 'source' ); + formData.from_template_id = [ SAVE_CONTEXTS.MOVE, SAVE_CONTEXTS.COPY ].includes( saveContext ) + ? this.model.get( 'template_id' ) + : Array.from( elementor.templates.getBulkSelectionItems() ); + } + }, + + updateToastConfig( formData ) { + if ( ! formData.source?.length ) { + return; + } + + const lastSource = formData.source.at( -1 ), + saveContext = this.getOption( 'context' ) ?? SAVE_CONTEXTS.SAVE, + toastMessage = this.getToastMessage( lastSource, saveContext, formData ); + + if ( ! toastMessage ) { + return; + } + + const toastButtons = formData.source?.length > 1 + ? null + : this.getToastButtons( lastSource, formData?.parentId?.trim(), formData?.parentTitle?.trim() ); + + elementor.templates.setToastConfig( { + show: true, + options: { + message: toastMessage, + buttons: toastButtons, + position: { + my: 'right bottom', + at: 'right-10 bottom-10', + of: '#elementor-template-library-modal .dialog-lightbox-widget-content', + }, + }, + } ); + }, + + updateSourceState( formData ) { + if ( ! formData.source?.length ) { + return; + } + + const saveContext = this.getOption( 'context' ) ?? SAVE_CONTEXTS.SAVE; + + if ( SAVE_CONTEXTS.SAVE !== saveContext ) { + return; + } + + const lastSource = formData.source.at( -1 ); + elementor.templates.setSourceSelection( lastSource ); + elementor.templates.setFilter( 'source', lastSource, true ); + }, + + getToastMessage( lastSource, saveContext, formData ) { + const key = `${ lastSource }_${ saveContext }`; + + if ( formData.source?.length > 1 ) { + return __( 'Template saved to your Site and Cloud Templates.', 'elementor' ); + } + + const actions = { + [ `local_${ SAVE_CONTEXTS.MOVE }` ]: this.getFormattedToastMessage( 'moved to your Site Templates', formData.title ), + [ `cloud_${ SAVE_CONTEXTS.MOVE }` ]: this.getFormattedToastMessage( 'moved to your Cloud Templates', formData.title ), + [ `local_${ SAVE_CONTEXTS.COPY }` ]: this.getFormattedToastMessage( 'copied to your Site Templates', formData.title ), + [ `cloud_${ SAVE_CONTEXTS.COPY }` ]: this.getFormattedToastMessage( 'copied to your Cloud Templates', formData.title ), + [ `local_${ SAVE_CONTEXTS.BULK_MOVE }` ]: this.getFormattedToastMessage( 'moved to your Site Templates', null, formData.from_template_id?.length ), + [ `cloud_${ SAVE_CONTEXTS.BULK_MOVE }` ]: this.getFormattedToastMessage( 'moved to your Cloud Templates', null, formData.from_template_id?.length ), + [ `local_${ SAVE_CONTEXTS.BULK_COPY }` ]: this.getFormattedToastMessage( 'copied to your Site Templates', null, formData.from_template_id?.length ), + [ `cloud_${ SAVE_CONTEXTS.BULK_COPY }` ]: this.getFormattedToastMessage( 'copied to your Cloud Templates', null, formData.from_template_id?.length ), + }; + + return actions[ key ] ?? false; + }, + + getFormattedToastMessage( action, title, count ) { + if ( count !== undefined ) { + /* Translators: 1: Number of templates, 2: Action performed (e.g., "moved", "copied"). */ + return sprintf( __( '%1$d Template(s) %2$s.', 'elementor' ), count, action ); + } + + /* Translators: 1: Template title or "Template" fallback, 2: Action performed. */ + return sprintf( __( '%1$s %2$s.', 'elementor' ), title ? `"${ title }"` : __( 'Template', 'elementor' ), action ); + }, + + getToastButtons( lastSource, parentId, parentTitle ) { + const parsedParentId = parseInt( parentId, 10 ) || null; + + return [ + { + name: 'template_after_save', + text: __( 'View', 'elementor' ), + callback: () => this.navigateToSavedSource( lastSource, parsedParentId, parentTitle ), + }, + ]; + }, + + navigateToSavedSource( lastSource, parentId, parentTitle ) { + elementor.templates.setSourceSelection( lastSource ); + elementor.templates.setFilter( 'source', lastSource, true ); + + if ( parentId ) { + const model = new TemplateLibraryTemplateModel( { template_id: parentId, title: parentTitle } ); + + $e.route( 'library/view-folder', { model } ); + + elementor.templates.layout.showTemplatesView( new TemplateLibraryCollection( elementor.templates.filterTemplates() ) ); + + return; + } + + $e.routes.refreshContainer( 'library' ); + }, + + onSelectedFolderTextClick() { + if ( ! this.folderCollectionView ) { + this.onEllipsisIconClick(); + + return; + } + + if ( ! this.ui.foldersDropdown.is( ':visible' ) ) { + this.ui.foldersDropdown.show(); + } + }, + + async onEllipsisIconClick() { + if ( this.ui.foldersDropdown.is( ':visible' ) ) { + this.ui.foldersDropdown.hide(); + + return; + } + + this.ui.foldersDropdown.show(); + + if ( ! this.folderCollectionView ) { + this.folderCollectionView = new FolderCollectionView( { + collection: new TemplateLibraryCollection(), + } ); + + this.addSpinner(); + this.renderFolderDropdown(); + + try { + await this.fetchFolders(); + } finally { + this.removeSpinner(); + this.disableSelectedFolder(); + } + } + + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.saveModalSelectFolder, + } ); + }, + + renderFolderDropdown() { + this.ui.foldersListContainer.html( this.folderCollectionView.render()?.el ); + }, + + addSpinner() { + const spinner = new TemplateLibraryTemplateModel( { + template_id: LOAD_MORE_ID, + title: '', + } ); + + this.folderCollectionView.collection.add( spinner ); + }, + + removeSpinner() { + const spinner = this.folderCollectionView.collection.findWhere( { template_id: LOAD_MORE_ID } ); + + if ( spinner ) { + this.folderCollectionView.collection.remove( spinner ); + } + }, + + fetchFolders() { + return new Promise( ( resolve ) => { + const offset = this.folderCollectionView.collection.length - 1; + + const ajaxOptions = { + data: { + source: 'cloud', + offset, + }, + success: ( response ) => { + this.folderCollectionView.collection.add( response?.templates ); + + if ( this.shouldAddLoadMoreItem( response ) ) { + this.addLoadMoreItem(); + } + + resolve( response ); + }, + error: ( error ) => { + elementor.templates.showErrorDialog( error ); + + resolve(); + }, + }; + + elementorCommon.ajax.addRequest( 'get_folders', ajaxOptions ); + } ); + }, + + disableSelectedFolder() { + if ( ! SAVE_CONTEXTS.MOVE === this.getOption( 'context' ) ) { + return; + } + + if ( ! this.model || ! Number.isInteger( this.model.get( 'parentId' ) ) ) { + return; + } + + this.$( `.folder-list li[data-id="${ this.model.get( 'parentId' ) }"]` ).addClass( 'disabled' ); + }, + + onFoldersListClick( event ) { + const { id, value } = event.target.dataset; + + if ( ! id || ! value ) { + return; + } + + if ( this.clickedOnLoadMore( id ) ) { + this.loadMoreFolders(); + + return; + } + + this.handleFolderSelected( id, value ); + }, + + clickedOnLoadMore( templateId ) { + return LOAD_MORE_ID === +templateId; + }, + + handleFolderSelected( id, value ) { + this.highlightSelectedFolder( id ); + this.ui.foldersDropdown.hide(); + this.ui.ellipsisIcon.hide(); + this.ui.selectedFolderText.html( value ); + this.ui.selectedFolder.show(); + this.ui.hiddenInputSelectedFolder.val( id ); + this.$( '.source-selections-input #cloud' ).prop( 'checked', true ); + this.maybeEnableSaveButton(); + }, + + highlightSelectedFolder( id ) { + this.clearSelectedFolder(); + this.$( `.folder-list li[data-id="${ id }"]` ).addClass( 'selected' ); + }, + + clearSelectedFolder() { + this.$( '.folder-list li.selected' ).removeClass( 'selected' ); + }, + + onRemoveFolderSelectionClick() { + this.clearSelectedFolder(); + this.ui.selectedFolderText.html( '' ); + this.ui.selectedFolder.hide(); + this.ui.ellipsisIcon.show(); + this.ui.hiddenInputSelectedFolder.val( '' ); + this.ui.foldersDropdown.hide(); + }, + + async loadMoreFolders() { + this.removeLoadMoreItem(); + this.addSpinner(); + + try { + await this.fetchFolders(); + } finally { + this.removeSpinner(); + this.disableSelectedFolder(); + } + }, + + shouldAddLoadMoreItem( response ) { + return this.folderCollectionView.collection.length < response?.total; + }, + + addLoadMoreItem() { + this.folderCollectionView.collection.add( { + template_id: LOAD_MORE_ID, + title: __( 'Load More', 'elementor' ), + } ); + }, + + removeLoadMoreItem() { + const loadMore = this.folderCollectionView.collection.findWhere( { template_id: LOAD_MORE_ID } ); + + if ( loadMore ) { + this.folderCollectionView.collection.remove( loadMore ); + } + }, + + handleSourceSelectionChange( event ) { + this.maybeAllowOnlyOneCheckboxToBeChecked( event ); + + this.maybeEnableSaveButton(); + }, + + maybeAllowOnlyOneCheckboxToBeChecked( event ) { + if ( this.moreThanOneCheckboxCanBeChecked() ) { + return; + } + + const selectedCheckbox = event.currentTarget; + + this.ui.sourceSelectionCheckboxes.each( ( _, checkbox ) => { + const wrapper = this.$( checkbox ).closest( '.source-selections-input' ); + + if ( checkbox !== selectedCheckbox ) { + if ( selectedCheckbox.checked ) { + wrapper.addClass( 'disabled' ); + checkbox.checked = false; + } else { + wrapper.removeClass( 'disabled' ); + } + } + } ); + }, + + moreThanOneCheckboxCanBeChecked() { + return SAVE_CONTEXTS.SAVE === this.getOption( 'context' ) || + 'cloud' !== elementor.templates.getFilter( 'source' ); + }, + + showInfoTip() { + if ( this.infoTipDialog ) { + this.infoTipDialog.hide(); + } + + const message = elementor.templates.hasCloudLibraryQuota() + ? __( 'Upgrade your subscription to get more space and reuse saved assets across all your sites.', 'elementor' ) + : __( 'Upgrade your subscription to access Cloud Templates and reuse saved assets across all your sites.', 'elementor' ); + + const goLink = elementor.templates.hasCloudLibraryQuota() + ? 'https://go.elementor.com/go-pro-cloud-templates-save-to-100-usage-notice' + : 'https://go.elementor.com/go-pro-cloud-templates-save-to-free-tooltip/'; + + this.infoTipDialog = elementor.dialogsManager.createWidget( 'buttons', { + id: 'elementor-library--infotip__dialog', + effects: { + show: 'show', + hide: 'hide', + }, + position: { + of: this.ui.infoIcon, + at: 'top-75', + }, + } ) + .setMessage( message ) + .addButton( { + name: 'learn_more', + text: __( + 'Upgrade Now', + 'elementor', + ), + classes: '', + callback: () => { + open( goLink, '_blank' ); + this.onUpgradeBadgeClicked(); + }, + } ); + + this.infoTipDialog.getElements( 'header' ).remove(); + this.infoTipDialog.show(); + }, + + showConnectInfoTip() { + if ( this.connectInfoTipDialog ) { + this.connectInfoTipDialog.hide(); + } + + this.connectInfoTipDialog = elementor.dialogsManager.createWidget( 'buttons', { + id: 'elementor-library--connect_infotip__dialog', + effects: { + show: 'show', + hide: 'hide', + }, + position: { + of: this.ui.connectBadge, + at: 'top+80', + }, + } ) + .setMessage( + __( + 'To access the Cloud Templates Library you must have an active Elementor Pro subscription', + 'elementor', + ) + + ' ' + + __( + 'and', + 'elementor', + ) + + ' ' + + __( + 'connect your site.', + 'elementor', + ), + ); + + this.connectInfoTipDialog.getElements( 'header' ).remove(); + this.connectInfoTipDialog.getElements( 'buttonsWrapper' ).remove(); + this.connectInfoTipDialog.show(); + }, + + handleElementorConnect() { + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.saveModalSelectConnect, + } ); + + this.ui.connect.elementorConnect( { + success: () => { + elementor.config.library_connect.is_connected = true; + + $e.run( 'library/close' ); + elementor.notifications.showToast( { + message: __( 'Connected successfully.', 'elementor' ), + } ); + }, + error: () => { + elementor.config.library_connect.is_connected = false; + }, + } ); + }, + + onTemplateNameInputChange() { + this.maybeEnableSaveButton(); + }, + + updateSubmitButtonState( shouldDisableSubmitButton ) { + this.ui.submitButton.toggleClass( 'e-primary', ! shouldDisableSubmitButton ); + this.ui.submitButton.prop( 'disabled', shouldDisableSubmitButton ); + }, + + onUpgradeBadgeClicked() { + const upgradePosition = elementor.templates.hasCloudLibraryQuota() ? 'save to-max' : 'save to-free'; + + elementor.templates.eventManager.sendUpgradeClickedEvent( { + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.saveModal, + upgrade_position: upgradePosition, + } ); }, } ); diff --git a/assets/dev/js/editor/components/template-library/views/parts/templates-empty.js b/assets/dev/js/editor/components/template-library/views/parts/templates-empty.js index 5faa84326d69..3abd3c829a8c 100644 --- a/assets/dev/js/editor/components/template-library/views/parts/templates-empty.js +++ b/assets/dev/js/editor/components/template-library/views/parts/templates-empty.js @@ -1,6 +1,4 @@ -var TemplateLibraryTemplatesEmptyView; - -TemplateLibraryTemplatesEmptyView = Marionette.ItemView.extend( { +const TemplateLibraryTemplatesEmptyView = Marionette.ItemView.extend( { id: 'elementor-template-library-templates-empty', template: '#tmpl-elementor-template-library-templates-empty', @@ -8,21 +6,67 @@ TemplateLibraryTemplatesEmptyView = Marionette.ItemView.extend( { ui: { title: '.elementor-template-library-blank-title', message: '.elementor-template-library-blank-message', + icon: '.elementor-template-library-blank-icon', + button: '.elementor-template-library-cloud-empty__button', + backToEditor: '.e-back-to-editor', + }, + + events: { + 'click @ui.backToEditor': 'closeLibrary', + }, + + closeLibrary( event ) { + event.preventDefault(); + $e.run( 'library/close' ); + }, + + modesStrings() { + const defaultIcon = this.getDefaultIcon(); + + return { + empty: { + title: __( 'Haven’t Saved Templates Yet?', 'elementor' ), + message: __( 'This is where your templates should be. Design it. Save it. Reuse it.', 'elementor' ), + icon: defaultIcon, + button: '', + }, + noResults: { + title: __( 'No Results Found', 'elementor' ), + message: __( 'Please make sure your search is spelled correctly or try a different words.', 'elementor' ), + icon: defaultIcon, + button: '', + }, + noFavorites: { + title: __( 'No Favorite Templates', 'elementor' ), + message: __( 'You can mark any pre-designed template as a favorite.', 'elementor' ), + icon: defaultIcon, + button: '', + }, + cloudEmpty: { + title: __( 'No templates saved just yet', 'elementor' ), + message: __( 'Once you save a template, it’ll show up here, ready for reuse across all of your Elementor sites—no extra work needed.', 'elementor' ), + icon: this.getCloudIcon(), + button: `${ __( 'Back to editor', 'elementor' ) }`, + }, + cloudFolderEmpty: { + title: __( 'No templates to show here, yet', 'elementor' ), + message: __( 'Once you save some templates to this folder, you can use them on any website you’re working on.', 'elementor' ), + icon: this.getEmptyFolderIcon(), + button: `${ __( 'Back to editor', 'elementor' ) }`, + }, + }; }, - modesStrings: { - empty: { - title: __( 'Haven’t Saved Templates Yet?', 'elementor' ), - message: __( 'This is where your templates should be. Design it. Save it. Reuse it.', 'elementor' ), - }, - noResults: { - title: __( 'No Results Found', 'elementor' ), - message: __( 'Please make sure your search is spelled correctly or try a different words.', 'elementor' ), - }, - noFavorites: { - title: __( 'No Favorite Templates', 'elementor' ), - message: __( 'You can mark any pre-designed template as a favorite.', 'elementor' ), - }, + getDefaultIcon() { + return ``; + }, + + getCloudIcon() { + return ``; + }, + + getEmptyFolderIcon() { + return ``; }, getCurrentMode() { @@ -34,15 +78,25 @@ TemplateLibraryTemplatesEmptyView = Marionette.ItemView.extend( { return 'noFavorites'; } + if ( 'cloud' === elementor.templates.getFilter( 'source' ) ) { + return null !== elementor.templates.getFilter( 'parent' ) + ? 'cloudFolderEmpty' + : 'cloudEmpty'; + } + return 'empty'; }, onRender() { - var modeStrings = this.modesStrings[ this.getCurrentMode() ]; + const modeStrings = this.modesStrings()[ this.getCurrentMode() ]; this.ui.title.html( modeStrings.title ); this.ui.message.html( modeStrings.message ); + + this.ui.button.html( modeStrings.button ); + + this.ui.icon.html( modeStrings.icon ); }, } ); diff --git a/assets/dev/js/editor/components/template-library/views/parts/templates.js b/assets/dev/js/editor/components/template-library/views/parts/templates.js index ee4c247d6c5c..bddc5e276f27 100644 --- a/assets/dev/js/editor/components/template-library/views/parts/templates.js +++ b/assets/dev/js/editor/components/template-library/views/parts/templates.js @@ -1,10 +1,11 @@ -var TemplateLibraryTemplateLocalView = require( 'elementor-templates/views/template/local' ), - TemplateLibraryTemplateRemoteView = require( 'elementor-templates/views/template/remote' ), - TemplateLibraryCollectionView; +const TemplateLibraryTemplateLocalView = require( 'elementor-templates/views/template/local' ); +const TemplateLibraryTemplateRemoteView = require( 'elementor-templates/views/template/remote' ); +const TemplateLibraryTemplateCloudView = require( 'elementor-templates/views/template/cloud' ); import Select2 from 'elementor-editor-utils/select2.js'; +import { SAVE_CONTEXTS, QUOTA_WARNINGS, QUOTA_BAR_STATES } from './../../constants'; -TemplateLibraryCollectionView = Marionette.CompositeView.extend( { +const TemplateLibraryCollectionView = Marionette.CompositeView.extend( { template: '#tmpl-elementor-template-library-templates', id: 'elementor-template-library-templates', @@ -25,6 +26,26 @@ TemplateLibraryCollectionView = Marionette.CompositeView.extend( { myFavoritesFilter: '#elementor-template-library-filter-my-favorites', orderInputs: '.elementor-template-library-order-input', orderLabels: 'label.elementor-template-library-order-label', + searchInputIcon: '#elementor-template-library-filter-text-wrapper i', + loadMoreAnchor: '#elementor-template-library-load-more-anchor', + selectSourceFilter: '.elementor-template-library-filter-select-source .source-option', + addNewFolder: '#elementor-template-library-add-new-folder', + addNewFolderDivider: '.elementor-template-library-filter-toolbar-side-actions .divider', + selectGridView: '#elementor-template-library-view-grid', + selectListView: '#elementor-template-library-view-list', + bulkSelectionActionBar: '.bulk-selection-action-bar', + bulkActionBarDelete: '.bulk-selection-action-bar .bulk-delete i', + bulkSelectedCount: '.bulk-selection-action-bar .selected-count', + bulkSelectAllCheckbox: '#bulk-select-all', + clearBulkSelections: '.bulk-selection-action-bar .clear-bulk-selections', + bulkMove: '.bulk-selection-action-bar .bulk-move', + bulkCopy: '.bulk-selection-action-bar .bulk-copy', + quota: '.quota-progress-container .quota-progress-bar', + quotaFill: '.quota-progress-container .quota-progress-bar .quota-progress-bar-fill', + quotaValue: '.quota-progress-container .quota-progress-bar-value', + quotaWarning: '.quota-progress-container .progress-bar-container .quota-warning', + quotaUpgrade: '.quota-progress-container .progress-bar-container .quota-warning a', + navigationContainer: '#elementor-template-library-navigation-container', }, events: { @@ -32,6 +53,134 @@ TemplateLibraryCollectionView = Marionette.CompositeView.extend( { 'change @ui.selectFilter': 'onSelectFilterChange', 'change @ui.myFavoritesFilter': 'onMyFavoritesFilterChange', 'mousedown @ui.orderLabels': 'onOrderLabelsClick', + 'click @ui.selectSourceFilter': 'onSelectSourceFilterChange', + 'click @ui.addNewFolder': 'onCreateNewFolderClick', + 'click @ui.selectGridView': 'onSelectGridViewClick', + 'click @ui.selectListView': 'onSelectListViewClick', + 'change @ui.bulkSelectAllCheckbox': 'onBulkSelectAllCheckbox', + 'click @ui.clearBulkSelections': 'onClearBulkSelections', + 'mouseenter @ui.bulkMove': 'onHoverBulkAction', + 'mouseenter @ui.bulkCopy': 'onHoverBulkAction', + 'click @ui.bulkMove': 'onClickBulkMove', + 'click @ui.bulkActionBarDelete': 'onBulkDeleteClick', + 'click @ui.bulkCopy': 'onClickBulkCopy', + 'click @ui.quotaUpgrade': 'onQuotaUpgradeClicked', + }, + + className: 'no-bulk-selections', + + resetQuotaBarStyles() { + this.ui.quota.removeClass( [ + 'quota-progress-bar-normal', + 'quota-progress-bar-warning', + 'quota-progress-bar-alert', + ] ); + this.ui.quotaFill.removeClass( [ + 'quota-progress-bar-fill-normal', + 'quota-progress-bar-fill-warning', + 'quota-progress-bar-fill-alert', + ] ); + }, + + setQuotaBarStyles( variant ) { + this.ui.quota.addClass( `quota-progress-bar-${ variant }` ); + this.ui.quotaFill.addClass( `quota-progress-bar-fill-${ variant }` ); + }, + + handleQuotaWarning( variant, quotaUsage ) { + const message = QUOTA_WARNINGS[ variant ]; + + if ( ! message ) { + return; + } + + this.ui.quotaWarning.html( sprintf( message, quotaUsage ) ); + this.ui.quotaWarning.show(); + }, + + handleQuotaBar() { + const quota = elementorAppConfig?.[ 'cloud-library' ]?.quota; + + const value = quota ? Math.round( ( quota.currentUsage / quota.threshold ) * 100 ) : 0; + + this.ui.quotaFill.css( 'width', `${ value }%` ); + + this.ui.quotaValue.text( `${ quota?.currentUsage?.toLocaleString() }/${ quota?.threshold?.toLocaleString() }` ); + + this.ui.quotaWarning.hide(); + + this.resetQuotaBarStyles(); + + const quotaState = this.resolveQuotaState( value ); + + this.handleQuotaWarning( quotaState, value ); + + this.setQuotaBarStyles( quotaState ); + }, + + resolveQuotaState( value ) { + if ( value < 80 ) { + return QUOTA_BAR_STATES.NORMAL; + } else if ( value < 100 ) { + return QUOTA_BAR_STATES.WARNING; + } + + return QUOTA_BAR_STATES.ALERT; + }, + + onClearBulkSelections() { + elementor.templates.clearBulkSelectionItems(); + elementor.templates.layout.handleBulkActionBar(); + elementor.templates.layout.selectAllCheckboxNormal(); + this.deselectAllBulkItems(); + }, + + deselectAllBulkItems() { + if ( 'list' === elementor.templates.getViewSelection() || 'local' === elementor.templates.getFilter( 'source' ) ) { + this.ui.bulkSelectAllCheckbox.prop( 'checked', false ).trigger( 'change' ); + } else { + document.querySelectorAll( '.bulk-selected-item' ).forEach( function( item ) { + item.classList.remove( 'bulk-selected-item' ); + } ); + } + }, + + onBulkSelectAllCheckbox() { + const isChecked = this.$( '#bulk-select-all:checked' ).length > 0; + + if ( isChecked ) { + elementor.templates.layout.selectAllCheckboxNormal(); + } + + this.updateBulkSelectedItems( isChecked ); + + elementor.templates.layout.handleBulkActionBarUi(); + }, + + updateBulkSelectedItems( isChecked ) { + document.querySelectorAll( '.bulk-selection-item-checkbox' ).forEach( function( checkbox ) { + checkbox.checked = isChecked; + const templateId = checkbox.dataset.template_id; + const parentDiv = checkbox.closest( '.elementor-template-library-template' ); + + if ( isChecked ) { + elementor.templates.addBulkSelectionItem( templateId ); + parentDiv?.classList.add( 'bulk-selected-item' ); + } else { + elementor.templates.removeBulkSelectionItem( templateId ); + parentDiv?.classList.remove( 'bulk-selected-item' ); + } + } ); + }, + + onBulkDeleteClick() { + this.ui.bulkActionBarDelete.toggleClass( 'disabled' ); + + elementor.templates.onBulkDeleteClick() + .finally( () => { + this.ui.bulkActionBarDelete.toggleClass( 'disabled' ); + elementor.templates.layout.handleBulkActionBar(); + } ); }, comparators: { @@ -59,18 +208,53 @@ TemplateLibraryCollectionView = Marionette.CompositeView.extend( { }, getChildView( childModel ) { - if ( 'remote' === childModel.get( 'source' ) ) { - return TemplateLibraryTemplateRemoteView; - } + const sourceMappings = { + local: TemplateLibraryTemplateLocalView, + remote: TemplateLibraryTemplateRemoteView, + cloud: TemplateLibraryTemplateCloudView, + }; + + const activeSource = childModel.get( 'source' ) ? childModel.get( 'source' ) : 'local'; + + /** + * Filter template source. + * + * @param bool isRemote - If `true` the source is a remote source. + * @param string activeSource - The current template source. + */ + const isRemote = elementor.hooks.applyFilters( 'templates/source/is-remote', 'remote' === activeSource, activeSource ); - return TemplateLibraryTemplateLocalView; + return isRemote + ? TemplateLibraryTemplateRemoteView + : sourceMappings[ activeSource ] || TemplateLibraryTemplateLocalView; }, initialize() { + this.handleQuotaBar = this.handleQuotaBar.bind( this ); + this.handleQuotaUpdate = this.handleQuotaUpdate.bind( this ); this.listenTo( elementor.channels.templates, 'filter:change', this._renderChildren ); + this.listenTo( elementor.channels.templates, 'quota:updated', this.handleQuotaUpdate ); + this.debouncedSearchTemplates = _.debounce( this.searchTemplates, 300 ); + }, + + handleQuotaUpdate() { + const activeSource = elementor.templates.getFilter( 'source' ) ?? 'local'; + + if ( 'cloud' === activeSource ) { + $e.components.get( 'cloud-library' ).utils.getQuotaConfig() + .then( () => { + this.handleQuotaBar(); + } ); + } }, filter( childModel ) { + const activeSource = elementor.templates.getFilter( 'source' ); + + if ( 'cloud' === activeSource ) { + return true; // Filtering happens on the backend. + } + var filterTerms = elementor.templates.getFilterTerms(), passingFilter = true; @@ -104,7 +288,13 @@ TemplateLibraryCollectionView = Marionette.CompositeView.extend( { }, order( by, reverseOrder ) { - var comparator = this.comparators[ by ] || by; + let comparator = this.comparators[ by ] || by; + + if ( 'cloud' === elementor.templates.getFilter( 'source' ) ) { + this.handleCloudOrder( by, reverseOrder ); + + return; + } if ( reverseOrder ) { comparator = this.reverseOrder( comparator ); @@ -115,6 +305,25 @@ TemplateLibraryCollectionView = Marionette.CompositeView.extend( { this.collection.sort(); }, + handleCloudOrder( by, reverseOrder ) { + elementor.templates.setFilter( 'orderby', by ); + elementor.templates.setFilter( 'order', reverseOrder ? 'desc' : 'asc' ); + + this.onClearBulkSelections(); + + this.collection.reset(); + + elementor.templates.layout.showLoadingView(); + + elementor.templates.loadMore( { + onUpdate: () => { + elementor.templates.layout.hideLoadingView(); + }, + search: this.ui.textFilter.val(), + refresh: true, + } ); + }, + reverseOrder( comparator ) { if ( 'function' !== typeof comparator ) { var comparatorValue = comparator; @@ -152,6 +361,12 @@ TemplateLibraryCollectionView = Marionette.CompositeView.extend( { this.$el.attr( 'data-template-source', isEmpty ? 'empty' : elementor.templates.getFilter( 'source' ) ); }, + addViewData() { + const view = elementor.templates.getViewSelection(); + + this.$el.attr( 'data-template-view', view ); + }, + setFiltersUI() { if ( ! this.select2Instance && this.$( this.ui.selectFilter ).length ) { const $filters = this.$( this.ui.selectFilter ), @@ -188,10 +403,37 @@ TemplateLibraryCollectionView = Marionette.CompositeView.extend( { return 'page' === templatesType || 'lp' === templatesType; }, + onDestroy() { + if ( this.removeScrollListener ) { + this.removeScrollListener(); + } + }, + onRender() { - if ( 'remote' === elementor.templates.getFilter( 'source' ) && 'page' !== elementor.templates.getFilter( 'type' ) && 'lb' !== elementor.templates.getFilter( 'type' ) ) { + elementor.templates.clearBulkSelectionItems(); + const activeSource = elementor.templates.getFilter( 'source' ); + const templateType = elementor.templates.getFilter( 'type' ); + + if ( 'remote' === activeSource && 'page' !== templateType && 'lb' !== templateType ) { this.setFiltersUI(); } + + if ( 'cloud' === activeSource ) { + const isFolderView = elementor.templates.getFilter( 'parentId' ); + const location = isFolderView + ? elementor.editorEvents.config.secondaryLocations.templateLibrary.cloudTabFolder + : elementor.editorEvents.config.secondaryLocations.templateLibrary.cloudTab; + + elementor.templates.eventManager.sendPageViewEvent( { location } ); + + this.handleQuotaBar(); + } + + if ( 'local' === activeSource ) { + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.siteTab, + } ); + } }, onRenderCollection() { @@ -199,9 +441,19 @@ TemplateLibraryCollectionView = Marionette.CompositeView.extend( { this.toggleFilterClass(); - if ( 'remote' === elementor.templates.getFilter( 'source' ) && ! this.isPageOrLandingPageTemplates() ) { + const activeSource = elementor.templates.getFilter( 'source' ); + + if ( 'remote' === activeSource && ! this.isPageOrLandingPageTemplates() ) { this.setMasonrySkin(); } + + if ( 'cloud' === activeSource ) { + this.handleLoadMore(); + + this.addViewData(); + + this.handleQuotaUpdate(); + } }, onBeforeRenderEmpty() { @@ -209,9 +461,42 @@ TemplateLibraryCollectionView = Marionette.CompositeView.extend( { }, onTextFilterInput() { + const activeSource = elementor.templates.getFilter( 'source' ); + + if ( [ 'cloud', 'local' ].includes( activeSource ) ) { + elementor.templates.clearBulkSelectionItems(); + elementor.templates.layout.handleBulkActionBar(); + } + + if ( 'cloud' === activeSource ) { + this.debouncedSearchTemplates( activeSource ); + return; + } + elementor.templates.setFilter( 'text', this.ui.textFilter.val() ); }, + async searchTemplates( source ) { + this.showLoadingSpinner(); + + try { + await elementor.templates.searchTemplates( { + source, + search: this.ui.textFilter.val(), + } ); + } finally { + this.showSearchIcon(); + } + }, + + showLoadingSpinner() { + this.ui.searchInputIcon.removeClass( 'eicon-search' ).addClass( 'eicon-loading eicon-animation-spin' ); + }, + + showSearchIcon() { + this.ui.searchInputIcon.removeClass( 'eicon-loading eicon-animation-spin' ).addClass( 'eicon-search' ); + }, + onSelectFilterChange( event ) { var $select = jQuery( event.currentTarget ), filterName = $select.data( 'elementor-filter' ); @@ -219,21 +504,154 @@ TemplateLibraryCollectionView = Marionette.CompositeView.extend( { elementor.templates.setFilter( filterName, $select.val() ); }, + onSelectSourceFilterChange( event ) { + elementor.templates.onSelectSourceFilterChange( event ); + }, + + onSelectGridViewClick() { + elementor.templates.onSelectViewChange( 'grid' ); + }, + + onSelectListViewClick() { + elementor.templates.onSelectViewChange( 'list' ); + }, + onMyFavoritesFilterChange() { elementor.templates.setFilter( 'favorite', this.ui.myFavoritesFilter[ 0 ].checked ); }, onOrderLabelsClick( event ) { - var $clickedInput = jQuery( event.currentTarget.control ), - toggle; + const $clickedInput = jQuery( event.currentTarget.control ); + let toggle; if ( ! $clickedInput[ 0 ].checked ) { toggle = 'asc' !== $clickedInput.data( 'default-ordering-direction' ); + } else { + toggle = ! $clickedInput.hasClass( 'elementor-template-library-order-reverse' ); } + $clickedInput.prop( 'checked', true ); + $clickedInput.toggleClass( 'elementor-template-library-order-reverse', toggle ); - this.order( $clickedInput.val(), $clickedInput.hasClass( 'elementor-template-library-order-reverse' ) ); + this.order( $clickedInput.val(), toggle ); + }, + + handleLoadMore() { + if ( this.removeScrollListener ) { + this.removeScrollListener(); + } + + const scrollableContainer = elementor?.templates?.layout?.modal.getElements( 'message' ); + + const listener = () => { + const scrollTop = scrollableContainer.scrollTop(); + const scrollHeight = scrollableContainer[ 0 ].scrollHeight; + const clientHeight = scrollableContainer.outerHeight(); + + const scrollPercentage = ( scrollTop / ( scrollHeight - clientHeight ) ) * 100; + + const canLoadMore = elementor.templates.canLoadMore() && ! elementor.templates.isLoading(); + + if ( scrollPercentage < 90 || ! canLoadMore ) { + return; + } + + this.ui.loadMoreAnchor.toggleClass( 'elementor-visibility-hidden' ); + elementor.templates.layout.selectAllCheckboxMinus(); + + elementor.templates.loadMore( { + onUpdate: () => { + this.ui.loadMoreAnchor.toggleClass( 'elementor-visibility-hidden' ); + }, + search: this.ui.textFilter.val(), + } ); + }; + + scrollableContainer.on( 'scroll', listener ); + + this.removeScrollListener = () => scrollableContainer.off( 'scroll', listener ); + }, + + onCreateNewFolderClick() { + const activeSource = elementor.templates.getFilter( 'source' ); + + if ( 'cloud' !== activeSource ) { + return; + } + + elementor.templates.createFolder( { + source: activeSource, + }, + { + onSuccess: () => { + $e.routes.refreshContainer( 'library' ); + }, + } ); + }, + + onHoverBulkAction() { + if ( this.hasFolderInBulkSelection() || this.hasLockedTemplatesInBulkSelection() ) { + this.ui.bulkMove.find( 'i' ).css( 'cursor', 'not-allowed' ); + this.ui.bulkCopy.find( 'i' ).css( 'cursor', 'not-allowed' ); + } else { + this.ui.bulkMove.find( 'i' ).css( 'cursor', 'pointer' ); + this.ui.bulkCopy.find( 'i' ).css( 'cursor', 'pointer' ); + } + }, + + onClickBulkMove() { + if ( this.hasFolderInBulkSelection() || this.hasLockedTemplatesInBulkSelection() ) { + return; + } + + $e.route( 'library/save-template', { + model: this.model, + context: SAVE_CONTEXTS.BULK_MOVE, + } ); + }, + + hasFolderInBulkSelection() { + const bulkSelectedItems = elementor.templates.getBulkSelectionItems(); + + return this.collection.some( ( model ) => { + const templateId = model.get( 'template_id' ); + const type = model.get( 'type' ); + + return bulkSelectedItems.has( templateId ) && 'folder' === type; + } ); + }, + + hasLockedTemplatesInBulkSelection() { + const bulkSelectedItems = elementor.templates.getBulkSelectionItems(); + + return this.collection.some( ( model ) => { + const templateId = model.get( 'template_id' ); + + return bulkSelectedItems.has( templateId ) && model.isLocked(); + } ); + }, + + onClickBulkCopy() { + if ( this.hasFolderInBulkSelection() || this.hasLockedTemplatesInBulkSelection() ) { + return; + } + + $e.route( 'library/save-template', { + model: this.model, + context: SAVE_CONTEXTS.BULK_COPY, + } ); + }, + + onQuotaUpgradeClicked() { + const quota = elementorAppConfig?.[ 'cloud-library' ]?.quota; + + const value = quota ? Math.round( ( quota.currentUsage / quota.threshold ) * 100 ) : 0; + + elementor.templates.eventManager.sendUpgradeClickedEvent( { + secondaryLocation: elementor.editorEvents.config.secondaryLocations.templateLibrary.quotaBar, + upgrade_position: `quota bar ${ value ? value + '%' : '' }`, + } ); }, } ); diff --git a/assets/dev/js/editor/components/template-library/views/template/base.js b/assets/dev/js/editor/components/template-library/views/template/base.js index d9997e873a80..3f75aaaccac2 100644 --- a/assets/dev/js/editor/components/template-library/views/template/base.js +++ b/assets/dev/js/editor/components/template-library/views/template/base.js @@ -1,11 +1,10 @@ -var TemplateLibraryInsertTemplateBehavior = require( 'elementor-templates/behaviors/insert-template' ), - TemplateLibraryTemplateView; +const TemplateLibraryInsertTemplateBehavior = require( 'elementor-templates/behaviors/insert-template' ); const { isTierAtLeast, TIERS } = require( 'elementor-utils/tiers' ); -TemplateLibraryTemplateView = Marionette.ItemView.extend( { +const TemplateLibraryTemplateView = Marionette.ItemView.extend( { className() { - var classes = 'elementor-template-library-template', - source = this.model.get( 'source' ); + let classes = 'elementor-template-library-template'; + const source = this.model.get( 'source' ); classes += ' elementor-template-library-template-' + source; diff --git a/assets/dev/js/editor/components/template-library/views/template/cloud.js b/assets/dev/js/editor/components/template-library/views/template/cloud.js new file mode 100644 index 000000000000..c248792f2ceb --- /dev/null +++ b/assets/dev/js/editor/components/template-library/views/template/cloud.js @@ -0,0 +1,152 @@ +var TemplateLibraryTemplateLocalView = require( 'elementor-templates/views/template/local' ), + TemplateLibraryTemplateCloudView; + +TemplateLibraryTemplateCloudView = TemplateLibraryTemplateLocalView.extend( { + className() { + const view = elementor.templates.getViewSelection(), + subType = 'FOLDER' === this.model.get( 'subType' ) ? 'folder' : 'template'; + + let classes = TemplateLibraryTemplateLocalView.prototype.className.apply( this, arguments ); + + classes += ' elementor-template-library-template-view-' + view; + classes += ' elementor-template-library-template-type-' + subType; + + return classes; + }, + + attributes() { + if ( 'grid' === elementor.templates.getViewSelection() ) { + const data = this.model.toJSON(); + + return { + 'data-template_id': data.template_id, + 'data-type': data.type, + 'data-status': data.status, + }; + } + }, + + ui() { + return _.extend( TemplateLibraryTemplateLocalView.prototype.ui.apply( this, arguments ), { + previewImg: '.elementor-template-library-template-thumbnail img', + } ); + }, + + modelEvents: _.extend( {}, TemplateLibraryTemplateLocalView.prototype.modelEvents, { + 'change:preview_url': 'onPreviewUrlChange', + } ), + + onRender() { + const previewUrl = this.model.get( 'preview_url' ); + + if ( this.shouldGeneratePreview() ) { + this.iframe = elementor.templates.layout.createScreenshotIframe( this.model.get( 'generate_preview_url' ) ); + this.isGeneratingPreview = true; + } + + if ( previewUrl ) { + this.updatePreviewImgStyle(); + } + }, + + onPreviewUrlChange() { + const previewUrl = this.model.get( 'preview_url' ); + this.isGeneratingPreview = false; + + if ( previewUrl ) { + this.ui.previewImg.attr( 'src', previewUrl ); + this.updatePreviewImgStyle(); + this.model.set( 'generate_preview_url', null ); + this.iframe.remove(); + } + }, + + updatePreviewImgStyle() { + this.ui.previewImg.css( 'object-fit', 'contain' ); + }, + + shouldGeneratePreview() { + const view = elementor.templates.getViewSelection(); + + return 'FOLDER' !== this.model.get( 'subType' ) && + this.model.get( 'generate_preview_url' ) && + ! this.model.get( 'preview_url' ) && + 'grid' === view && + ! this.isGeneratingPreview; + }, + + onPreviewButtonClick( event ) { + event.stopPropagation(); + + if ( 'FOLDER' === this.model.get( 'subType' ) ) { + $e.route( 'library/view-folder', { + model: this.model, + onAfter: () => { + elementor.templates.resetBulkActionBar(); + }, + } ); + } + + if ( 'TEMPLATE' === this.model.get( 'subType' ) ) { + this.handleGridViewItemSingleClick(); + } + }, + + onDeleteButtonClick( event ) { + event.stopPropagation(); + + if ( 'FOLDER' === this.model.get( 'subType' ) ) { + this.handleDeleteFolderClick(); + return; + } + + TemplateLibraryTemplateLocalView.prototype.onDeleteButtonClick.apply( this, arguments ); + }, + + handleDeleteFolderClick() { + const toggleMoreIcon = this.ui.toggleMoreIcon; + + elementor.templates.deleteFolder( this.model, { + onConfirm() { + toggleMoreIcon.removeClass( 'eicon-ellipsis-h' ).addClass( 'eicon-loading eicon-animation-spin' ); + }, + onSuccess() { + $e.routes.refreshContainer( 'library' ); + }, + } ); + }, + + handleItemSingleClick() { + if ( 'grid' === elementor.templates.getViewSelection() ) { + this.handleGridViewItemSingleClick(); + } else { + this.handleListViewItemSingleClick(); + } + }, + + handleItemDoubleClick() { + if ( 'FOLDER' === this.model.get( 'subType' ) ) { + $e.route( 'library/view-folder', { + model: this.model, + onAfter: () => { + elementor.templates.resetBulkActionBar(); + }, + } ); + } + }, + + handleGridViewItemSingleClick() { + const itemIsSelected = this.$el.hasClass( 'bulk-selected-item' ); + + if ( itemIsSelected ) { + elementor.templates.removeBulkSelectionItem( this.model.get( 'template_id' ) ); + } else { + elementor.templates.addBulkSelectionItem( this.model.get( 'template_id' ) ); + } + + this.$el.toggleClass( 'bulk-selected-item' ); + elementor.templates.layout.handleBulkActionBar(); + }, +} ); + +module.exports = TemplateLibraryTemplateCloudView; diff --git a/assets/dev/js/editor/components/template-library/views/template/local.js b/assets/dev/js/editor/components/template-library/views/template/local.js index b4979fb77b87..59326723d3c0 100644 --- a/assets/dev/js/editor/components/template-library/views/template/local.js +++ b/assets/dev/js/editor/components/template-library/views/template/local.js @@ -1,45 +1,196 @@ -var TemplateLibraryTemplateView = require( 'elementor-templates/views/template/base' ), - TemplateLibraryTemplateLocalView; +const TemplateLibraryTemplateView = require( 'elementor-templates/views/template/base' ); -TemplateLibraryTemplateLocalView = TemplateLibraryTemplateView.extend( { +import { SAVE_CONTEXTS } from './../../constants'; + +const TemplateLibraryTemplateLocalView = TemplateLibraryTemplateView.extend( { template: '#tmpl-elementor-template-library-template-local', ui() { return _.extend( TemplateLibraryTemplateView.prototype.ui.apply( this, arguments ), { + bulkSelectionItemCheckbox: '.bulk-selection-item-checkbox', deleteButton: '.elementor-template-library-template-delete', + renameButton: '.elementor-template-library-template-rename', + moveButton: '.elementor-template-library-template-move', + copyButton: '.elementor-template-library-template-copy', + exportButton: '.elementor-template-library-template-export', morePopup: '.elementor-template-library-template-more', toggleMore: '.elementor-template-library-template-more-toggle', toggleMoreIcon: '.elementor-template-library-template-more-toggle i', + titleCell: '.elementor-template-library-template-name span', + resourceIcon: '.elementor-template-library-template-name i', } ); }, events() { return _.extend( TemplateLibraryTemplateView.prototype.events.apply( this, arguments ), { + click: 'handleItemClicked', + 'change @ui.bulkSelectionItemCheckbox': 'onSelectBulkSelectionItemCheckbox', 'click @ui.deleteButton': 'onDeleteButtonClick', 'click @ui.toggleMore': 'onToggleMoreClick', + 'click @ui.renameButton': 'onRenameClick', + 'click @ui.moveButton': 'onMoveClick', + 'click @ui.copyButton': 'onCopyClick', + 'click @ui.exportButton': 'onExportClick', } ); }, - onDeleteButtonClick() { + modelEvents: { + 'change:title': 'onTitleChange', + }, + + handleLockedTemplate() { + const isLocked = this.model.isLocked(); + + this.ui.renameButton.toggleClass( 'disabled', isLocked ); + this.ui.moveButton.toggleClass( 'disabled', isLocked ); + this.ui.copyButton.toggleClass( 'disabled', isLocked ); + this.ui.exportButton.toggleClass( 'disabled', isLocked ); + }, + + onTitleChange() { + const title = _.escape( this.model.get( 'title' ) ); + + this.ui.titleCell.text( title ); + }, + + handleItemClicked( event ) { + if ( event.target.closest( '.bulk-selection-item-checkbox' ) ) { + return; // Ignore clicks from checkbox + } + + if ( ! this._clickState ) { + this._clickState = { + timeoutId: null, + delay: 250, + }; + } + + const state = this._clickState; + + if ( state.timeoutId ) { + clearTimeout( state.timeoutId ); + state.timeoutId = null; + + this.handleItemDoubleClick(); + } else { + state.timeoutId = setTimeout( () => { + state.timeoutId = null; + + this.handleItemSingleClick(); + }, state.delay ); + } + }, + + handleItemSingleClick() { + this.handleListViewItemSingleClick(); + }, + + handleItemDoubleClick() {}, + + handleListViewItemSingleClick() { + const checkbox = this.ui.bulkSelectionItemCheckbox; + const isChecked = checkbox.prop( 'checked' ); + + checkbox.prop( 'checked', ! isChecked ).trigger( 'change' ); + }, + + onDeleteButtonClick( event ) { + event.stopPropagation(); + var toggleMoreIcon = this.ui.toggleMoreIcon; elementor.templates.deleteTemplate( this.model, { onConfirm() { toggleMoreIcon.removeClass( 'eicon-ellipsis-h' ).addClass( 'eicon-loading eicon-animation-spin' ); }, - onSuccess() { - elementor.templates.showTemplates(); - }, } ); }, - onToggleMoreClick() { + onToggleMoreClick( event ) { + event.stopPropagation(); + + this.handleLockedTemplate(); + this.ui.morePopup.show(); + + elementor.templates.eventManager.sendPageViewEvent( { + location: elementor.editorEvents.config.secondaryLocations.templateLibrary.morePopup, + } ); }, - onPreviewButtonClick() { + onPreviewButtonClick( event ) { + event.stopPropagation(); + open( this.model.get( 'url' ), '_blank' ); }, + + async onRenameClick( event ) { + event.stopPropagation(); + + if ( this.model.isLocked() ) { + return; + } + + try { + await elementor.templates.renameTemplate( this.model, { + onConfirm: () => this.showToggleMoreLoader(), + } ); + } finally { + this.hideToggleMoreLoader(); + } + }, + + onMoveClick() { + if ( this.model.isLocked() ) { + return; + } + + $e.route( 'library/save-template', { + model: this.model, + context: SAVE_CONTEXTS.MOVE, + } ); + }, + + onCopyClick() { + if ( this.model.isLocked() ) { + return; + } + + $e.route( 'library/save-template', { + model: this.model, + context: SAVE_CONTEXTS.COPY, + } ); + }, + + onExportClick( e ) { + e.stopPropagation(); + + if ( this.model.isLocked() ) { + e.preventDefault(); + } + }, + + showToggleMoreLoader() { + this.ui.toggleMoreIcon.removeClass( 'eicon-ellipsis-h' ).addClass( 'eicon-loading eicon-animation-spin' ); + }, + + hideToggleMoreLoader() { + this.ui.toggleMoreIcon.addClass( 'eicon-ellipsis-h' ).removeClass( 'eicon-loading eicon-animation-spin' ); + }, + + onSelectBulkSelectionItemCheckbox( event ) { + event.stopPropagation(); + + if ( event?.target?.checked ) { + elementor.templates.addBulkSelectionItem( event.target.dataset.template_id ); + this.$el.addClass( 'bulk-selected-item' ); + } else { + elementor.templates.removeBulkSelectionItem( event.target.dataset.template_id ); + this.$el.removeClass( 'bulk-selected-item' ); + } + + elementor.templates.layout.handleBulkActionBarUi(); + }, } ); module.exports = TemplateLibraryTemplateLocalView; diff --git a/assets/dev/js/editor/components/template-library/views/template/remote.js b/assets/dev/js/editor/components/template-library/views/template/remote.js index fc0bc52984e8..ddf3d36574f6 100644 --- a/assets/dev/js/editor/components/template-library/views/template/remote.js +++ b/assets/dev/js/editor/components/template-library/views/template/remote.js @@ -16,7 +16,9 @@ TemplateLibraryTemplateRemoteView = TemplateLibraryTemplateView.extend( { } ); }, - onPreviewButtonClick() { + onPreviewButtonClick( event ) { + event.stopPropagation(); + $e.route( 'library/preview', { model: this.model } ); }, diff --git a/assets/dev/js/editor/controls/color.js b/assets/dev/js/editor/controls/color.js index 13dafbec37e6..82908eb5ab60 100644 --- a/assets/dev/js/editor/controls/color.js +++ b/assets/dev/js/editor/controls/color.js @@ -188,7 +188,7 @@ export default class extends ControlBaseDataView { const $color = jQuery( '
', { class: 'e-global__preview-item e-global__color', 'data-global-id': globalData.id } ), $colorPreview = this.createColorPreviewBox( globalData.value ), $colorTitle = jQuery( '', { class: 'e-global__color-title' } ) - .html( globalData.title ), + .html( _.escape( globalData.title ) ), $colorHex = jQuery( '', { class: 'e-global__color-hex' } ) .html( globalData.value ); diff --git a/assets/dev/js/editor/controls/icons.js b/assets/dev/js/editor/controls/icons.js index 12b50f975335..d27dff6acfd0 100644 --- a/assets/dev/js/editor/controls/icons.js +++ b/assets/dev/js/editor/controls/icons.js @@ -26,14 +26,16 @@ class ControlIconsView extends ControlMultipleBaseItemView { if ( iconSetting.enqueue ) { iconSetting.enqueue.forEach( ( assetURL ) => { - elementor.helpers.enqueueEditorStylesheet( assetURL ); - elementor.helpers.enqueuePreviewStylesheet( assetURL ); + const versionAddedURL = `${ assetURL }${ iconSetting?.ver ? '?ver=' + iconSetting.ver : '' }`; + elementor.helpers.enqueueEditorStylesheet( versionAddedURL ); + elementor.helpers.enqueuePreviewStylesheet( versionAddedURL ); } ); } if ( iconSetting.url ) { - elementor.helpers.enqueueEditorStylesheet( iconSetting.url ); - elementor.helpers.enqueuePreviewStylesheet( iconSetting.url ); + const versionAddedURL = `${ iconSetting.url }${ iconSetting?.ver ? '?ver=' + iconSetting.ver : '' }`; + elementor.helpers.enqueueEditorStylesheet( versionAddedURL ); + elementor.helpers.enqueuePreviewStylesheet( versionAddedURL ); } } diff --git a/assets/dev/js/editor/controls/popover-toggle.js b/assets/dev/js/editor/controls/popover-toggle.js index 67a721d90b1f..fa9ddb9135e9 100644 --- a/assets/dev/js/editor/controls/popover-toggle.js +++ b/assets/dev/js/editor/controls/popover-toggle.js @@ -125,7 +125,7 @@ export default class ControlPopoverStarterView extends ControlChooseView { } ); $typographyPreview - .html( globalData.title ) + .html( _.escape( globalData.title ) ) .css( this.buildPreviewItemCSS( globalData.value ) ); return $typographyPreview; diff --git a/assets/dev/js/editor/controls/repeater-row.js b/assets/dev/js/editor/controls/repeater-row.js index c96e54345e38..75700e72c552 100644 --- a/assets/dev/js/editor/controls/repeater-row.js +++ b/assets/dev/js/editor/controls/repeater-row.js @@ -5,6 +5,10 @@ module.exports = Marionette.CompositeView.extend( { className: 'elementor-repeater-fields', + attributes: { + role: 'listitem', + }, + ui() { return { duplicateButton: '.elementor-repeater-tool-duplicate', diff --git a/assets/dev/js/editor/controls/visual-choice.js b/assets/dev/js/editor/controls/visual-choice.js new file mode 100644 index 000000000000..8a9122f15213 --- /dev/null +++ b/assets/dev/js/editor/controls/visual-choice.js @@ -0,0 +1,79 @@ +var ControlBaseDataView = require( 'elementor-controls/base-data' ), + ControlVisualChoiceItemView; + +ControlVisualChoiceItemView = ControlBaseDataView.extend( { + ui() { + var ui = ControlBaseDataView.prototype.ui.apply( this, arguments ); + + ui.inputs = '[type="radio"]'; + + return ui; + }, + + events() { + return _.extend( ControlBaseDataView.prototype.events.apply( this, arguments ), { + 'mousedown label': 'onMouseDownLabel', + 'click @ui.inputs': 'onClickInput', + 'change @ui.inputs': 'onBaseInputChange', + } ); + }, + + updatePlaceholder() { + const placeholder = this.getControlPlaceholder(); + + if ( ! this.getControlValue() && placeholder ) { + // Find the input which has value equals to the placeholder (which is the parent's value), + // and add it a placeholder class, to indicate which value is selected in the parent. + this.ui.inputs.filter( `[value="${ this.getControlPlaceholder() }"]` ) + .addClass( 'e-visual-choice-placeholder' ); + } else { + this.ui.inputs.removeClass( 'e-visual-choice-placeholder' ); + } + }, + + onReady() { + this.updatePlaceholder(); + }, + + applySavedValue() { + const currentValue = this.getControlValue(); + + if ( currentValue ) { + this.ui.inputs.filter( '[value="' + currentValue + '"]' ).prop( 'checked', true ); + } else { + this.ui.inputs.filter( ':checked' ).prop( 'checked', false ); + } + }, + + onMouseDownLabel( event ) { + var $clickedLabel = this.$( event.currentTarget ), + $selectedInput = this.$( '#' + $clickedLabel.attr( 'for' ) ); + + $selectedInput.data( 'checked', $selectedInput.prop( 'checked' ) ); + }, + + onClickInput( event ) { + if ( ! this.model.get( 'toggle' ) ) { + return; + } + + var $selectedInput = this.$( event.currentTarget ); + + if ( $selectedInput.data( 'checked' ) ) { + $selectedInput.prop( 'checked', false ).trigger( 'change' ); + } + }, + + onBaseInputChange() { + ControlBaseDataView.prototype.onBaseInputChange.apply( this, arguments ); + + this.updatePlaceholder(); + }, +}, { + + onPasteStyle( control, clipboardValue ) { + return '' === clipboardValue || undefined !== control.options[ clipboardValue ]; + }, +} ); + +module.exports = ControlVisualChoiceItemView; diff --git a/assets/dev/js/editor/document/elements/commands/delete.js b/assets/dev/js/editor/document/elements/commands/delete.js index 9ea731669346..a8e57e43bcae 100644 --- a/assets/dev/js/editor/document/elements/commands/delete.js +++ b/assets/dev/js/editor/document/elements/commands/delete.js @@ -48,6 +48,8 @@ export class Delete extends $e.modules.editor.document.CommandHistoryBase { } ); } + this.deselectRecursive( container.model.get( 'id' ) ); + container.model.destroy(); container.panel.refresh(); } ); @@ -58,6 +60,18 @@ export class Delete extends $e.modules.editor.document.CommandHistoryBase { return containers; } + + deselectRecursive( id ) { + const container = elementor.getContainer( id ); + + if ( elementor.selection.has( container ) ) { + $e.run( 'document/elements/deselect', { container } ); + } + + container?.model.get( 'elements' ).forEach( ( childModel ) => { + this.deselectRecursive( childModel.get( 'id' ) ); + } ); + } } export default Delete; diff --git a/assets/dev/js/editor/document/elements/commands/reset-settings.js b/assets/dev/js/editor/document/elements/commands/reset-settings.js index f8b3f5ee3f7a..799b1fd3f479 100644 --- a/assets/dev/js/editor/document/elements/commands/reset-settings.js +++ b/assets/dev/js/editor/document/elements/commands/reset-settings.js @@ -30,6 +30,8 @@ export class ResetSettings extends $e.modules.editor.document.CommandHistoryBase defaultValues[ controlName ] = control.default; } ); + defaultValues.__globals__ = {}; + $e.run( 'document/elements/settings', { container, options, diff --git a/assets/dev/js/editor/document/ui/commands/paste.js b/assets/dev/js/editor/document/ui/commands/paste.js index 18e2962efd6a..7f8ff468fd9f 100644 --- a/assets/dev/js/editor/document/ui/commands/paste.js +++ b/assets/dev/js/editor/document/ui/commands/paste.js @@ -15,6 +15,7 @@ export class Paste extends $e.modules.CommandBase { const { containers = [ args.container ] } = args; this.storage = this.getPasteData( args ); + if ( ! this.storage || ! this.storage?.elements?.length || 'elementor' !== this.storage?.type ) { return false; } @@ -23,12 +24,7 @@ export class Paste extends $e.modules.CommandBase { new Backbone.Model( model ), ); - if ( ! containers[ 0 ] ) { - this.target = elementor.getCurrentElement(); - this.target = this.target ? [ this.target.getContainer() ] : null; - } else { - this.target = containers; - } + this.target = this.getTarget( containers ); if ( ! this.target || 0 === this.storage.elements.length ) { return false; @@ -80,6 +76,17 @@ export class Paste extends $e.modules.CommandBase { return result; } + + getTarget( containers ) { + if ( containers[ 0 ] ) { + return containers; + } + + const selectedContainers = elementor.selection?.getElements() || []; + const currentElementContainer = elementor.getCurrentElement()?.getContainer(); + + return selectedContainers.length ? selectedContainers : currentElementContainer; + } } export default Paste; diff --git a/assets/dev/js/editor/editor-base.js b/assets/dev/js/editor/editor-base.js index 576fa7633649..1f56ddb37d50 100644 --- a/assets/dev/js/editor/editor-base.js +++ b/assets/dev/js/editor/editor-base.js @@ -32,6 +32,7 @@ import EditorEvents from 'elementor/modules/editor-events/assets/js/editor/modul import FloatingButtonsLibraryModule from 'elementor/modules/floating-buttons/assets/js/floating-buttons/editor/module'; import FloatingBarsLibraryModule from 'elementor/modules/floating-buttons/assets/js/floating-bars/editor/module'; import LinkInBioLibraryModule from 'elementor/modules/link-in-bio/assets/js/editor/module'; +import CloudLibraryModule from 'elementor/modules/cloud-library/assets/js/editor/module'; import * as elementTypes from './elements/types'; import ElementBase from './elements/types/base/element-base'; @@ -184,6 +185,7 @@ export default class EditorBase extends Marionette.Application { Box_shadow: require( 'elementor-controls/box-shadow' ), Button: require( 'elementor-controls/button' ), Choose: require( 'elementor-controls/choose' ), + Visual_choice: require( 'elementor-controls/visual-choice' ), Code: require( 'elementor-controls/code' ), Color: ColorControl, Date_time: DateTimeControl, @@ -236,6 +238,9 @@ export default class EditorBase extends Marionette.Application { BaseWidget: require( 'elementor-elements/views/base-widget' ), Widget: require( 'elementor-elements/views/widget' ), }, + components: { + AddSectionView: require( 'elementor-views/add-section/inline' ).default, + }, }, layouts: { panel: { @@ -287,7 +292,28 @@ export default class EditorBase extends Marionette.Application { } if ( ! this.widgetsCache[ widgetType ].commonMerged && ! this.widgetsCache[ widgetType ].atomic_controls ) { - jQuery.extend( this.widgetsCache[ widgetType ].controls, this.widgetsCache.common.controls ); + let commonControls = this.widgetsCache.common.controls; + + /** + * Filter widgets common controls. + * + * @param array commonControls - An array of the default common controls. + * @param string widgetType - The widget type. + */ + commonControls = elementor.hooks.applyFilters( 'elements/widget/controls/common/default', commonControls, widgetType ); + jQuery.extend( this.widgetsCache[ widgetType ].controls, commonControls ); + + if ( ! this.widgetsCache[ widgetType ].has_widget_inner_wrapper && elementorCommon.config.experimentalFeatures.e_optimized_markup ) { + let commonOptimizedControls = this.widgetsCache[ 'common-optimized' ].controls; + /** + * Filter widgets common-optimized controls. + * + * @param array commonOptimizedControls - An array of the default common controls. + * @param string widgetType - The widget type. + */ + commonOptimizedControls = elementor.hooks.applyFilters( 'elements/widget/controls/common-optimized/default', commonOptimizedControls, widgetType ); + jQuery.extend( this.widgetsCache[ widgetType ].controls, commonOptimizedControls ); + } this.widgetsCache[ widgetType ].controls = elementor.hooks.applyFilters( 'elements/widget/controls/common', this.widgetsCache[ widgetType ].controls, widgetType, this.widgetsCache[ widgetType ] ); @@ -453,6 +479,10 @@ export default class EditorBase extends Marionette.Application { this.modules.promotionModule = new PromotionModule(); + if ( elementorCommon.config.experimentalFeatures[ 'cloud-library' ] ) { + this.modules.cloudLibraryModule = new CloudLibraryModule(); + } + // TODO: Move to elementor:init-data-components $e.components.register( new DataGlobalsComponent() ); @@ -548,7 +578,7 @@ export default class EditorBase extends Marionette.Application { initPreviewView( document ) { elementor.trigger( 'document:before:preview', document ); - this.previewView = this.createPreviewView( document.$element[ 0 ], elementor.elementsModel ); + this.previewView = this.createPreviewView( document.$element[ 0 ], elementor.elementsModel ); this.renderPreview( this.previewView ); } @@ -758,7 +788,7 @@ export default class EditorBase extends Marionette.Application { } getBreakpointResizeOptions( currentBreakpoint ) { - const previewHeight = elementor.$previewWrapper.height() - 80, // 80 = responsive bar height + ui-resizable-handle + const previewHeight = elementor.$previewWrapper.height(), specialBreakpointsHeights = { mobile: { minHeight: 480, diff --git a/assets/dev/js/editor/elements/models/document.js b/assets/dev/js/editor/elements/models/document.js index 04900be89bdd..5f72d1617e55 100644 --- a/assets/dev/js/editor/elements/models/document.js +++ b/assets/dev/js/editor/elements/models/document.js @@ -1,10 +1,11 @@ import BaseElementModel from './base-element-model'; +import { getAllElementTypes } from 'elementor-editor/utils/element-types'; export default class Document extends BaseElementModel { isValidChild( childModel ) { const childElType = childModel.get( 'elType' ); // Valid children. - return [ 'section', 'container' ].includes( childElType ); + return getAllElementTypes().includes( childElType ); } } diff --git a/assets/dev/js/editor/elements/models/element.js b/assets/dev/js/editor/elements/models/element.js index b533a7d23f37..3942a6ed5a1a 100644 --- a/assets/dev/js/editor/elements/models/element.js +++ b/assets/dev/js/editor/elements/models/element.js @@ -149,7 +149,9 @@ ElementModel = BaseElementModel.extend( { }, getTitle() { - let title = this.getSetting( '_title' ) || this.getSetting( 'presetTitle' ); + const editorSettings = this.get( 'editor_settings' ); + let title = editorSettings?.title || this.getSetting( '_title' ) || this.getSetting( 'presetTitle' ); + const custom = this.get( 'custom' ); if ( ! title && ( custom?.isPreset ?? false ) ) { @@ -163,6 +165,30 @@ ElementModel = BaseElementModel.extend( { return title; }, + getVisibility() { + if ( elementor.helpers.isAtomicWidget( this ) ) { + return !! this.get( 'editor_settings' )?.is_hidden; + } + + return !! this.get( 'hidden' ); + }, + + setVisibility( isHidden = false ) { + if ( elementor.helpers.isAtomicWidget( this ) ) { + const prevEditorSettings = this.get( 'editor_settings' ) || {}; + + this.set( 'editor_settings', { ...prevEditorSettings, is_hidden: isHidden } ); + } else { + this.set( 'hidden', isHidden ); + } + }, + + toggleVisibility() { + const isHidden = this.getVisibility(); + + this.setVisibility( ! isHidden ); + }, + getIcon() { const mainIcon = elementor.getElementData( this ).icon, custom = this.get( 'custom' ); diff --git a/assets/dev/js/editor/elements/views/base.js b/assets/dev/js/editor/elements/views/base.js index 26e1dbf6a648..0a4f73f906d1 100644 --- a/assets/dev/js/editor/elements/views/base.js +++ b/assets/dev/js/editor/elements/views/base.js @@ -79,6 +79,7 @@ BaseElementView = BaseContainer.extend( { events() { return { mousedown: 'onMouseDown', + click: 'handleAnchorClick', 'click @ui.editButton': 'onEditButtonClick', 'click @ui.duplicateButton': 'onDuplicateButtonClick', 'click @ui.addButton': 'onAddButtonClick', @@ -277,16 +278,21 @@ BaseElementView = BaseContainer.extend( { }, getHandlesOverlay() { - const elementType = this.getElementType(), - $handlesOverlay = jQuery( '
', { class: 'elementor-element-overlay' } ), + const elementType = this.getElementType(); + if ( ! elementor.userCan( 'design' ) && elementType !== 'widget' ) { + return; + } + + const $handlesOverlay = jQuery( '
', { class: 'elementor-element-overlay' } ), $overlayList = jQuery( '