Compare commits
11
Commits
leal-cyber
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0882dc9291 | ||
|
|
00a50dd79c | ||
|
|
97bb8ecd32 | ||
|
|
3fc1f17757 | ||
|
|
a9468409fb | ||
|
|
d58b6a6cc4 | ||
|
|
81cb75b62f | ||
|
|
88ffa329eb | ||
|
|
899fce1c92 | ||
|
|
b6b856260f | ||
|
|
79b18e7009 |
@@ -1,24 +0,0 @@
|
||||
name: Build Chatwoot
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- leal-cyber
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout source
|
||||
run: |
|
||||
git clone --depth 1 https://github.com/chatwoot/chatwoot.git /source
|
||||
ls /source/docker/Dockerfile
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
cd /source
|
||||
docker build -t gitea.lealcyber.com/akadmin/chatwoot:test -f docker/Dockerfile --build-arg RAILS_ENV=production --build-arg BUNDLE_WITHOUT='development:test:enterprise' --build-arg NODE_OPTIONS='--max-old-space-size=1024' .
|
||||
|
||||
- name: Verify image
|
||||
run: docker images gitea.lealcyber.com/akadmin/chatwoot:test
|
||||
@@ -0,0 +1,2 @@
|
||||
## All enterprise related files should be reviewed by sojan before merging
|
||||
/enterprise/* @sojan-official
|
||||
@@ -0,0 +1,2 @@
|
||||
open_collective: chatwoot
|
||||
github: chatwoot
|
||||
@@ -0,0 +1,78 @@
|
||||
name: 🐞 Bug report
|
||||
description: Create a report to help us improve
|
||||
labels: 'Bug'
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A concise description of what you expected to happen along with screenshots if applicable.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: To Reproduce
|
||||
description: Steps to reproduce the behavior.
|
||||
placeholder: |
|
||||
1. In this environment...
|
||||
2. With this config...
|
||||
3. Run '...'
|
||||
4. See error...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: A concise description of what you expected to happen.
|
||||
- type: dropdown
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Describe whether you are using Chatwoot Cloud (app.chatwoot.com) or a self-hosted installation of Chatwoot. If you are using a self-hosted installation of Chatwoot, describe the type of deployment (Docker/Linux VM installation/Heroku/Kubernetes/Other).
|
||||
options:
|
||||
- app.chatwoot.com
|
||||
- Linux VM
|
||||
- Docker
|
||||
- Kubernetes
|
||||
- Heroku
|
||||
- Other [please specify in the description]
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: provider
|
||||
attributes:
|
||||
label: Cloud Provider
|
||||
description:
|
||||
options:
|
||||
- AWS
|
||||
- GCP
|
||||
- Azure
|
||||
- DigitalOcean
|
||||
- Other [please specify in the description]
|
||||
- type: dropdown
|
||||
id: platform
|
||||
attributes:
|
||||
label: Platform
|
||||
description: Describe the platform you are using
|
||||
options:
|
||||
- Browser
|
||||
- Mobile
|
||||
- type: input
|
||||
attributes:
|
||||
label: Operating system
|
||||
description: The operating system and the version you are using.
|
||||
- type: input
|
||||
attributes:
|
||||
label: Browser and version
|
||||
description: The name of the browser and version you are using.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Docker (if applicable)
|
||||
description: |
|
||||
Please share the output of the following.
|
||||
- `docker version`
|
||||
- `docker info`
|
||||
- `docker-compose version`
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context about the problem here.
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Report a security issue
|
||||
url: https://www.chatwoot.com/docs/contributing-guide/security-reports/
|
||||
about: Guidelines and steps to report a security vulnerability. Please report security vulnerabilities here.
|
||||
- name: Product Documentation
|
||||
url: https://www.chatwoot.com/help-center
|
||||
about: If you have questions, are confused, or just want to understand our product better, please check out our documentation.
|
||||
@@ -0,0 +1,28 @@
|
||||
name: 🧙 Feature request
|
||||
description: Suggest an idea for this project
|
||||
labels: 'feature-request'
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Is your feature or enhancement related to a problem? Please describe.
|
||||
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like
|
||||
description: A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe alternatives you've considered
|
||||
description: A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,31 @@
|
||||
# Pull Request Template
|
||||
|
||||
## Description
|
||||
|
||||
Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires.
|
||||
Fixes # (issue)
|
||||
|
||||
## Type of change
|
||||
|
||||
Please delete options that are not relevant.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## How Has This Been Tested?
|
||||
|
||||
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration.
|
||||
|
||||
|
||||
## Checklist:
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented on my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published in downstream modules
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 966 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 934 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync triage GitHub security advisories to Linear issues."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
GITHUB_API = "https://api.github.com"
|
||||
LINEAR_API = "https://api.linear.app/graphql"
|
||||
|
||||
SEVERITY_PRIORITY = {"critical": 1, "high": 2, "medium": 3, "low": 4}
|
||||
SEVERITY_COLOR = {
|
||||
"critical": 15548997,
|
||||
"high": 15105570,
|
||||
"medium": 15844367,
|
||||
"low": 3066993,
|
||||
}
|
||||
DEFAULT_COLOR = 9807270
|
||||
|
||||
|
||||
def required_env(name: str) -> str:
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
sys.exit(f"Missing required env var: {name}")
|
||||
return value
|
||||
|
||||
|
||||
def fetch_triage_advisories(repo: str, token: str) -> list[dict[str, Any]]:
|
||||
url: str | None = f"{GITHUB_API}/repos/{repo}/security-advisories"
|
||||
params: dict[str, Any] | None = {"state": "triage", "per_page": 100}
|
||||
headers = {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
advisories: list[dict[str, Any]] = []
|
||||
while url:
|
||||
r = requests.get(url, headers=headers, params=params, timeout=30)
|
||||
r.raise_for_status()
|
||||
advisories.extend(r.json())
|
||||
next_link = r.links.get("next")
|
||||
url = next_link["url"] if next_link else None
|
||||
params = None
|
||||
return advisories
|
||||
|
||||
|
||||
def linear_call(query: str, variables: dict[str, Any], api_key: str) -> dict[str, Any]:
|
||||
r = requests.post(
|
||||
LINEAR_API,
|
||||
headers={"Authorization": api_key},
|
||||
json={"query": query, "variables": variables},
|
||||
timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def linear_issue_exists(ghsa_id: str, api_key: str) -> bool:
|
||||
query = (
|
||||
"query($q: String!) { issues(filter: {title: {contains: $q}}, first: 1) "
|
||||
"{ nodes { id } } }"
|
||||
)
|
||||
resp = linear_call(query, {"q": ghsa_id}, api_key)
|
||||
return len(resp.get("data", {}).get("issues", {}).get("nodes", [])) > 0
|
||||
|
||||
|
||||
def linear_create_issue(input_data: dict[str, Any], api_key: str) -> dict[str, str] | None:
|
||||
query = (
|
||||
"mutation($input: IssueCreateInput!) { issueCreate(input: $input) "
|
||||
"{ success issue { identifier url } } }"
|
||||
)
|
||||
resp = linear_call(query, {"input": input_data}, api_key)
|
||||
create = resp.get("data", {}).get("issueCreate") or {}
|
||||
if not create.get("success"):
|
||||
return None
|
||||
return create.get("issue")
|
||||
|
||||
|
||||
def reporter_login(advisory: dict[str, Any]) -> str:
|
||||
for credit in advisory.get("credits") or []:
|
||||
user = (credit or {}).get("user") or {}
|
||||
if user.get("login"):
|
||||
return user["login"]
|
||||
return "unknown"
|
||||
|
||||
|
||||
def cvss_score(advisory: dict[str, Any]) -> str:
|
||||
score = (advisory.get("cvss") or {}).get("score")
|
||||
return str(score) if score is not None else "n/a"
|
||||
|
||||
|
||||
def build_description(adv: dict[str, Any]) -> str:
|
||||
return (
|
||||
f"**GHSA:** {adv['ghsa_id']}\n"
|
||||
f"**CVE:** {adv.get('cve_id') or 'n/a'}\n"
|
||||
f"**Severity:** {adv.get('severity') or 'unknown'} (CVSS {cvss_score(adv)})\n"
|
||||
f"**Reporter:** {reporter_login(adv)}\n"
|
||||
f"**Reported:** {(adv.get('created_at') or '').split('T')[0]}\n"
|
||||
f"**Advisory:** {adv['html_url']}\n\n"
|
||||
f"---\n\n"
|
||||
f"{adv.get('description') or 'No description provided.'}"
|
||||
)
|
||||
|
||||
|
||||
def post_discord(adv: dict[str, Any], issue: dict[str, str], webhook_url: str) -> None:
|
||||
severity = adv.get("severity") or "unknown"
|
||||
title = f"[{adv['ghsa_id']}] {adv['summary']}"[:250]
|
||||
payload = {
|
||||
"username": "GHSA Sync",
|
||||
"embeds": [
|
||||
{
|
||||
"title": title,
|
||||
"url": issue["url"],
|
||||
"color": SEVERITY_COLOR.get(severity, DEFAULT_COLOR),
|
||||
"fields": [
|
||||
{"name": "Linear", "value": issue["identifier"], "inline": True},
|
||||
{
|
||||
"name": "Severity",
|
||||
"value": f"{severity} (CVSS {cvss_score(adv)})",
|
||||
"inline": True,
|
||||
},
|
||||
{
|
||||
"name": "Advisory",
|
||||
"value": f"[GitHub]({adv['html_url']})",
|
||||
"inline": True,
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
try:
|
||||
requests.post(webhook_url, json=payload, timeout=10)
|
||||
except requests.RequestException:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
repo = required_env("GITHUB_REPOSITORY")
|
||||
gh_token = required_env("GHSA_READ_TOKEN")
|
||||
linear_api_key = required_env("LINEAR_API_KEY")
|
||||
team_id = required_env("LINEAR_TEAM_ID")
|
||||
project_id = required_env("LINEAR_PROJECT_ID")
|
||||
label_id = required_env("LINEAR_LABEL_ID")
|
||||
discord_webhook = os.environ.get("DISCORD_WEBHOOK_URL") or None
|
||||
|
||||
advisories = fetch_triage_advisories(repo, gh_token)
|
||||
print(f"Fetched {len(advisories)} triage advisories")
|
||||
|
||||
created = skipped = failed = 0
|
||||
|
||||
for adv in advisories:
|
||||
ghsa_id = adv.get("ghsa_id")
|
||||
if not ghsa_id:
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
if linear_issue_exists(ghsa_id, linear_api_key):
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
severity = adv.get("severity") or "unknown"
|
||||
issue = linear_create_issue(
|
||||
{
|
||||
"title": f"[{ghsa_id}] {adv.get('summary', '')}",
|
||||
"description": build_description(adv),
|
||||
"teamId": team_id,
|
||||
"projectId": project_id,
|
||||
"labelIds": [label_id],
|
||||
"priority": SEVERITY_PRIORITY.get(severity, 3),
|
||||
},
|
||||
linear_api_key,
|
||||
)
|
||||
except requests.RequestException:
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
if not issue:
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
created += 1
|
||||
if discord_webhook:
|
||||
post_discord(adv, issue, discord_webhook)
|
||||
|
||||
print(f"Created {created}, skipped {skipped}, failed {failed}")
|
||||
return 1 if failed > 0 else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Auto-assign PR to Author
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
auto-assign:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Auto-assign PR to author
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pull_number = context.payload.pull_request.number;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
|
||||
await github.rest.issues.addAssignees({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: pull_number,
|
||||
assignees: [author]
|
||||
});
|
||||
|
||||
console.log(`Assigned PR #${pull_number} to ${author}`);
|
||||
@@ -0,0 +1,53 @@
|
||||
## github action to check deployment success
|
||||
## curl the deployment url and check for 200 status
|
||||
## deployment url will be of the form chatwoot-pr-<pr_number>.herokuapp.com
|
||||
name: Deploy Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deployment_check:
|
||||
name: Check Deployment
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Install jq
|
||||
run: sudo apt-get install -y jq
|
||||
- name: Print Deployment URL
|
||||
run: echo "https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com"
|
||||
- name: Check Deployment Status
|
||||
run: |
|
||||
max_attempts=10
|
||||
attempt=1
|
||||
status_code=0
|
||||
echo "Waiting for review app to be deployed/redeployed, trying in 10 minutes..."
|
||||
sleep 600
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
|
||||
status_code=$(echo $response | head -n 1)
|
||||
if [ $status_code -eq 200 ]; then
|
||||
body=$(curl -s https://chatwoot-pr-${{ github.event.pull_request.number }}.herokuapp.com/api)
|
||||
if echo "$body" | jq -e '.version and .timestamp and .queue_services == "ok" and .data_services == "ok"' > /dev/null; then
|
||||
echo "Deployment successful"
|
||||
exit 0
|
||||
else
|
||||
echo "Deployment status unknown, retrying in 3 minutes..."
|
||||
sleep 180
|
||||
fi
|
||||
else
|
||||
echo "Waiting for review app to be ready, retrying in 3 minutes..."
|
||||
sleep 180
|
||||
attempt=$((attempt + 1))
|
||||
fi
|
||||
done
|
||||
echo "Deployment failed after $max_attempts attempts"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Frontend Lint & Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run eslint
|
||||
run: pnpm run eslint
|
||||
|
||||
- name: Run frontend tests with coverage
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
pnpm run test:coverage
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Sync GHSA advisories to Linear
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 4 * * *' # daily at 09:30 IST
|
||||
workflow_dispatch: {}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: pip install requests==2.32.3
|
||||
- name: Sync advisories
|
||||
env:
|
||||
GHSA_READ_TOKEN: ${{ secrets.GHSA_READ_TOKEN }}
|
||||
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
||||
LINEAR_TEAM_ID: ${{ secrets.LINEAR_TEAM_ID }}
|
||||
LINEAR_PROJECT_ID: ${{ secrets.LINEAR_PROJECT_ID }}
|
||||
LINEAR_LABEL_ID: ${{ secrets.LINEAR_LABEL_ID }}
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
run: python3 .github/scripts/ghsa_linear_sync.py
|
||||
@@ -0,0 +1,23 @@
|
||||
# ref: https://github.com/amannn/action-semantic-pull-request
|
||||
# ensure PR title is in semantic format
|
||||
|
||||
name: "Lint PR"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -0,0 +1,29 @@
|
||||
# We often have cases where users would comment over stale closed Github Issues.
|
||||
# This creates unnecessary noise for the original reporter and makes it harder for triaging.
|
||||
# This action locks the closed threads once it is inactive for over a month.
|
||||
|
||||
name: 'Lock Threads'
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: lock
|
||||
|
||||
jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository == 'chatwoot/chatwoot' }}
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v3
|
||||
with:
|
||||
issue-inactive-days: '30'
|
||||
issue-lock-reason: 'resolved'
|
||||
pr-inactive-days: '30'
|
||||
pr-lock-reason: 'resolved'
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Log Lines Percentage Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
log_lines_check:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for log lines and calculate percentage
|
||||
run: |
|
||||
# Define the log line pattern
|
||||
LOG_LINE_PATTERN="Rails\.logger"
|
||||
|
||||
# Get the list of changed files in the pull request
|
||||
CHANGED_FILES=$(git diff --name-only)
|
||||
|
||||
# Initialize a flag to track if any files have insufficient log lines
|
||||
INSUFFICIENT_LOGS=0
|
||||
|
||||
for file in $CHANGED_FILES; do
|
||||
if [[ $file =~ \.rb$ && ! $file =~ _spec\.rb$ ]]; then
|
||||
# Count the total number of lines in the file
|
||||
total_lines=$(wc -l < "$file")
|
||||
|
||||
# Count the number of log lines in the file
|
||||
log_lines=$(grep -c "$LOG_LINE_PATTERN" "$file")
|
||||
|
||||
# Calculate the percentage of log lines
|
||||
if [ "$total_lines" -gt 0 ]; then
|
||||
percentage=$(awk "BEGIN { pc=100*${log_lines}/${total_lines}; i=int(pc); print (pc-i<0.5)?i:i+1 }")
|
||||
else
|
||||
percentage=0
|
||||
fi
|
||||
|
||||
# Check if the percentage is less than 5%
|
||||
if [ "$percentage" -lt 5 ]; then
|
||||
echo "Error: Log lines percentage is less than 5% ($percentage%) in $file. Please add more log lines using Rails.logger statements."
|
||||
INSUFFICIENT_LOGS=1
|
||||
else
|
||||
echo "Log lines percentage is $percentage% in $file. Code looks good!"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# If any files have insufficient log lines, fail the action
|
||||
if [ "$INSUFFICIENT_LOGS" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
# #
|
||||
# #
|
||||
# # Linux nightly installer action
|
||||
# # This action will try to install and setup
|
||||
# # chatwoot on an Ubuntu 22.04 machine using
|
||||
# # the linux installer script.
|
||||
# #
|
||||
# # This is set to run daily at midnight.
|
||||
# #
|
||||
|
||||
name: Run Linux nightly installer
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
nightly:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
|
||||
- name: get installer
|
||||
run: |
|
||||
wget https://get.chatwoot.app/linux/install.sh
|
||||
chmod +x install.sh
|
||||
#fix for postgtres not starting automatically in gh action env
|
||||
sed -i '/function configure_db() {/a sudo service postgresql start' install.sh
|
||||
|
||||
- name: create input file
|
||||
run: |
|
||||
echo "no" > input
|
||||
echo "yes" >> input
|
||||
|
||||
- name: Run the installer
|
||||
run: |
|
||||
sudo ./install.sh --install < input
|
||||
|
||||
# disabling http verify for now as http
|
||||
# access to port 3000 fails in gh action env
|
||||
# - name: Verify
|
||||
# if: always()
|
||||
# run: |
|
||||
# sudo netstat -ntlp | grep 3000
|
||||
# sudo systemctl restart chatwoot.target
|
||||
# curl http://localhost:3000/api
|
||||
|
||||
- name: Upload chatwoot setup log file as an artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: chatwoot-setup-log-file
|
||||
path: /var/log/chatwoot-setup.log
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Publish Codespace Base Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
publish-code-space-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build the Codespace Base Image
|
||||
run: |
|
||||
docker compose -f .devcontainer/docker-compose.base.yml build base
|
||||
docker push ghcr.io/chatwoot/chatwoot_codespace:latest
|
||||
@@ -0,0 +1,143 @@
|
||||
# #
|
||||
# # This action will publish Chatwoot EE docker image.
|
||||
# # This is set to run against merges to develop, master
|
||||
# # and when tags are created.
|
||||
# #
|
||||
|
||||
name: Publish Chatwoot EE docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set Chatwoot edition
|
||||
run: |
|
||||
echo -en '\nENV CW_EDITION="ee"' >> docker/Dockerfile
|
||||
|
||||
- name: Set Docker Tags
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:latest" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t $TAG \
|
||||
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}"
|
||||
fi
|
||||
|
||||
docker buildx imagetools inspect $TAG
|
||||
@@ -0,0 +1,148 @@
|
||||
# #
|
||||
# # This action will publish Chatwoot CE docker image.
|
||||
# # This is set to run against merges to develop, master
|
||||
# # and when tags are created.
|
||||
# #
|
||||
|
||||
name: Publish Chatwoot CE docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
DOCKER_REPO: chatwoot/chatwoot
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: Set Chatwoot edition
|
||||
run: |
|
||||
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
|
||||
|
||||
- name: Set Docker Tags
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:latest-ce" >> $GITHUB_ENV
|
||||
else
|
||||
echo "DOCKER_TAG=${DOCKER_REPO}:${SANITIZED_REF}-ce" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' }}
|
||||
outputs: type=image,name=${{ env.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
- name: Export digest
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Create manifest list and push
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest-ce"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
|
||||
fi
|
||||
|
||||
docker buildx imagetools create -t $TAG \
|
||||
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }}
|
||||
run: |
|
||||
SANITIZED_REF=$(echo "$GIT_REF" | sed 's/\//-/g')
|
||||
if [ "${{ github.ref_name }}" = "master" ]; then
|
||||
TAG="${DOCKER_REPO}:latest-ce"
|
||||
else
|
||||
TAG="${DOCKER_REPO}:${SANITIZED_REF}-ce"
|
||||
fi
|
||||
|
||||
docker buildx imagetools inspect $TAG
|
||||
@@ -0,0 +1,146 @@
|
||||
name: Run Chatwoot CE spec
|
||||
permissions:
|
||||
contents: read
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
# Separate linting jobs for faster feedback
|
||||
lint-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
- name: Run Rubocop
|
||||
run: bundle exec rubocop --parallel
|
||||
|
||||
lint-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
- name: Run ESLint
|
||||
run: pnpm run eslint
|
||||
|
||||
# Frontend tests run in parallel with backend
|
||||
frontend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
- name: Run frontend tests
|
||||
run: pnpm run test:coverage
|
||||
|
||||
# Backend tests with parallelization
|
||||
backend-tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
ci_node_total: [16]
|
||||
ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg16
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ''
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install pnpm dependencies
|
||||
run: pnpm i
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: Run backend tests (parallelized)
|
||||
run: |
|
||||
# Get all spec files and split them using round-robin distribution
|
||||
# This ensures slow tests are distributed evenly across all nodes
|
||||
SPEC_FILES=($(find spec -name '*_spec.rb' | sort))
|
||||
TESTS=""
|
||||
|
||||
for i in "${!SPEC_FILES[@]}"; do
|
||||
# Assign spec to this node if: index % total == node_index
|
||||
if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then
|
||||
TESTS="$TESTS ${SPEC_FILES[$i]}"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$TESTS" ]; then
|
||||
bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS
|
||||
fi
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: rspec-results-${{ matrix.ci_node_index }}
|
||||
path: tmp/rspec_results.json
|
||||
|
||||
- name: Upload rails log folder
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: rails-log-folder-${{ matrix.ci_node_index }}
|
||||
path: log
|
||||
@@ -0,0 +1,100 @@
|
||||
name: Run MFA Tests
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
# Only run if MFA test keys are available
|
||||
if: github.event_name == 'workflow_dispatch' || (github.repository == 'chatwoot/chatwoot' && github.actor != 'dependabot[bot]')
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg15
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ''
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
POSTGRES_HOST: localhost
|
||||
# Active Record encryption keys required for MFA - test keys only, not for production use
|
||||
ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: 'test_key_a6cde8f7b9c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7'
|
||||
ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: 'test_key_b7def9a8c0d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d8'
|
||||
ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: 'test_salt_c8efa0b9d1e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d9'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
- name: Install pgvector extension
|
||||
run: |
|
||||
PGPASSWORD="" psql -h localhost -U postgres -d chatwoot_test -c "CREATE EXTENSION IF NOT EXISTS vector;"
|
||||
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: Run MFA-related backend tests
|
||||
run: |
|
||||
bundle exec rspec \
|
||||
spec/services/mfa/token_service_spec.rb \
|
||||
spec/services/mfa/authentication_service_spec.rb \
|
||||
spec/requests/api/v1/profile/mfa_controller_spec.rb \
|
||||
spec/controllers/devise_overrides/sessions_controller_spec.rb \
|
||||
spec/models/application_record_external_credentials_encryption_spec.rb \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Run MFA-related tests in user_spec
|
||||
run: |
|
||||
# Run specific MFA-related tests from user_spec
|
||||
bundle exec rspec spec/models/user_spec.rb \
|
||||
-e "two factor" \
|
||||
-e "2FA" \
|
||||
-e "MFA" \
|
||||
-e "otp" \
|
||||
-e "backup code" \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Upload test logs
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: mfa-test-logs
|
||||
path: |
|
||||
log/test.log
|
||||
tmp/screenshots/
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Run Size Limit Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
# If two pushes happen within a short time in the same PR, cancel the run of the oldest push
|
||||
concurrency:
|
||||
group: pr-${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: pnpm
|
||||
run: pnpm install
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: setup env
|
||||
run: |
|
||||
cp .env.example .env
|
||||
|
||||
- name: Run asset compile
|
||||
run: bundle exec rake assets:precompile
|
||||
env:
|
||||
RAILS_ENV: production
|
||||
|
||||
- name: Size Check
|
||||
run: pnpm run size
|
||||
@@ -0,0 +1,28 @@
|
||||
# This workflow warns and then closes PRs that have had no activity for a specified amount of time.
|
||||
#
|
||||
# You can adjust the behavior by modifying this file.
|
||||
# For more information, see:
|
||||
# https://github.com/actions/stale
|
||||
name: Mark stale issues and pull requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '28 3 * * *'
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@v5
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-issue-close: -1,
|
||||
days-before-issue-stale: -1
|
||||
days-before-pr-close: -1,
|
||||
days-before-pr-stale: 30,
|
||||
stale-pr-message: '🐢 Turtley slow progress alert! This pull request has been idle for over 30 days. Can we please speed things up and either merge it or release it back into the wild?'
|
||||
stale-pr-label: 'stale'
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Test Docker Build
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-22.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
push: false
|
||||
load: false
|
||||
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||
@@ -137,9 +137,3 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
|
||||
|
||||
|
||||
*Chatwoot* © 2017-2026, Chatwoot Inc - Released under the MIT License.
|
||||
# Trigger rebuild
|
||||
### Runner test
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-6
@@ -28,12 +28,7 @@ const inboxes = computed(() => {
|
||||
return {
|
||||
name: inbox.name,
|
||||
id: inbox.id,
|
||||
icon: getInboxIconByType(
|
||||
inbox.channelType,
|
||||
inbox.medium,
|
||||
'line',
|
||||
inbox.voiceEnabled
|
||||
),
|
||||
icon: getInboxIconByType(inbox.channelType, inbox.medium, 'line'),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { relativeDayTimestamp } from 'shared/helpers/timeHelper';
|
||||
import { getInboxVoiceIcon } from 'dashboard/helper/inbox';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import AudioPlayer from 'dashboard/components-next/audio/AudioPlayer.vue';
|
||||
@@ -61,7 +60,7 @@ const resultLabel = computed(() => {
|
||||
});
|
||||
|
||||
const providerIcon = computed(() =>
|
||||
getInboxVoiceIcon(props.call.inbox.channelType, props.call.inbox.medium)
|
||||
props.call.provider === 'whatsapp' ? 'i-woot-whatsapp' : 'i-lucide-phone'
|
||||
);
|
||||
|
||||
const createdAtLabel = computed(() =>
|
||||
@@ -151,7 +150,7 @@ const conversationRoute = computed(() => ({
|
||||
<div
|
||||
class="hidden items-center gap-x-1.5 gap-y-2.5 border-b border-n-weak lg:flex lg:items-center lg:gap-1.5"
|
||||
>
|
||||
<div class="flex items-center gap-2.5 min-w-0 w-52 shrink-0 py-3.5">
|
||||
<div class="flex items-center gap-2.5 min-w-0 w-40 shrink-0 py-3.5">
|
||||
<Avatar
|
||||
:src="call.contact.avatar"
|
||||
:name="contactName"
|
||||
@@ -170,12 +169,9 @@ const conversationRoute = computed(() => ({
|
||||
>
|
||||
<div class="flex items-center gap-x-2 min-w-0 lg:contents py-3.5">
|
||||
<CallStatusBadge :kind="kind" class="shrink-0" />
|
||||
<div
|
||||
v-if="agentActionLabel"
|
||||
class="gap-x-1.5 min-w-0 flex items-center"
|
||||
>
|
||||
<template v-if="agentActionLabel">
|
||||
<span
|
||||
class="text-label-small text-n-slate-10 truncate shrink min-w-8 xl:min-w-14"
|
||||
class="text-label-small text-n-slate-10 truncate min-w-0 shrink min-w-8"
|
||||
>
|
||||
{{ agentActionLabel }}
|
||||
</span>
|
||||
@@ -196,7 +192,7 @@ const conversationRoute = computed(() => ({
|
||||
{{ call.agent.name }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<span
|
||||
v-else-if="resultLabel"
|
||||
class="text-body-main truncate text-n-slate-10 min-w-0 shrink-[20]"
|
||||
@@ -216,10 +212,10 @@ const conversationRoute = computed(() => ({
|
||||
content: call.inbox.name,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="flex items-center gap-1 justify-end w-40 min-w-4 shrink-[100] py-3.5"
|
||||
class="flex items-center gap-1.5 justify-start min-w-14 shrink-[100] py-3.5"
|
||||
>
|
||||
<Icon :icon="providerIcon" class="size-4 text-n-slate-11 shrink-0" />
|
||||
<span class="text-body-main truncate text-n-slate-11 min-w-0">
|
||||
<span class="text-body-main truncate text-n-slate-11">
|
||||
{{ call.inbox.name }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -236,7 +232,7 @@ const conversationRoute = computed(() => ({
|
||||
content: createdAtLabel,
|
||||
delay: { show: 500, hide: 0 },
|
||||
}"
|
||||
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end min-w-16 max-w-20 shrink-0"
|
||||
class="text-label-small text-end text-n-slate-11 truncate py-3.5 tabular-nums justify-self-end w-16 shrink-0"
|
||||
>
|
||||
{{ createdAtLabel }}
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
|
||||
const props = defineProps({
|
||||
// Null while a fetch is in flight so stale counts are never shown.
|
||||
@@ -118,16 +117,10 @@ const moreFiltersSections = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const selectedAssignee = computed(
|
||||
() => props.agents.find(agent => agent.id === assigneeId.value) || null
|
||||
);
|
||||
|
||||
const selectedAssigneeLabel = computed(
|
||||
() => selectedAssignee.value?.name || t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
||||
);
|
||||
|
||||
const isOtherActivitySelected = computed(() =>
|
||||
OTHER_ACTIVITIES.includes(activity.value)
|
||||
() =>
|
||||
props.agents.find(agent => agent.id === assigneeId.value)?.name ||
|
||||
t('CALLS_PAGE.FILTERS.ASSIGNEE')
|
||||
);
|
||||
|
||||
const hasMoreFilters = computed(() => Boolean(inboxId.value));
|
||||
@@ -150,7 +143,7 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<span v-if="!activity" class="text-heading-3 text-n-slate-11 shrink-0">
|
||||
{{
|
||||
totalCount === null
|
||||
@@ -164,13 +157,13 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
color="blue"
|
||||
size="sm"
|
||||
:icon="ACTIVITY_ICONS[activity]"
|
||||
class="shrink-0 !h-7 !px-2"
|
||||
class="shrink-0"
|
||||
@click="setActivity(null)"
|
||||
>
|
||||
{{ activeChipLabel }}
|
||||
<Icon icon="i-lucide-x" />
|
||||
</Button>
|
||||
<div class="w-px h-3.5 mx-1 bg-n-strong shrink-0" />
|
||||
<div class="w-px h-4 bg-n-strong shrink-0" />
|
||||
<Button
|
||||
v-for="chip in inactiveChips"
|
||||
:key="chip"
|
||||
@@ -179,7 +172,7 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
size="sm"
|
||||
:icon="ACTIVITY_ICONS[chip]"
|
||||
:label="activityLabel(chip)"
|
||||
class="shrink-0 text-n-slate-11 !h-7 !px-2"
|
||||
class="shrink-0 text-n-slate-12"
|
||||
@click="setActivity(chip)"
|
||||
/>
|
||||
<OnClickOutside
|
||||
@@ -191,10 +184,7 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-lucide-phone"
|
||||
class="!h-7 !px-2"
|
||||
:class="
|
||||
isOtherActivitySelected ? 'text-n-slate-12' : 'text-n-slate-11'
|
||||
"
|
||||
class="text-n-slate-12"
|
||||
@click="toggleMenu('activity')"
|
||||
>
|
||||
{{ t('CALLS_PAGE.FILTERS.OTHER_ACTIVITY') }}
|
||||
@@ -218,19 +208,10 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
variant="outline"
|
||||
color="slate"
|
||||
size="sm"
|
||||
icon="i-woot-empty-assignee"
|
||||
class="max-w-52 !h-7 !px-2"
|
||||
:class="assigneeId ? 'text-n-slate-12' : 'text-n-slate-11'"
|
||||
icon="i-lucide-user-round-cog"
|
||||
class="max-w-52 text-n-slate-12"
|
||||
@click="toggleMenu('assignee')"
|
||||
>
|
||||
<template v-if="selectedAssignee" #icon>
|
||||
<Avatar
|
||||
:src="selectedAssignee.thumbnail"
|
||||
:name="selectedAssignee.name"
|
||||
:size="16"
|
||||
rounded-full
|
||||
/>
|
||||
</template>
|
||||
<span class="truncate">{{ selectedAssigneeLabel }}</span>
|
||||
<Icon icon="i-lucide-chevron-down" class="text-n-slate-11 shrink-0" />
|
||||
</Button>
|
||||
@@ -247,9 +228,8 @@ const applyMoreFilter = ({ action, value }) => {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon="i-lucide-list-filter"
|
||||
class="!h-7 !px-2"
|
||||
:color="hasMoreFilters ? 'blue' : 'slate'"
|
||||
:class="hasMoreFilters ? '' : 'text-n-slate-11'"
|
||||
:class="hasMoreFilters ? '' : 'text-n-slate-12'"
|
||||
@click="toggleMenu('more')"
|
||||
>
|
||||
{{ t('CALLS_PAGE.FILTERS.MORE_FILTERS') }}
|
||||
|
||||
@@ -83,12 +83,8 @@ const campaignStatus = computed(() => {
|
||||
const inboxName = computed(() => props.inbox?.name || '');
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const {
|
||||
medium,
|
||||
channel_type: type,
|
||||
voice_enabled: voiceEnabled,
|
||||
} = props.inbox;
|
||||
return getInboxIconByType(type, medium, 'fill', voiceEnabled);
|
||||
const { medium, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, medium);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
+2
-2
@@ -48,8 +48,8 @@ const inbox = computed(() => props.stateInbox);
|
||||
const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
|
||||
const lastActivityAt = computed(() => {
|
||||
|
||||
@@ -49,8 +49,8 @@ const isUnread = computed(() => !props.inboxItem?.readAt);
|
||||
const inbox = computed(() => props.stateInbox);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
|
||||
const hasSlaThreshold = computed(() => {
|
||||
|
||||
+1
-3
@@ -37,11 +37,10 @@ const transformInbox = ({
|
||||
channelType,
|
||||
phoneNumber,
|
||||
medium,
|
||||
voiceEnabled,
|
||||
...rest
|
||||
}) => ({
|
||||
id,
|
||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
||||
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||
label: generateLabelForContactableInboxesList({
|
||||
name,
|
||||
email,
|
||||
@@ -55,7 +54,6 @@ const transformInbox = ({
|
||||
phoneNumber,
|
||||
channelType,
|
||||
medium,
|
||||
voiceEnabled,
|
||||
...rest,
|
||||
});
|
||||
|
||||
|
||||
-14
@@ -79,20 +79,6 @@ describe('composeConversationHelper', () => {
|
||||
channelType: INBOX_TYPES.EMAIL,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the voice glyph for a voice-enabled inbox', () => {
|
||||
const inboxes = [
|
||||
{
|
||||
id: 2,
|
||||
name: 'WhatsApp Cloud',
|
||||
channelType: INBOX_TYPES.WHATSAPP,
|
||||
voiceEnabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const result = helpers.buildContactableInboxesList(inboxes);
|
||||
expect(result[0].icon).toBe('i-woot-whatsapp-voice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCapitalizedNameFromEmail', () => {
|
||||
|
||||
@@ -117,8 +117,8 @@ const downloadRecording = () => {
|
||||
>
|
||||
<template #icon>
|
||||
<Icon
|
||||
:icon="isPlaying ? 'i-woot-audio-pause' : 'i-woot-audio-play'"
|
||||
class="size-4 flex-shrink-0 text-n-slate-11"
|
||||
:icon="isPlaying ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||
class="size-4 flex-shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
@@ -127,10 +127,10 @@ const downloadRecording = () => {
|
||||
min="0"
|
||||
:max="duration || 0"
|
||||
:value="currentTime"
|
||||
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-0.5 rounded-full appearance-none cursor-pointer bg-n-slate-12/30 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-n-slate-11 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:size-2 [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-n-slate-11"
|
||||
class="flex-1 min-w-0 lg:grow-0 lg:basis-24 h-1 rounded-lg appearance-none cursor-pointer bg-n-slate-12/30 accent-n-slate-11"
|
||||
@input="seek"
|
||||
/>
|
||||
<span class="text-label-small tabular-nums text-n-slate-11 shrink-0">
|
||||
<span class="text-sm tabular-nums text-n-slate-11 shrink-0">
|
||||
{{ displayedTime }}
|
||||
</span>
|
||||
<div class="w-px h-3.5 bg-n-slate-6 shrink-0" />
|
||||
|
||||
@@ -58,12 +58,8 @@ const menuItems = computed(() => [
|
||||
]);
|
||||
|
||||
const icon = computed(() => {
|
||||
const {
|
||||
medium,
|
||||
channel_type: type,
|
||||
voice_enabled: voiceEnabled,
|
||||
} = props.inbox;
|
||||
return getInboxIconByType(type, medium, 'outline', voiceEnabled);
|
||||
const { medium, channel_type: type } = props.inbox;
|
||||
return getInboxIconByType(type, medium, 'outline');
|
||||
});
|
||||
|
||||
const handleAction = ({ action, value }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, toRef } from 'vue';
|
||||
import { isVoiceCallEnabled } from 'dashboard/helper/inbox';
|
||||
import { useChannelIcon, useChannelBrandIcon } from './provider';
|
||||
import Icon from 'next/icon/Icon.vue';
|
||||
|
||||
@@ -20,6 +21,7 @@ defineOptions({ inheritAttrs: false });
|
||||
|
||||
const inboxRef = toRef(props, 'inbox');
|
||||
|
||||
const hasVoiceBadge = computed(() => isVoiceCallEnabled(props.inbox));
|
||||
const channelIcon = useChannelIcon(inboxRef);
|
||||
const brandIcon = useChannelBrandIcon(inboxRef);
|
||||
|
||||
@@ -29,7 +31,13 @@ const icon = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex" v-bind="$attrs">
|
||||
<span class="relative inline-flex" v-bind="$attrs">
|
||||
<Icon :icon="icon" class="size-full" />
|
||||
<span
|
||||
v-if="hasVoiceBadge"
|
||||
class="absolute top-0 ltr:right-0 rtl:left-0 inline-flex items-center justify-center size-2 rounded-full bg-n-surface-1"
|
||||
>
|
||||
<Icon icon="i-lucide-audio-lines" class="size-1.5 text-n-slate-12" />
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
INBOX_TYPES,
|
||||
TWILIO_CHANNEL_MEDIUM,
|
||||
isVoiceCallEnabled,
|
||||
getInboxVoiceIcon,
|
||||
} from 'dashboard/helper/inbox';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const channelTypeIconMap = {
|
||||
@@ -66,9 +61,16 @@ export function useChannelIcon(inbox) {
|
||||
icon = 'i-woot-whatsapp';
|
||||
}
|
||||
|
||||
// Voice-enabled inboxes use the combined channel + voice-wave badge glyph.
|
||||
if (isVoiceCallEnabled(inboxDetails)) {
|
||||
icon = getInboxVoiceIcon(type, inboxDetails.medium);
|
||||
// Native Twilio voice inbox: a TwilioSms with voice enabled (and no WhatsApp medium)
|
||||
// is presented as a Voice channel, so show the phone icon.
|
||||
const voiceEnabled =
|
||||
inboxDetails.voice_enabled || inboxDetails.voiceEnabled;
|
||||
if (
|
||||
type === INBOX_TYPES.TWILIO &&
|
||||
voiceEnabled &&
|
||||
inboxDetails.medium !== TWILIO_CHANNEL_MEDIUM.WHATSAPP
|
||||
) {
|
||||
icon = 'i-woot-voice';
|
||||
}
|
||||
|
||||
return icon ?? 'i-ri-global-fill';
|
||||
|
||||
@@ -19,32 +19,13 @@ describe('useChannelIcon', () => {
|
||||
expect(icon).toBe('i-woot-whatsapp');
|
||||
});
|
||||
|
||||
it('returns the voice-call glyph for a voice-enabled Twilio channel', () => {
|
||||
it('returns correct icon for voice-enabled Twilio channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-voice-call');
|
||||
});
|
||||
|
||||
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-whatsapp-voice');
|
||||
});
|
||||
|
||||
it('returns the WhatsApp voice glyph for a voice-enabled Twilio WhatsApp channel', () => {
|
||||
const inbox = {
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
medium: 'whatsapp',
|
||||
voice_enabled: true,
|
||||
};
|
||||
const { value: icon } = useChannelIcon(inbox);
|
||||
expect(icon).toBe('i-woot-whatsapp-voice');
|
||||
expect(icon).toBe('i-woot-voice');
|
||||
});
|
||||
|
||||
it('returns correct icon for Line channel', () => {
|
||||
|
||||
@@ -457,11 +457,10 @@ function handleReplyTo() {
|
||||
|
||||
const avatarInfo = computed(() => {
|
||||
if (props.contentAttributes?.externalEcho) {
|
||||
const { name, avatar_url, channel_type, medium, voice_enabled } =
|
||||
inbox.value;
|
||||
const { name, avatar_url, channel_type, medium } = inbox.value;
|
||||
const iconName = avatar_url
|
||||
? null
|
||||
: getInboxIconByType(channel_type, medium, 'fill', voice_enabled);
|
||||
: getInboxIconByType(channel_type, medium);
|
||||
return {
|
||||
name: iconName ? '' : name || t('CONVERSATION.NATIVE_APP'),
|
||||
src: avatar_url || '',
|
||||
|
||||
@@ -77,15 +77,6 @@ const handleClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleFocusOut = event => {
|
||||
// Keep the menu open while focus stays inside it (e.g. the label search
|
||||
// input); close it once focus leaves the menu entirely.
|
||||
if (menuRef.value?.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
handleClose();
|
||||
};
|
||||
|
||||
onUnmounted(() => {
|
||||
isLocked.value = false;
|
||||
});
|
||||
@@ -98,7 +89,7 @@ onUnmounted(() => {
|
||||
class="fixed outline-none z-[9999] cursor-pointer"
|
||||
:style="position"
|
||||
tabindex="0"
|
||||
@focusout="handleFocusOut"
|
||||
@blur="handleClose"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,10 @@ import {
|
||||
getSortedAgentsByAvailability,
|
||||
getAgentsByUpdatedPresence,
|
||||
} from 'dashboard/helper/agentHelper.js';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import MenuItem from './menuItem.vue';
|
||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
import NextInput from 'dashboard/components-next/input/Input.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const MENU = {
|
||||
MARK_AS_READ: 'mark-as-read',
|
||||
@@ -34,8 +31,6 @@ export default {
|
||||
MenuItem,
|
||||
MenuItemWithSubmenu,
|
||||
AgentLoadingPlaceholder,
|
||||
NextInput,
|
||||
Icon,
|
||||
},
|
||||
props: {
|
||||
chatId: {
|
||||
@@ -92,7 +87,6 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
MENU,
|
||||
labelSearchQuery: '',
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
readOption: {
|
||||
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
|
||||
@@ -222,14 +216,6 @@ export default {
|
||||
// Don't show snooze if the conversation is already snoozed/resolved/pending
|
||||
return this.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
filteredLabels() {
|
||||
const labels = this.labelSearchQuery
|
||||
? picoSearch(this.labels, this.labelSearchQuery, ['title'])
|
||||
: this.labels;
|
||||
// Assigned labels first, keeping each group's existing order.
|
||||
const isAssigned = label => this.conversationLabels.includes(label.title);
|
||||
return [...labels].sort((a, b) => isAssigned(b) - isAssigned(a));
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
|
||||
@@ -349,49 +335,21 @@ export default {
|
||||
:option="labelMenuConfig"
|
||||
:sub-menu-available="!!labels.length"
|
||||
>
|
||||
<div class="pb-1 w-[12.5rem]">
|
||||
<NextInput
|
||||
v-model="labelSearchQuery"
|
||||
type="search"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
custom-input-class="!ps-8 !text-xs"
|
||||
:placeholder="$t('CONVERSATION.CARD_CONTEXT_MENU.SEARCH_LABELS')"
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute z-10 -translate-y-1/2 pointer-events-none size-3.5 text-n-slate-10 top-1/2 start-2"
|
||||
/>
|
||||
</template>
|
||||
</NextInput>
|
||||
</div>
|
||||
<div class="overflow-x-hidden overflow-y-auto max-h-[12.5rem]">
|
||||
<MenuItem
|
||||
v-for="label in filteredLabels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
:variant="
|
||||
conversationLabels.includes(label.title)
|
||||
? 'label-assigned'
|
||||
: 'label'
|
||||
"
|
||||
@mousedown.prevent
|
||||
@click.stop="
|
||||
conversationLabels.includes(label.title)
|
||||
? $emit('removeLabel', label)
|
||||
: $emit('assignLabel', label)
|
||||
"
|
||||
/>
|
||||
<p
|
||||
v-if="!filteredLabels.length"
|
||||
class="px-2 py-2 m-0 text-xs text-center text-n-slate-11"
|
||||
>
|
||||
{{ $t('CONVERSATION.CARD_CONTEXT_MENU.NO_LABELS_FOUND') }}
|
||||
</p>
|
||||
</div>
|
||||
<MenuItem
|
||||
v-for="label in labels"
|
||||
:key="label.id"
|
||||
:option="generateMenuLabelConfig(label, 'label')"
|
||||
:variant="
|
||||
conversationLabels.includes(label.title)
|
||||
? 'label-assigned'
|
||||
: 'label'
|
||||
"
|
||||
@click.stop="
|
||||
conversationLabels.includes(label.title)
|
||||
? $emit('removeLabel', label)
|
||||
: $emit('assignLabel', label)
|
||||
"
|
||||
/>
|
||||
</MenuItemWithSubmenu>
|
||||
<MenuItemWithSubmenu
|
||||
v-if="isAllowed([MENU.AGENT])"
|
||||
|
||||
@@ -15,7 +15,7 @@ defineProps({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="menu group text-n-slate-12 min-h-7 min-w-0" role="button">
|
||||
<div class="menu text-n-slate-12 min-h-7 min-w-0" role="button">
|
||||
<fluent-icon
|
||||
v-if="variant === 'icon' && option.icon"
|
||||
:icon="option.icon"
|
||||
@@ -52,7 +52,7 @@ defineProps({
|
||||
<Icon
|
||||
v-if="variant === 'label-assigned'"
|
||||
icon="i-lucide-check"
|
||||
class="flex-shrink-0 size-3.5 text-n-brand group-hover:text-white"
|
||||
class="flex-shrink-0 size-3.5 mr-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -55,30 +55,11 @@ export const getVoiceCallProvider = inbox => {
|
||||
|
||||
export const isVoiceCallEnabled = inbox => getVoiceCallProvider(inbox) !== null;
|
||||
|
||||
// Combined channel + voice-wave badge glyph per voice-call provider.
|
||||
export const VOICE_CALL_ICONS = {
|
||||
[VOICE_CALL_PROVIDERS.WHATSAPP]: 'i-woot-whatsapp-voice',
|
||||
[VOICE_CALL_PROVIDERS.TWILIO]: 'i-woot-voice-call',
|
||||
};
|
||||
|
||||
export const getVoiceCallIcon = provider =>
|
||||
VOICE_CALL_ICONS[provider] ?? VOICE_CALL_ICONS[VOICE_CALL_PROVIDERS.TWILIO];
|
||||
|
||||
export const TWILIO_CHANNEL_MEDIUM = {
|
||||
WHATSAPP: 'whatsapp',
|
||||
SMS: 'sms',
|
||||
};
|
||||
|
||||
export const getInboxVoiceIcon = (channelType, medium) => {
|
||||
const isWhatsapp =
|
||||
channelType === INBOX_TYPES.WHATSAPP ||
|
||||
(channelType === INBOX_TYPES.TWILIO &&
|
||||
medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP);
|
||||
return getVoiceCallIcon(
|
||||
isWhatsapp ? VOICE_CALL_PROVIDERS.WHATSAPP : VOICE_CALL_PROVIDERS.TWILIO
|
||||
);
|
||||
};
|
||||
|
||||
const INBOX_ICON_MAP_FILL = {
|
||||
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
|
||||
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
|
||||
@@ -201,14 +182,7 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getInboxIconByType = (
|
||||
type,
|
||||
medium,
|
||||
variant = 'fill',
|
||||
voiceEnabled = false
|
||||
) => {
|
||||
if (voiceEnabled) return getInboxVoiceIcon(type, medium);
|
||||
|
||||
export const getInboxIconByType = (type, medium, variant = 'fill') => {
|
||||
const iconMap =
|
||||
variant === 'fill' ? INBOX_ICON_MAP_FILL : INBOX_ICON_MAP_LINE;
|
||||
const defaultIcon =
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import {
|
||||
INBOX_TYPES,
|
||||
VOICE_CALL_PROVIDERS,
|
||||
getInboxClassByType,
|
||||
getInboxIconByType,
|
||||
getInboxVoiceIcon,
|
||||
getInboxWarningIconClass,
|
||||
getVoiceCallIcon,
|
||||
} from '../inbox';
|
||||
|
||||
describe('#Inbox Helpers', () => {
|
||||
@@ -169,63 +166,4 @@ describe('#Inbox Helpers', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVoiceCallIcon', () => {
|
||||
it('returns the WhatsApp voice glyph for the whatsapp provider', () => {
|
||||
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.WHATSAPP)).toBe(
|
||||
'i-woot-whatsapp-voice'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the generic voice-call glyph for the twilio provider', () => {
|
||||
expect(getVoiceCallIcon(VOICE_CALL_PROVIDERS.TWILIO)).toBe(
|
||||
'i-woot-voice-call'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the generic voice-call glyph for an unknown provider', () => {
|
||||
expect(getVoiceCallIcon('unknown')).toBe('i-woot-voice-call');
|
||||
expect(getVoiceCallIcon(undefined)).toBe('i-woot-voice-call');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInboxVoiceIcon', () => {
|
||||
it('returns the WhatsApp voice glyph for a WhatsApp inbox', () => {
|
||||
expect(getInboxVoiceIcon(INBOX_TYPES.WHATSAPP)).toBe(
|
||||
'i-woot-whatsapp-voice'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the WhatsApp voice glyph for a Twilio WhatsApp inbox', () => {
|
||||
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'whatsapp')).toBe(
|
||||
'i-woot-whatsapp-voice'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the generic voice-call glyph for a Twilio voice inbox', () => {
|
||||
expect(getInboxVoiceIcon(INBOX_TYPES.TWILIO, 'sms')).toBe(
|
||||
'i-woot-voice-call'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInboxIconByType with voice enabled', () => {
|
||||
it('returns the WhatsApp voice glyph for a voice-enabled WhatsApp inbox', () => {
|
||||
expect(
|
||||
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', true)
|
||||
).toBe('i-woot-whatsapp-voice');
|
||||
});
|
||||
|
||||
it('returns the generic voice-call glyph for a voice-enabled Twilio inbox', () => {
|
||||
expect(getInboxIconByType(INBOX_TYPES.TWILIO, 'sms', 'line', true)).toBe(
|
||||
'i-woot-voice-call'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the normal channel icon when voice is not enabled', () => {
|
||||
expect(
|
||||
getInboxIconByType(INBOX_TYPES.WHATSAPP, undefined, 'line', false)
|
||||
).toBe('i-woot-whatsapp');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,8 +193,6 @@
|
||||
},
|
||||
"ASSIGN_AGENT": "Assign agent",
|
||||
"ASSIGN_LABEL": "Assign label",
|
||||
"SEARCH_LABELS": "Search labels",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
"AGENTS_LOADING": "Loading agents...",
|
||||
"ASSIGN_TEAM": "Assign team",
|
||||
"DELETE": "Delete conversation",
|
||||
|
||||
@@ -87,8 +87,8 @@ const inboxName = computed(() => props.inbox?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
if (!inbox.value) return null;
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ const inboxName = computed(() => inbox.value?.name);
|
||||
|
||||
const inboxIcon = computed(() => {
|
||||
if (!inbox.value) return null;
|
||||
const { channelType, medium, voiceEnabled } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium, 'fill', voiceEnabled);
|
||||
const { channelType, medium } = inbox.value;
|
||||
return getInboxIconByType(channelType, medium);
|
||||
});
|
||||
|
||||
const fileAttachments = computed(() => {
|
||||
|
||||
@@ -129,22 +129,22 @@ onMounted(async () => {
|
||||
v-else
|
||||
class="flex flex-col w-full h-full overflow-hidden bg-n-surface-1"
|
||||
>
|
||||
<header class="shrink-0">
|
||||
<div class="w-full px-6 pt-6">
|
||||
<header class="px-6 pt-6 pb-4 shrink-0">
|
||||
<div class="w-full">
|
||||
<h1 class="text-xl font-medium text-n-slate-12">
|
||||
{{ t('CALLS_PAGE.HEADER') }}
|
||||
</h1>
|
||||
<CallsFilterBar
|
||||
v-model:activity="activity"
|
||||
v-model:assignee-id="assigneeId"
|
||||
v-model:inbox-id="inboxId"
|
||||
class="mt-5"
|
||||
:total-count="isFetching ? null : meta.count"
|
||||
:agents="agents"
|
||||
:inboxes="voiceInboxes"
|
||||
:show-assignee="isAdmin"
|
||||
/>
|
||||
</div>
|
||||
<CallsFilterBar
|
||||
v-model:activity="activity"
|
||||
v-model:assignee-id="assigneeId"
|
||||
v-model:inbox-id="inboxId"
|
||||
class="mt-5 pb-4 border-b border-n-weak mx-6"
|
||||
:total-count="isFetching ? null : meta.count"
|
||||
:agents="agents"
|
||||
:inboxes="voiceInboxes"
|
||||
:show-assignee="isAdmin"
|
||||
/>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
<div class="w-full">
|
||||
|
||||
+7
-9
@@ -79,15 +79,13 @@ const breadcrumbItems = computed(() => {
|
||||
});
|
||||
|
||||
const buildInboxList = allInboxes =>
|
||||
allInboxes?.map(
|
||||
({ name, id, email, phoneNumber, channelType, medium, voiceEnabled }) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
||||
})
|
||||
) || [];
|
||||
allInboxes?.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||
})) || [];
|
||||
|
||||
const policyInboxes = computed(() =>
|
||||
buildInboxList(selectedPolicy.value?.inboxes)
|
||||
|
||||
+7
-17
@@ -64,23 +64,13 @@ const allInboxes = computed(
|
||||
inboxes.value
|
||||
?.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(
|
||||
({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
channelType,
|
||||
medium,
|
||||
voiceEnabled,
|
||||
}) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line', voiceEnabled),
|
||||
})
|
||||
) || []
|
||||
.map(({ name, id, email, phoneNumber, channelType, medium }) => ({
|
||||
name,
|
||||
id,
|
||||
email,
|
||||
phoneNumber,
|
||||
icon: getInboxIconByType(channelType, medium, 'line'),
|
||||
})) || []
|
||||
);
|
||||
|
||||
const formData = computed(() => ({
|
||||
|
||||
+1
-2
@@ -29,8 +29,7 @@ const inboxIcon = computed(() => {
|
||||
return getInboxIconByType(
|
||||
props.inbox.channelType,
|
||||
props.inbox.medium,
|
||||
'line',
|
||||
props.inbox.voiceEnabled
|
||||
'line'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -103,7 +103,6 @@ export default {
|
||||
<label :class="{ error: v$.selectedAgentIds.$error }">
|
||||
{{ $t('INBOX_MGMT.ADD.AGENTS.TITLE') }}
|
||||
<div
|
||||
data-testid="agent-selector"
|
||||
class="rounded-xl outline outline-1 -outline-offset-1 outline-n-weak hover:outline-n-strong px-2 py-2"
|
||||
>
|
||||
<TagInput
|
||||
|
||||
@@ -281,12 +281,8 @@ export default {
|
||||
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
|
||||
},
|
||||
inboxIcon() {
|
||||
const {
|
||||
medium,
|
||||
channel_type: type,
|
||||
voice_enabled: voiceEnabled,
|
||||
} = this.inbox;
|
||||
return getInboxIconByType(type, medium, 'line', voiceEnabled);
|
||||
const { medium, channel_type: type } = this.inbox;
|
||||
return getInboxIconByType(type, medium, 'line');
|
||||
},
|
||||
bannerMaxWidth() {
|
||||
const narrowTabs = ['collaborators', 'bot-configuration'];
|
||||
|
||||
@@ -62,7 +62,7 @@ class CallFinder
|
||||
end
|
||||
|
||||
def paginated_calls
|
||||
@calls.includes(:contact, :conversation, :accepted_by_agent, inbox: :channel)
|
||||
@calls.includes(:contact, :inbox, :conversation, :accepted_by_agent)
|
||||
.order(created_at: :desc)
|
||||
.page(@params[:page] || 1)
|
||||
.per(RESULTS_PER_PAGE)
|
||||
|
||||
@@ -19,8 +19,6 @@ end
|
||||
json.inbox do
|
||||
json.id call.inbox_id
|
||||
json.name call.inbox.name
|
||||
json.channel_type call.inbox.channel_type
|
||||
json.medium call.inbox.channel.try(:medium)
|
||||
end
|
||||
|
||||
if call.accepted_by_agent
|
||||
|
||||
@@ -32,7 +32,6 @@ RSpec.describe 'Calls API', type: :request do
|
||||
item = body['payload'].find { |c| c['id'] == agent_call.id }
|
||||
expect(item['transcript']).to eq('hello world')
|
||||
expect(item['contact']['phone_number']).to eq(contact.phone_number)
|
||||
expect(item['inbox']).to include('id' => inbox.id, 'name' => inbox.name, 'channel_type' => inbox.channel_type)
|
||||
end
|
||||
|
||||
it 'scopes the list to calls the agent accepted' do
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class AddAgentModal {
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
getModalTitle() {
|
||||
return this.page.locator('[data-test-id="modal-header-title"]');
|
||||
}
|
||||
|
||||
getAgentNameInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Agent Name' });
|
||||
}
|
||||
|
||||
getEmailInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Email Address' });
|
||||
}
|
||||
|
||||
getRoleCombobox() {
|
||||
return this.page.getByRole('combobox', { name: 'Role' });
|
||||
}
|
||||
|
||||
getSubmitButton() {
|
||||
return this.page.locator('form').getByRole('button', { name: 'Add Agent' });
|
||||
}
|
||||
|
||||
getCancelButton() {
|
||||
return this.page.getByRole('button', { name: 'Cancel' });
|
||||
}
|
||||
|
||||
getSuccessMessage() {
|
||||
return this.page.getByText('Agent added successfully');
|
||||
}
|
||||
|
||||
async fillAgentName(name: string) {
|
||||
await this.getAgentNameInput().fill(name);
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async fillEmail(email: string) {
|
||||
await this.getEmailInput().fill(email);
|
||||
await this.page.waitForTimeout(1000);
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
await this.getSubmitButton().click();
|
||||
}
|
||||
|
||||
async cancelForm() {
|
||||
await this.getCancelButton().click();
|
||||
}
|
||||
|
||||
async createAgent(name: string, email: string) {
|
||||
await this.fillAgentName(name);
|
||||
await this.fillEmail(email);
|
||||
await this.submitForm();
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class AddAgentsForm {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page
|
||||
.locator('form')
|
||||
.getByRole('heading', { name: 'Agents', level: 2, exact: true });
|
||||
}
|
||||
|
||||
getAgentDropdown() {
|
||||
return this.page.getByPlaceholder('Pick agents for the inbox');
|
||||
}
|
||||
|
||||
getAgentSelector() {
|
||||
return this.page.getByTestId('agent-selector');
|
||||
}
|
||||
|
||||
getAgentOption(agentName: string) {
|
||||
return this.getAgentSelector().getByRole('button', {
|
||||
name: agentName,
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
|
||||
getDropdownButtons() {
|
||||
return this.getAgentSelector().getByRole('button');
|
||||
}
|
||||
|
||||
getSubmitButton() {
|
||||
return this.page.getByRole('button', { name: 'Add agents' });
|
||||
}
|
||||
|
||||
async openAgentDropdown() {
|
||||
await this.getAgentDropdown().click();
|
||||
}
|
||||
|
||||
async selectAgent(agentName: string) {
|
||||
await this.openAgentDropdown();
|
||||
await this.getAgentOption(agentName).waitFor({ state: 'visible' });
|
||||
await this.getAgentOption(agentName).click();
|
||||
}
|
||||
|
||||
async selectAgentByIndex(index: number = 0) {
|
||||
await this.openAgentDropdown();
|
||||
const buttons = this.getDropdownButtons();
|
||||
await buttons.first().waitFor({ state: 'visible' });
|
||||
await buttons.nth(index).click();
|
||||
}
|
||||
|
||||
async closeDropdown() {
|
||||
await this.page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
await this.getSubmitButton().click();
|
||||
}
|
||||
|
||||
async addAgents(agentNames: string[]) {
|
||||
for (const agentName of agentNames) {
|
||||
await this.selectAgent(agentName);
|
||||
}
|
||||
await this.closeDropdown();
|
||||
await this.submitForm();
|
||||
}
|
||||
|
||||
async addFirstAgent() {
|
||||
await this.selectAgentByIndex(0);
|
||||
await this.closeDropdown();
|
||||
await this.submitForm();
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class AgentPage {
|
||||
private page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async navigate(accountId: number = 1) {
|
||||
await this.page.goto(`/app/accounts/${accountId}/settings/agents/list`);
|
||||
}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', { name: 'Agents', level: 1 });
|
||||
}
|
||||
|
||||
getDescriptionText() {
|
||||
return this.page.getByText(
|
||||
'An agent is a member of your customer support team who can view and respond to user messages.'
|
||||
);
|
||||
}
|
||||
|
||||
getLearnLink() {
|
||||
return this.page.getByRole('link', { name: 'Learn about user roles' });
|
||||
}
|
||||
|
||||
getAddAgentButton() {
|
||||
return this.page.getByRole('button', { name: 'Add Agent' });
|
||||
}
|
||||
|
||||
async openAddAgentModal() {
|
||||
await this.getAddAgentButton().click();
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class ApiChannelForm {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getChannelNameInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Channel Name' });
|
||||
}
|
||||
|
||||
getWebhookUrlInput() {
|
||||
return this.page.getByRole('textbox', { name: 'Webhook URL' });
|
||||
}
|
||||
|
||||
getSubmitButton() {
|
||||
return this.page.getByRole('button', { name: 'Create API Channel' });
|
||||
}
|
||||
|
||||
async fillChannelName(name: string) {
|
||||
await this.getChannelNameInput().fill(name);
|
||||
}
|
||||
|
||||
async fillWebhookUrl(url: string) {
|
||||
await this.getWebhookUrlInput().fill(url);
|
||||
}
|
||||
|
||||
async submitForm() {
|
||||
await this.getSubmitButton().click();
|
||||
}
|
||||
|
||||
async createApiChannel(channelName: string, webhookUrl?: string) {
|
||||
await this.fillChannelName(channelName);
|
||||
if (webhookUrl) {
|
||||
await this.fillWebhookUrl(webhookUrl);
|
||||
}
|
||||
await this.submitForm();
|
||||
}
|
||||
|
||||
getValidationError() {
|
||||
return this.page.locator('.message, .error-message').first();
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class ChannelSelector {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', { name: /choose channel/i });
|
||||
}
|
||||
|
||||
getApiChannelCard() {
|
||||
return this.page.getByRole('button', { name: /API.*Make a custom channel/i });
|
||||
}
|
||||
|
||||
getWebsiteChannelCard() {
|
||||
return this.page.getByRole('button', { name: /Website.*Create a live-chat widget/i });
|
||||
}
|
||||
|
||||
async selectApiChannel() {
|
||||
await this.getApiChannelCard().click();
|
||||
}
|
||||
|
||||
async selectWebsiteChannel() {
|
||||
await this.getWebsiteChannelCard().click();
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
export class FinishSetup {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', {
|
||||
name: 'Your Inbox is ready!',
|
||||
exact: true,
|
||||
});
|
||||
}
|
||||
|
||||
getGoToInboxButton() {
|
||||
return this.page.getByRole('button', { name: /go to inbox|view inbox/i });
|
||||
}
|
||||
|
||||
getMoreSettingsButton() {
|
||||
return this.page.getByRole('button', { name: /more settings|settings/i });
|
||||
}
|
||||
|
||||
getWebhookUrl() {
|
||||
return this.page.locator('code, pre').filter({ hasText: /http/i }).first();
|
||||
}
|
||||
|
||||
async goToInbox() {
|
||||
await this.getGoToInboxButton().click();
|
||||
}
|
||||
|
||||
async goToSettings() {
|
||||
await this.getMoreSettingsButton().click();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1 @@
|
||||
export { Login } from './login.component';
|
||||
export { AgentPage } from './agent-page.component';
|
||||
export { AddAgentModal } from './add-agent-modal.component';
|
||||
export { AddAgentsForm } from './add-agents-form.component';
|
||||
export { SettingsInboxPage } from './settings-inbox-page.component';
|
||||
export { ChannelSelector } from './channel-selector.component';
|
||||
export { ApiChannelForm } from './api-channel-form.component';
|
||||
export { FinishSetup } from './finish-setup.component';
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Page } from '@playwright/test';
|
||||
|
||||
type DashboardApi = {
|
||||
delete: (url: string) => Promise<unknown>;
|
||||
get: (url: string) => Promise<{
|
||||
data: {
|
||||
payload: Array<{ id: number }>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export class SettingsInboxPage {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async navigate(accountId: number = 1) {
|
||||
await this.page.goto(`/app/accounts/${accountId}/settings/inboxes/list`);
|
||||
}
|
||||
|
||||
getAddInboxButton() {
|
||||
return this.page.getByRole('link', { name: 'Add Inbox' });
|
||||
}
|
||||
|
||||
async clickAddInboxButton() {
|
||||
await this.getAddInboxButton().click();
|
||||
}
|
||||
|
||||
getPageHeading() {
|
||||
return this.page.getByRole('heading', { name: /inboxes/i });
|
||||
}
|
||||
|
||||
async deleteInbox(accountId: number, inboxId: number) {
|
||||
await this.page.evaluate(
|
||||
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
|
||||
const api = (window as typeof window & { axios: DashboardApi }).axios;
|
||||
await api.delete(
|
||||
`/api/v1/accounts/${currentAccountId}/inboxes/${currentInboxId}`
|
||||
);
|
||||
},
|
||||
{ accountId, inboxId }
|
||||
);
|
||||
}
|
||||
|
||||
async isInboxPresent(accountId: number, inboxId: number) {
|
||||
return this.page.evaluate(
|
||||
async ({ accountId: currentAccountId, inboxId: currentInboxId }) => {
|
||||
const api = (window as typeof window & { axios: DashboardApi }).axios;
|
||||
const response = await api.get(
|
||||
`/api/v1/accounts/${currentAccountId}/inboxes`
|
||||
);
|
||||
return response.data.payload.some(
|
||||
inbox => inbox.id === currentInboxId
|
||||
);
|
||||
},
|
||||
{ accountId, inboxId }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { AddAgentModal, AgentPage, Login } from '@components/ui';
|
||||
|
||||
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
|
||||
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
|
||||
|
||||
test.describe('Agent Onboarding - UI', () => {
|
||||
let loginComponent: Login;
|
||||
let agentPage: AgentPage;
|
||||
let addAgentModal: AddAgentModal;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
loginComponent = new Login(page);
|
||||
agentPage = new AgentPage(page);
|
||||
addAgentModal = new AddAgentModal(page);
|
||||
|
||||
await loginComponent.navigate();
|
||||
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
|
||||
|
||||
await expect(page).toHaveURL(/\/app\/accounts\/\d+\/dashboard/);
|
||||
const accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
|
||||
await agentPage.navigate(accountId);
|
||||
});
|
||||
|
||||
test('should validate all UI elements on agents page', async () => {
|
||||
await expect(agentPage.getPageHeading()).toBeVisible();
|
||||
await expect(agentPage.getDescriptionText()).toBeVisible();
|
||||
|
||||
const learnLink = agentPage.getLearnLink();
|
||||
await expect(learnLink).toBeVisible();
|
||||
await expect(learnLink).toHaveAttribute('href', 'https://chwt.app/hc/agents');
|
||||
|
||||
await expect(agentPage.getAddAgentButton()).toBeVisible();
|
||||
await agentPage.openAddAgentModal();
|
||||
|
||||
await expect(addAgentModal.getModalTitle()).toBeVisible();
|
||||
await expect(addAgentModal.getModalTitle()).toHaveText('Add agent to your team');
|
||||
|
||||
await expect(addAgentModal.getAgentNameInput()).toBeVisible();
|
||||
await expect(addAgentModal.getEmailInput()).toBeVisible();
|
||||
await expect(addAgentModal.getRoleCombobox()).toBeVisible();
|
||||
await expect(addAgentModal.getSubmitButton()).toBeVisible();
|
||||
await expect(addAgentModal.getCancelButton()).toBeVisible();
|
||||
|
||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
||||
|
||||
await addAgentModal.getAgentNameInput().fill('Test');
|
||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
||||
|
||||
await addAgentModal.getAgentNameInput().clear();
|
||||
await addAgentModal.getEmailInput().fill('test@example.com');
|
||||
await expect(addAgentModal.getSubmitButton()).toBeDisabled();
|
||||
|
||||
await addAgentModal.getAgentNameInput().fill('Test');
|
||||
await expect(addAgentModal.getSubmitButton()).toBeEnabled();
|
||||
|
||||
await addAgentModal.cancelForm();
|
||||
await expect(addAgentModal.getModalTitle()).toBeHidden();
|
||||
});
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
AddAgentsForm,
|
||||
ApiChannelForm,
|
||||
ChannelSelector,
|
||||
FinishSetup,
|
||||
Login,
|
||||
SettingsInboxPage,
|
||||
} from '@components/ui';
|
||||
|
||||
const TEST_EMAIL = process.env.TEST_USER_EMAIL || 'admin@chatwoot.com';
|
||||
const TEST_PASSWORD = process.env.TEST_USER_PASSWORD || 'Password123@#';
|
||||
|
||||
test.describe('Inbox Creation - UI Flow', () => {
|
||||
const testInbox = {
|
||||
name: `Test Inbox ${Date.now()}`,
|
||||
webhookUrl: 'https://example.com/webhook',
|
||||
};
|
||||
|
||||
let accountId: number | undefined;
|
||||
let inboxId: number | undefined;
|
||||
|
||||
test.beforeEach(() => {
|
||||
accountId = undefined;
|
||||
inboxId = undefined;
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
const currentAccountId = accountId;
|
||||
const currentInboxId = inboxId;
|
||||
if (!currentAccountId || !currentInboxId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsInboxPage = new SettingsInboxPage(page);
|
||||
await settingsInboxPage.deleteInbox(currentAccountId, currentInboxId);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
settingsInboxPage.isInboxPresent(currentAccountId, currentInboxId),
|
||||
{
|
||||
message: `Inbox ${currentInboxId} was not deleted`,
|
||||
timeout: 30_000,
|
||||
}
|
||||
)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test('should complete full inbox creation flow with UI validation', async ({
|
||||
page,
|
||||
}) => {
|
||||
const loginComponent = new Login(page);
|
||||
await loginComponent.navigate();
|
||||
await loginComponent.login(TEST_EMAIL, TEST_PASSWORD);
|
||||
await page.waitForURL(/\/app\/accounts\/\d+\/dashboard/);
|
||||
|
||||
accountId = Number(page.url().match(/\/app\/accounts\/(\d+)\//)![1]);
|
||||
const settingsInboxPage = new SettingsInboxPage(page);
|
||||
await settingsInboxPage.navigate(accountId);
|
||||
|
||||
await expect(settingsInboxPage.getPageHeading()).toBeVisible();
|
||||
await expect(settingsInboxPage.getAddInboxButton()).toBeVisible();
|
||||
|
||||
await settingsInboxPage.clickAddInboxButton();
|
||||
await page.waitForURL(/\/settings\/inboxes\/new/);
|
||||
|
||||
const channelSelector = new ChannelSelector(page);
|
||||
await expect(channelSelector.getPageHeading()).toBeVisible();
|
||||
await channelSelector.selectApiChannel();
|
||||
|
||||
page.on('response', async response => {
|
||||
if (
|
||||
response.url().includes('/api/v1/accounts/') &&
|
||||
response.url().includes('/inboxes') &&
|
||||
response.request().method() === 'POST' &&
|
||||
response.status() === 200
|
||||
) {
|
||||
try {
|
||||
const responseData = await response.json();
|
||||
if (responseData.id) {
|
||||
inboxId = responseData.id;
|
||||
}
|
||||
} catch {
|
||||
// ignore non-JSON responses
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const apiChannelForm = new ApiChannelForm(page);
|
||||
await apiChannelForm.fillChannelName(testInbox.name);
|
||||
await apiChannelForm.fillWebhookUrl(testInbox.webhookUrl);
|
||||
await apiChannelForm.submitForm();
|
||||
await expect.poll(() => inboxId).toBeTruthy();
|
||||
|
||||
const addAgentsForm = new AddAgentsForm(page);
|
||||
await expect(addAgentsForm.getPageHeading()).toBeVisible();
|
||||
await addAgentsForm.addFirstAgent();
|
||||
|
||||
await page.waitForURL(/\/settings\/inboxes\/.*\/finish/);
|
||||
const finishSetup = new FinishSetup(page);
|
||||
await expect(finishSetup.getPageHeading()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -269,16 +269,6 @@ export const icons = {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'voice-call': {
|
||||
body: `<mask id="cvc" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4" r="4" fill="#000"/></mask><g mask="url(#cvc)"><path d="M7.916 10.784a.5.5 0 0 0 .607-.152L8.7 10.4a1 1 0 0 1 .8-.4H11a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1 9 9 0 0 1-9-9 1 1 0 0 1 1-1h1.5a1 1 0 0 1 1 1V6a1 1 0 0 1-.4.8l-.234.176a.5.5 0 0 0-.146.616 7 7 0 0 0 3.196 3.192" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"/></g><circle cx="12" cy="4" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.2v1.6M12 2.4v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
'whatsapp-voice': {
|
||||
body: `<mask id="cwv" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16"><rect width="16" height="16" fill="#fff"/><circle cx="12" cy="4.5" r="4" fill="#000"/></mask><g mask="url(#cwv)"><path fill-rule="evenodd" clip-rule="evenodd" d="M12.349 3.655A5.6 5.6 0 0 0 8.357 2a5.65 5.65 0 0 0-5.643 5.642c0 .995.26 1.966.753 2.821l-.512 1.873a.63.63 0 0 0 .767.775l1.936-.508a5.64 5.64 0 0 0 2.697.687h.002A5.65 5.65 0 0 0 14 7.647a5.6 5.6 0 0 0-1.652-3.992m-3.992 8.682h-.002a4.7 4.7 0 0 1-2.387-.654l-.171-.102-1.776.466.474-1.73-.111-.178a4.7 4.7 0 0 1-.717-2.496 4.697 4.697 0 0 1 4.692-4.69c1.253 0 2.43.489 3.316 1.376a4.66 4.66 0 0 1 1.372 3.317 4.697 4.697 0 0 1-4.69 4.69m2.573-3.513c-.141-.07-.835-.411-.964-.458-.13-.047-.223-.07-.317.07a8 8 0 0 1-.446.553c-.083.094-.165.106-.306.035s-.595-.22-1.134-.7a4.3 4.3 0 0 1-.784-.976c-.082-.141-.009-.218.061-.288.064-.063.141-.165.212-.247.07-.082.094-.141.141-.235s.024-.176-.012-.247c-.035-.07-.317-.765-.434-1.047-.115-.275-.231-.237-.318-.242a6 6 0 0 0-.27-.005.52.52 0 0 0-.376.177c-.13.14-.493.482-.493 1.176 0 .693.505 1.364.575 1.458.071.094.995 1.518 2.409 2.13.336.145.599.232.804.297.337.107.645.092.888.056.27-.041.834-.342.951-.671.118-.33.118-.612.083-.67-.035-.06-.13-.095-.27-.166" fill="currentColor"/></g><circle cx="12" cy="4.5" r="4" fill="currentColor" fill-opacity="0.15"/><path d="M10.4 3.7v1.6M12 2.9v3.2m1.6-2.4v1.6" stroke="currentColor" stroke-width=".833" stroke-linecap="round" stroke-linejoin="round"/>`,
|
||||
width: 16,
|
||||
height: 16,
|
||||
},
|
||||
instagram: {
|
||||
body: `<g fill="none" stroke="currentColor"><path d="M12.0003 15.3329C13.8412 15.3329 15.3337 13.8405 15.3337 11.9996C15.3337 10.1586 13.8412 8.66626 12.0003 8.66626C10.1594 8.66626 8.66699 10.1586 8.66699 11.9996C8.66699 13.8405 10.1594 15.3329 12.0003 15.3329Z" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.5 15.3333V8.66667C4.5 6.36548 6.36548 4.5 8.66667 4.5H15.3333C17.6345 4.5 19.5 6.36548 19.5 8.66667V15.3333C19.5 17.6345 17.6345 19.5 15.3333 19.5H8.66667C6.36548 19.5 4.5 17.6345 4.5 15.3333Z" stroke-width="1.5"/><path d="M16.583 7.42552L16.5913 7.41626" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></g>`,
|
||||
width: 24,
|
||||
@@ -480,15 +470,5 @@ export const icons = {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
'audio-play': {
|
||||
body: `<path d="M3.31445 11.3998V2.6002C3.31445 2.35931 3.39947 2.15725 3.56951 1.99401C3.73955 1.83077 3.93793 1.74944 4.16465 1.75C4.2355 1.75 4.31004 1.76049 4.38826 1.78146C4.46647 1.80243 4.54073 1.83446 4.61101 1.87753L11.5401 6.27732C11.6677 6.36234 11.7635 6.46862 11.8275 6.59615C11.8916 6.72368 11.9233 6.85829 11.9227 6.99999C11.9222 7.14169 11.8904 7.27631 11.8275 7.40384C11.7646 7.53137 11.6688 7.63764 11.5401 7.72266L4.61101 12.1224C4.54016 12.165 4.46591 12.197 4.38826 12.2185C4.3106 12.2401 4.23607 12.2505 4.16465 12.25C3.93793 12.25 3.73955 12.1684 3.56951 12.0051C3.39947 11.8419 3.31445 11.6401 3.31445 11.3998Z" fill="currentColor"/>`,
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
'audio-pause': {
|
||||
body: `<path d="M10.5001 1.75H8.75008C8.42792 1.75 8.16675 2.01117 8.16675 2.33333V11.6667C8.16675 11.9888 8.42792 12.25 8.75008 12.25H10.5001C10.8222 12.25 11.0834 11.9888 11.0834 11.6667V2.33333C11.0834 2.01117 10.8222 1.75 10.5001 1.75Z" fill="currentColor"/><path d="M5.25008 1.75H3.50008C3.17792 1.75 2.91675 2.01117 2.91675 2.33333V11.6667C2.91675 11.9888 3.17792 12.25 3.50008 12.25H5.25008C5.57225 12.25 5.83342 11.9888 5.83342 11.6667V2.33333C5.83342 2.01117 5.57225 1.75 5.25008 1.75Z" fill="currentColor"/>`,
|
||||
width: 14,
|
||||
height: 14,
|
||||
},
|
||||
/** Ends */
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user