Compare commits
2 Commits
develop
...
accessibli
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2bee130f7 | ||
|
|
dfb90fe0b5 |
5
.github/helpers/install.sh
vendored
5
.github/helpers/install.sh
vendored
@@ -40,7 +40,6 @@ sed -i 's/schedule:/# schedule:/g' Procfile
|
||||
sed -i 's/socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
|
||||
|
||||
bench get-app erpnext --branch "develop"
|
||||
bench get-app telephony
|
||||
bench get-app helpdesk "${GITHUB_WORKSPACE}"
|
||||
bench setup requirements --dev
|
||||
@@ -49,7 +48,3 @@ bench setup requirements --dev
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
CI=Yes bench build --app frappe &
|
||||
bench --site test_site reinstall --yes
|
||||
|
||||
bench --verbose --site test_site install-app erpnext
|
||||
bench --verbose --site test_site install-app telephony
|
||||
bench --verbose --site test_site install-app helpdesk
|
||||
|
||||
4
.github/helpers/update_pot_file.sh
vendored
4
.github/helpers/update_pot_file.sh
vendored
@@ -5,7 +5,7 @@ cd ~ || exit
|
||||
echo "Setting Up Bench..."
|
||||
|
||||
pip install frappe-bench
|
||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch develop
|
||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)" --frappe-branch "${BASE_BRANCH}"
|
||||
|
||||
cd ./frappe-bench || exit
|
||||
|
||||
@@ -38,4 +38,4 @@ gh auth setup-git
|
||||
git push -u upstream "${branch_name}"
|
||||
|
||||
echo "Creating a PR..."
|
||||
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/helpdesk
|
||||
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" -R frappe/helpdesk
|
||||
@@ -27,13 +27,13 @@ jobs:
|
||||
steps:
|
||||
- name: Update notes
|
||||
run: |
|
||||
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/helpdesk/releases/generate-notes -f tag_name=$RELEASE_TAG \
|
||||
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/generate-notes -f tag_name=$RELEASE_TAG \
|
||||
| jq -r '.body' \
|
||||
| sed -E '/^\* (chore|ci|test|docs|style)/d' \
|
||||
| sed -E 's/by @mergify //'
|
||||
)
|
||||
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/helpdesk/releases/tags/$RELEASE_TAG | jq -r '.id')
|
||||
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/helpdesk/releases/$RELEASE_ID -f body="$NEW_NOTES"
|
||||
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/tags/$RELEASE_TAG | jq -r '.id')
|
||||
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/crm/releases/$RELEASE_ID -f body="$NEW_NOTES"
|
||||
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
15
.github/workflows/assets.yml
vendored
15
.github/workflows/assets.yml
vendored
@@ -1,15 +0,0 @@
|
||||
name: Pre-build assets
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [develop, main]
|
||||
|
||||
jobs:
|
||||
assets:
|
||||
name: Pre-build assets
|
||||
uses: frappe/pilot/.github/workflows/app-assets.yml@develop
|
||||
with:
|
||||
# main supports Frappe v15 and v16; Pilot benches run v16 or later.
|
||||
frappe-branch: ${{ github.ref_name == 'main' && 'version-16' || 'develop' }}
|
||||
permissions:
|
||||
contents: write
|
||||
29
.github/workflows/build.yml
vendored
29
.github/workflows/build.yml
vendored
@@ -13,15 +13,21 @@ jobs:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
|
||||
steps:
|
||||
- name: Checkout Entire Repository
|
||||
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
|
||||
with:
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
@@ -32,7 +38,8 @@ jobs:
|
||||
|
||||
- name: Set Branch
|
||||
run: |
|
||||
echo '[{"url": "https://github.com/frappe/telephony","branch": "develop"},{"url": "https://github.com/frappe/helpdesk","branch": "main"}]' > apps.json
|
||||
export APPS_JSON='[{"url": "https://github.com/frappe/telephony","branch": "develop"},{"url": "https://github.com/frappe/helpdesk","branch": "main"}]'
|
||||
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
|
||||
echo "FRAPPE_BRANCH=version-15" >> $GITHUB_ENV
|
||||
|
||||
- name: Set Image Tag
|
||||
@@ -44,10 +51,10 @@ jobs:
|
||||
repository: frappe/frappe_docker
|
||||
path: builds
|
||||
|
||||
- name: Build
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
load: true
|
||||
push: true
|
||||
context: builds
|
||||
file: builds/images/layered/Containerfile
|
||||
tags: >
|
||||
@@ -55,14 +62,4 @@ jobs:
|
||||
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
|
||||
build-args: |
|
||||
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
|
||||
secret-files: |
|
||||
apps_json=apps.json
|
||||
|
||||
- name: Verify apps are installed
|
||||
run: |
|
||||
apps=$(docker run --rm --entrypoint ls ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }} apps)
|
||||
echo "$apps"
|
||||
grep -qx helpdesk <<< "$apps" && grep -qx telephony <<< "$apps"
|
||||
|
||||
- name: Push
|
||||
run: docker push --all-tags ghcr.io/${{ github.repository }}
|
||||
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
|
||||
|
||||
9
.github/workflows/generate-pot-file.yml
vendored
9
.github/workflows/generate-pot-file.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branch: ["develop", "main-hotfix"]
|
||||
branch: ["develop"]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -25,12 +25,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Run script to update POT file
|
||||
run: |
|
||||
|
||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
fetch-depth: 200
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 18
|
||||
check-latest: true
|
||||
- name: Check commit titles
|
||||
run: |
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.14'
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install and Run Pre-commit
|
||||
uses: pre-commit/action@v3.0.1
|
||||
|
||||
32
.github/workflows/on_release.yml
vendored
32
.github/workflows/on_release.yml
vendored
@@ -1,32 +0,0 @@
|
||||
name: Generate Semantic Release
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Entire Repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Setup dependencies
|
||||
run: |
|
||||
npm install @semantic-release/git @semantic-release/exec --no-save
|
||||
- name: Create Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GIT_AUTHOR_NAME: "Frappe PR Bot"
|
||||
GIT_AUTHOR_EMAIL: "developers@frappe.io"
|
||||
GIT_COMMITTER_NAME: "Frappe PR Bot"
|
||||
GIT_COMMITTER_EMAIL: "developers@frappe.io"
|
||||
run: npx semantic-release
|
||||
31
.github/workflows/server-tests.yml
vendored
31
.github/workflows/server-tests.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.14'
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
if: github.event.pull_request.base.ref == 'develop'
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 18
|
||||
check-latest: true
|
||||
|
||||
- name: Install Yarn
|
||||
@@ -104,32 +104,7 @@ jobs:
|
||||
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Run Tests
|
||||
run: 'cd ~/frappe-bench/ && bench --site test_site run-tests --app helpdesk --coverage'
|
||||
run: 'cd ~/frappe-bench/ && bench --site test_site run-tests --app helpdesk'
|
||||
env:
|
||||
TYPE: server
|
||||
CI_BUILD_ID: ${{ github.run_id }}
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-${{ matrix.container }}
|
||||
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
name: MariaDB
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
187
.github/workflows/ui-tests.yml
vendored
187
.github/workflows/ui-tests.yml
vendored
@@ -1,187 +0,0 @@
|
||||
name: UI Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- "desk/**"
|
||||
- "e2e/**"
|
||||
- "playwright.config.ts"
|
||||
- "package.json"
|
||||
- "yarn.lock"
|
||||
- ".github/workflows/ui-tests.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ui-tests-helpdesk-${{ github.event.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
FRAPPE_BRANCH: develop
|
||||
SITE: helpdesk.test
|
||||
|
||||
jobs:
|
||||
ui-tests:
|
||||
name: Playwright E2E Tests (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2]
|
||||
|
||||
services:
|
||||
redis-cache:
|
||||
image: redis/redis-stack-server:latest
|
||||
ports:
|
||||
- 13000:6379
|
||||
redis-queue:
|
||||
image: redis:alpine
|
||||
ports:
|
||||
- 11000:6379
|
||||
mariadb:
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
|
||||
restore-keys: ${{ runner.os }}-pip-
|
||||
|
||||
- name: Cache yarn
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/yarn
|
||||
key: ${{ runner.os }}-yarn-ui-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-yarn-ui-
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('yarn.lock') }}
|
||||
|
||||
- name: Install MariaDB Client
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y mariadb-client
|
||||
|
||||
- name: Setup Bench
|
||||
run: |
|
||||
pip install frappe-bench
|
||||
bench init --skip-redis-config-generation --skip-assets --frappe-branch "$FRAPPE_BRANCH" --python "$(which python)" ~/frappe-bench
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
- name: Install Helpdesk
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
bench get-app telephony
|
||||
bench get-app helpdesk $GITHUB_WORKSPACE
|
||||
bench setup requirements --dev
|
||||
bench new-site --db-root-password root --admin-password admin --install-app helpdesk $SITE
|
||||
bench use $SITE
|
||||
bench --site $SITE set-config host_name "http://localhost:8000"
|
||||
bench build --apps frappe,helpdesk
|
||||
env:
|
||||
CI: "Yes"
|
||||
|
||||
- name: Start Frappe Server
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
sed -i 's/^watch:/# watch:/g' Procfile
|
||||
sed -i 's/^schedule:/# schedule:/g' Procfile
|
||||
bench start &> bench_start.log &
|
||||
timeout 120 bash -c "until curl -s http://localhost:8000 > /dev/null; do sleep 2; done"
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
yarn install --frozen-lockfile --ignore-scripts
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Playwright Tests
|
||||
run: npx playwright test --shard=${{ matrix.shard }}/2
|
||||
env:
|
||||
# localhost is a secure context, which crypto.randomUUID in frappe/ui needs.
|
||||
BASE_URL: http://localhost:8000
|
||||
ADMIN_PASSWORD: admin
|
||||
|
||||
- name: Upload Blob Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-${{ matrix.shard }}
|
||||
path: blob-report/
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload Test Results
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
with:
|
||||
name: test-results-${{ matrix.shard }}
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Show Bench Logs on Failure
|
||||
if: failure()
|
||||
working-directory: /home/runner/frappe-bench
|
||||
run: |
|
||||
cat bench_start.log || true
|
||||
cat logs/*.log || true
|
||||
|
||||
merge-reports:
|
||||
name: Merge Playwright Reports
|
||||
needs: ui-tests
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install Playwright
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Download Blob Reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-blob-reports
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge Into HTML Report
|
||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||
|
||||
- name: Upload Playwright Report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -29,12 +29,6 @@ share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
.playwright-mcp
|
||||
.claude-flow
|
||||
.claude
|
||||
.claude-worktrees
|
||||
CLAUDE.md
|
||||
|
||||
|
||||
helpdesk/docs/current
|
||||
helpdesk/public/desk
|
||||
@@ -42,8 +36,3 @@ helpdesk/public/frontend
|
||||
helpdesk/public/node_modules
|
||||
helpdesk/www/helpdesk/index.html
|
||||
desk/components.d.ts
|
||||
desk/stats.html
|
||||
# Playwright
|
||||
e2e/.auth
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
85
.mergify.yml
85
.mergify.yml
@@ -1,42 +1,45 @@
|
||||
pull_request_rules:
|
||||
- name: Auto-close PRs on stable branch
|
||||
conditions:
|
||||
- and:
|
||||
- author != RitvikSardana
|
||||
- author != frappe-pr-bot
|
||||
- author != mergify[bot]
|
||||
- author != aerodeval
|
||||
- or:
|
||||
- base = main
|
||||
actions:
|
||||
close:
|
||||
comment:
|
||||
message: |
|
||||
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on develop branch.
|
||||
- name: backport to develop
|
||||
conditions:
|
||||
- label = "backport develop"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- develop
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- name: backport to main-hotfix
|
||||
conditions:
|
||||
- label = "backport main-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- name: backport to main
|
||||
conditions:
|
||||
- label = "backport main"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- name: Auto-close PRs on stable branch
|
||||
conditions:
|
||||
- and:
|
||||
- and:
|
||||
- author!=RitvikSardana
|
||||
- author!=frappe-pr-bot
|
||||
- author!=mergify[bot]
|
||||
- or:
|
||||
- base=main
|
||||
actions:
|
||||
close:
|
||||
comment:
|
||||
message: |
|
||||
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on develop branch.
|
||||
|
||||
- name: backport to develop
|
||||
conditions:
|
||||
- label="backport develop"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- develop
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main-hotfix
|
||||
conditions:
|
||||
- label="backport main-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to main
|
||||
conditions:
|
||||
- label="backport main"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- main
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
@@ -4,19 +4,19 @@ fail_fast: false
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 25.11.0
|
||||
rev: 22.6.0
|
||||
hooks:
|
||||
- id: black
|
||||
types_or: [python, pyi]
|
||||
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 7.3.0
|
||||
rev: 5.0.4
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies:
|
||||
- flake8-bugbear
|
||||
- flake8-comprehensions
|
||||
- flake8-simplify==0.30.0
|
||||
- flake8-simplify
|
||||
args: ['--config', '.github/helpers/.flake8_strict']
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
|
||||
21
.releaserc
21
.releaserc
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"branches": ["main"],
|
||||
"plugins": [
|
||||
"@semantic-release/commit-analyzer", {
|
||||
"preset": "angular"
|
||||
},
|
||||
"@semantic-release/release-notes-generator",
|
||||
[
|
||||
"@semantic-release/exec", {
|
||||
"prepareCmd": 'sed -ir "s/[0-9]*\.[0-9]*\.[0-9]*/${nextRelease.version}/" helpdesk/__init__.py'
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/git", {
|
||||
"assets": ["helpdesk/__init__.py"],
|
||||
"message": "chore(release): Bumped to Version ${nextRelease.version}"
|
||||
}
|
||||
],
|
||||
"@semantic-release/github"
|
||||
]
|
||||
}
|
||||
152
AGENTS.md
152
AGENTS.md
@@ -1,21 +1,8 @@
|
||||
# Copilot Instructions for Frappe Helpdesk
|
||||
|
||||
## Skills
|
||||
|
||||
For Frappe development tasks, reference the frappe-dev skill:
|
||||
- **Location**: `~/.agents/skills/frappe-dev/`
|
||||
- **Usage**: When working on DocType, controllers, whitelisted APIs, background jobs, permissions, or frontend tasks, read the relevant skill reference files:
|
||||
- `doctype.md` — Creating/modifying DocTypes and fields
|
||||
- `controllers.md` — Document lifecycle and server logic
|
||||
- `api.md` — REST endpoints with `@frappe.whitelist()`
|
||||
- `database.md` — `frappe.db`, queries, raw SQL
|
||||
- `background-jobs.md` — `frappe.enqueue`, scheduled jobs
|
||||
- `permissions.md` — Roles, DocType permissions
|
||||
- `frontend-desk.md`, `frontend-vue.md`, `frontend-portal.md` — Frontend references
|
||||
|
||||
## Project Overview
|
||||
|
||||
Frappe Helpdesk is an open-source ticket management tool built on the [Frappe Framework](https://github.com/frappe/frappe) (Python backend) and [Frappe UI](https://github.com/frappe/frappe-ui) (Vue.js frontend). It provides agent/customer portals, SLAs, assignment rules, a knowledge base, and saved replies.
|
||||
Frappe Helpdesk is an open-source ticket management tool built on the [Frappe Framework](https://github.com/frappe/frappe) (Python backend) and [Frappe UI](https://github.com/frappe/frappe-ui) (Vue.js frontend). It provides agent/customer portals, SLAs, assignment rules, a knowledge base, and canned responses.
|
||||
|
||||
## Tech Stack & Architecture
|
||||
|
||||
@@ -157,12 +144,8 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
|
||||
### Icons
|
||||
|
||||
- **Primary icon system**: Lucide icons via unplugin-icons for consistency and modern design
|
||||
- **Secondary**: FeatherIcon component from frappe-ui (legacy usage only)
|
||||
- **Rule**: Never introduce `FeatherIcon` in new code. New components must use Lucide
|
||||
icons (`<LucideInfo />`, `<LucidePlus />`, …) — no exceptions, including small
|
||||
helper icons like tooltip/info glyphs.
|
||||
- Use Lucide icons for all new components. They are globally auto-registered
|
||||
(see `desk/components.d.ts`), so no import is needed when used as a template tag:
|
||||
- **Secondary**: FeatherIcon component from frappe-ui (legacy usage, prefer Lucide for new components)
|
||||
- Use Lucide icons for all new components:
|
||||
```vue
|
||||
<template>
|
||||
<LucidePlus class="size-4" />
|
||||
@@ -215,9 +198,7 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
|
||||
|
||||
**Data Query Patterns**:
|
||||
|
||||
- Always prefer `frappe.get_list()` over `frappe.get_all()` for new code. It handles perms out of the box and is more flexible.
|
||||
- `frappe.get_all()` is fine for child tables (e.g. `Has Role`, `HD Customer Member`), which have no permissions of their own.
|
||||
- When `frappe.get_all()` is used on a parent DocType, make sure the caller may see the result some other way, for example a `frappe.has_permission(...)` check first, or `@agent_only` / `@agent_manager_only` (`helpdesk/utils.py`) on the endpoint.
|
||||
- Always prefer `frappe.qb.get_query()` over `frappe.db.get_all()` for new code
|
||||
- Use `frappe.qb.get_query(..., ignore_permissions=False)` when permission checks are needed
|
||||
- Example query patterns from helpdesk context
|
||||
|
||||
@@ -234,48 +215,6 @@ apiCall.submit({ ticket_id: "<id>", agent_id: "<agent>" });
|
||||
- Use proper field types and validation in DocType definitions
|
||||
- Leverage DocType methods for business logic encapsulation
|
||||
|
||||
## Controller Code Structure
|
||||
|
||||
All controller logic must follow this structure strictly:
|
||||
|
||||
**Rule: Lifecycle hooks call methods. Methods are defined below on the class.**
|
||||
|
||||
```python
|
||||
class HDFoo(Document):
|
||||
# 1. Lifecycle hooks at the top — each hook calls methods, no inline logic
|
||||
def after_insert(self):
|
||||
self.do_a()
|
||||
self.do_b()
|
||||
|
||||
def on_update(self):
|
||||
self.do_a()
|
||||
|
||||
# 2. Methods below — one responsibility each
|
||||
def do_a(self):
|
||||
pass
|
||||
|
||||
def do_b(self):
|
||||
pass
|
||||
|
||||
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Lifecycle hooks (`after_insert`, `on_update`, `before_save`, `on_trash`, `after_rename`, etc.) contain **no inline logic** — they only call `self.method()`.
|
||||
- All logic lives in methods on the class, not in module-level helper functions.
|
||||
- Methods that mutate a related document (e.g. an Assignment Rule) receive that doc as an argument and mutate it in memory. The **caller** is responsible for the final `doc.save()` — this keeps saves to a minimum (one save per hook).
|
||||
- Never fetch the same document twice in one hook. Fetch once, pass the doc through the call chain.
|
||||
- `@staticmethod` is allowed for pure transformations (e.g. building a condition string) that don't touch `self` or any Frappe doc.
|
||||
- If there is an issue in framework/ui or frappe-ui or frappe framework, dont do a workaround. Instead, raise a blocker in the spec and wait for a fix. Do not implement workarounds that will be removed later. If possible, raise a PR to fix the issue in the framework instead of implementing a workaround in helpdesk.
|
||||
|
||||
**Save discipline:**
|
||||
|
||||
- Mutate all fields on a document before calling `.save()` once.
|
||||
- Never call `.save()` inside a helper method — save in the hook or the method that owns the full mutation.
|
||||
- Exception: if a method truly owns a complete document lifecycle (e.g. `create_assignment_rule` creates and fully initialises a new doc), it may save internally.
|
||||
Take inspiration from "helpdesk.doctype.hd_team.hd_team.HDTeam" for examples of this pattern in practice.
|
||||
|
||||
## Conventions & Patterns
|
||||
|
||||
- **API Design:** Python modules in `helpdesk/api/` follow RESTful patterns. Extend via `helpdesk/extends/`.
|
||||
@@ -374,7 +313,7 @@ const ticket = createDocumentResource({
|
||||
- Use ESLint with Vue 3 recommended rules and Prettier for consistency
|
||||
- Write meaningful commit messages and maintain clean git history
|
||||
- Document complex logic with JSDoc/TSDoc comments
|
||||
- Keep a comment to 2 lines at most. If it needs more to explain, simplify the code instead
|
||||
- Only add comments that explain why something is done, not what is done
|
||||
- Keep dependencies updated and audit for security vulnerabilities
|
||||
|
||||
## References
|
||||
@@ -387,85 +326,4 @@ const ticket = createDocumentResource({
|
||||
|
||||
---
|
||||
|
||||
## Agent Working Rules
|
||||
|
||||
These rules apply to all agents working in this codebase. Follow them without exception.
|
||||
|
||||
### 1. Site Data — Read Before You Write
|
||||
|
||||
When a Frappe site is referenced in the conversation (e.g. `erp-hd.localhost`, `hd-tests`),
|
||||
treat it as the **source of truth for current data state**.
|
||||
|
||||
- Before assuming anything about existing records, custom fields, settings, or DocType state —
|
||||
query the site first using the bench venv Python or `bench execute`.
|
||||
- **Never write data to a site unless the user explicitly asks you to.**
|
||||
Querying is always safe. Mutations (insert, update, delete, migrate, patch) require explicit
|
||||
user instruction.
|
||||
- If you need to verify a fix worked, re-query the site — do not assume success from code
|
||||
inspection alone.
|
||||
|
||||
```python
|
||||
# Safe pattern for querying a site
|
||||
import sys, os
|
||||
sys.path.insert(0, 'apps/frappe')
|
||||
os.chdir('sites')
|
||||
import frappe
|
||||
frappe.init(site='<site-name>', sites_path='.')
|
||||
frappe.connect()
|
||||
|
||||
# ... read-only queries here ...
|
||||
|
||||
frappe.destroy()
|
||||
```
|
||||
|
||||
Run via: `cd /path/to/frappe-bench && env/bin/python /tmp/your_script.py`
|
||||
|
||||
---
|
||||
|
||||
### 2. Spec-Driven Development — Keep the Spec Current
|
||||
|
||||
When a feature has a spec document (e.g. `docs/erpnext-customer-sync.md`), the spec is the
|
||||
**living source of truth** for that feature. It must be kept in sync with the actual
|
||||
implementation at all times.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Update the spec in the same session as the code change.** Never defer spec updates to a
|
||||
later session.
|
||||
- **The spec reflects what is implemented, not what was originally planned.** If a decision
|
||||
changes mid-build (e.g. removing a scheduler job, changing how a field is set), update the
|
||||
spec immediately after the code change.
|
||||
- **Removed features must be removed from the spec.** Do not leave stale sections, blockers, or
|
||||
file manifest entries for things that no longer exist.
|
||||
- **New features must be added to the spec.** If a new API, component, or behaviour is
|
||||
introduced, add a corresponding section.
|
||||
- **Decisions and their rationale belong in the spec.** If something was done a specific way to
|
||||
avoid a bug or framework limitation (e.g. not setting `customer_group` to avoid the group-node
|
||||
error), document that in the relevant Blocker section.
|
||||
- When in doubt about whether a spec update is needed — update it.
|
||||
|
||||
---
|
||||
|
||||
### 3. Test Utilities — Always Use `helpdesk/test_utils.py`
|
||||
|
||||
All backend test helpers must live in `helpdesk/test_utils.py` and be imported from there.
|
||||
Never define helper functions (factories, enable/disable toggles, fixture builders) directly
|
||||
inside individual test files.
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Before writing a helper in a test file**, check `helpdesk/test_utils.py` first — it may
|
||||
already exist.
|
||||
- **If a helper doesn't exist**, add it to `helpdesk/test_utils.py` and import it in the test
|
||||
file. Do not leave it inline.
|
||||
- **If you write helpers inline during development**, move them to `test_utils.py` before
|
||||
finishing the task.
|
||||
- Helper functions in `test_utils.py` must have a docstring explaining what they create or do. Only if the function is completely self-explanatory (e.g. `enable_erpnext_sync`) can the docstring be omitted.
|
||||
- Naming conventions:
|
||||
- Factories: `make_<doctype>(...)` — e.g. `make_hd_customer`, `make_ticket`, `make_team`
|
||||
- Toggle helpers: `enable_<feature>()` / `disable_<feature>()` — e.g. `enable_erpnext_sync`
|
||||
- Query helpers: descriptive verb — e.g. `get_latest_ticket_communication`
|
||||
|
||||
**For updates, merge new conventions here. If anything is unclear or missing, ask for clarification.**
|
||||
|
||||
**In the end your code will be reviewed by Codex, but following these rules will help ensure a smooth review process and a high-quality codebase.**
|
||||
@@ -42,7 +42,7 @@ Managing issues from our customers was a big challenge for us. We were using the
|
||||
|
||||
- **Knowledge Base**: Learn how to create and manage help articles to empower users and reduce tickets.
|
||||
|
||||
- **Saved Replies**: Pre-written replies for common queries to ensure quick and consistent communication.
|
||||
- **Canned Responses**: Pre-written replies for common queries to ensure quick and consistent communication.
|
||||
|
||||
<details open>
|
||||
<summary >View Screenshots</summary>
|
||||
@@ -165,7 +165,6 @@ To setup the repository locally follow the steps mentioned below:
|
||||
1. Start the server by running `bench start`
|
||||
1. In a separate terminal window, create a new site by running `bench new-site helpdesk.test`
|
||||
1. Map your site to localhost with the command `bench --site helpdesk.test add-to-hosts`
|
||||
1. Get the Telephony app. Run `bench get-app https://github.com/frappe/telephony`
|
||||
1. Get the Helpdesk app. Run `bench get-app https://github.com/frappe/helpdesk`
|
||||
1. Run `bench --site helpdesk.test install-app helpdesk`.
|
||||
1. Run `bench build --app helpdesk`
|
||||
@@ -188,7 +187,6 @@ To setup the repository locally follow the steps mentioned below:
|
||||
| Helpdesk Branch | Compatible Frappe Framework Version |
|
||||
|-----------------|-------------------------------------|
|
||||
| main | version-15 |
|
||||
| main | version-16 |
|
||||
| develop | develop branch |
|
||||
|
||||
|
||||
|
||||
6
desk/auto-imports.d.ts
vendored
6
desk/auto-imports.d.ts
vendored
@@ -6,9 +6,7 @@
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
const LucideAlignJustify: typeof import('~icons/lucide/align-justify')['default']
|
||||
const LucideCornerUpLeft: typeof import('~icons/lucide/corner-up-left')['default']
|
||||
const LucideFileSearch: typeof import('~icons/lucide/file-search')['default']
|
||||
const LucideMessageCircle: typeof import('~icons/lucide/message-circle')['default']
|
||||
const LucideTag: typeof import('~icons/lucide/tag')['default']
|
||||
const LucideKeyboard: typeof import('~icons/lucide/keyboard')['default']
|
||||
const LucideSearch: typeof import('~icons/lucide/search')['default']
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ boot.lang or 'en' }}" dir="{{ boot.dir or 'ltr' }}">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Helpdesk</title>
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
@@ -205,7 +206,12 @@
|
||||
<div id="popovers"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script>
|
||||
window.favicon = "{{ favicon }}";
|
||||
window.site_name = "{{ site_name }}";
|
||||
|
||||
document.querySelectorAll("link[rel='icon']").forEach((link) => {
|
||||
link.href = window.favicon;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
37
desk/lucide.ts
Normal file
37
desk/lucide.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import * as LucideIcons from "lucide-static";
|
||||
|
||||
const icons = {};
|
||||
for (const icon in LucideIcons) {
|
||||
let iconSvg = LucideIcons[icon];
|
||||
if (icon == "default") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// set stroke-width to 1.5
|
||||
if (iconSvg && iconSvg.includes("stroke-width")) {
|
||||
iconSvg = iconSvg.replace(/stroke-width="2"/g, 'stroke-width="1.5"');
|
||||
}
|
||||
icons[icon] = iconSvg;
|
||||
|
||||
const dashKeys = camelToDash(icon);
|
||||
for (const dashKey of dashKeys) {
|
||||
if (dashKey !== icon) {
|
||||
icons[dashKey] = iconSvg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default icons;
|
||||
|
||||
function camelToDash(key) {
|
||||
// barChart2 -> bar-chart-2
|
||||
const withNumber = key.replace(/[A-Z0-9]/g, (m) => "-" + m.toLowerCase());
|
||||
// barChart2 -> bar-chart2
|
||||
const withoutNumber = key.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
|
||||
if (withNumber !== withoutNumber) {
|
||||
// both are required because unplugin icon resolver doesn't put a dash before numbers
|
||||
return [withNumber, withoutNumber];
|
||||
}
|
||||
return [withNumber];
|
||||
}
|
||||
@@ -6,56 +6,54 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"postinstall": "patch-package"
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@framework/ui": "link:../../frappe/ui",
|
||||
"@headlessui/vue": "^1.7.22",
|
||||
"@iconify-json/lucide": "^1.1.99",
|
||||
"@iconify-json/ph": "^1.1.5",
|
||||
"@iconify/tools": "^2.2.6",
|
||||
"@iconify/vue": "^4.1.1",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@tiptap/core": "^3.26.0",
|
||||
"@tiptap/extension-table": "^3.26.0",
|
||||
"@tiptap/extension-text-align": "^3.26.0",
|
||||
"@tiptap/extension-text-style": "^3.26.0",
|
||||
"@tiptap/pm": "^3.26.0",
|
||||
"@tiptap/starter-kit": "^3.26.0",
|
||||
"@tiptap/core": "^2.2.4",
|
||||
"@twilio/voice-sdk": "^2.15.0",
|
||||
"@vee-validate/zod": "^4.8.2",
|
||||
"@vitejs/plugin-vue": "^4.6.2",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@vueuse/integrations": "^14.1.0",
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@vueuse/core": "^13.8.0",
|
||||
"@vueuse/integrations": "^13.8.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"echarts": "^6.1.0",
|
||||
"grid-layout-plus": "^1.1.0",
|
||||
"frappe-ui": "1.0.0-rc.1",
|
||||
"echarts": "^5.4.1",
|
||||
"frappe-ui": "0.1.192",
|
||||
"gemoji": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-static": "^0.276.0",
|
||||
"mime": "^3.0.0",
|
||||
"pinia": "^2.0.33",
|
||||
"pluralize": "^8.0.0",
|
||||
"sanitize-html": "^2.10.0",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"tailwindcss-rtl": "^0.9.0",
|
||||
"unplugin-icons": "^0.16.1",
|
||||
"unplugin-vue-components": "^0.25.2",
|
||||
"vee-validate": "^4.8.2",
|
||||
"vue": "^3.5.14",
|
||||
"vue-echarts": "^6.5.4",
|
||||
"vue-router": "^4.2.2",
|
||||
"vuedraggable": "^4.1.0",
|
||||
"zod": "^3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"patch-package": "^8.0.1",
|
||||
"postcss": "^8.4.5",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "2.8.4",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.0.2",
|
||||
"unplugin-icons": "^22.1.0",
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-pwa": "0.17.5"
|
||||
},
|
||||
"resolutions": {
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"prosemirror-model": "1.25.9",
|
||||
"prosemirror-state": "1.4.4",
|
||||
"prosemirror-view": "1.41.9",
|
||||
"prosemirror-transform": "1.12.0"
|
||||
"prosemirror-model": "1.25.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue-jsx": "^3.0.1",
|
||||
"prettier": "2.8.4",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"postcss": "^8.4.5",
|
||||
"tailwindcss": "^3.4.15"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
diff --git a/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts b/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts
|
||||
index 91c8dc4..2883b93 100644
|
||||
--- a/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts
|
||||
+++ b/node_modules/frappe-ui/src/molecules/editor/extensions/shared/suggestion-renderer.ts
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from '@tiptap/suggestion'
|
||||
import type { Component } from 'vue'
|
||||
import {
|
||||
+ autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
@@ -40,6 +41,9 @@ export function createSuggestionRenderer(
|
||||
let isActive = false
|
||||
// Monotonic token; a stale onUpdate from a superseded query bails.
|
||||
let renderToken = 0
|
||||
+ // Monotonic token; a stale computePosition settling later bails.
|
||||
+ let positionToken = 0
|
||||
+ let cleanupAutoUpdate: (() => void) | null = null
|
||||
|
||||
function getListExpose(): SuggestionListExpose | null {
|
||||
const ref = renderer?.ref as SuggestionListExpose | null | undefined
|
||||
@@ -52,6 +56,7 @@ export function createSuggestionRenderer(
|
||||
const rect = getReferenceClientRect()
|
||||
if (!rect) return
|
||||
const reference = { getBoundingClientRect: () => rect }
|
||||
+ const token = ++positionToken
|
||||
void computePosition(reference, floatingEl, {
|
||||
placement: floatingOptions?.placement ?? 'bottom-start',
|
||||
middleware: [
|
||||
@@ -60,7 +65,7 @@ export function createSuggestionRenderer(
|
||||
shift({ padding: 8 }),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
- if (!floatingEl) return
|
||||
+ if (!floatingEl || token !== positionToken) return
|
||||
Object.assign(floatingEl.style, {
|
||||
position: 'absolute',
|
||||
left: `${x}px`,
|
||||
@@ -93,7 +98,19 @@ export function createSuggestionRenderer(
|
||||
floatingEl.style.position = 'absolute'
|
||||
document.body.appendChild(floatingEl)
|
||||
getReferenceClientRect = props.clientRect as () => DOMRect | null
|
||||
- updatePosition()
|
||||
+
|
||||
+ // Positions once, then keeps the popup on the caret through scroll, window
|
||||
+ // resize and its own height changes (`flip()` measures a height that async
|
||||
+ // rows and late fonts change after the fact).
|
||||
+ cleanupAutoUpdate = autoUpdate(
|
||||
+ {
|
||||
+ getBoundingClientRect: () =>
|
||||
+ getReferenceClientRect?.() ?? new DOMRect(),
|
||||
+ contextElement: props.editor?.view?.dom as HTMLElement | undefined,
|
||||
+ },
|
||||
+ floatingEl,
|
||||
+ updatePosition,
|
||||
+ )
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -139,6 +156,9 @@ export function createSuggestionRenderer(
|
||||
onExit() {
|
||||
isActive = false
|
||||
renderToken++
|
||||
+ positionToken++
|
||||
+ cleanupAutoUpdate?.()
|
||||
+ cleanupAutoUpdate = null
|
||||
floatingEl?.remove()
|
||||
renderer?.destroy()
|
||||
floatingEl = null
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 67 KiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<FrappeUIProvider>
|
||||
<router-view />
|
||||
<PortalRoot />
|
||||
</FrappeUIProvider>
|
||||
<Dialogs />
|
||||
</template>
|
||||
@@ -8,38 +8,47 @@
|
||||
<script setup lang="ts">
|
||||
import { Dialogs } from "@/components/dialogs";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { useFavicon } from "@vueuse/core";
|
||||
import { FrappeUIProvider, setConfig, toast, useColorScheme } from "frappe-ui";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { h, onMounted } from "vue";
|
||||
import { stopSession } from "@/telemetry";
|
||||
import { FrappeUIProvider, toast } from "frappe-ui";
|
||||
import { computed, defineAsyncComponent, h, onMounted, onUnmounted } from "vue";
|
||||
import Wifi from "~icons/lucide/wifi";
|
||||
import WifiOff from "~icons/lucide/wifi-off";
|
||||
import { __ } from "./translation";
|
||||
import { isCustomerPortal } from "./utils";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const { favicon } = storeToRefs(configStore);
|
||||
|
||||
useFavicon(favicon);
|
||||
|
||||
if (!localStorage.getItem("theme")) {
|
||||
localStorage.setItem("theme", "light");
|
||||
}
|
||||
useColorScheme();
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
useConfigStore();
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("online", () => {
|
||||
toast(__("You are now online."), {
|
||||
icon: () => h(Wifi, { class: "text-ink-base" }),
|
||||
toast.create({
|
||||
message: "You are now online",
|
||||
icon: h(Wifi, { class: "text-white" }),
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("offline", () => {
|
||||
toast(__("You are now offline."), {
|
||||
icon: () => h(WifiOff, { class: "text-ink-base" }),
|
||||
toast.create({
|
||||
message: "You are now offline",
|
||||
icon: h(WifiOff, { class: "text-white" }),
|
||||
});
|
||||
});
|
||||
!isCustomerPortal.value && setConfig("localTimezone", window.timezone?.user);
|
||||
setConfig("systemTimezone", window.timezone?.system || null);
|
||||
});
|
||||
|
||||
const AgentPortalRoot = defineAsyncComponent(
|
||||
() => import("@/pages/desk/AgentRoot.vue")
|
||||
);
|
||||
const CustomerPortalRoot = defineAsyncComponent(
|
||||
() => import("@/pages/CustomerPortalRoot.vue")
|
||||
);
|
||||
|
||||
const PortalRoot = computed(() => {
|
||||
const authStore = useAuthStore();
|
||||
if (authStore.hasDeskAccess && authStore.isAgent) {
|
||||
return AgentPortalRoot;
|
||||
} else {
|
||||
return CustomerPortalRoot;
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopSession();
|
||||
});
|
||||
</script>
|
||||
|
||||
72
desk/src/components/Apps.vue
Normal file
72
desk/src/components/Apps.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<Popover
|
||||
placement="right-start"
|
||||
class="flex w-full"
|
||||
trigger="hover"
|
||||
:hoverDelay="0.1"
|
||||
:leaveDelay="0.1"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
:class="[
|
||||
'group w-full flex h-7 items-center justify-between rounded px-2 text-base text-gray-800 hover:bg-gray-100',
|
||||
]"
|
||||
@click.prevent="togglePopover()"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<AppsIcon />
|
||||
<span class="whitespace-nowrap">Apps</span>
|
||||
</div>
|
||||
<ChevronRight class="h-4 w-4 stroke-1.5" />
|
||||
</button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="flex flex-col justify-between mx-3 p-1.5 rounded-lg border border-gray-100 bg-white shadow-xl"
|
||||
>
|
||||
<div v-for="app in apps.data" key="name">
|
||||
<a
|
||||
:href="app.route"
|
||||
class="flex gap-2 rounded items-center hover:bg-gray-100 p-1.5"
|
||||
>
|
||||
<img class="size-6" :src="app.logo" />
|
||||
<div class="text-sm" @click="app.onClick">
|
||||
{{ app.title }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Popover, createResource } from "frappe-ui";
|
||||
import AppsIcon from "./icons/AppsIcon.vue";
|
||||
import ChevronRight from "~icons/lucide/chevron-right";
|
||||
|
||||
const apps = createResource({
|
||||
url: "frappe.apps.get_apps",
|
||||
cache: "apps",
|
||||
auto: true,
|
||||
transform: (data) => {
|
||||
let _apps = [
|
||||
{
|
||||
name: "frappe",
|
||||
logo: "/assets/helpdesk/desk/desk.png",
|
||||
title: "Desk",
|
||||
route: "/app",
|
||||
},
|
||||
];
|
||||
data.map((app) => {
|
||||
if (app.name === "helpdesk") return;
|
||||
_apps.push({
|
||||
name: app.name,
|
||||
logo: app.logo,
|
||||
title: app.title,
|
||||
route: app.route,
|
||||
});
|
||||
});
|
||||
return _apps;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
201
desk/src/components/AssignmentModal.vue
Normal file
201
desk/src/components/AssignmentModal.vue
Normal file
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: 'Assign To',
|
||||
size: 'xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<AutocompleteNew
|
||||
v-if="showRestrictedMembers"
|
||||
placeholder="Search agents"
|
||||
:model-value="search"
|
||||
:options="members"
|
||||
@update:model-value="
|
||||
({ _, value }) => {
|
||||
addAssignee(value);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar class="mr-2" :name="option.value" size="sm" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</Tooltip>
|
||||
</template>
|
||||
</AutocompleteNew>
|
||||
|
||||
<SearchComplete
|
||||
v-else
|
||||
class="form-control"
|
||||
doctype="HD Agent"
|
||||
:custom-filters="customFilters"
|
||||
:reset-input="true"
|
||||
@change="
|
||||
(option) => {
|
||||
addAssignee(option.value);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #item-prefix="{ option }">
|
||||
<UserAvatar class="mr-2" :name="option.value" size="sm" />
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<Tooltip :text="option.value">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</Tooltip>
|
||||
</template>
|
||||
</SearchComplete>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||
<Tooltip
|
||||
v-for="currentAssignee in assignees"
|
||||
:key="currentAssignee.name"
|
||||
:text="currentAssignee.name"
|
||||
>
|
||||
<Button
|
||||
:label="getUser(currentAssignee.name).full_name"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar :name="currentAssignee.name" size="sm" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
class="h-3.5"
|
||||
name="x"
|
||||
@click.stop="removeCurrentAssignee(currentAssignee.name)"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="mt-2" :message="error" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AutocompleteNew, SearchComplete, UserAvatar } from "@/components";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { call, createResource } from "frappe-ui";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
docname: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
assignees: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
team: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const show = defineModel();
|
||||
|
||||
const emit = defineEmits(["update"]);
|
||||
|
||||
const { getUser } = useUserStore();
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
const { isManager } = useAuthStore();
|
||||
const { teamRestrictionApplied, assignWithinTeam } = useConfigStore();
|
||||
|
||||
const error = ref("");
|
||||
|
||||
const addAssignee = (value) => {
|
||||
error.value = "";
|
||||
createResource({
|
||||
url: "frappe.desk.form.assign_to.add",
|
||||
auto: true,
|
||||
params: {
|
||||
doctype: props.doctype,
|
||||
name: props.docname,
|
||||
assign_to: [value],
|
||||
},
|
||||
onSuccess: () => {
|
||||
emit("update");
|
||||
if (isManager) {
|
||||
updateOnboardingStep("assign_to_agent");
|
||||
}
|
||||
members.value = members.value.filter((m) => m.value !== value);
|
||||
},
|
||||
});
|
||||
emit("update");
|
||||
};
|
||||
|
||||
const removeCurrentAssignee = (value) => {
|
||||
createResource({
|
||||
url: "frappe.desk.form.assign_to.remove",
|
||||
auto: true,
|
||||
params: {
|
||||
doctype: props.doctype,
|
||||
name: props.docname,
|
||||
assign_to: value,
|
||||
},
|
||||
onSuccess: () => {
|
||||
emit("update");
|
||||
members.value.push({
|
||||
label: value,
|
||||
value: value,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const customFilters = computed(() => {
|
||||
const filters = {};
|
||||
filters["is_active"] = ["=", 1];
|
||||
if (Boolean(props.assignees?.length)) {
|
||||
filters["name"] = ["not in", [...props.assignees.map((a) => a.name)]];
|
||||
}
|
||||
return filters;
|
||||
});
|
||||
|
||||
const search = ref("");
|
||||
const members = ref([]);
|
||||
async function getMembers() {
|
||||
let teamMembers = await call(
|
||||
"helpdesk.helpdesk.doctype.hd_team.hd_team.get_team_members",
|
||||
{
|
||||
team: props.team,
|
||||
}
|
||||
);
|
||||
let assignedMembers = props.assignees.map((a) => a.name);
|
||||
teamMembers = teamMembers.filter((member: string) => {
|
||||
return !assignedMembers.includes(member);
|
||||
});
|
||||
|
||||
members.value = teamMembers.map((member: string) => {
|
||||
return {
|
||||
label: member,
|
||||
value: member,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const showRestrictedMembers = computed(() => {
|
||||
return teamRestrictionApplied && assignWithinTeam && props.team;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (showRestrictedMembers.value) {
|
||||
getMembers();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<span>
|
||||
<a :href="preview ? undefined : url" target="_blank">
|
||||
<a :href="isShowable ? null : url" target="_blank">
|
||||
<Button
|
||||
:label="label"
|
||||
theme="gray"
|
||||
@@ -8,113 +8,72 @@
|
||||
@click="toggleDialog()"
|
||||
>
|
||||
<template #prefix>
|
||||
<component :is="icon" class="h-4 w-4" />
|
||||
<component :is="getIcon()" class="h-4 w-4" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<slot name="suffix" />
|
||||
</template>
|
||||
</Button>
|
||||
</a>
|
||||
<Dialog v-model:open="showDialog" :title="label" size="4xl">
|
||||
<template #default>
|
||||
<Dialog
|
||||
v-model="showDialog"
|
||||
:options="{
|
||||
title: label,
|
||||
size: '4xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div
|
||||
v-if="preview === 'text'"
|
||||
v-if="isText"
|
||||
class="prose prose-sm max-w-none whitespace-pre-wrap"
|
||||
>
|
||||
{{ content }}
|
||||
</div>
|
||||
<img
|
||||
v-if="preview === 'image'"
|
||||
:src="url"
|
||||
class="m-auto rounded-4 border"
|
||||
/>
|
||||
<video
|
||||
v-if="preview === 'video'"
|
||||
:src="url"
|
||||
controls
|
||||
class="m-auto max-h-[70vh] rounded-4 border"
|
||||
/>
|
||||
<img v-if="isImage" :src="url" class="m-auto rounded border" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Button, Dialog } from "frappe-ui";
|
||||
import { getType as getMime } from "mime";
|
||||
import { markRaw, ref, type Component } from "vue";
|
||||
import LucideFile from "~icons/lucide/file";
|
||||
import LucideFileImage from "~icons/lucide/file-image";
|
||||
import LucideFileSpreadsheet from "~icons/lucide/file-spreadsheet";
|
||||
import LucideFileText from "~icons/lucide/file-text";
|
||||
import LucideFileType from "~icons/lucide/file-type";
|
||||
import LucideFileVideo from "~icons/lucide/file-video";
|
||||
import LucideFileImage from "~icons/lucide/file-image";
|
||||
import LucideFileText from "~icons/lucide/file-text";
|
||||
import LucideFileSpreadsheet from "~icons/lucide/file-spreadsheet";
|
||||
import LucideFile from "~icons/lucide/file";
|
||||
|
||||
interface P {
|
||||
label: string;
|
||||
url?: string | null;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<P>(), {
|
||||
url: null,
|
||||
});
|
||||
|
||||
type AttachmentKind =
|
||||
| "image"
|
||||
| "video"
|
||||
| "pdf"
|
||||
| "spreadsheet"
|
||||
| "text"
|
||||
| "file";
|
||||
|
||||
const ICONS: Record<AttachmentKind, Component> = {
|
||||
image: markRaw(LucideFileImage),
|
||||
video: markRaw(LucideFileVideo),
|
||||
pdf: markRaw(LucideFileText),
|
||||
spreadsheet: markRaw(LucideFileSpreadsheet),
|
||||
text: markRaw(LucideFileType),
|
||||
file: markRaw(LucideFile),
|
||||
};
|
||||
|
||||
// browsers render only some image/video mimes (.dwg is image/vnd.dwg, .avi is
|
||||
// video/x-msvideo) — anything absent here becomes a download link like pdf
|
||||
type Preview = "image" | "video" | "text";
|
||||
|
||||
const PREVIEWS: Record<string, Preview> = {
|
||||
"image/png": "image",
|
||||
"image/jpeg": "image",
|
||||
"image/gif": "image",
|
||||
"image/webp": "image",
|
||||
"image/svg+xml": "image",
|
||||
"image/avif": "image",
|
||||
"image/bmp": "image",
|
||||
"image/apng": "image",
|
||||
"image/vnd.microsoft.icon": "image",
|
||||
"video/mp4": "video",
|
||||
"video/webm": "video",
|
||||
"video/ogg": "video",
|
||||
"video/x-m4v": "video",
|
||||
"text/plain": "text",
|
||||
};
|
||||
|
||||
function getKind(mime: string): AttachmentKind {
|
||||
if (mime.startsWith("image/")) return "image";
|
||||
if (mime.startsWith("video/")) return "video";
|
||||
if (mime === "application/pdf") return "pdf";
|
||||
if (mime.includes("spreadsheet")) return "spreadsheet";
|
||||
if (mime === "text/plain") return "text";
|
||||
return "file";
|
||||
}
|
||||
|
||||
const showDialog = ref(false);
|
||||
const mimeType = getMime(props.label) || "";
|
||||
const icon = ICONS[getKind(mimeType)];
|
||||
const preview = props.url ? PREVIEWS[mimeType] : undefined;
|
||||
const isImage = mimeType.startsWith("image/");
|
||||
const isPdf = mimeType === "application/pdf";
|
||||
const isSpreadsheet = mimeType.includes("spreadsheet");
|
||||
const isText = mimeType === "text/plain";
|
||||
const isShowable = props.url && (isText || isImage);
|
||||
const content = ref("");
|
||||
|
||||
function getIcon() {
|
||||
if (isText) return LucideFileType;
|
||||
else if (isImage) return LucideFileImage;
|
||||
else if (isPdf) return LucideFileText;
|
||||
else if (isSpreadsheet) return LucideFileSpreadsheet;
|
||||
else return LucideFile;
|
||||
}
|
||||
|
||||
function toggleDialog() {
|
||||
if (!preview) return;
|
||||
if (preview === "text") {
|
||||
if (!isShowable) return;
|
||||
if (isText) {
|
||||
fetch(props.url).then((res) => res.text().then((t) => (content.value = t)));
|
||||
}
|
||||
showDialog.value = !showDialog.value;
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<AttachmentItem
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.file_url"
|
||||
:label="attachment.file_name"
|
||||
:url="attachment.file_url"
|
||||
>
|
||||
<template #suffix>
|
||||
<LucideX
|
||||
class="size-3.5 cursor-pointer text-ink-gray-6 transition-colors hover:text-ink-gray-9"
|
||||
@click.stop.prevent="emit('remove', attachment)"
|
||||
/>
|
||||
</template>
|
||||
</AttachmentItem>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentItem } from "@/components";
|
||||
import LucideX from "~icons/lucide/x";
|
||||
|
||||
interface Attachment {
|
||||
file_url: string;
|
||||
file_name: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<{ attachments?: Attachment[] }>(), {
|
||||
attachments: () => [],
|
||||
});
|
||||
|
||||
const emit = defineEmits<{ remove: [attachment: Attachment] }>();
|
||||
</script>
|
||||
284
desk/src/components/Autocomplete.vue
Normal file
284
desk/src/components/Autocomplete.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<Combobox v-slot="{ open: isComboboxOpen }" v-model="selectedValue" nullable>
|
||||
<Popover v-model:show="showOptions">
|
||||
<template #target="{ open: openPopover, togglePopover }">
|
||||
<slot name="target" v-bind="{ open: openPopover, togglePopover }">
|
||||
<div class="w-full -ml-0.5">
|
||||
<button
|
||||
class="flex w-full items-center justify-between focus:outline-none"
|
||||
:class="inputClasses"
|
||||
@click="
|
||||
() => {
|
||||
!disabled && togglePopover();
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<slot name="prefix" />
|
||||
<span
|
||||
v-if="selectedValue"
|
||||
class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5"
|
||||
>
|
||||
{{ displayValue(selectedValue) }}
|
||||
</span>
|
||||
<span v-else class="text-base leading-5 text-gray-500">
|
||||
{{ placeholder || "" }}
|
||||
</span>
|
||||
</div>
|
||||
<FeatherIcon
|
||||
name="chevron-down"
|
||||
class="h-4 w-4 text-gray-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
placeholder="Search"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value;
|
||||
}
|
||||
"
|
||||
/>
|
||||
<button
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="selectedValue = null"
|
||||
>
|
||||
<FeatherIcon name="x" class="w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
>
|
||||
<div
|
||||
v-for="group in groups"
|
||||
v-show="group.items.length > 0"
|
||||
:key="group.key"
|
||||
class="mt-1.5"
|
||||
>
|
||||
<div
|
||||
v-if="group.group && !group.hideLabel"
|
||||
class="px-2.5 py-1.5 text-sm font-medium text-gray-500"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
<ComboboxOption
|
||||
v-for="option in group.items"
|
||||
:key="option.value"
|
||||
v-slot="{ active, selected }"
|
||||
as="template"
|
||||
:value="option"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex items-center rounded px-2.5 py-1.5 text-base',
|
||||
{ 'bg-gray-100': active },
|
||||
]"
|
||||
>
|
||||
<slot
|
||||
name="item-prefix"
|
||||
v-bind="{ active, selected, option }"
|
||||
/>
|
||||
<slot
|
||||
name="item-label"
|
||||
v-bind="{ active, selected, option }"
|
||||
>
|
||||
{{ option.label }}
|
||||
</slot>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</div>
|
||||
<li
|
||||
v-if="groups.length == 0"
|
||||
class="mt-1.5 rounded-md px-2.5 py-1.5 text-base text-gray-600"
|
||||
>
|
||||
No results found
|
||||
</li>
|
||||
</ComboboxOptions>
|
||||
<div v-if="slots.footer" class="border-t p-1.5 pb-0.5">
|
||||
<slot
|
||||
name="footer"
|
||||
v-bind="{ value: search?.el._value, close }"
|
||||
></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from "@headlessui/vue";
|
||||
import { Popover, Button, FeatherIcon } from "frappe-ui";
|
||||
import { ref, computed, useAttrs, useSlots, watch, nextTick } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
options: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "md",
|
||||
},
|
||||
variant: {
|
||||
type: String,
|
||||
default: "subtle",
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "update:query", "change"]);
|
||||
|
||||
const query = ref("");
|
||||
const showOptions = ref(false);
|
||||
const search = ref(null);
|
||||
|
||||
const attrs = useAttrs();
|
||||
const slots = useSlots();
|
||||
|
||||
const valuePropPassed = computed(() => "value" in attrs);
|
||||
|
||||
const selectedValue = computed({
|
||||
get() {
|
||||
return valuePropPassed.value ? attrs.value : props.modelValue;
|
||||
},
|
||||
set(val) {
|
||||
query.value = "";
|
||||
if (val) {
|
||||
showOptions.value = false;
|
||||
}
|
||||
emit(valuePropPassed.value ? "change" : "update:modelValue", val);
|
||||
},
|
||||
});
|
||||
|
||||
function close() {
|
||||
showOptions.value = false;
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
if (!props.options || props.options.length == 0) return [];
|
||||
|
||||
let groups = props.options[0]?.group
|
||||
? props.options
|
||||
: [{ group: "", items: props.options }];
|
||||
|
||||
return groups
|
||||
.map((group, i) => {
|
||||
return {
|
||||
key: i,
|
||||
group: group.group,
|
||||
hideLabel: group.hideLabel || false,
|
||||
items: props.filterable ? filterOptions(group.items) : group.items,
|
||||
};
|
||||
})
|
||||
.filter((group) => group.items.length > 0);
|
||||
});
|
||||
|
||||
function filterOptions(options) {
|
||||
if (!query.value) {
|
||||
return options;
|
||||
}
|
||||
return options.filter((option) => {
|
||||
let searchTexts = [option.label, option.value];
|
||||
return searchTexts.some((text) =>
|
||||
(text || "").toString().toLowerCase().includes(query.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function displayValue(option) {
|
||||
if (typeof option === "string") {
|
||||
let allOptions = groups.value.flatMap((group) => group.items);
|
||||
let selectedOption = allOptions.find((o) => o.value === option);
|
||||
return selectedOption?.label || option;
|
||||
}
|
||||
return option?.label;
|
||||
}
|
||||
|
||||
watch(query, (q) => {
|
||||
emit("update:query", q);
|
||||
});
|
||||
|
||||
watch(showOptions, (val) => {
|
||||
if (val) {
|
||||
nextTick(() => {
|
||||
search.value.el.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const textColor = computed(() => {
|
||||
return props.disabled ? "text-gray-600" : "text-gray-800";
|
||||
});
|
||||
|
||||
const inputClasses = computed(() => {
|
||||
let sizeClasses = {
|
||||
sm: "text-base rounded h-7",
|
||||
md: "text-base rounded h-8",
|
||||
lg: "text-lg rounded-md h-10",
|
||||
xl: "text-xl rounded-md h-10",
|
||||
}[props.size];
|
||||
|
||||
let paddingClasses = {
|
||||
sm: "py-1.5 px-2",
|
||||
md: "py-1.5 px-2.5",
|
||||
lg: "py-1.5 px-3",
|
||||
xl: "py-1.5 px-3",
|
||||
}[props.size];
|
||||
|
||||
let variant = props.disabled ? "disabled" : props.variant;
|
||||
let variantClasses = {
|
||||
subtle:
|
||||
"border border-gray-100 bg-gray-100 placeholder-gray-500 hover:border-gray-200 hover:bg-gray-200 focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400",
|
||||
outline:
|
||||
"border border-gray-300 bg-white placeholder-gray-500 hover:border-gray-400 hover:shadow-sm focus:bg-white focus:border-gray-500 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400",
|
||||
disabled: [
|
||||
"border bg-gray-50 placeholder-gray-400",
|
||||
props.variant === "outline" ? "border-gray-300" : "border-transparent",
|
||||
],
|
||||
}[variant];
|
||||
|
||||
return [
|
||||
sizeClasses,
|
||||
paddingClasses,
|
||||
variantClasses,
|
||||
textColor.value,
|
||||
"transition-colors w-full",
|
||||
];
|
||||
});
|
||||
|
||||
defineExpose({ query });
|
||||
</script>
|
||||
@@ -1,32 +0,0 @@
|
||||
<template>
|
||||
<Select
|
||||
:model-value="agentStatusStore.myStatus"
|
||||
:options="dropdownOptions"
|
||||
:placeholder="__('Set status')"
|
||||
variant="subtle"
|
||||
@update:model-value="agentStatusStore.setMyStatus"
|
||||
>
|
||||
<template #item-prefix="{ item }">
|
||||
<div
|
||||
class="size-2 rounded-full flex-shrink-0"
|
||||
:class="agentStatusStore.statusColor(item.value)"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAgentStatusStore } from "@/stores/agentStatus";
|
||||
import { __ } from "@/translation";
|
||||
import { Select } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
|
||||
const agentStatusStore = useAgentStatusStore();
|
||||
|
||||
const dropdownOptions = computed(() =>
|
||||
agentStatusStore.statusOptions.map((statusOption: string) => ({
|
||||
label: __(statusOption),
|
||||
value: statusOption,
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="lucide-chevron-left"
|
||||
:label="label"
|
||||
:size="size"
|
||||
class="!justify-start truncate font-medium text-ink-gray-8 hover:bg-transparent hover:opacity-70 focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 active:bg-transparent active:outline-none active:ring-0 rtl:flex-row-reverse ps-1"
|
||||
@click="$emit('back')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button } from "frappe-ui";
|
||||
|
||||
interface P {
|
||||
label: string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
interface E {
|
||||
(event: "back"): void;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<P>(), { size: "md" });
|
||||
defineEmits<E>();
|
||||
</script>
|
||||
@@ -1,283 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden">
|
||||
<CardBase
|
||||
:title="title"
|
||||
:text="chartData.text"
|
||||
:percentageChange="chartData.percentageChange"
|
||||
:chartConfig="hasData ? chartConfig : placeholderChartConfig"
|
||||
:currentDuration="currentDuration"
|
||||
:orientation="orientation"
|
||||
:timelineFilter="timelineFilter"
|
||||
@changeDuration="changeDuration"
|
||||
>
|
||||
<template v-if="$slots.text" #text>
|
||||
<slot name="text" v-bind="{ text: chartData.text }" />
|
||||
</template>
|
||||
</CardBase>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { __ } from "@/translation";
|
||||
import { buildPercentageChange } from "@/utils";
|
||||
import { EChartsOption } from "echarts";
|
||||
import { createResource } from "frappe-ui";
|
||||
import { computed, onMounted, ref, type PropType } from "vue";
|
||||
import CardBase from "./ChartCardBase.vue";
|
||||
|
||||
interface BarChartData {
|
||||
percentage_change?: number;
|
||||
total?: number;
|
||||
average?: number;
|
||||
data: { date: string; count: number }[] | Record<number, number>;
|
||||
total_reviews?: number;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
apiUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
data: {
|
||||
type: Object as PropType<BarChartData>,
|
||||
required: true,
|
||||
},
|
||||
barColor: {
|
||||
type: String,
|
||||
default: "#3BBDE5",
|
||||
},
|
||||
dt: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
dn: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
orientation: {
|
||||
type: String as PropType<"vertical" | "horizontal">,
|
||||
default: "vertical",
|
||||
},
|
||||
measure: {
|
||||
type: String as PropType<"total" | "average">,
|
||||
default: "total",
|
||||
},
|
||||
negativeIsBetter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
timelineFilter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
// the untranslated key: it is lowercased straight into the API period
|
||||
const currentDuration = ref("Last month");
|
||||
|
||||
const chartData = computed(() => {
|
||||
const isDataFetched = resource.fetched;
|
||||
const _data: BarChartData = isDataFetched ? resource.data : props.data;
|
||||
|
||||
// Star-distribution shape: data is { 1: n, 2: n, ... }
|
||||
if (!props.timelineFilter && _data?.data && !Array.isArray(_data.data)) {
|
||||
const distribution = _data.data as Record<number, number>;
|
||||
const values = [1, 2, 3, 4, 5].map((s) => distribution[s] ?? 0);
|
||||
return {
|
||||
labels: ["1", "2", "3", "4", "5"],
|
||||
counts: values,
|
||||
percentageChange: buildPercentageChange(0, props.negativeIsBetter),
|
||||
text: _data?.average ?? 0,
|
||||
isStarDistribution: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Time-series shape: data is [{ date, count }, ...]
|
||||
const timeData = Array.isArray(_data?.data)
|
||||
? (_data.data as { date: string; count: number }[])
|
||||
: [];
|
||||
const labels = timeData.map((item) => item.date);
|
||||
const counts = timeData.map((item) => item.count);
|
||||
const _percentageChange = _data?.percentage_change || 0;
|
||||
const percentageChange = buildPercentageChange(
|
||||
_percentageChange,
|
||||
props.negativeIsBetter
|
||||
);
|
||||
|
||||
return {
|
||||
labels,
|
||||
counts,
|
||||
percentageChange,
|
||||
text: props.measure === "average" ? _data?.average ?? 0 : _data?.total ?? 0,
|
||||
isStarDistribution: false,
|
||||
};
|
||||
});
|
||||
|
||||
const hasData = computed(() => {
|
||||
const counts = chartData.value.counts as number[];
|
||||
return counts.length > 0 && counts.some((c) => c > 0);
|
||||
});
|
||||
|
||||
const chartConfig = computed<EChartsOption>(() => {
|
||||
const color = props.barColor;
|
||||
|
||||
if (chartData.value.isStarDistribution) {
|
||||
const values = chartData.value.counts as number[];
|
||||
// rank by value descending: index 0 = highest, 4 = lowest
|
||||
const opacities = [1, 0.7, 0.5, 0.3, 0.2];
|
||||
const baseColor = props.barColor; // #E79913
|
||||
|
||||
// unique sorted counts descending (for tie-aware rank lookup)
|
||||
const uniqueSorted = [...new Set(values)].sort((a, b) => b - a);
|
||||
|
||||
const opacityByIndex: number[] = values.map((v) => {
|
||||
const rank = uniqueSorted.indexOf(v); // same count → same rank
|
||||
return opacities[Math.min(rank, opacities.length - 1)];
|
||||
});
|
||||
|
||||
// helper: hex + opacity → rgba
|
||||
const hexToRgba = (hex: string, alpha: number) => {
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
};
|
||||
|
||||
return {
|
||||
grid: { left: 4, right: 4, top: 16, bottom: 20, containLabel: false },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: ["1", "2", "3", "4", "5"],
|
||||
axisLine: { show: true, lineStyle: { color: "#ededed" } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: "#6b7280", fontSize: 11 },
|
||||
},
|
||||
yAxis: { type: "value", show: false, min: 0 },
|
||||
series: [
|
||||
{
|
||||
type: "bar",
|
||||
data: values.map((value, i) => ({
|
||||
value: value === 0 ? 0 : value,
|
||||
itemStyle: {
|
||||
color: hexToRgba(baseColor, opacityByIndex[i]),
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: "top" as const,
|
||||
formatter: value > 0 ? String(value) : "",
|
||||
color: "#6b7280",
|
||||
fontSize: 11,
|
||||
},
|
||||
})),
|
||||
barWidth: 16,
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
tooltip: { show: false },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: chartData.value.labels,
|
||||
show: false,
|
||||
boundaryGap: true,
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
show: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: (chartData.value.counts as number[]).map((value) => ({
|
||||
value,
|
||||
itemStyle: {
|
||||
color: value === 0 ? "#e5e7eb" : color,
|
||||
borderRadius: [3, 3, 0, 0],
|
||||
},
|
||||
})),
|
||||
type: "bar",
|
||||
barMaxWidth: 12,
|
||||
barMinHeight: 4,
|
||||
emphasis: { disabled: true },
|
||||
label: { show: false },
|
||||
},
|
||||
],
|
||||
grid: {
|
||||
left: 2,
|
||||
right: 2,
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
trigger: "axis",
|
||||
appendToBody: true,
|
||||
position: (point: number[]) => [point[0] + 12, point[1] - 50],
|
||||
axisPointer: { type: "none" },
|
||||
formatter: (params: any) => {
|
||||
const p = Array.isArray(params) ? params[0] : params;
|
||||
if (!p.value) return "";
|
||||
return `<span style="font-size:12px;color:#6b7280">${p.name}: <b style="color:#374151">${p.value}</b></span>`;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const placeholderChartConfig = computed<EChartsOption>(() => {
|
||||
const placeholderValues = Array.from(
|
||||
{ length: 7 },
|
||||
() => Math.floor(Math.random() * 8) + 3
|
||||
);
|
||||
return {
|
||||
xAxis: { type: "category", show: false, boundaryGap: true },
|
||||
yAxis: { type: "value", show: false },
|
||||
series: [
|
||||
{
|
||||
data: placeholderValues.map((value) => ({
|
||||
value,
|
||||
itemStyle: { color: "#f1f1f1", borderRadius: [3, 3, 0, 0] },
|
||||
})),
|
||||
type: "bar",
|
||||
barMaxWidth: 12,
|
||||
emphasis: { disabled: true },
|
||||
},
|
||||
],
|
||||
grid: { left: 2, right: 2, top: 2, bottom: 2 },
|
||||
tooltip: { show: false },
|
||||
animation: false,
|
||||
};
|
||||
});
|
||||
|
||||
const resource = createResource({
|
||||
url: props.apiUrl || "",
|
||||
type: "GET",
|
||||
makeParams: () => {
|
||||
const params: Record<string, any> = {
|
||||
period: currentDuration.value.toLowerCase(),
|
||||
};
|
||||
if (props.dt && props.dn) {
|
||||
params.dt = props.dt;
|
||||
params.dn = props.dn;
|
||||
}
|
||||
return params;
|
||||
},
|
||||
});
|
||||
|
||||
const changeDuration = (period: string) => {
|
||||
currentDuration.value = period;
|
||||
if (props.apiUrl) resource.submit();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.timelineFilter && props.apiUrl && !props.data?.data) {
|
||||
resource.submit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -5,7 +5,7 @@
|
||||
alt="Brand Logo"
|
||||
class="h-8 w-8 shrink-0 object-cover"
|
||||
/>
|
||||
<HDLogo v-else class="h-8 w-8 shrink-0 rounded-4" />
|
||||
<HDLogo v-else class="h-8 w-8 shrink-0 rounded" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
:label="activity?._caller?.label"
|
||||
size="md"
|
||||
/>
|
||||
<span class="font-medium text-ink-gray-8 ms-1">
|
||||
<span class="font-medium text-ink-gray-8 ml-1">
|
||||
{{ activity?._caller?.label }}
|
||||
</span>
|
||||
<span>{{
|
||||
@@ -16,7 +16,7 @@
|
||||
: __("has made a call")
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="ms-auto whitespace-nowrap">
|
||||
<div class="ml-auto whitespace-nowrap">
|
||||
<Tooltip :text="dateFormat(activity.creation, 'MMM D, dddd')">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __(timeAgo(activity.creation)) }}
|
||||
@@ -26,10 +26,10 @@
|
||||
</div>
|
||||
<div
|
||||
@click="showCallLogDetailModal = true"
|
||||
class="flex flex-col gap-2 cursor-pointer border-transparent shadow rounded-5 bg-surface-elevation-1 px-3 py-2.5 text-ink-gray-9"
|
||||
class="flex flex-col gap-2 cursor-pointer border-transparent shadow rounded-md bg-surface-cards px-3 py-2.5 text-ink-gray-9"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="inline-flex gap-2 items-center text-base-medium">
|
||||
<div class="inline-flex gap-2 items-center text-base font-medium">
|
||||
<div>
|
||||
{{
|
||||
activity.call_type == "Incoming"
|
||||
|
||||
110
desk/src/components/CannedResponseSelectorModal.vue
Normal file
110
desk/src/components/CannedResponseSelectorModal.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: 'Canned Responses',
|
||||
size: '4xl',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<TextInput
|
||||
ref="searchInput"
|
||||
v-model="search"
|
||||
type="text"
|
||||
:placeholder="'Site Down'"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="search" class="h-4 w-4 text-gray-500" />
|
||||
</template>
|
||||
</TextInput>
|
||||
<div
|
||||
v-if="filteredTemplates.length"
|
||||
class="mt-2 grid max-h-[560px] grid-cols-1 md:grid-cols-3 gap-2 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.name"
|
||||
class="flex h-56 cursor-pointer flex-col gap-2 rounded-lg border p-3 hover:bg-gray-100"
|
||||
@click="emit('apply', template)"
|
||||
>
|
||||
<div class="border-b pb-2 text-base font-semibold">
|
||||
{{ template.title }}
|
||||
</div>
|
||||
<TextEditor
|
||||
v-if="template.message"
|
||||
:content="template.message"
|
||||
:editable="false"
|
||||
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
|
||||
class="flex-1 overflow-hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-2">
|
||||
<div class="flex h-56 flex-col items-center justify-center">
|
||||
<div class="text-lg text-gray-500">
|
||||
{{ "No templates found" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end mt-4">
|
||||
<Button
|
||||
label="New Canned Response"
|
||||
@click="
|
||||
() => {
|
||||
$router.push('/canned-responses#new');
|
||||
templates.data = null;
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { TextEditor, createListResource } from "frappe-ui";
|
||||
import { ref, computed, nextTick, watch, onMounted, onUnmounted } from "vue";
|
||||
import { showEmailBox } from "../pages/ticket/modalStates";
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const show = defineModel();
|
||||
const searchInput = ref("");
|
||||
|
||||
const emit = defineEmits(["apply"]);
|
||||
|
||||
const search = ref("");
|
||||
|
||||
const templates = createListResource({
|
||||
type: "list",
|
||||
doctype: "HD Canned Response",
|
||||
cache: ["cannedResponses", props.doctype],
|
||||
fields: ["title", "message", "modified"],
|
||||
orderBy: "modified desc",
|
||||
pageLength: 99999,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (templates.data == null) {
|
||||
templates.fetch();
|
||||
}
|
||||
});
|
||||
onUnmounted(() => {
|
||||
showEmailBox.value = true;
|
||||
});
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
return (
|
||||
templates.data?.filter((template) => {
|
||||
return template.title.toLowerCase().includes(search.value.toLowerCase());
|
||||
}) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
watch(show, (value) => value && nextTick(() => searchInput.value?.el?.focus()));
|
||||
</script>
|
||||
@@ -1,173 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col w-full h-full rounded-5 p-4"
|
||||
:class="[orientation == 'horizontal' && 'pt-3']"
|
||||
>
|
||||
<slot name="title">
|
||||
<div class="text-ink-gray-5 text-base mb-2">
|
||||
{{ title }}
|
||||
</div>
|
||||
</slot>
|
||||
<div
|
||||
class="flex flex-col gap-2 h-full w-full"
|
||||
v-if="orientation === 'vertical'"
|
||||
>
|
||||
<div class="flex items-end w-full gap-2">
|
||||
<slot name="text">
|
||||
<div
|
||||
class="text-2xl-medium text-center text-ink-gray-8 whitespace-nowrap"
|
||||
>
|
||||
{{ text }}
|
||||
</div>
|
||||
</slot>
|
||||
<div v-if="timelineFilter" class="flex items-center text-sm gap-1">
|
||||
<div class="flex items-center gap-1" :class="percentageChange.color">
|
||||
<span
|
||||
v-if="percentageChange.icon"
|
||||
:class="percentageChange.icon"
|
||||
class="size-4"
|
||||
/>
|
||||
<div>{{ percentageChange.value }}%</div>
|
||||
</div>
|
||||
<Dropdown :options="durationOptions">
|
||||
<div
|
||||
class="flex items-center gap-0.5 text-ink-gray-5 hover:text-ink-gray-6 cursor-pointer shrink-0"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<span>vs</span>
|
||||
<span>{{ __(currentDuration).toLowerCase() }}</span>
|
||||
</div>
|
||||
<LucideChevronDown class="size-4" />
|
||||
</div>
|
||||
<template #item-label="{ item }">
|
||||
<div
|
||||
class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded-4 px-2 text-base focus:outline-none focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3 whitespace-nowrap text-ink-gray-7 cursor-pointer justify-between"
|
||||
>
|
||||
<span>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item-suffix="{ item }">
|
||||
<LucideCheck
|
||||
v-if="item.label == __(currentDuration)"
|
||||
class="size-4"
|
||||
/>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="chart">
|
||||
<div v-if="chartConfig" class="w-full h-full">
|
||||
<ECharts :options="chartConfig" class="w-full h-full" />
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 h-full w-full" v-else>
|
||||
<div class="flex items-end w-full gap-2 justify-between flex-1">
|
||||
<slot name="text">
|
||||
<span
|
||||
class="text-2xl-medium text-center text-ink-gray-8 whitespace-nowrap"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
</slot>
|
||||
<slot name="chart">
|
||||
<div v-if="chartConfig" class="h-full max-w-[50%] w-[50%]">
|
||||
<ECharts :options="chartConfig" class="w-full h-full" />
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="timelineFilter" class="flex items-center text-sm gap-1">
|
||||
<div class="flex items-center gap-1" :class="percentageChange.color">
|
||||
<span
|
||||
v-if="percentageChange.icon"
|
||||
:class="percentageChange.icon"
|
||||
class="size-4"
|
||||
/>
|
||||
<div>{{ percentageChange.value }}%</div>
|
||||
</div>
|
||||
<Dropdown :options="durationOptions">
|
||||
<div
|
||||
class="flex items-center gap-0.5 text-ink-gray-5 hover:text-ink-gray-6 cursor-pointer shrink-0"
|
||||
>
|
||||
vs {{ __(currentDuration).toLowerCase() }}
|
||||
<LucideChevronDown class="size-4" />
|
||||
</div>
|
||||
<template #item-label="{ item }">
|
||||
<div
|
||||
class="data-[disabled]:cursor-not-allowed group flex w-full items-center rounded-4 px-2 text-base focus:outline-none focus:bg-surface-gray-3 data-[highlighted]:bg-surface-gray-3 data-[state=open]:bg-surface-gray-3 whitespace-nowrap text-ink-gray-7 cursor-pointer justify-between"
|
||||
>
|
||||
<span>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #item-suffix="{ item }">
|
||||
<LucideCheck
|
||||
v-if="item.label == __(currentDuration)"
|
||||
class="size-4"
|
||||
/>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import { __ } from "@/translation";
|
||||
import { EChartsOption } from "echarts";
|
||||
import { Dropdown } from "frappe-ui";
|
||||
import { ECharts } from "frappe-ui/experimental";
|
||||
import { computed, type PropType } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
},
|
||||
text: {
|
||||
type: [Number, String] as PropType<number | string>,
|
||||
default: "",
|
||||
},
|
||||
percentageChange: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
chartConfig: {
|
||||
type: Object as PropType<EChartsOption>,
|
||||
required: true,
|
||||
},
|
||||
currentDuration: {
|
||||
type: String,
|
||||
default: __("Last month"),
|
||||
},
|
||||
orientation: {
|
||||
type: String as PropType<"vertical" | "horizontal">,
|
||||
default: "vertical",
|
||||
},
|
||||
timelineFilter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const chartConfig = computed<EChartsOption>(() => props.chartConfig);
|
||||
|
||||
const currentDuration = computed(() => props.currentDuration);
|
||||
|
||||
const emit = defineEmits(["changeDuration"]);
|
||||
|
||||
// the emitted value is the untranslated key: it travels to the API as the
|
||||
// period, so only the label goes through __()
|
||||
const DURATIONS = ["Last week", "Last month", "Last 3 months"];
|
||||
|
||||
const durationOptions = DURATIONS.map((duration) => ({
|
||||
label: __(duration),
|
||||
onClick: () => {
|
||||
if (currentDuration.value !== duration) emit("changeDuration", duration);
|
||||
},
|
||||
}));
|
||||
</script>
|
||||
203
desk/src/components/CommentBox.vue
Normal file
203
desk/src/components/CommentBox.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<div class="flex-col text-base flex-1" ref="commentBoxRef">
|
||||
<div class="mb-1 ml-0.5 flex items-center justify-between">
|
||||
<div class="text-gray-600 flex items-center gap-2">
|
||||
<Avatar
|
||||
size="md"
|
||||
:label="commenter"
|
||||
:image="getUser(commentedBy).user_image"
|
||||
/>
|
||||
<p>
|
||||
<span class="font-medium text-gray-800">
|
||||
{{ commenter }}
|
||||
</span>
|
||||
<span> added a</span>
|
||||
<span class="max-w-xs truncate font-medium text-gray-800">
|
||||
comment
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
|
||||
<span class="pl-0.5 text-sm text-gray-600">
|
||||
{{ timeAgo(creation) }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<div v-if="authStore.userId === commentedBy && !editable">
|
||||
<Dropdown
|
||||
:placement="'right'"
|
||||
:options="[
|
||||
{
|
||||
label: 'Edit',
|
||||
onClick: () => handleEditMode(),
|
||||
icon: 'edit-2',
|
||||
condition: () => !isTicketMergedComment,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: () => (showDialog = true),
|
||||
icon: 'trash-2',
|
||||
},
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
class="text-gray-600"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:id="`comment-${name}`"
|
||||
class="rounded bg-gray-50 transition-colors px-4 py-3"
|
||||
>
|
||||
<TextEditor
|
||||
ref="editorRef"
|
||||
:editor-class="[
|
||||
'prose-f shrink text-p-sm transition-all duration-300 ease-in-out block w-full content',
|
||||
getFontFamily(_content),
|
||||
]"
|
||||
:content="_content"
|
||||
:editable="editable"
|
||||
:bubble-menu="textEditorMenuButtons"
|
||||
@change="(event:string) => {_content = event}"
|
||||
>
|
||||
<template #bottom v-if="editable">
|
||||
<div class="flex flex-row-reverse gap-2">
|
||||
<Button label="Save" @click="handleSaveComment" variant="solid" />
|
||||
<Button label="Discard" @click="handleDiscard" />
|
||||
</div>
|
||||
</template>
|
||||
</TextEditor>
|
||||
<div class="flex flex-wrap gap-2" v-if="!editable">
|
||||
<AttachmentItem
|
||||
v-for="a in attachments"
|
||||
:key="a.file_url"
|
||||
:label="a.file_name"
|
||||
:url="a.file_url"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
v-model="showDialog"
|
||||
:options="{
|
||||
title: 'Delete Comment',
|
||||
message: 'Are you sure you want to confirm this action?',
|
||||
actions: [
|
||||
{ label: 'Cancel', onClick: () => (showDialog = false) },
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick: () => deleteComment.submit(),
|
||||
variant: 'solid',
|
||||
},
|
||||
],
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentItem } from "@/components";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { updateRes as updateComment } from "@/stores/knowledgeBase";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { CommentActivity } from "@/types";
|
||||
import {
|
||||
dateFormat,
|
||||
dateTooltipFormat,
|
||||
getFontFamily,
|
||||
isContentEmpty,
|
||||
textEditorMenuButtons,
|
||||
timeAgo,
|
||||
} from "@/utils";
|
||||
import {
|
||||
Avatar,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
TextEditor,
|
||||
createResource,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { PropType, computed, onMounted, ref } from "vue";
|
||||
const authStore = useAuthStore();
|
||||
const props = defineProps({
|
||||
activity: {
|
||||
type: Object as PropType<CommentActivity>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const { getUser } = useUserStore();
|
||||
|
||||
const { name, creation, content, commenter, commentedBy, attachments } =
|
||||
props.activity;
|
||||
|
||||
const isTicketMergedComment = computed(() => {
|
||||
const regex = /has been merged with ticket #\d+/;
|
||||
return regex.test(content);
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update"]);
|
||||
const showDialog = ref(false);
|
||||
const editable = ref(false);
|
||||
const _content = ref(content);
|
||||
|
||||
// HTML refs
|
||||
const commentBoxRef = ref(null);
|
||||
const editorRef = ref(null);
|
||||
|
||||
function handleEditMode() {
|
||||
editable.value = true;
|
||||
editorRef.value.editor.chain().focus("start");
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
_content.value = content;
|
||||
editable.value = false;
|
||||
}
|
||||
|
||||
const deleteComment = createResource({
|
||||
url: "frappe.client.delete",
|
||||
makeParams: () => ({
|
||||
doctype: "HD Ticket Comment",
|
||||
name: name,
|
||||
}),
|
||||
onSuccess() {
|
||||
emit("update");
|
||||
showDialog.value = false;
|
||||
toast.success("Comment deleted");
|
||||
},
|
||||
});
|
||||
|
||||
function handleSaveComment() {
|
||||
if (content === _content.value) {
|
||||
editable.value = false;
|
||||
return;
|
||||
}
|
||||
if (isContentEmpty(_content.value)) {
|
||||
toast.error("Comment cannot be empty");
|
||||
return;
|
||||
}
|
||||
|
||||
updateComment.submit(
|
||||
{
|
||||
doctype: "HD Ticket Comment",
|
||||
name: name,
|
||||
fieldname: "content",
|
||||
value: _content.value,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
editable.value = false;
|
||||
emit("update");
|
||||
toast.success("Comment updated");
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
commentBoxRef.value.style.width = "0px";
|
||||
});
|
||||
</script>
|
||||
@@ -1,142 +1,141 @@
|
||||
<template>
|
||||
<Editor
|
||||
<TextEditor
|
||||
v-if="agentsList.data"
|
||||
ref="editorRef"
|
||||
v-model="newComment"
|
||||
:extensions="extensions"
|
||||
:editable="editable"
|
||||
:editor-class="[
|
||||
'prose-sm max-w-none',
|
||||
editable &&
|
||||
'min-h-[7rem] mx-6 md:ml-10 md:mr-9 max-h-[50vh] overflow-y-auto border-t py-3',
|
||||
getFontFamily(newComment),
|
||||
]"
|
||||
:content="newComment"
|
||||
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
|
||||
:placeholder="placeholder"
|
||||
:upload-function="
|
||||
(file: any, options: any) =>
|
||||
track(uploadFunction(file, doctype, ticketId, true, options))
|
||||
"
|
||||
:editable="editable"
|
||||
:mentions="agents"
|
||||
@change="editable ? (newComment = $event) : null"
|
||||
:extensions="[PreserveVideoControls]"
|
||||
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
|
||||
>
|
||||
<template #default="{ isEmpty }">
|
||||
<!-- Scroll here so selected nodes aren't clipped. -->
|
||||
<div :class="editable && 'max-h-[44vh] overflow-y-auto'">
|
||||
<EditorContent
|
||||
:class="[
|
||||
'prose-sm max-w-none',
|
||||
editable && 'min-h-[7rem] mx-5 border-t py-3',
|
||||
getFontFamily(newComment),
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<!-- Attachments -->
|
||||
<AttachmentList
|
||||
class="my-2 ms-5"
|
||||
:attachments="attachments"
|
||||
@remove="removeAttachment"
|
||||
/>
|
||||
<div v-if="editable" class="flex flex-col gap-2 border-t">
|
||||
<div class="px-4">
|
||||
<!-- Fixed Menu -->
|
||||
<div class="flex justify-between gap-2 overflow-hidden py-2.5">
|
||||
<div class="flex min-w-0 flex-1 items-center overflow-x-auto">
|
||||
<div class="inline-flex items-center gap-1.5 p-1">
|
||||
<FileUploader
|
||||
:doctype="doctype"
|
||||
:docname="ticketId"
|
||||
private
|
||||
@success="(f) => attachments.push(f)"
|
||||
>
|
||||
<template #default="{ openFileSelector, uploading }">
|
||||
{{ void (loading = uploading) }}
|
||||
<button
|
||||
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
|
||||
@click="openFileSelector()"
|
||||
:disabled="uploading"
|
||||
>
|
||||
<LoadingIndicator v-if="uploading" class="h-4 w-4" />
|
||||
<AttachmentIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
style="stroke-width: 1.5 !important"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div class="h-4 w-[2px] border-s ml-1" />
|
||||
</div>
|
||||
<EditorFixedMenu :items="fullToolbar" />
|
||||
<EditorTableMenu />
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center justify-end gap-x-2">
|
||||
<Button
|
||||
label="Discard"
|
||||
@click="
|
||||
() => {
|
||||
newComment = '';
|
||||
attachments = [];
|
||||
dropUnused(null);
|
||||
emit('discard');
|
||||
}
|
||||
"
|
||||
<template #bottom>
|
||||
<div v-if="editable" class="flex flex-col gap-2 px-6 md:pl-10 md:pr-9">
|
||||
<!-- Attachments -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<AttachmentItem
|
||||
v-for="a in attachments"
|
||||
:key="a.file_url"
|
||||
:label="a.file_name"
|
||||
:url="!['MOV', 'MP4'].includes(a.file_type) ? a.file_url : null"
|
||||
>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
class="h-3.5"
|
||||
name="x"
|
||||
@click.stop="removeAttachment(a)"
|
||||
/>
|
||||
<!-- A disabled button fires no pointer events, so the span
|
||||
carries the hover for the tooltip -->
|
||||
<Tooltip
|
||||
:text="isUploading ? __('Please wait, media is uploading') : ''"
|
||||
>
|
||||
<span class="inline-flex">
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="label"
|
||||
:disabled="isDisabled"
|
||||
:loading="loading"
|
||||
@click="submitComment()"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</AttachmentItem>
|
||||
</div>
|
||||
<!-- Fixed Menu -->
|
||||
<div class="flex justify-between overflow-hidden border-t py-2.5">
|
||||
<div class="flex items-center overflow-x-auto w-[60%]">
|
||||
<TextEditorFixedMenu
|
||||
class="-ml-1"
|
||||
:buttons="textEditorMenuButtons"
|
||||
/>
|
||||
<FileUploader
|
||||
:upload-args="{
|
||||
doctype: doctype,
|
||||
docname: ticketId,
|
||||
private: true,
|
||||
}"
|
||||
@success="(f) => attachments.push(f)"
|
||||
>
|
||||
<template #default="{ openFileSelector }">
|
||||
<Button
|
||||
theme="gray"
|
||||
variant="ghost"
|
||||
@click="openFileSelector()"
|
||||
>
|
||||
<template #icon>
|
||||
<AttachmentIcon
|
||||
class="h-4"
|
||||
style="color: #000000; stroke-width: 1.5 !important"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
</div>
|
||||
<div class="flex items-center justify-end space-x-2 w-[40%]">
|
||||
<Button
|
||||
label="Discard"
|
||||
@click="
|
||||
() => {
|
||||
newComment = '';
|
||||
attachments = [];
|
||||
emit('discard');
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="label"
|
||||
:disabled="commentEmpty"
|
||||
:loading="loading"
|
||||
@click="
|
||||
() => {
|
||||
loading = true;
|
||||
submitComment();
|
||||
newComment = '';
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Editor>
|
||||
</TextEditor>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
FileUploader,
|
||||
LoadingIndicator,
|
||||
Tooltip,
|
||||
TextEditor,
|
||||
TextEditorFixedMenu,
|
||||
createResource,
|
||||
dayjs,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
EditorFixedMenu,
|
||||
EditorTableMenu,
|
||||
} from "frappe-ui/editor";
|
||||
import { addPendingActivity } from "@framework/ui/ActivityTimeline";
|
||||
import { useOnboarding } from "@framework/ui";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { AttachmentList } from "@/components/";
|
||||
import { buildEditorExtensions, fullToolbar } from "@/components/editor/config";
|
||||
import { AttachmentItem } from "@/components/";
|
||||
import { AttachmentIcon } from "@/components/icons/";
|
||||
import { useTyping } from "@/composables/realtime";
|
||||
import { useUploadTracker } from "@/composables/useUploadTracker";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { __ } from "@/translation";
|
||||
import { capture } from "@/telemetry";
|
||||
import { PreserveVideoControls } from "@/tiptap-extensions";
|
||||
import {
|
||||
getFontFamily,
|
||||
isContentEmpty,
|
||||
removeAttachmentFromServer,
|
||||
textEditorMenuButtons,
|
||||
uploadFunction,
|
||||
} from "@/utils";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
|
||||
const { agents: agentsList, dropdown } = storeToRefs(useAgentStore());
|
||||
const authStore = useAuthStore();
|
||||
const { isManager } = authStore;
|
||||
const { getUser } = useUserStore();
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
const { agents: agentsList } = useAgentStore();
|
||||
const { isManager } = useAuthStore();
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
agentsList.loading ||
|
||||
agentsList.data?.length ||
|
||||
agentsList.list.promise
|
||||
) {
|
||||
return;
|
||||
}
|
||||
agentsList.fetch();
|
||||
});
|
||||
|
||||
const props = defineProps({
|
||||
ticketId: {
|
||||
@@ -161,25 +160,43 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["submit", "discard", "sending", "restore"]);
|
||||
const emit = defineEmits(["submit", "discard"]);
|
||||
|
||||
const newComment = useStorage("commentBoxContent" + props.ticketId, null);
|
||||
|
||||
// Mentions as a reactive getter so the `@` list stays in sync as agents load.
|
||||
const extensions = buildEditorExtensions({
|
||||
mentions: () => dropdown.value ?? [],
|
||||
});
|
||||
|
||||
// Initialize typing composable
|
||||
const { onUserType, cleanup } = useTyping(props.ticketId);
|
||||
|
||||
const attachments = ref([]);
|
||||
const { isUploading, track, dropUnused } = useUploadTracker();
|
||||
const isDisabled = computed(() => {
|
||||
return isContentEmpty(newComment.value) || loading.value || isUploading.value;
|
||||
const commentEmpty = computed(() => {
|
||||
return isContentEmpty(newComment.value);
|
||||
});
|
||||
const loading = ref(false);
|
||||
|
||||
// Watch for changes in comment content to trigger typing events
|
||||
watch(newComment, (newValue, oldValue) => {
|
||||
if (newValue !== oldValue && newValue) {
|
||||
onUserType();
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const label = computed(() => {
|
||||
return loading.value ? "Sending..." : props.label;
|
||||
});
|
||||
|
||||
const agents = computed(() => {
|
||||
return (
|
||||
agentsList.data?.map((agent) => ({
|
||||
label: agent.agent_name.trimEnd(),
|
||||
value: agent.name,
|
||||
})) || []
|
||||
);
|
||||
});
|
||||
|
||||
function removeAttachment(attachment) {
|
||||
attachments.value = attachments.value.filter((a) => a !== attachment);
|
||||
removeAttachmentFromServer(attachment.name);
|
||||
@@ -189,58 +206,28 @@ async function submitComment() {
|
||||
if (isContentEmpty(newComment.value)) {
|
||||
return false;
|
||||
}
|
||||
// The keyboard shortcut reaches here without passing the disabled button
|
||||
if (isUploading.value || loading.value) return false;
|
||||
|
||||
const content = newComment.value;
|
||||
const sentAttachments = attachments.value;
|
||||
const pendingRow = addPendingActivity(props.doctype, props.ticketId, {
|
||||
type: "comment",
|
||||
timestamp: dayjs().format("YYYY-MM-DD HH:mm:ss"),
|
||||
author: {
|
||||
email: getUser(authStore.userId).email,
|
||||
// the users list may still be a stub with a guessed name and no avatar
|
||||
fullname: authStore.userName,
|
||||
image: authStore.userImage,
|
||||
},
|
||||
data: { name: "", content, attachments: sentAttachments },
|
||||
});
|
||||
|
||||
// drop before clearing, or an unmount mid-send deletes what the comment carries
|
||||
dropUnused(content);
|
||||
newComment.value = null;
|
||||
attachments.value = [];
|
||||
loading.value = true;
|
||||
emit("sending");
|
||||
|
||||
const comment = createResource({
|
||||
url: "run_doc_method",
|
||||
makeParams: () => ({
|
||||
dt: props.doctype,
|
||||
dn: props.ticketId,
|
||||
method: "new_comment",
|
||||
args: { content, attachments: sentAttachments },
|
||||
args: {
|
||||
content: newComment.value,
|
||||
attachments: attachments.value,
|
||||
},
|
||||
}),
|
||||
onSuccess: (res: { message?: string } | string) => {
|
||||
// run_doc_method answers with the whole body, since it always carries `docs`
|
||||
const name = typeof res === "string" ? res : res?.message;
|
||||
// the real row replaces the pending one the moment it arrives; unkeyed,
|
||||
// it would outlive it
|
||||
name ? pendingRow.resolve(`comment:${name}`) : pendingRow.drop();
|
||||
capture("comment_added");
|
||||
onSuccess: () => {
|
||||
if (isManager) {
|
||||
updateOnboardingStep?.("comment_on_ticket");
|
||||
updateOnboardingStep("comment_on_ticket");
|
||||
}
|
||||
emit("submit");
|
||||
loading.value = false;
|
||||
attachments.value = [];
|
||||
newComment.value = null;
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__("Could not add the comment"));
|
||||
pendingRow.drop();
|
||||
newComment.value = content;
|
||||
attachments.value = sentAttachments;
|
||||
loading.value = false;
|
||||
emit("restore");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -250,33 +237,6 @@ async function submitComment() {
|
||||
const editorRef = ref(null);
|
||||
const editor = computed(() => editorRef.value?.editor);
|
||||
|
||||
// Watch for changes in comment content to trigger typing events
|
||||
watch(newComment, (newValue, oldValue) => {
|
||||
if (newValue !== oldValue && newValue) {
|
||||
onUserType();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (
|
||||
agentsList.value.loading ||
|
||||
agentsList.value.data?.length ||
|
||||
agentsList.value.list.promise
|
||||
) {
|
||||
return;
|
||||
}
|
||||
agentsList.value.fetch();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup();
|
||||
// the saved draft still references what it shows, so only what it dropped goes
|
||||
dropUnused(newComment.value);
|
||||
if (isContentEmpty(newComment.value)) {
|
||||
localStorage.removeItem("commentBoxContent" + props.ticketId);
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
submitComment,
|
||||
editor,
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<div class="comm-area" resizable="true">
|
||||
<div class="comm-area">
|
||||
<div
|
||||
class="flex justify-between gap-3 border-t px-6 md:px-5 py-4 md:py-2.5"
|
||||
class="flex justify-between gap-3 border-t px-6 md:px-10 py-4 md:py-2.5"
|
||||
>
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<Button
|
||||
ref="sendEmailRef"
|
||||
variant="ghost"
|
||||
label="Reply"
|
||||
:class="[
|
||||
showEmailBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
|
||||
]"
|
||||
:class="[showEmailBox ? '!bg-gray-300 hover:!bg-gray-200' : '']"
|
||||
@click="toggleEmailBox()"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -20,9 +18,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
label="Comment"
|
||||
:class="[
|
||||
showCommentBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
|
||||
]"
|
||||
:class="[showCommentBox ? '!bg-gray-300 hover:!bg-gray-200' : '']"
|
||||
@click="toggleCommentBox()"
|
||||
>
|
||||
<template #prefix>
|
||||
@@ -32,87 +28,69 @@
|
||||
<TypingIndicator :ticketId="ticketId" />
|
||||
</div>
|
||||
</div>
|
||||
<Transition name="slide">
|
||||
<div
|
||||
v-show="showEmailBox"
|
||||
ref="emailBoxRef"
|
||||
@keydown.ctrl.enter.capture.stop="submitEmail"
|
||||
@keydown.meta.enter.capture.stop="submitEmail"
|
||||
@keydown.esc.capture.stop="showEmailBox = false"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<EmailEditor
|
||||
ref="emailEditorRef"
|
||||
:label="
|
||||
isMobileView ? 'Send' : isMac ? 'Send (⌘ + ⏎)' : 'Send (Ctrl + ⏎)'
|
||||
"
|
||||
placeholder="Hi John, we are looking into this issue."
|
||||
:ticketId="ticketId"
|
||||
:to-emails="toEmails"
|
||||
:cc-emails="ccEmails"
|
||||
:bcc-emails="bccEmails"
|
||||
@sending="
|
||||
() => {
|
||||
showEmailBox = false;
|
||||
}
|
||||
"
|
||||
@submit="() => emit('update')"
|
||||
@restore="
|
||||
() => {
|
||||
showEmailBox = true;
|
||||
}
|
||||
"
|
||||
@discard="
|
||||
() => {
|
||||
showEmailBox = false;
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="slide">
|
||||
<div
|
||||
v-show="showCommentBox"
|
||||
ref="commentBoxRef"
|
||||
@keydown.ctrl.enter.capture.stop="submitComment"
|
||||
@keydown.meta.enter.capture.stop="submitComment"
|
||||
@keydown.esc.capture.stop="showCommentBox = false"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<CommentTextEditor
|
||||
ref="commentTextEditorRef"
|
||||
:label="
|
||||
isMobileView
|
||||
? 'Comment'
|
||||
: isMac
|
||||
? 'Comment (⌘ + ⏎)'
|
||||
: 'Comment (Ctrl + ⏎)'
|
||||
"
|
||||
:ticketId="ticketId"
|
||||
:editable="showCommentBox"
|
||||
:doctype="doctype"
|
||||
placeholder="@John could you please look into this?"
|
||||
@sending="
|
||||
() => {
|
||||
showCommentBox = false;
|
||||
}
|
||||
"
|
||||
@submit="() => emit('update')"
|
||||
@restore="
|
||||
() => {
|
||||
showCommentBox = true;
|
||||
}
|
||||
"
|
||||
@discard="
|
||||
() => {
|
||||
showCommentBox = false;
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<div
|
||||
v-show="showEmailBox"
|
||||
ref="emailBoxRef"
|
||||
class="flex gap-1.5 flex-1"
|
||||
@keydown.ctrl.enter.capture.stop="submitEmail"
|
||||
@keydown.meta.enter.capture.stop="submitEmail"
|
||||
>
|
||||
<EmailEditor
|
||||
ref="emailEditorRef"
|
||||
:label="
|
||||
isMobileView ? 'Send' : isMac ? 'Send (⌘ + ⏎)' : 'Send (Ctrl + ⏎)'
|
||||
"
|
||||
v-model:content="content"
|
||||
placeholder="Hi John, we are looking into this issue."
|
||||
:ticketId="ticketId"
|
||||
:to-emails="toEmails"
|
||||
:cc-emails="ccEmails"
|
||||
:bcc-emails="bccEmails"
|
||||
@submit="
|
||||
() => {
|
||||
showEmailBox = false;
|
||||
emit('update');
|
||||
}
|
||||
"
|
||||
@discard="
|
||||
() => {
|
||||
showEmailBox = false;
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-show="showCommentBox"
|
||||
ref="commentBoxRef"
|
||||
@keydown.ctrl.enter.capture.stop="submitComment"
|
||||
@keydown.meta.enter.capture.stop="submitComment"
|
||||
>
|
||||
<CommentTextEditor
|
||||
ref="commentTextEditorRef"
|
||||
:label="
|
||||
isMobileView
|
||||
? 'Comment'
|
||||
: isMac
|
||||
? 'Comment (⌘ + ⏎)'
|
||||
: 'Comment (Ctrl + ⏎)'
|
||||
"
|
||||
:ticketId="ticketId"
|
||||
:editable="showCommentBox"
|
||||
:doctype="doctype"
|
||||
placeholder="@John could you please look into this?"
|
||||
@submit="
|
||||
() => {
|
||||
showCommentBox = false;
|
||||
emit('update');
|
||||
}
|
||||
"
|
||||
@discard="
|
||||
() => {
|
||||
showCommentBox = false;
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -121,10 +99,9 @@ import { CommentTextEditor, EmailEditor, TypingIndicator } from "@/components";
|
||||
import { CommentIcon, EmailIcon } from "@/components/icons/";
|
||||
import { useDevice } from "@/composables";
|
||||
import { useScreenSize } from "@/composables/screen";
|
||||
import { useShortcut } from "@/composables/shortcuts";
|
||||
import { showCommentBox, showEmailBox } from "@/pages/ticket/modalStates";
|
||||
import { onClickOutside } from "@vueuse/core";
|
||||
import { ref, watch } from "vue";
|
||||
import { useShortcut } from "@/composables/shortcuts";
|
||||
|
||||
const emit = defineEmits(["update"]);
|
||||
const content = defineModel("content");
|
||||
@@ -151,14 +128,16 @@ function toggleCommentBox() {
|
||||
showCommentBox.value = !showCommentBox.value;
|
||||
}
|
||||
|
||||
// the editors emit `submit` once the write lands; reloading here too would
|
||||
// fetch twice, and the guards' `false` never reaches this side anyway
|
||||
function submitEmail() {
|
||||
emailEditorRef.value.submitMail();
|
||||
if (emailEditorRef.value.submitMail()) {
|
||||
emit("update");
|
||||
}
|
||||
}
|
||||
|
||||
function submitComment() {
|
||||
commentTextEditorRef.value.submitComment();
|
||||
if (commentTextEditorRef.value.submitComment()) {
|
||||
emit("update");
|
||||
}
|
||||
}
|
||||
|
||||
function splitIfString(str: string | string[]) {
|
||||
@@ -206,7 +185,7 @@ watch(
|
||||
() => showEmailBox.value,
|
||||
(value) => {
|
||||
if (value) {
|
||||
emailEditorRef.value?.editor?.commands?.focus("start");
|
||||
emailEditorRef.value?.editor?.commands?.focus();
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -234,50 +213,19 @@ defineExpose({
|
||||
editor: emailEditorRef,
|
||||
});
|
||||
|
||||
const IGNORED_SELECTORS = [
|
||||
".tippy-box",
|
||||
".tippy-content",
|
||||
".PopoverContent",
|
||||
'[role="dialog"]',
|
||||
'[role="presentation"]',
|
||||
'[role="menu"]',
|
||||
".dialog-overlay",
|
||||
// Grammarly suggestions appear outside the box, allow them to stop collapsing.
|
||||
"grammarly-extension",
|
||||
"grammarly-popups",
|
||||
"[data-grammarly-part]",
|
||||
];
|
||||
import { onClickOutside } from "@vueuse/core";
|
||||
|
||||
// `ignore` is only consulted on pointerdown, which dialogs stop, so the click
|
||||
// through has to be checked too. Without this a dialog button closes the box.
|
||||
function isIgnored(event: Event): boolean {
|
||||
const target = event.target as HTMLElement | null;
|
||||
return Boolean(target?.closest?.(IGNORED_SELECTORS.join(", ")));
|
||||
}
|
||||
|
||||
onClickOutside(
|
||||
emailBoxRef,
|
||||
(event) => {
|
||||
if (showEmailBox.value && !isIgnored(event)) {
|
||||
showEmailBox.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
ignore: IGNORED_SELECTORS,
|
||||
onClickOutside(emailBoxRef, () => {
|
||||
if (showEmailBox.value) {
|
||||
showEmailBox.value = false;
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onClickOutside(
|
||||
commentBoxRef,
|
||||
(event) => {
|
||||
if (showCommentBox.value && !isIgnored(event)) {
|
||||
showCommentBox.value = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
ignore: IGNORED_SELECTORS,
|
||||
onClickOutside(commentBoxRef, () => {
|
||||
if (showCommentBox.value) {
|
||||
showCommentBox.value = false;
|
||||
}
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -286,18 +234,4 @@ onClickOutside(
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
.slide-enter-active,
|
||||
.slide-leave-active {
|
||||
display: grid;
|
||||
transition: grid-template-rows 0.25s ease;
|
||||
}
|
||||
.slide-enter-from,
|
||||
.slide-leave-to {
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
.slide-enter-to,
|
||||
.slide-leave-from {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<Editor
|
||||
ref="editorRef"
|
||||
v-model="internalContent"
|
||||
:extensions="extensions"
|
||||
v-bind="uploadFn ? { uploadFunction: uploadFn } : {}"
|
||||
:placeholder="placeholder"
|
||||
>
|
||||
<template #default>
|
||||
<EditorBubbleMenu :items="commentToolbar" />
|
||||
<div
|
||||
class="flex items-center overflow-x-auto rounded-t-4 border border-b-0 border-outline-gray-1 bg-surface-gray-1 px-1 py-1"
|
||||
>
|
||||
<div
|
||||
v-if="showAttachments"
|
||||
class="inline-flex items-center gap-1.5 pe-1"
|
||||
>
|
||||
<FileUploader private @success="addAttachment">
|
||||
<template #default="{ openFileSelector, uploading }">
|
||||
{{ syncUploadingState(uploading) }}
|
||||
<button
|
||||
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors hover:bg-surface-gray-3 disabled:opacity-40"
|
||||
:disabled="uploading"
|
||||
@click="openFileSelector()"
|
||||
>
|
||||
<LoadingIndicator v-if="uploading" class="h-4 w-4" />
|
||||
<AttachmentIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
style="stroke-width: 1.5 !important"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<div class="h-4 w-[2px] border-s ml-1" />
|
||||
</div>
|
||||
<EditorFixedMenu :items="fullToolbar" />
|
||||
<EditorTableMenu />
|
||||
</div>
|
||||
<EditorContent :class="editorClass" />
|
||||
</template>
|
||||
</Editor>
|
||||
<AttachmentList
|
||||
v-if="showAttachments && attachments?.length"
|
||||
class="mt-2"
|
||||
:attachments="attachments ?? []"
|
||||
@remove="removeAttachment"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentList } from "@/components";
|
||||
import {
|
||||
buildEditorExtensions,
|
||||
commentToolbar,
|
||||
fullToolbar,
|
||||
} from "@/components/editor/config";
|
||||
import { AttachmentIcon } from "@/components/icons";
|
||||
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
|
||||
import { isContentEmpty } from "@/utils";
|
||||
import { FileUploader, LoadingIndicator, type UploadedFile } from "frappe-ui";
|
||||
import {
|
||||
Editor,
|
||||
EditorBubbleMenu,
|
||||
EditorContent,
|
||||
EditorFixedMenu,
|
||||
EditorTableMenu,
|
||||
} from "frappe-ui/editor";
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
|
||||
type UploadFunction = (file: File) => Promise<UploadedFile>;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
placeholder?: string;
|
||||
minHeight?: string;
|
||||
maxHeight?: string;
|
||||
extensions?: any[];
|
||||
showSignature?: boolean;
|
||||
showAttachments?: boolean;
|
||||
uploadFn?: UploadFunction;
|
||||
}>(),
|
||||
{
|
||||
placeholder: "",
|
||||
minHeight: "min-h-[180px]",
|
||||
maxHeight: "max-h-80",
|
||||
extensions: () => [],
|
||||
showSignature: false,
|
||||
showAttachments: false,
|
||||
}
|
||||
);
|
||||
|
||||
const internalContent = defineModel<string>({ default: "" });
|
||||
|
||||
const editorRef = ref<any>(null);
|
||||
const attachments = defineModel<UploadedFile[] | null>("attachments", {
|
||||
default: () => [],
|
||||
});
|
||||
const isUploading = ref(false);
|
||||
|
||||
const extensions = buildEditorExtensions({ extra: props.extensions });
|
||||
|
||||
const contentClass = [
|
||||
"!prose-sm max-w-full overflow-auto py-1.5 px-3",
|
||||
"rounded-b-4 border border-outline-gray-1 bg-surface-base",
|
||||
"placeholder-ink-gray-4 text-ink-gray-8 -mt-0.5",
|
||||
];
|
||||
|
||||
const editorClass = computed(() => [
|
||||
...contentClass,
|
||||
props.minHeight,
|
||||
props.maxHeight,
|
||||
]);
|
||||
|
||||
const userResource = getUserEmailInfo();
|
||||
|
||||
function getDefaultContent(signature: string): string {
|
||||
return `<p></p><p></p>${signature}`;
|
||||
}
|
||||
|
||||
function applySignature(signature: string) {
|
||||
if (!isContentEmpty(internalContent.value)) return;
|
||||
const commands = editorRef.value?.editor?.commands;
|
||||
if (commands) {
|
||||
commands.setContent(signature);
|
||||
setTimeout(() => {
|
||||
editorRef.value?.editor?.commands?.focus("start");
|
||||
}, 0);
|
||||
} else {
|
||||
internalContent.value = signature;
|
||||
}
|
||||
}
|
||||
|
||||
function addAttachment(file: UploadedFile) {
|
||||
attachments.value = [...(attachments.value ?? []), file];
|
||||
}
|
||||
|
||||
function removeAttachment(attachment: UploadedFile) {
|
||||
attachments.value = (attachments.value ?? []).filter((a) => a !== attachment);
|
||||
}
|
||||
|
||||
// FileUploader exposes `uploading` only via slot scope, so mirror it into a
|
||||
// ref that the parent can read through the exposed `isUploading`.
|
||||
function syncUploadingState(uploading: boolean): string {
|
||||
isUploading.value = uploading;
|
||||
return "";
|
||||
}
|
||||
|
||||
function getContent(): string {
|
||||
return editorRef.value?.editor?.getHTML() ?? "";
|
||||
}
|
||||
|
||||
function isEmpty(): boolean {
|
||||
return !editorRef.value?.editor?.state?.doc?.textContent?.trim()?.length;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
const commands = editorRef.value?.editor?.commands;
|
||||
if (!commands) {
|
||||
internalContent.value = "";
|
||||
return;
|
||||
}
|
||||
const data = userResource?.data as { email_signature?: string } | null;
|
||||
const sig = data?.email_signature
|
||||
? getDefaultContent(data.email_signature)
|
||||
: "";
|
||||
commands.setContent(sig || "<p></p>");
|
||||
internalContent.value = sig;
|
||||
attachments.value = null;
|
||||
setTimeout(() => {
|
||||
editorRef.value?.editor?.commands?.focus("start");
|
||||
}, 0);
|
||||
}
|
||||
|
||||
if (props.showSignature) {
|
||||
// Late arrival: resource resolves after mount
|
||||
watch(
|
||||
() => userResource.data,
|
||||
(data: { email_signature?: string } | null) => {
|
||||
if (!data?.email_signature) return;
|
||||
applySignature(getDefaultContent(data.email_signature));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!props.showSignature) return;
|
||||
|
||||
nextTick(() => {
|
||||
const data = userResource.data as { email_signature?: string } | null;
|
||||
if (data?.email_signature) {
|
||||
applySignature(getDefaultContent(data.email_signature));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
getContent,
|
||||
isEmpty,
|
||||
reset,
|
||||
editorRef,
|
||||
isUploading,
|
||||
});
|
||||
</script>
|
||||
@@ -1,12 +1,8 @@
|
||||
<template>
|
||||
<Dialog v-model:open="isOpen" :title="title" @close="closeDialog">
|
||||
<template #default>
|
||||
<Dialog v-model="showDialog" :options="{ title }" @close="closeDialog">
|
||||
<template #body-content>
|
||||
<div class="space-y-4">
|
||||
<p
|
||||
class="text-p-base text-ink-gray-8"
|
||||
v-if="message"
|
||||
v-html="message"
|
||||
/>
|
||||
<p class="text-p-base text-gray-800" v-if="message" v-html="message" />
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
@@ -32,12 +28,6 @@ export default {
|
||||
message: {
|
||||
type: String,
|
||||
},
|
||||
// Optional v-model; when absent, visibility falls back to the internal
|
||||
// state (open on mount), preserving the `v-if` + `show()`/`hide()` mode.
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: undefined,
|
||||
},
|
||||
onConfirm: {
|
||||
type: Function,
|
||||
default: null,
|
||||
@@ -47,7 +37,6 @@ export default {
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
expose: ["show", "hide"],
|
||||
components: {
|
||||
Dialog,
|
||||
@@ -59,19 +48,6 @@ export default {
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isOpen: {
|
||||
get() {
|
||||
return this.modelValue !== undefined
|
||||
? this.modelValue
|
||||
: this.showDialog;
|
||||
},
|
||||
set(value) {
|
||||
this.showDialog = value;
|
||||
this.$emit("update:modelValue", value);
|
||||
},
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async handleConfirmation() {
|
||||
if (this.isLoading) return;
|
||||
@@ -90,14 +66,14 @@ export default {
|
||||
}
|
||||
},
|
||||
show() {
|
||||
this.isOpen = true;
|
||||
this.showDialog = true;
|
||||
},
|
||||
closeDialog() {
|
||||
this.hide();
|
||||
this.onCancel?.();
|
||||
},
|
||||
hide() {
|
||||
this.isOpen = false;
|
||||
this.showDialog = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<div v-if="normalActions.length" class="flex gap-2">
|
||||
<!-- onClick rides along in `action`; binding it twice would run it twice -->
|
||||
<Button
|
||||
v-for="action in normalActions"
|
||||
:key="action.label"
|
||||
:label="action.label"
|
||||
v-bind="action"
|
||||
@click="action.onClick()"
|
||||
>
|
||||
<template v-if="action.icon" #prefix>
|
||||
<Icon :icon="action.icon" class="h-4 w-4" />
|
||||
<FeatherIcon :name="action.icon" class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -17,8 +16,8 @@
|
||||
<Dropdown v-slot="{ open }" :options="g.action">
|
||||
<Button :label="g.label">
|
||||
<template #suffix>
|
||||
<component
|
||||
:is="open ? LucideChevronUp : LucideChevronDown"
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
@@ -27,14 +26,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown v-if="groupedActions.length" :options="groupedActions">
|
||||
<Button icon="lucide-more-horizontal" variant="ghost" />
|
||||
<Button icon="more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LucideChevronUp from "~icons/lucide/chevron-up";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import Icon from "@/components/Icon.vue";
|
||||
import { Dropdown } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
<template>
|
||||
<Dialog v-model:open="open" :title="dialogTitle" size="md">
|
||||
<template #default>
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-p-base text-ink-gray-7">{{ message }}</p>
|
||||
<Checkbox
|
||||
v-if="count"
|
||||
size="sm"
|
||||
v-model="deleteLinkedTickets"
|
||||
:label="__('Delete {0} ticket(s)', [count])"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
variant="solid"
|
||||
theme="red"
|
||||
icon-left="lucide-trash-2"
|
||||
:label="__('Delete')"
|
||||
:loading="isDeleting"
|
||||
@click="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { __ } from "@/translation";
|
||||
import { Button, Checkbox, createResource, Dialog } from "frappe-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
/** Name of the record being deleted. */
|
||||
name: string;
|
||||
/** HD Ticket link field that ties tickets to this record. */
|
||||
linkField: "customer" | "contact";
|
||||
/** Confirmation copy shown above the checkbox. */
|
||||
message: string;
|
||||
title?: string;
|
||||
/** Async delete handler; the button stays in a loading state until it settles. */
|
||||
onDelete: (payload: { deleteLinkedTickets: boolean }) => Promise<void> | void;
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>({ default: false });
|
||||
|
||||
const resource = createResource({
|
||||
url: "frappe.client.get_count",
|
||||
params: {
|
||||
doctype: "HD Ticket",
|
||||
filters: { [props.linkField]: props.name },
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
|
||||
const count = computed<number>(() => {
|
||||
if (resource.loading) return 0;
|
||||
return resource.data ?? 0;
|
||||
});
|
||||
|
||||
const deleteLinkedTickets = ref(false);
|
||||
const isDeleting = ref(false);
|
||||
|
||||
const dialogTitle = computed(() => props.title ?? __("Delete"));
|
||||
|
||||
async function confirmDelete() {
|
||||
if (isDeleting.value) return;
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await props.onDelete({ deleteLinkedTickets: deleteLinkedTickets.value });
|
||||
open.value = false;
|
||||
} catch {
|
||||
// Keep the dialog open on failure; the handler surfaces the error.
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
open,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
resource.reload();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -34,11 +34,9 @@ function handleDiscard() {
|
||||
},
|
||||
actions: [
|
||||
{
|
||||
label: "Delete",
|
||||
theme: "red",
|
||||
iconLeft: "lucide-trash-2",
|
||||
label: "Confirm",
|
||||
variant: "solid",
|
||||
onClick({ close }: { close: () => void }) {
|
||||
onClick(close: Function) {
|
||||
emit("discard");
|
||||
close();
|
||||
},
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<template>
|
||||
<Tooltip text="View documentation">
|
||||
<a :href="url" target="_blank">
|
||||
<lucide-circle-question-mark
|
||||
class="h-4 w-4 text-ink-gray-6"
|
||||
:class="color"
|
||||
/>
|
||||
<lucide-info class="h-4 w-4 text-ink-gray-6" :class="color" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</template>
|
||||
|
||||
273
desk/src/components/EmailArea.vue
Normal file
273
desk/src/components/EmailArea.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<template>
|
||||
<div
|
||||
:id="`communication-${name}`"
|
||||
v-bind="$attrs"
|
||||
class="grow cursor-pointer border-transparent bg-white rounded-md shadow text-base leading-6 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2"
|
||||
:class="isMobileView && 'items-start'"
|
||||
>
|
||||
<!-- email design for mobile -->
|
||||
<div v-if="isMobileView" class="flex items-center gap-2 text-sm">
|
||||
<div class="leading-tight">
|
||||
<p>{{ sender.full_name || "No name found" }}</p>
|
||||
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
|
||||
<p class="text-xs md:text-sm text-gray-600">
|
||||
{{ timeAgo(creation) }}
|
||||
</p>
|
||||
</Tooltip>
|
||||
<p class="sm:flex hidden text-sm text-gray-600" v-if="sender.name">
|
||||
{{ "<" + sender.name + ">" }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- email design for desktop -->
|
||||
<div v-else class="flex items-center gap-1">
|
||||
<span>{{ sender.full_name || "No name found" }}</span>
|
||||
<span class="sm:flex hidden text-sm text-gray-600" v-if="sender.name">{{
|
||||
"<" + sender.name + ">"
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-0.5 items-center">
|
||||
<Badge
|
||||
v-if="status.label"
|
||||
:label="__(status.label)"
|
||||
variant="subtle"
|
||||
:theme="status.color"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
<Tooltip
|
||||
:text="dateFormat(creation, dateTooltipFormat)"
|
||||
v-if="!isMobileView"
|
||||
>
|
||||
<p class="text-xs md:text-sm text-gray-600">
|
||||
{{ timeAgo(creation) }}
|
||||
</p>
|
||||
</Tooltip>
|
||||
<Button variant="ghost" class="text-gray-700" @click="reply">
|
||||
<ReplyIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" class="text-gray-700" @click="replyAll">
|
||||
<ReplyAllIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
<Dropdown
|
||||
v-if="showSplitOption"
|
||||
:placement="'right'"
|
||||
:options="[
|
||||
{
|
||||
label: 'Split Ticket',
|
||||
icon: LucideSplit,
|
||||
onClick: () => (showSplitModal = true),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
class="text-gray-600"
|
||||
variant="ghost"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="text-sm leading-5 text-gray-600">
|
||||
{{ subject }}
|
||||
</div> -->
|
||||
<div class="text-sm leading-5 text-gray-600">
|
||||
<span v-if="to" class="text-2xs mr-1 font-bold text-gray-500">TO:</span>
|
||||
<span v-if="to"> {{ to }} </span>
|
||||
<span v-if="cc">, </span>
|
||||
<span v-if="cc" class="text-2xs mr-1 font-bold text-gray-500"> CC: </span>
|
||||
<span v-if="cc">{{ cc }}</span>
|
||||
<span v-if="bcc">, </span>
|
||||
<span v-if="bcc" class="text-2xs mr-1 font-bold text-gray-500">
|
||||
BCC:
|
||||
</span>
|
||||
<span v-if="bcc">{{ bcc }}</span>
|
||||
</div>
|
||||
<div class="border-0 border-t my-3 border-outline-gray-modals" />
|
||||
<EmailContent :content="content" />
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<AttachmentItem
|
||||
v-for="a in attachments"
|
||||
:key="a.file_url"
|
||||
:label="a.file_name"
|
||||
:url="a.file_url"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TicketSplitModal
|
||||
v-model="showSplitModal"
|
||||
:ticket_id="name"
|
||||
:communication_id="name"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentItem } from "@/components";
|
||||
import { useScreenSize } from "@/composables/screen";
|
||||
import { dateFormat, dateTooltipFormat, timeAgo } from "@/utils";
|
||||
import { Dropdown } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
import LucideSplit from "~icons/lucide/split";
|
||||
import { ReplyAllIcon, ReplyIcon } from "./icons";
|
||||
import TicketSplitModal from "./ticket/TicketSplitModal.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { storeToRefs } from "pinia";
|
||||
|
||||
const props = defineProps({
|
||||
activity: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showSplitOption: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
sender,
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
creation,
|
||||
subject,
|
||||
attachments,
|
||||
content,
|
||||
name,
|
||||
deliveryStatus,
|
||||
} = props.activity;
|
||||
|
||||
const emit = defineEmits(["reply"]);
|
||||
|
||||
const auth = storeToRefs(useAuthStore());
|
||||
|
||||
const { isMobileView } = useScreenSize();
|
||||
|
||||
const showSplitModal = ref(false);
|
||||
|
||||
const status = computed(() => {
|
||||
let _status = deliveryStatus;
|
||||
let indicator_color = "red";
|
||||
if (["Sent", "Clicked"].includes(_status)) {
|
||||
indicator_color = "green";
|
||||
} else if (["Sending", "Scheduled"].includes(_status)) {
|
||||
indicator_color = "orange";
|
||||
} else if (["Opened", "Read"].includes(_status)) {
|
||||
indicator_color = "blue";
|
||||
} else if (_status == "Error") {
|
||||
indicator_color = "red";
|
||||
}
|
||||
return { label: _status, color: indicator_color };
|
||||
});
|
||||
|
||||
const reply = () => {
|
||||
const user = auth.user.value;
|
||||
emit("reply", {
|
||||
content: content,
|
||||
to: user === sender.name ? to : sender.name,
|
||||
});
|
||||
};
|
||||
|
||||
const replyAll = () => {
|
||||
const user = auth.user.value;
|
||||
|
||||
const normalizeAndFilter = (field) => {
|
||||
let arr;
|
||||
if (typeof field === "string") {
|
||||
arr = field.split(",").map((s) => s.trim());
|
||||
} else {
|
||||
arr = field || [];
|
||||
}
|
||||
return arr.filter((item) => item !== user && item !== sender.name);
|
||||
};
|
||||
|
||||
const filteredTo = normalizeAndFilter(to);
|
||||
const filteredCc = normalizeAndFilter(cc);
|
||||
const filteredBcc = normalizeAndFilter(bcc);
|
||||
|
||||
let _to, _cc, _bcc;
|
||||
|
||||
if (user === sender.name) {
|
||||
// User is the sender, reply to all original recipients
|
||||
_to = filteredTo.join(", ");
|
||||
_cc = filteredCc;
|
||||
_bcc = filteredBcc;
|
||||
} else {
|
||||
// User is a recipient, reply to sender with all other recipients in cc
|
||||
_to = sender.name;
|
||||
_cc = [...filteredTo, ...filteredCc];
|
||||
_bcc = filteredBcc;
|
||||
}
|
||||
|
||||
emit("reply", {
|
||||
content: content,
|
||||
to: _to,
|
||||
cc: _cc.filter(Boolean),
|
||||
bcc: _bcc.filter(Boolean),
|
||||
});
|
||||
};
|
||||
|
||||
// TODO: Implement reply functionality using this way instead of emit drillup
|
||||
// function reply(email, reply_all = false) {
|
||||
// emailBox.toggleEmailBox();
|
||||
// let editor = emailBox.editor;
|
||||
// let message = email.content;
|
||||
// let recipients = sender.name;
|
||||
// editor.toEmails = [email.sender];
|
||||
// editor.cc = editor.bcc = false;
|
||||
// editor.ccEmails = [];
|
||||
// editor.bccEmails = [];
|
||||
// console.log(recipients);
|
||||
|
||||
// if (!email.subject.startsWith("Re:")) {
|
||||
// editor.subject = `Re: ${email.subject}`;
|
||||
// } else {
|
||||
// editor.subject = email.subject;
|
||||
// }
|
||||
|
||||
// if (reply_all) {
|
||||
// let cc = email.cc?.split(",").map((r) => r.trim());
|
||||
// let bcc = email.bcc?.split(",").map((r) => r.trim());
|
||||
|
||||
// if (cc?.length) {
|
||||
// recipients = recipients.filter((r) => !cc?.includes(r));
|
||||
// cc.push(...recipients);
|
||||
// } else {
|
||||
// cc = recipients;
|
||||
// }
|
||||
|
||||
// editor.cc = cc ? true : false;
|
||||
// editor.bcc = bcc ? true : false;
|
||||
|
||||
// editor.ccEmails = cc;
|
||||
// editor.bccEmails = bcc;
|
||||
// }
|
||||
|
||||
// let repliedMessage = `<blockquote>${message}</blockquote>`;
|
||||
|
||||
// editor.editor
|
||||
// .chain()
|
||||
// .clearContent()
|
||||
// .insertContent("<p>.</p>")
|
||||
// .updateAttributes("paragraph", { class: "reply-to-content" })
|
||||
// .insertContent(repliedMessage)
|
||||
// .focus("all")
|
||||
// .insertContentAt(0, { type: "paragraph" })
|
||||
// .focus("start")
|
||||
// .run();
|
||||
// }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.email-content {
|
||||
max-width: 100%;
|
||||
}
|
||||
.email-content > * {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -6,9 +6,9 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { dataTheme, getFontFamily, stripEmailColors } from "@/utils";
|
||||
import { computed, ref, watch } from "vue";
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { getFontFamily } from "@/utils";
|
||||
|
||||
const props = defineProps({
|
||||
content: {
|
||||
@@ -17,30 +17,12 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const iframeRef = ref<HTMLIFrameElement | null>(null);
|
||||
const files = import.meta.globEager("/src/index.css", { query: "?inline" });
|
||||
const css = files["/src/index.css"].default;
|
||||
|
||||
const iframeRef = ref(null);
|
||||
const _content = ref(props.content);
|
||||
|
||||
const themedContent = computed(() =>
|
||||
dataTheme.value === "dark" ? stripEmailColors(_content.value) : _content.value
|
||||
);
|
||||
|
||||
// Get CSS path - in dev Vite serves it directly, in prod we need the built path
|
||||
const cssHref = computed(() => {
|
||||
if (import.meta.env.DEV) {
|
||||
return "/src/index.css";
|
||||
}
|
||||
// In production, find the built CSS file from the document
|
||||
const links = document.querySelectorAll('link[rel="stylesheet"]');
|
||||
for (const link of links) {
|
||||
const href = link.getAttribute("href");
|
||||
if (href?.includes("/assets/helpdesk/desk/") && href.endsWith(".css")) {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
// Fallback to a reasonable path
|
||||
return "/assets/helpdesk/desk/index.css";
|
||||
});
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(_content.value, "text/html");
|
||||
|
||||
@@ -48,11 +30,7 @@ const gmailReplyToContent = doc.querySelectorAll("div.gmail_quote");
|
||||
const outlookReplyToContent = doc.querySelectorAll("div#appendonsend");
|
||||
const replyToContent = doc.querySelectorAll("p.reply-to-content");
|
||||
|
||||
if (doc.body.children.length === 0) {
|
||||
// Plain-text emails (e.g. from Thunderbird) arrive as text with newlines and no spacing losing formatting.
|
||||
const plainText = doc.body.innerHTML.replace(/\n\n/g, "\n");
|
||||
_content.value = `<div class="whitespace-pre-wrap">${plainText}</div>`;
|
||||
} else if (gmailReplyToContent.length) {
|
||||
if (gmailReplyToContent.length) {
|
||||
_content.value = parseReplyToContent(doc, "div.gmail_quote", true);
|
||||
} else if (outlookReplyToContent.length) {
|
||||
_content.value = parseReplyToContent(doc, "div#appendonsend");
|
||||
@@ -60,12 +38,8 @@ if (doc.body.children.length === 0) {
|
||||
_content.value = parseReplyToContent(doc, "p.reply-to-content");
|
||||
}
|
||||
|
||||
function parseReplyToContent(
|
||||
doc: Document,
|
||||
selector: string,
|
||||
forGmail = false
|
||||
) {
|
||||
function handleAllInstances(doc: Document) {
|
||||
function parseReplyToContent(doc, selector, forGmail = false) {
|
||||
function handleAllInstances(doc) {
|
||||
const replyToContentElements = doc.querySelectorAll(selector);
|
||||
if (replyToContentElements.length === 0) return;
|
||||
const replyToContentElement = replyToContentElements[0];
|
||||
@@ -74,15 +48,13 @@ function parseReplyToContent(
|
||||
}
|
||||
|
||||
handleAllInstances(doc);
|
||||
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function replaceReplyToContent(
|
||||
replyToContentElement: Element,
|
||||
forGmail: boolean
|
||||
) {
|
||||
function replaceReplyToContent(replyToContentElement, forGmail) {
|
||||
if (!replyToContentElement) return;
|
||||
const randomId = Math.random().toString(36).substring(2, 7);
|
||||
let randomId = Math.random().toString(36).substring(2, 7);
|
||||
const wrapper = doc.createElement("div");
|
||||
wrapper.classList.add("replied-content");
|
||||
|
||||
@@ -103,153 +75,166 @@ function replaceReplyToContent(
|
||||
if (prevSibling && prevSibling.tagName === "BR") {
|
||||
prevSibling.remove();
|
||||
}
|
||||
const cloned = replyToContentElement.cloneNode(true) as Element;
|
||||
let cloned = replyToContentElement.cloneNode(true);
|
||||
cloned.classList.remove("gmail_quote");
|
||||
wrapper.appendChild(cloned);
|
||||
} else {
|
||||
const allSiblings = Array.from(
|
||||
replyToContentElement.parentElement?.children || []
|
||||
replyToContentElement.parentElement.children
|
||||
);
|
||||
const replyToContentIndex = allSiblings.indexOf(replyToContentElement);
|
||||
const followingSiblings = allSiblings.slice(replyToContentIndex + 1);
|
||||
|
||||
if (followingSiblings.length === 0) return;
|
||||
|
||||
const clonedFollowingSiblings = followingSiblings.map((sibling) =>
|
||||
let clonedFollowingSiblings = followingSiblings.map((sibling) =>
|
||||
sibling.cloneNode(true)
|
||||
);
|
||||
|
||||
const div = doc.createElement("div");
|
||||
div.append(...clonedFollowingSiblings);
|
||||
|
||||
wrapper.append(div);
|
||||
|
||||
// Remove all siblings after the reply-to-content element
|
||||
for (let i = replyToContentIndex + 1; i < allSiblings.length; i++) {
|
||||
replyToContentElement.parentElement?.removeChild(allSiblings[i]);
|
||||
replyToContentElement.parentElement.removeChild(allSiblings[i]);
|
||||
}
|
||||
}
|
||||
|
||||
replyToContentElement.parentElement?.replaceChild(
|
||||
replyToContentElement.parentElement.replaceChild(
|
||||
wrapper,
|
||||
replyToContentElement
|
||||
);
|
||||
}
|
||||
|
||||
const htmlContent = computed(
|
||||
() => `
|
||||
const htmlContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="${cssHref.value}" />
|
||||
<base target="_blank" />
|
||||
<style>
|
||||
:root {
|
||||
--bg-surface-gray-3: #ededed;
|
||||
--bg-surface-gray-4: #e2e2e2;
|
||||
}
|
||||
[data-theme='dark'] {
|
||||
--bg-surface-gray-3: #343434;
|
||||
--bg-surface-gray-4: #424242;
|
||||
}
|
||||
.replied-content .collapse {
|
||||
margin: 10px 0 10px 0;
|
||||
visibility: visible;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: larger;
|
||||
font-weight: 700;
|
||||
height: 12px;
|
||||
line-height: 0.1;
|
||||
color: #383838;
|
||||
background: #e8eaed;
|
||||
width: 23px;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.replied-content .collapse:hover {
|
||||
background: #dadce0;
|
||||
}
|
||||
[data-theme='dark'] .replied-content .collapse {
|
||||
color: #e2e2e2;
|
||||
background: #424242;
|
||||
}
|
||||
[data-theme='dark'] .replied-content .collapse:hover {
|
||||
background: #525252;
|
||||
}
|
||||
.replied-content .collapse + input {
|
||||
display: none;
|
||||
}
|
||||
.replied-content .collapse + input + div {
|
||||
display: none;
|
||||
}
|
||||
.replied-content .collapse + input:checked + div {
|
||||
display: block;
|
||||
}
|
||||
.email-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
|
||||
.email-content :is(:where(img):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
|
||||
border-width: 0;
|
||||
}
|
||||
.email-content :where(img):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||
margin: 0;
|
||||
}
|
||||
.email-content :where(blockquote p:first-of-type):not(:where([class~='not-prose'], [class~='not-prose'] *))::before {
|
||||
content: none;
|
||||
}
|
||||
.email-content :where(blockquote p:last-of-type):not(:where([class~='not-prose'], [class~='not-prose'] *))::after {
|
||||
content: none;
|
||||
}
|
||||
|
||||
</style>
|
||||
<style>
|
||||
${css}
|
||||
|
||||
.replied-content .collapse {
|
||||
margin: 10px 0 10px 0;
|
||||
visibility: visible;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: larger;
|
||||
font-weight: 700;
|
||||
height: 12px;
|
||||
line-height: 0.1;
|
||||
background: #e8eaed;
|
||||
width: 23px;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.replied-content .collapse:hover {
|
||||
background: #dadce0;
|
||||
}
|
||||
|
||||
.replied-content .collapse + input {
|
||||
display: none;
|
||||
}
|
||||
.replied-content .collapse + input + div {
|
||||
display: none;
|
||||
}
|
||||
.replied-content .collapse + input:checked + div {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
word-break: break-word;
|
||||
}
|
||||
.email-content
|
||||
:is(:where(table):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*))) {
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.email-content
|
||||
:where(table):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||
width: unset;
|
||||
table-layout: auto;
|
||||
text-align: unset;
|
||||
margin-top: unset;
|
||||
margin-bottom: unset;
|
||||
font-size: unset;
|
||||
line-height: unset;
|
||||
}
|
||||
|
||||
/* tr */
|
||||
|
||||
.email-content
|
||||
:where(tbody tr):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*)) {
|
||||
border-bottom-width: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
/* td */
|
||||
|
||||
.email-content
|
||||
:is(:where(td):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
|
||||
position: unset;
|
||||
border-width: 0;
|
||||
border-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.email-content
|
||||
:where(tbody td):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*)) {
|
||||
vertical-align: revert;
|
||||
}
|
||||
|
||||
/* image */
|
||||
.email-content
|
||||
:is(:where(img):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*))) {
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
.email-content
|
||||
:where(img):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* before & after */
|
||||
|
||||
.email-content
|
||||
:where(blockquote
|
||||
p:first-of-type):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*))::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.email-content
|
||||
:where(blockquote
|
||||
p:last-of-type):not(:where([class~='not-prose'], [class~='not-prose']
|
||||
*))::after {
|
||||
content: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-content prose-f">${themedContent.value}</div>
|
||||
<div ref="emailContentRef" class="email-content prose-f">${_content.value}</div>
|
||||
</body>
|
||||
<base target="_blank" />
|
||||
</html>
|
||||
`
|
||||
);
|
||||
`;
|
||||
|
||||
watch(iframeRef, (iframe) => {
|
||||
if (iframe) {
|
||||
iframe.onload = () => {
|
||||
const emailContent =
|
||||
iframe.contentWindow?.document.querySelector(".email-content");
|
||||
if (!emailContent) return;
|
||||
|
||||
const parent = emailContent.closest("html");
|
||||
if (!parent) return;
|
||||
parent.setAttribute("data-theme", dataTheme.value);
|
||||
|
||||
const font = getFontFamily(_content.value);
|
||||
if (font) emailContent.classList.add(font);
|
||||
|
||||
iframe.contentWindow.document.querySelector(".email-content");
|
||||
let parent = emailContent.closest("html");
|
||||
emailContent.classList.add(getFontFamily(_content.value));
|
||||
iframe.style.height = parent.offsetHeight + 1 + "px";
|
||||
|
||||
// Clicks inside the iframe don't bubble to the parent document, popovers/dropdowns that close on outside-click never fire.
|
||||
iframe.contentDocument?.addEventListener("pointerdown", () => {
|
||||
document.dispatchEvent(
|
||||
new PointerEvent("pointerdown", { bubbles: true })
|
||||
);
|
||||
});
|
||||
|
||||
// This is to ensure that keyboard shortcuts work even when the iframe is focused. For example, pressing "r" to reply to an email should work even if the user has clicked inside the email content.
|
||||
iframe.contentDocument?.addEventListener("keydown", (e) => {
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: e.key,
|
||||
code: e.code,
|
||||
ctrlKey: e.ctrlKey,
|
||||
metaKey: e.metaKey,
|
||||
shiftKey: e.shiftKey,
|
||||
altKey: e.altKey,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const replyCollapsers = emailContent.querySelectorAll(".replyCollapser");
|
||||
let replyCollapsers = emailContent.querySelectorAll(".replyCollapser");
|
||||
if (replyCollapsers.length) {
|
||||
replyCollapsers.forEach((replyCollapser) => {
|
||||
replyCollapser.addEventListener("change", () => {
|
||||
@@ -260,9 +245,4 @@ watch(iframeRef, (iframe) => {
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
watch(dataTheme, (theme) => {
|
||||
const html = iframeRef.value?.contentDocument?.documentElement;
|
||||
if (html) html.setAttribute("data-theme", theme);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,280 +1,196 @@
|
||||
<template>
|
||||
<Editor
|
||||
<TextEditor
|
||||
ref="editorRef"
|
||||
v-model="newEmail"
|
||||
:editor-class="[
|
||||
'prose-sm max-w-full mx-6 md:mx-10 max-h-[50vh] py-3',
|
||||
'min-h-[7rem]',
|
||||
getFontFamily(newEmail),
|
||||
editable && '!max-h-[35vh] overflow-y-auto',
|
||||
]"
|
||||
:content="newEmail"
|
||||
:starterkit-options="{ heading: { levels: [2, 3, 4, 5, 6] } }"
|
||||
:placeholder="placeholder"
|
||||
:editable="editable"
|
||||
:extensions="extensions"
|
||||
:upload-function="
|
||||
(file: any, options: any) =>
|
||||
track(uploadFunction(file, doctype, ticketId, true, options))
|
||||
"
|
||||
@change="editable ? (newEmail = $event) : null"
|
||||
:extensions="[PreserveVideoControls]"
|
||||
:uploadFunction="(file:any)=>uploadFunction(file, doctype, ticketId)"
|
||||
>
|
||||
<template #default>
|
||||
<div @keydown.capture="handleKeydown">
|
||||
<!-- Email headers -->
|
||||
<div
|
||||
v-if="hasMultipleSenders"
|
||||
class="mx-6 md:mx-5 flex items-center gap-2 border-t py-2.5 h-12.5"
|
||||
>
|
||||
<span class="text-p-xs text-ink-gray-4">{{ __("From") }}:</span>
|
||||
<FormControl
|
||||
v-model="fromEmail"
|
||||
type="select"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
:placeholder="__('')"
|
||||
:options="from"
|
||||
/>
|
||||
</div>
|
||||
<div class="mx-6 md:mx-5 flex items-center gap-2 border-y py-2.5">
|
||||
<span class="text-p-xs text-ink-gray-4">{{ __("To") }}:</span>
|
||||
<EmailMultiSelect
|
||||
v-model="toEmailsClone"
|
||||
class="flex-1"
|
||||
scope="contact"
|
||||
variant="ghost"
|
||||
allow-custom-email
|
||||
copy-on-click
|
||||
:validate="validateEmailWithZod"
|
||||
:custom-email-label="__('Add to recipients')"
|
||||
/>
|
||||
<div class="flex gap-1.5">
|
||||
<Button
|
||||
:label="__('Cc')"
|
||||
variant="ghost"
|
||||
:class="[
|
||||
cc || showCC
|
||||
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
|
||||
: '!text-ink-gray-4',
|
||||
]"
|
||||
@click="toggleCC()"
|
||||
/>
|
||||
<Button
|
||||
:label="__('Bcc')"
|
||||
variant="ghost"
|
||||
:class="[
|
||||
bcc || showBCC
|
||||
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
|
||||
: '!text-ink-gray-4',
|
||||
]"
|
||||
@click="toggleBCC()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="showCC || cc"
|
||||
class="mx-5 flex items-center gap-2 py-2.5"
|
||||
:class="cc || showCC ? 'border-b' : ''"
|
||||
>
|
||||
<span class="text-xs text-ink-gray-4">{{ __("Cc:") }}</span>
|
||||
<EmailMultiSelect
|
||||
ref="ccInput"
|
||||
v-model="ccEmailsClone"
|
||||
class="flex-1"
|
||||
scope="contact"
|
||||
variant="ghost"
|
||||
allow-custom-email
|
||||
copy-on-click
|
||||
:validate="validateEmailWithZod"
|
||||
:custom-email-label="__('Add to recipients')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showBCC || bcc"
|
||||
class="mx-5 flex items-center gap-2 py-2.5"
|
||||
:class="bcc || showBCC ? 'border-b' : ''"
|
||||
>
|
||||
<span class="text-xs text-ink-gray-4">{{ __("Bcc:") }}</span>
|
||||
<EmailMultiSelect
|
||||
ref="bccInput"
|
||||
v-model="bccEmailsClone"
|
||||
class="flex-1"
|
||||
scope="contact"
|
||||
variant="ghost"
|
||||
allow-custom-email
|
||||
copy-on-click
|
||||
:validate="validateEmailWithZod"
|
||||
:custom-email-label="__('Add to recipients')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Editor content + quoted reply -->
|
||||
<div class="overflow-y-auto min-h-[7rem] max-h-[30vh] flex flex-col">
|
||||
<div class="flex-1">
|
||||
<EditorContent
|
||||
:class="[
|
||||
'prose-sm max-w-full mx-6 md:mx-5 py-3',
|
||||
getFontFamily(newEmail),
|
||||
'[&_p.reply-to-content]:hidden',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="quotedContent"
|
||||
class="replied-content mx-6 md:mx-5 mb-2 mt-auto"
|
||||
>
|
||||
<label class="collapse" for="quoted-toggle">...</label>
|
||||
<input
|
||||
id="quoted-toggle"
|
||||
class="replyCollapser"
|
||||
type="checkbox"
|
||||
:checked="isQuoteExpanded"
|
||||
/>
|
||||
<div
|
||||
ref="quotedContentRef"
|
||||
contenteditable="true"
|
||||
class="prose !max-w-full mx-1 my-2 border-s-4 border-outline-gray-2 ps-4 text-sm focus:outline-none"
|
||||
@input="onQuotedInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<AttachmentList
|
||||
class="px-5 my-2"
|
||||
:attachments="attachments"
|
||||
@remove="removeAttachment"
|
||||
<template #top>
|
||||
<div class="mx-6 md:mx-10 flex items-center gap-2 border-y py-2.5">
|
||||
<span class="text-xs text-gray-500">TO:</span>
|
||||
<MultiSelectInput
|
||||
v-model="toEmailsClone"
|
||||
class="flex-1"
|
||||
:validate="validateEmail"
|
||||
:error-message="(value) => `${value} is an invalid email address`"
|
||||
/>
|
||||
<!-- Saved reply actions, applied once the reply is sent -->
|
||||
<SavedReplyActions
|
||||
ref="savedReplyActionsRef"
|
||||
class="mx-5 my-2"
|
||||
:ticket-id="ticketId"
|
||||
:doctype="doctype"
|
||||
<Button
|
||||
:label="'CC'"
|
||||
:class="[cc ? 'bg-gray-300 hover:bg-gray-200' : '']"
|
||||
@click="toggleCC()"
|
||||
/>
|
||||
<!-- Fixed Menu -->
|
||||
<div
|
||||
class="flex justify-between gap-2 overflow-scroll px-4 py-2.5 items-center border-t"
|
||||
<Button
|
||||
:label="'BCC'"
|
||||
:class="[bcc ? 'bg-gray-300 hover:bg-gray-200' : '']"
|
||||
@click="toggleBCC()"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showCC || cc"
|
||||
class="mx-10 flex items-center gap-2 py-2.5"
|
||||
:class="cc || showCC ? 'border-b' : ''"
|
||||
>
|
||||
<span class="text-xs text-gray-500">CC:</span>
|
||||
<MultiSelectInput
|
||||
ref="ccInput"
|
||||
v-model="ccEmailsClone"
|
||||
class="flex-1"
|
||||
:validate="validateEmail"
|
||||
:error-message="(value) => `${value} is an invalid email address`"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="showBCC || bcc"
|
||||
class="mx-10 flex items-center gap-2 py-2.5"
|
||||
:class="bcc || showBCC ? 'border-b' : ''"
|
||||
>
|
||||
<span class="text-xs text-gray-500">BCC:</span>
|
||||
<MultiSelectInput
|
||||
ref="bccInput"
|
||||
v-model="bccEmailsClone"
|
||||
class="flex-1"
|
||||
:validate="validateEmail"
|
||||
:error-message="(value) => `${value} is an invalid email address`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- <template v-slot:editor="{ _editor }">
|
||||
<EditorContent
|
||||
:class="[editable && 'max-h-[35vh] overflow-y-auto']"
|
||||
:editor="_editor"
|
||||
/>
|
||||
</template> -->
|
||||
<template #bottom>
|
||||
<!-- Attachments -->
|
||||
<div class="flex flex-wrap gap-2 px-10">
|
||||
<AttachmentItem
|
||||
v-for="a in attachments"
|
||||
:key="a.file_url"
|
||||
:label="a.file_name"
|
||||
:url="!['MOV', 'MP4'].includes(a.file_type) ? a.file_url : null"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center overflow-x-auto">
|
||||
<div class="inline-flex items-center gap-1.5 p-1">
|
||||
<FileUploader
|
||||
:doctype="doctype"
|
||||
:docname="ticketId"
|
||||
private
|
||||
@success="
|
||||
(f) => {
|
||||
attachments.push(f);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #default="{ openFileSelector, uploading }">
|
||||
{{ void (attachmentUploading = uploading) }}
|
||||
<Tooltip :text="__('Attach file')">
|
||||
<button
|
||||
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
|
||||
@click="openFileSelector()"
|
||||
:disabled="uploading"
|
||||
>
|
||||
<LoadingIndicator v-if="uploading" class="h-4 w-4" />
|
||||
<AttachmentIcon
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
style="stroke-width: 1.5 !important"
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<Tooltip :text="__('Saved replies')">
|
||||
<button
|
||||
class="flex rounded-4 p-1 text-ink-gray-8 transition-colors focus-within:ring-0 hover:bg-surface-gray-3"
|
||||
@click="showSavedRepliesSelectorModal = true"
|
||||
>
|
||||
<ZapIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<div class="h-4 w-[2px] border-s ml-1" />
|
||||
</div>
|
||||
<EditorFixedMenu :items="fullToolbar" />
|
||||
<EditorTableMenu />
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center justify-end gap-x-2">
|
||||
<Button label="Discard" @click="handleDiscard" />
|
||||
<!-- A disabled button fires no pointer events, so the span
|
||||
carries the hover for the tooltip -->
|
||||
<Tooltip
|
||||
:text="isUploading ? __('Please wait, media is uploading') : ''"
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
class="h-3.5"
|
||||
name="x"
|
||||
@click.self.stop="removeAttachment(a)"
|
||||
/>
|
||||
</template>
|
||||
</AttachmentItem>
|
||||
</div>
|
||||
<!-- TextEditor Fixed Menu -->
|
||||
<div
|
||||
class="flex justify-between overflow-scroll pl-10 py-2.5 items-center"
|
||||
>
|
||||
<div class="flex items-center overflow-x-auto w-[60%]">
|
||||
<div class="flex gap-1">
|
||||
<FileUploader
|
||||
:upload-args="{
|
||||
doctype: doctype,
|
||||
docname: ticketId,
|
||||
private: true,
|
||||
}"
|
||||
@success="
|
||||
(f) => {
|
||||
attachments.push(f);
|
||||
}
|
||||
"
|
||||
>
|
||||
<span class="inline-flex">
|
||||
<Button
|
||||
variant="solid"
|
||||
:disabled="isDisabled"
|
||||
:loading="sendMail.loading"
|
||||
:label="label"
|
||||
@click="
|
||||
() => {
|
||||
submitMail();
|
||||
}
|
||||
"
|
||||
<template #default="{ openFileSelector }">
|
||||
<Button variant="ghost" @click="openFileSelector()">
|
||||
<template #icon>
|
||||
<AttachmentIcon
|
||||
class="h-4"
|
||||
style="color: #000000; stroke-width: 1.5 !important"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@click="showCannedResponseSelectorModal = true"
|
||||
>
|
||||
<template #icon>
|
||||
<EmailIcon
|
||||
class="h-4"
|
||||
style="color: #000000; stroke-width: 1.2"
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<TextEditorFixedMenu class="ml-1" :buttons="textEditorMenuButtons" />
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center justify-end space-x-2 sm:mt-0 w-[40%] mr-9"
|
||||
>
|
||||
<Button label="Discard" @click="handleDiscard" />
|
||||
<Button
|
||||
variant="solid"
|
||||
:disabled="emailEmpty"
|
||||
:loading="sendMail.loading"
|
||||
:label="label"
|
||||
@click="
|
||||
() => {
|
||||
submitMail();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Editor>
|
||||
<SavedRepliesSelectorModal
|
||||
v-model="showSavedRepliesSelectorModal"
|
||||
</TextEditor>
|
||||
<CannedResponseSelectorModal
|
||||
v-if="showCannedResponseSelectorModal"
|
||||
v-model="showCannedResponseSelectorModal"
|
||||
:doctype="doctype"
|
||||
@apply="applySavedReplies"
|
||||
:ticketId="ticketId"
|
||||
@apply="applyCannedResponse"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AttachmentList, SavedRepliesSelectorModal } from "@/components";
|
||||
import { buildEditorExtensions, fullToolbar } from "@/components/editor/config";
|
||||
import EmailMultiSelect from "@/components/EmailMultiSelect.vue";
|
||||
import { createDialog } from "@/components/dialogs";
|
||||
import { AttachmentIcon } from "@/components/icons";
|
||||
import SavedReplyActions from "@/components/SavedReplyActions/SavedReplyActions.vue";
|
||||
import {
|
||||
AttachmentItem,
|
||||
CannedResponseSelectorModal,
|
||||
MultiSelectInput,
|
||||
} from "@/components";
|
||||
import { AttachmentIcon, EmailIcon } from "@/components/icons";
|
||||
import { useTyping } from "@/composables/realtime";
|
||||
import { getUserEmailInfo } from "@/composables/useUserEmailInfo";
|
||||
import { useUploadTracker } from "@/composables/useUploadTracker";
|
||||
import { replyComposer } from "@/pages/ticket/modalStates";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { __ } from "@/translation";
|
||||
import { RenderedSavedReply } from "@/types";
|
||||
import { PreserveVideoControls } from "@/tiptap-extensions";
|
||||
import {
|
||||
getFontFamily,
|
||||
htmlToText,
|
||||
isContentEmpty,
|
||||
removeAttachmentFromServer,
|
||||
textEditorMenuButtons,
|
||||
uploadFunction,
|
||||
validateEmailWithZod,
|
||||
validateEmail,
|
||||
} from "@/utils";
|
||||
// import { EditorContent } from "@tiptap/vue-3";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import {
|
||||
FileUploader,
|
||||
LoadingIndicator,
|
||||
Tooltip,
|
||||
TextEditor,
|
||||
TextEditorFixedMenu,
|
||||
createResource,
|
||||
dayjs,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import {
|
||||
Editor,
|
||||
EditorContent,
|
||||
EditorFixedMenu,
|
||||
EditorTableMenu,
|
||||
} from "frappe-ui/editor";
|
||||
import { addPendingActivity } from "@framework/ui/ActivityTimeline";
|
||||
import { useOnboarding } from "@framework/ui";
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from "vue";
|
||||
import ZapIcon from "~icons/lucide/zap";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
||||
|
||||
const editorRef = ref(null);
|
||||
const showCannedResponseSelectorModal = ref(false);
|
||||
|
||||
// ─── Props & Emits ────────────────────────────────────────────
|
||||
const props = defineProps({
|
||||
ticketId: {
|
||||
type: String,
|
||||
@@ -310,53 +226,34 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["submit", "discard", "sending", "restore"]);
|
||||
const label = computed(() => {
|
||||
return sendMail.loading ? "Sending..." : props.label;
|
||||
});
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
|
||||
const authStore = useAuthStore();
|
||||
const { isManager } = authStore;
|
||||
const { getUser } = useUserStore();
|
||||
const emit = defineEmits(["submit", "discard"]);
|
||||
|
||||
const newEmail = useStorage("emailBoxContent" + props.ticketId, null);
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
const { isManager } = useAuthStore();
|
||||
|
||||
// Initialize typing composable
|
||||
const { onUserType, cleanup } = useTyping(props.ticketId);
|
||||
|
||||
const extensions = buildEditorExtensions();
|
||||
const attachments = ref([]);
|
||||
const emailEmpty = computed(() => {
|
||||
return isContentEmpty(newEmail.value);
|
||||
});
|
||||
|
||||
const editorRef = ref(null);
|
||||
const editor = computed(() => editorRef.value?.editor);
|
||||
// Watch for changes in email content to trigger typing events
|
||||
watch(newEmail, (newValue, oldValue) => {
|
||||
if (newValue !== oldValue && newValue) {
|
||||
onUserType();
|
||||
}
|
||||
});
|
||||
|
||||
function focusEditorAtStart() {
|
||||
setTimeout(() => {
|
||||
editorRef.value?.editor?.commands?.focus("start");
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const cachedEmail = useStorage<null | string>(
|
||||
"emailBoxContent" + props.ticketId,
|
||||
null
|
||||
);
|
||||
|
||||
const newEmail = ref<null | string>(cachedEmail.value);
|
||||
|
||||
const emailSignature = ref<string | null>(null);
|
||||
|
||||
function isOnlySignature(content: string | null) {
|
||||
if (!content || !emailSignature.value) return false;
|
||||
return htmlToText(content) === htmlToText(emailSignature.value);
|
||||
}
|
||||
|
||||
const userResource = getUserEmailInfo();
|
||||
|
||||
const quotedContent = useStorage<null | string>(
|
||||
"quotedEmailBoxContent" + props.ticketId,
|
||||
null
|
||||
);
|
||||
const quotedContentRef = ref<HTMLElement | null>(null);
|
||||
const isQuoteExpanded = ref(false);
|
||||
|
||||
function onQuotedInput() {
|
||||
const el = quotedContentRef.value;
|
||||
if (!el) return;
|
||||
quotedContent.value = el.innerHTML || null;
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const toEmailsClone = ref([...props.toEmails]);
|
||||
const ccEmailsClone = ref([...props.ccEmails]);
|
||||
@@ -368,8 +265,53 @@ const bcc = computed(() => (bccEmailsClone.value?.length ? true : false));
|
||||
const ccInput = ref(null);
|
||||
const bccInput = ref(null);
|
||||
|
||||
function applyCannedResponse(template) {
|
||||
newEmail.value = template.message;
|
||||
showCannedResponseSelectorModal.value = false;
|
||||
}
|
||||
|
||||
const sendMail = createResource({
|
||||
url: "run_doc_method",
|
||||
makeParams: () => ({
|
||||
dt: props.doctype,
|
||||
dn: props.ticketId,
|
||||
method: "reply_via_agent",
|
||||
args: {
|
||||
attachments: attachments.value.map((x) => x.name),
|
||||
to: toEmailsClone.value.join(","),
|
||||
cc: ccEmailsClone.value?.join(","),
|
||||
bcc: bccEmailsClone.value?.join(","),
|
||||
message: newEmail.value,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
resetState();
|
||||
emit("submit");
|
||||
|
||||
if (isManager) {
|
||||
updateOnboardingStep("reply_on_ticket");
|
||||
}
|
||||
},
|
||||
debounce: 300,
|
||||
});
|
||||
|
||||
function submitMail() {
|
||||
if (isContentEmpty(newEmail.value)) {
|
||||
return false;
|
||||
}
|
||||
if (!toEmailsClone.value.length) {
|
||||
toast.warning(
|
||||
"Email has no recipients. Please add at least one email address in the 'TO' field."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
sendMail.submit();
|
||||
}
|
||||
|
||||
function toggleCC() {
|
||||
showCC.value = !showCC.value;
|
||||
|
||||
showCC.value &&
|
||||
nextTick(() => {
|
||||
ccInput.value.setFocus();
|
||||
@@ -384,224 +326,11 @@ function toggleBCC() {
|
||||
});
|
||||
}
|
||||
|
||||
const fromEmail = useStorage<string | "">("from-email", "");
|
||||
|
||||
const outgoingEmails = computed<{ email_account: string; email_id: string }[]>(
|
||||
() => userResource.data?.outgoing_emails ?? []
|
||||
);
|
||||
|
||||
// selected mail from the outgoing emails list
|
||||
const selectedFromEmail = computed(() =>
|
||||
outgoingEmails.value.find((e) => e.email_id === fromEmail.value)
|
||||
);
|
||||
|
||||
const from = computed(() => {
|
||||
if (!outgoingEmails.value.length) return [];
|
||||
if (
|
||||
outgoingEmails.value.length === 1 &&
|
||||
outgoingEmails.value[0].email_id === userResource.data?.email
|
||||
)
|
||||
return [];
|
||||
return outgoingEmails.value.map((e) => ({
|
||||
label: e.email_account + " <" + e.email_id + ">",
|
||||
value: e.email_id,
|
||||
}));
|
||||
});
|
||||
|
||||
const hasMultipleSenders = computed(() => (from?.value.length ?? 0) > 1);
|
||||
|
||||
const attachments = ref([]);
|
||||
const attachmentUploading = ref(false);
|
||||
const { isUploading: editorUploading, track, dropUnused } = useUploadTracker();
|
||||
|
||||
// The paperclip and the editor's own media buttons upload by different routes
|
||||
const isUploading = computed(
|
||||
() => attachmentUploading.value || editorUploading.value
|
||||
);
|
||||
|
||||
async function removeAttachment(attachment) {
|
||||
attachments.value = attachments.value.filter((a) => a !== attachment);
|
||||
await removeAttachmentFromServer(attachment.name);
|
||||
}
|
||||
|
||||
const showSavedRepliesSelectorModal = ref(false);
|
||||
const savedReplyActionsRef = ref<InstanceType<typeof SavedReplyActions>>();
|
||||
|
||||
/** A reply is only replaced when another one is already applied. */
|
||||
function applySavedReplies(reply: RenderedSavedReply) {
|
||||
const staged = savedReplyActionsRef.value?.stagedSummary();
|
||||
if (!staged) {
|
||||
insertSavedReply(reply);
|
||||
return;
|
||||
}
|
||||
createDialog({
|
||||
title: __("Replace saved reply"),
|
||||
message: __(
|
||||
'Applying "{0}" discards the reply you have now, along with its {1} action(s).',
|
||||
[reply.title, staged.count]
|
||||
),
|
||||
actions: [
|
||||
{ label: __("Cancel") },
|
||||
{
|
||||
label: __("Replace"),
|
||||
variant: "solid",
|
||||
onClick: ({ close }: { close: () => void }) => {
|
||||
replaceSavedReply(reply);
|
||||
close();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** First reply of a draft: added to whatever the agent has already written. */
|
||||
function insertSavedReply(reply: RenderedSavedReply) {
|
||||
const textEditor = editorRef.value?.editor;
|
||||
if (!textEditor) return;
|
||||
textEditor.chain().focus("start").insertContent(reply.message).run();
|
||||
savedReplyActionsRef.value?.add(reply);
|
||||
}
|
||||
|
||||
/** Confirmed replace: the new reply's body and actions stand alone. */
|
||||
function replaceSavedReply(reply: RenderedSavedReply) {
|
||||
newEmail.value = reply.message + (emailSignature.value ?? "");
|
||||
savedReplyActionsRef.value?.add(reply);
|
||||
focusEditorAtStart();
|
||||
}
|
||||
|
||||
/** Set by submitMail, so the composer can be cleared before the request goes out. */
|
||||
let pendingRow: ReturnType<typeof addPendingActivity> | null = null;
|
||||
let sentDraft: {
|
||||
content: string | null;
|
||||
attachments: any[];
|
||||
quoted: string | null;
|
||||
quoteExpanded: boolean;
|
||||
} | null = null;
|
||||
|
||||
const sendMail = createResource({
|
||||
url: "run_doc_method",
|
||||
makeParams: (params) => params,
|
||||
onSuccess: (res: { message?: string } | string) => {
|
||||
// run_doc_method answers with the whole body, since it always carries `docs`
|
||||
const name = typeof res === "string" ? res : res?.message;
|
||||
// the real row replaces the pending one the moment it arrives; unkeyed,
|
||||
// it would outlive it
|
||||
name ? pendingRow?.resolve(`email:${name}`) : pendingRow?.drop();
|
||||
pendingRow = null;
|
||||
sentDraft = null;
|
||||
savedReplyActionsRef.value?.submit();
|
||||
emit("submit");
|
||||
|
||||
if (isManager) {
|
||||
updateOnboardingStep?.("reply_on_ticket");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(__("Could not send the reply"));
|
||||
pendingRow?.drop();
|
||||
pendingRow = null;
|
||||
if (sentDraft) {
|
||||
newEmail.value = sentDraft.content;
|
||||
attachments.value = sentDraft.attachments;
|
||||
quotedContent.value = sentDraft.quoted;
|
||||
isQuoteExpanded.value = sentDraft.quoteExpanded;
|
||||
sentDraft = null;
|
||||
}
|
||||
emit("restore");
|
||||
},
|
||||
debounce: 300,
|
||||
});
|
||||
|
||||
const label = computed(() => (sendMail.loading ? "Sending..." : props.label));
|
||||
|
||||
const isDisabled = computed(
|
||||
() =>
|
||||
(isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) ||
|
||||
sendMail.loading ||
|
||||
isUploading.value
|
||||
);
|
||||
|
||||
function submitMail() {
|
||||
if (isContentEmpty(newEmail.value) && isContentEmpty(quotedContent.value)) {
|
||||
return false;
|
||||
}
|
||||
// The keyboard shortcut reaches here without passing the disabled button
|
||||
if (isUploading.value || sendMail.loading) return false;
|
||||
if (
|
||||
!toEmailsClone.value.length &&
|
||||
!ccEmailsClone.value.length &&
|
||||
!bccEmailsClone.value.length
|
||||
) {
|
||||
toast.warning(
|
||||
"Email has no recipients. Please add at least one recipient (To, Cc, or Bcc) before sending."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const message =
|
||||
newEmail.value +
|
||||
(quotedContentRef.value
|
||||
? `<p class="reply-to-content"></p><blockquote>${quotedContentRef.value.innerHTML}</blockquote>`
|
||||
: "");
|
||||
const sender = selectedFromEmail.value?.email_id ?? authStore.userId;
|
||||
const ownEmail = getUser(authStore.userId).email;
|
||||
|
||||
pendingRow?.drop();
|
||||
pendingRow = addPendingActivity(props.doctype, props.ticketId, {
|
||||
type: "email",
|
||||
timestamp: dayjs().format("YYYY-MM-DD HH:mm:ss"),
|
||||
author: {
|
||||
email: sender,
|
||||
// the users list may still be a stub with a guessed name and no avatar
|
||||
fullname: authStore.userName,
|
||||
image: sender === ownEmail ? authStore.userImage : undefined,
|
||||
},
|
||||
data: {
|
||||
name: "",
|
||||
sender,
|
||||
to: toEmailsClone.value.join(", "),
|
||||
cc: ccEmailsClone.value?.join(", "),
|
||||
bcc: bccEmailsClone.value?.join(", "),
|
||||
content: message,
|
||||
attachments: attachments.value.map((a) => ({
|
||||
file_url: a.file_url,
|
||||
file_name: a.file_name,
|
||||
is_private: a.is_private,
|
||||
})),
|
||||
},
|
||||
});
|
||||
sentDraft = {
|
||||
content: newEmail.value,
|
||||
attachments: attachments.value,
|
||||
quoted: quotedContent.value,
|
||||
quoteExpanded: isQuoteExpanded.value,
|
||||
};
|
||||
|
||||
const params = {
|
||||
dt: props.doctype,
|
||||
dn: props.ticketId,
|
||||
method: "reply_via_agent",
|
||||
args: {
|
||||
attachments: attachments.value.map((x) => x.name),
|
||||
from_email: selectedFromEmail.value,
|
||||
to: toEmailsClone.value.join(","),
|
||||
cc: ccEmailsClone.value?.join(","),
|
||||
bcc: bccEmailsClone.value?.join(","),
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
// drop before resetState clears, or an unmount mid-send deletes what it carries
|
||||
dropUnused(message);
|
||||
resetState();
|
||||
emit("sending");
|
||||
sendMail.submit(params);
|
||||
}
|
||||
|
||||
function getInitialContent() {
|
||||
return emailSignature.value ? emailSignature.value : "<p></p>";
|
||||
}
|
||||
|
||||
function addToReply(
|
||||
body: string,
|
||||
toEmails: string[],
|
||||
@@ -611,192 +340,42 @@ function addToReply(
|
||||
toEmailsClone.value = toEmails;
|
||||
ccEmailsClone.value = ccEmails;
|
||||
bccEmailsClone.value = bccEmails;
|
||||
|
||||
// Plain-text emails (e.g. Thunderbird) have no HTML tags, so their newlines/spacing
|
||||
// thus lose formatting when added to replied content
|
||||
const doc = new DOMParser().parseFromString(body, "text/html");
|
||||
if (doc.body.children.length === 0) {
|
||||
body = `<div style="white-space: pre-wrap; line-height: 1.5">${doc.body.innerHTML}</div>`;
|
||||
}
|
||||
|
||||
if (body !== quotedContent.value) {
|
||||
//trigger change for watch when replied to body data is different from current quoted content
|
||||
quotedContent.value = null;
|
||||
isQuoteExpanded.value = false;
|
||||
nextTick(() => {
|
||||
quotedContent.value = body;
|
||||
});
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
newEmail.value = getInitialContent();
|
||||
});
|
||||
focusEditorAtStart();
|
||||
editorRef.value.editor
|
||||
.chain()
|
||||
.clearContent()
|
||||
.insertContent(body)
|
||||
.focus("all")
|
||||
.setBlockquote()
|
||||
.insertContentAt(0, { type: "paragraph" })
|
||||
.focus("start")
|
||||
.run();
|
||||
}
|
||||
|
||||
// Staged actions are left to `submit()`, which unstages them either way
|
||||
function resetState() {
|
||||
newEmail.value = emailSignature.value ? emailSignature.value : null;
|
||||
newEmail.value = null;
|
||||
attachments.value = [];
|
||||
quotedContent.value = null;
|
||||
isQuoteExpanded.value = false;
|
||||
focusEditorAtStart();
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
attachments.value = [];
|
||||
savedReplyActionsRef.value?.clear();
|
||||
newEmail.value = getInitialContent();
|
||||
quotedContent.value = null;
|
||||
newEmail.value = null;
|
||||
|
||||
ccEmailsClone.value = [];
|
||||
bccEmailsClone.value = [];
|
||||
ccEmailsClone.value = [];
|
||||
showCC.value = false;
|
||||
showBCC.value = false;
|
||||
isQuoteExpanded.value = false;
|
||||
|
||||
dropUnused(null);
|
||||
focusEditorAtStart();
|
||||
emit("discard");
|
||||
}
|
||||
|
||||
function handleSelectAll(e: KeyboardEvent) {
|
||||
const active = document.activeElement;
|
||||
const editorContext = editorRef.value?.editor;
|
||||
const editorDom = editorContext?.view?.dom as HTMLElement | undefined;
|
||||
const quotedEl = quotedContentRef.value;
|
||||
const sel = window.getSelection();
|
||||
if (!sel || !editorDom) return;
|
||||
if (!editorDom.contains(active) && !(quotedEl && quotedEl.contains(active))) {
|
||||
return;
|
||||
}
|
||||
// after the focus check: select-all from a recipient field must not unfold
|
||||
// the quoted reply
|
||||
isQuoteExpanded.value = true;
|
||||
e.preventDefault();
|
||||
editorContext?.commands.selectAll();
|
||||
sel.removeAllRanges();
|
||||
const range = document.createRange();
|
||||
|
||||
if (quotedEl) {
|
||||
range.setStartBefore(editorDom);
|
||||
range.setEndAfter(quotedEl);
|
||||
} else {
|
||||
range.selectNodeContents(editorDom);
|
||||
}
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
function handleDelete(e: KeyboardEvent) {
|
||||
const sel = window.getSelection();
|
||||
const quotedEl = quotedContentRef.value;
|
||||
const editorDom = editorRef.value?.editor?.view?.dom as
|
||||
| HTMLElement
|
||||
| undefined;
|
||||
|
||||
if (!sel || sel.isCollapsed || !quotedEl || !editorDom) return;
|
||||
|
||||
const isSelectingEntireEditor = sel.containsNode(editorDom, true);
|
||||
const isSelectingEntireQuote = sel.containsNode(quotedEl, true);
|
||||
|
||||
if (isSelectingEntireEditor && isSelectingEntireQuote) {
|
||||
e.preventDefault();
|
||||
|
||||
editorRef.value?.editor?.commands?.clearContent();
|
||||
newEmail.value = null;
|
||||
quotedContent.value = null;
|
||||
|
||||
sel.removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const key = e.key.toLowerCase();
|
||||
|
||||
if ((e.metaKey || e.ctrlKey) && key === "a") {
|
||||
handleSelectAll(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "backspace" || key === "delete") {
|
||||
handleDelete(e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
watch(newEmail, (newValue, oldValue) => {
|
||||
// The signature drops into an empty editor on load and on every reply. That
|
||||
// is not the agent typing, and broadcasting it would show a typing indicator
|
||||
// to everyone else on the ticket.
|
||||
if (
|
||||
newValue !== oldValue &&
|
||||
!isContentEmpty(newValue) &&
|
||||
!isOnlySignature(newValue)
|
||||
) {
|
||||
onUserType();
|
||||
}
|
||||
cachedEmail.value = isOnlySignature(newValue) ? null : newValue;
|
||||
});
|
||||
|
||||
watch(quotedContent, (newVal, oldVal) => {
|
||||
if (!oldVal && newVal) {
|
||||
nextTick(() => {
|
||||
if (quotedContentRef.value) {
|
||||
quotedContentRef.value.innerHTML = newVal;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => userResource.data,
|
||||
(data: { email_signature?: string } | null) => {
|
||||
if (!data?.email_signature) return;
|
||||
emailSignature.value = `<br>${data.email_signature}`;
|
||||
if (isOnlySignature(cachedEmail.value)) {
|
||||
cachedEmail.value = null;
|
||||
}
|
||||
if (isContentEmpty(newEmail.value) && !quotedContent.value) {
|
||||
newEmail.value = emailSignature.value;
|
||||
focusEditorAtStart();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
from,
|
||||
(fromOptions) => {
|
||||
if (!fromOptions.find((f) => f.value === fromEmail.value)) {
|
||||
fromEmail.value = fromOptions.length ? fromOptions[0].value : "";
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
// Published for the command palette, which cannot reach `editorRef` from
|
||||
// module scope. See modalStates.ts.
|
||||
replyComposer.value = applySavedReplies;
|
||||
if (quotedContent.value) {
|
||||
nextTick(() => {
|
||||
if (quotedContentRef.value) {
|
||||
quotedContentRef.value.innerHTML = quotedContent.value;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
replyComposer.value = null;
|
||||
cleanup();
|
||||
// the saved draft still references what it shows, so only what it dropped goes
|
||||
dropUnused(`${newEmail.value ?? ""}${quotedContent.value ?? ""}`);
|
||||
const editor = computed(() => {
|
||||
return editorRef.value.editor;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
addToReply,
|
||||
editor,
|
||||
isUploading,
|
||||
submitMail,
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div ref="pills" class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
v-for="value in values"
|
||||
:key="value"
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
:tooltip="copyOnClick ? __('Click to copy') : undefined"
|
||||
:class="[
|
||||
{
|
||||
'rounded-4 !bg-surface-base hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
|
||||
variant === 'subtle',
|
||||
},
|
||||
copyOnClick
|
||||
? 'cursor-pointer transition-transform active:scale-[0.98]'
|
||||
: '',
|
||||
]"
|
||||
@click="copyOnClick ? copy(value) : null"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
>
|
||||
<template #suffix>
|
||||
<LucideX class="size-3.5" @click.stop="removeValue(value)" />
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex-1 min-w-32">
|
||||
<ComboboxRoot
|
||||
:model-value="tempSelection"
|
||||
:open="showOptions"
|
||||
@update:open="(o) => (showOptions = o)"
|
||||
@update:modelValue="onSelect"
|
||||
:ignore-filter="true"
|
||||
highlight-on-hover
|
||||
>
|
||||
<ComboboxAnchor
|
||||
class="flex h-7 max-w-full w-auto items-center gap-2 rounded-4 px-2 py-1 border border-transparent"
|
||||
:class="[
|
||||
variant == 'ghost'
|
||||
? 'bg-surface-base hover:bg-surface-base'
|
||||
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
|
||||
inputClass,
|
||||
]"
|
||||
>
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full focus:outline-none focus:ring-0 focus:border-0"
|
||||
:placeholder="placeholder"
|
||||
@focus="showOptions = true"
|
||||
@input="onInput"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
@keydown="onKeydown"
|
||||
@paste="onPaste"
|
||||
/>
|
||||
</ComboboxAnchor>
|
||||
<ComboboxPortal>
|
||||
<ComboboxContent
|
||||
class="z-[100] mt-1 min-w-48 w-auto max-w-96 bg-surface-elevation-2 overflow-hidden rounded-6 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
position="popper"
|
||||
:align="'start'"
|
||||
@openAutoFocus.prevent
|
||||
@closeAutoFocus.prevent
|
||||
>
|
||||
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
|
||||
<ComboboxEmpty
|
||||
class="flex gap-2 rounded-4 px-2 py-1 text-base text-ink-gray-5"
|
||||
>
|
||||
<LucideSearch class="size-4" />
|
||||
{{ __(emptyPlaceholder) }}
|
||||
</ComboboxEmpty>
|
||||
<ComboboxItem
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="text-base leading-none text-ink-gray-7 rounded-4 flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
|
||||
@mousedown.prevent="onSelect(option.value)"
|
||||
>
|
||||
<UserAvatar
|
||||
class="mr-0.5 shrink-0"
|
||||
:name="getUsernameLabel(option.label)"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex min-w-0 flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="truncate text-base-medium">
|
||||
{{ getUsernameLabel(option.label) }}
|
||||
</div>
|
||||
<div class="truncate text-sm text-ink-gray-5">
|
||||
{{ option.isCustom ? customEmailLabel : option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
</ComboboxViewport>
|
||||
</ComboboxContent>
|
||||
</ComboboxPortal>
|
||||
</ComboboxRoot>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
|
||||
<div
|
||||
v-if="info"
|
||||
class="whitespace-pre-line text-sm text-ink-blue-6 mt-2 pl-2"
|
||||
>
|
||||
{{ info }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import LucideSearch from "~icons/lucide/search";
|
||||
import LucideX from "~icons/lucide/x";
|
||||
import UserAvatar from "@/components/UserAvatar.vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { copy } from "@/utils";
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
import { createResource } from "frappe-ui";
|
||||
import {
|
||||
ComboboxAnchor,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxPortal,
|
||||
ComboboxRoot,
|
||||
ComboboxViewport,
|
||||
} from "reka-ui";
|
||||
import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
// "contact" searches HD contacts; "agents" picks from the user store.
|
||||
scope: { type: String, default: "contact" },
|
||||
validate: { type: Function, default: null },
|
||||
emptyPlaceholder: { type: String, default: __("No results found") },
|
||||
variant: { type: String, default: "subtle" },
|
||||
placeholder: { type: String, default: "" },
|
||||
inputClass: { type: String, default: "" },
|
||||
existingUsers: { type: Array, default: () => [] },
|
||||
allowCustomEmail: { type: Boolean, default: false },
|
||||
additionalFilters: { type: Array, default: () => [] },
|
||||
copyOnClick: { type: Boolean, default: false },
|
||||
customEmailLabel: { type: String, default: __("Invite via email") },
|
||||
});
|
||||
|
||||
const errorMessage = (value) => __("{0} is an Invalid Email Address", [value]);
|
||||
|
||||
// v-model values
|
||||
const values = defineModel();
|
||||
|
||||
// Common state
|
||||
const pills = ref(null);
|
||||
const search = ref(null);
|
||||
const error = ref(null);
|
||||
const info = ref(null);
|
||||
const query = ref("");
|
||||
const showOptions = ref(false);
|
||||
const tempSelection = ref(null);
|
||||
|
||||
const { users } = useUserStore();
|
||||
|
||||
// --- Contacts resource (used when scope = "contact") ---
|
||||
const filterContacts = createResource({
|
||||
url: "helpdesk.api.contact.search_contacts",
|
||||
method: "GET",
|
||||
params: contactSearchParams(""),
|
||||
transform: (data) =>
|
||||
data.map(({ full_name, email_id, name }) => ({
|
||||
label: full_name || email_id || name,
|
||||
value: email_id || name,
|
||||
email: email_id,
|
||||
name,
|
||||
})),
|
||||
});
|
||||
|
||||
watchDebounced(
|
||||
query,
|
||||
(newVal) => {
|
||||
if (props.scope === "agents") return;
|
||||
filterContacts.update({ params: contactSearchParams(newVal) });
|
||||
filterContacts.reload();
|
||||
},
|
||||
{ debounce: 250, immediate: true }
|
||||
);
|
||||
|
||||
function contactSearchParams(txt) {
|
||||
const params = { txt: txt || "" };
|
||||
if (props.additionalFilters?.length) {
|
||||
params.additional_filters = JSON.stringify(props.additionalFilters);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
// --- Options ---
|
||||
const options = computed(() => {
|
||||
if (props.scope === "agents") {
|
||||
let list = (users?.data || []).map((u) => ({
|
||||
label: u.full_name || u.name,
|
||||
value: u.name,
|
||||
}));
|
||||
if (values.value?.length) {
|
||||
list = list.filter((o) => !values.value.includes(o.value));
|
||||
}
|
||||
if (props.existingUsers?.length) {
|
||||
list = list.filter((o) => !props.existingUsers.includes(o.value));
|
||||
}
|
||||
return list.filter(matchesQuery);
|
||||
}
|
||||
|
||||
// contacts mode; the search is debounced, so narrow the last results to the
|
||||
// typed text, or a quick Enter picks a contact that no longer matches
|
||||
let list = (filterContacts.data || []).filter(matchesQuery);
|
||||
if (values.value?.length) {
|
||||
list = list.filter((o) => !values.value.includes(o.value));
|
||||
}
|
||||
if (props.existingUsers?.length) {
|
||||
list = list.filter((o) => !isExistingUser(o.email));
|
||||
}
|
||||
// offer the typed text itself when nothing matches, like the email To field
|
||||
if (
|
||||
props.allowCustomEmail &&
|
||||
query.value &&
|
||||
!list.length &&
|
||||
!isExistingUser(query.value)
|
||||
) {
|
||||
list = [{ label: query.value, value: query.value, isCustom: true }];
|
||||
}
|
||||
return list;
|
||||
});
|
||||
|
||||
function matchesQuery(option) {
|
||||
const q = query.value.toLowerCase();
|
||||
return [option.label, option.value, option.name].some((field) =>
|
||||
field?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
function isExistingUser(email) {
|
||||
if (!email) return false;
|
||||
return props.existingUsers?.some(
|
||||
(user) => user?.toLowerCase?.() === email.toLowerCase()
|
||||
);
|
||||
}
|
||||
|
||||
function addValue(input) {
|
||||
if (!input) return;
|
||||
error.value = null;
|
||||
info.value = null;
|
||||
const parts = splitRecipients(input);
|
||||
for (const email of parts) {
|
||||
if (values.value?.includes(email)) continue;
|
||||
const inList = options.value.some((o) => o.value === email && !o.isCustom);
|
||||
if (!inList && !canAddCustomValue(email)) break;
|
||||
if (!values.value) values.value = [email];
|
||||
else values.value.push(email);
|
||||
}
|
||||
}
|
||||
|
||||
function canAddCustomValue(email) {
|
||||
if (!props.allowCustomEmail) {
|
||||
error.value =
|
||||
props.scope === "agents"
|
||||
? __("{0} is not an existing user", [email])
|
||||
: __("{0} is not an existing contact", [email]);
|
||||
query.value = email;
|
||||
return false;
|
||||
}
|
||||
if (isExistingUser(email)) {
|
||||
error.value = __("{0} is already added", [email]);
|
||||
query.value = email;
|
||||
return false;
|
||||
}
|
||||
if (props.validate && !props.validate(email)) {
|
||||
error.value = errorMessage(email);
|
||||
query.value = email;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Split on commas/semicolons/newlines while keeping a quoted display name
|
||||
// intact, so '"Doe, John" <a@b.com>' stays a single token.
|
||||
function splitRecipients(input) {
|
||||
const parts = [];
|
||||
let current = "";
|
||||
let inQuotes = false;
|
||||
for (const char of input) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
current += char;
|
||||
} else if ((char === "," || char === ";" || char === "\n") && !inQuotes) {
|
||||
if (current.trim()) parts.push(current.trim());
|
||||
current = "";
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
if (current.trim()) parts.push(current.trim());
|
||||
return parts;
|
||||
}
|
||||
|
||||
// Reduce a "Name <email>" value to just the display name for rendering.
|
||||
function getUsernameLabel(value) {
|
||||
if (!value) return "";
|
||||
return value.split("<")[0].replace(/"/g, "").trim();
|
||||
}
|
||||
|
||||
function removeValue(value) {
|
||||
values.value = values.value.filter((v) => v !== value);
|
||||
}
|
||||
|
||||
// Read the pills from the DOM: a Button with a tooltip renders extra root
|
||||
// nodes, so its `$el` is not the <button>.
|
||||
function lastPill() {
|
||||
const buttons = pills.value?.querySelectorAll(":scope > button");
|
||||
return buttons?.[buttons.length - 1] ?? null;
|
||||
}
|
||||
|
||||
function removeLastValue() {
|
||||
if (query.value) return;
|
||||
const pill = lastPill();
|
||||
if (document.activeElement === pill) {
|
||||
values.value.pop();
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
lastPill()?.focus();
|
||||
} else {
|
||||
setFocus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
pill?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: use single contact component. This refocus also fires when focus has
|
||||
// already moved on, so a click elsewhere within 200ms is pulled back here.
|
||||
function setFocus() {
|
||||
setTimeout(() => {
|
||||
search.value?.$el?.focus();
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function onInput(e) {
|
||||
query.value = e.target.value;
|
||||
error.value = null;
|
||||
info.value = null;
|
||||
showOptions.value = true;
|
||||
}
|
||||
|
||||
function onSelect(val) {
|
||||
if (!val) return;
|
||||
addValue(val);
|
||||
if (error.value) {
|
||||
showOptions.value = false;
|
||||
return;
|
||||
}
|
||||
query.value = "";
|
||||
tempSelection.value = null;
|
||||
showOptions.value = true;
|
||||
nextTick(() => setFocus());
|
||||
}
|
||||
|
||||
// Commit the current query as a pill on Enter, comma or space.
|
||||
function onKeydown(e) {
|
||||
const isCommitKey = e.key === "Enter" || e.key === "," || e.key === " ";
|
||||
if (!isCommitKey || !query.value.trim()) return;
|
||||
e.preventDefault();
|
||||
onSelect(query.value);
|
||||
}
|
||||
|
||||
// Split a pasted, comma/space/newline-separated list into multiple pills.
|
||||
function onPaste(e) {
|
||||
const text = e.clipboardData?.getData("text") ?? "";
|
||||
if (!/[\s,;]/.test(text)) return;
|
||||
e.preventDefault();
|
||||
addValue(text);
|
||||
if (!error.value) {
|
||||
query.value = "";
|
||||
tempSelection.value = null;
|
||||
nextTick(() => setFocus());
|
||||
}
|
||||
}
|
||||
|
||||
watch(options, (list) => {
|
||||
tempSelection.value = list[0]?.value ?? null;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// reka-ui hardcodes autocomplete="off", which Chrome ignores for fields it
|
||||
// reads as email/address. "new-password" is the value Chrome won't autofill,
|
||||
// so its native address dropdown stops covering our options. (No password UI:
|
||||
// the input is type=text and not inside a form.)
|
||||
search.value?.$el?.setAttribute("autocomplete", "new-password");
|
||||
});
|
||||
|
||||
defineExpose({ setFocus });
|
||||
</script>
|
||||
@@ -1,94 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="variant === 'badge'"
|
||||
class="flex flex-col items-center justify-center gap-4 absolute inset-x-0 top-5.5 bottom-0 pointer-events-none"
|
||||
>
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<div
|
||||
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
|
||||
class="flex flex-col items-center gap-3 text-xl font-medium text-ink-gray-4"
|
||||
>
|
||||
<component v-if="icon" :is="icon" class="size-6 text-ink-gray-6" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="text-base-medium text-ink-gray-6">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<div
|
||||
v-if="descriptionText"
|
||||
class="text-p-sm text-ink-gray-5 max-w-60 text-center"
|
||||
>
|
||||
{{ __(descriptionText) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center absolute inset-x-0 top-0 pointer-events-none"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-2 text-xl-medium text-ink-gray-4 w-9/12 md:w-4/12"
|
||||
>
|
||||
<!-- overlay variant (for charts) -->
|
||||
<div
|
||||
v-if="variant === 'overlay'"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none -z-10"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'radial-gradient(ellipse at center, var(--surface-base) 10%, color-mix(in srgb, var(--surface-base) 90%, transparent) 25%, transparent 70%)',
|
||||
}"
|
||||
/>
|
||||
<!-- Icon -->
|
||||
<component v-if="icon" :is="icon" class="h-10 w-10" />
|
||||
<!-- title -->
|
||||
<div class="flex flex-col items-center justify-center gap-0.5">
|
||||
<span
|
||||
:class="{
|
||||
'text-sm font-medium text-ink-gray-8': text === 'sm',
|
||||
'text-base font-medium text-ink-gray-8': text === 'md' || !text,
|
||||
'text-md font-medium text-ink-gray-8': text === 'lg',
|
||||
}"
|
||||
>
|
||||
{{ __(title) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="descriptionText"
|
||||
:class="{
|
||||
'text-center text-p-xs text-ink-gray-6 mt-1': text === 'sm',
|
||||
'text-center text-p-sm text-ink-gray-6 mt-1':
|
||||
text === 'md' || !text,
|
||||
'text-center text-p-base text-ink-gray-6 mt-1': text === 'lg',
|
||||
}"
|
||||
>
|
||||
{{ __(descriptionText) }}
|
||||
</span>
|
||||
</div>
|
||||
<span>{{ title }}</span>
|
||||
<!-- Button which emits Empty State Action -->
|
||||
<Button label="Create" @click="emit('emptyStateAction')" variant="subtle">
|
||||
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { VNode, computed } from "vue";
|
||||
import { VNode } from "vue";
|
||||
interface Props {
|
||||
title: string;
|
||||
icon?: VNode | string;
|
||||
description?: string;
|
||||
variant?: "default" | "overlay" | "badge";
|
||||
text?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<Props>(), {
|
||||
title: "No Data Found",
|
||||
icon: "",
|
||||
variant: "default",
|
||||
text: "lg",
|
||||
});
|
||||
|
||||
const descriptionText = computed(() =>
|
||||
props.description !== undefined && props.description !== ""
|
||||
? props.description
|
||||
: `Create new ${props.title
|
||||
.split(" ")[1]
|
||||
.toLocaleLowerCase()} using the Create button.`
|
||||
);
|
||||
const emit = defineEmits(["emptyStateAction"]);
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
<article
|
||||
ref="scrollableDiv"
|
||||
:style="`maskImage: ${maskStyle}`"
|
||||
@scroll="updateMaskStyle"
|
||||
>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<div class="w-[106px] shrink-0 truncate text-base text-ink-gray-5">
|
||||
<Tooltip :text="__(label)">
|
||||
<span>{{ __(label) }}</span>
|
||||
</Tooltip>
|
||||
<span v-if="required" class="text-ink-red-6"> * </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Tooltip } from "frappe-ui";
|
||||
|
||||
defineProps<{
|
||||
label: string;
|
||||
// Frappe fields carry 1 / 0, not true / false
|
||||
required?: boolean | number;
|
||||
}>();
|
||||
</script>
|
||||
76
desk/src/components/HistoryBox.vue
Normal file
76
desk/src/components/HistoryBox.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="mt-1.5 flex justify-between text-base items-start md:items-center"
|
||||
>
|
||||
<div
|
||||
v-if="relatedActivities.length > 1"
|
||||
class="inline-flex flex-wrap gap-1.5 text-ink-gray-8 font-medium w-4/5"
|
||||
>
|
||||
<span>{{ `${show_others ? "Hide " : "Show "}` }}</span>
|
||||
<span>+{{ relatedActivities.length }} </span>
|
||||
<span>changes from </span>
|
||||
<span>{{ user }}</span>
|
||||
|
||||
<Button
|
||||
class="!size-4"
|
||||
variant="ghost"
|
||||
@click="show_others = !show_others"
|
||||
>
|
||||
<template #icon>
|
||||
<SelectIcon />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="text-gray-600 w-4/6" v-else>
|
||||
<span class="font-medium text-gray-800">
|
||||
{{ user }}
|
||||
</span>
|
||||
<span> {{ content }}</span>
|
||||
</div>
|
||||
|
||||
<Tooltip :text="dateFormat(creation, dateTooltipFormat)">
|
||||
<div class="text-gray-600 text-sm w-2/6 flex justify-end">
|
||||
{{ timeAgo(creation) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-if="show_others">
|
||||
<div
|
||||
v-for="relatedActivity in relatedActivities"
|
||||
:key="relatedActivity.creation"
|
||||
class="mt-2 flex justify-between text-base"
|
||||
>
|
||||
<div class="text-gray-600 w-4/6">
|
||||
<span class="font-medium text-gray-800">
|
||||
{{ relatedActivity.user }}
|
||||
</span>
|
||||
<span> {{ relatedActivity.content }}</span>
|
||||
</div>
|
||||
<Tooltip
|
||||
:text="dateFormat(relatedActivity.creation, dateTooltipFormat)"
|
||||
>
|
||||
<div class="text-gray-600 text-sm w-2/6 flex justify-end">
|
||||
{{ timeAgo(relatedActivity.creation) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SelectIcon } from "@/components/icons";
|
||||
import { dateFormat, dateTooltipFormat, timeAgo } from "@/utils";
|
||||
import { ref } from "vue";
|
||||
const props = defineProps({
|
||||
activity: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { user, content, creation, relatedActivities } = props.activity;
|
||||
|
||||
let show_others = ref(false);
|
||||
</script>
|
||||
@@ -2,18 +2,15 @@
|
||||
<div v-if="isEmoji(icon)" v-bind="$attrs">
|
||||
{{ icon }}
|
||||
</div>
|
||||
<SpriteIcon
|
||||
<FeatherIcon
|
||||
v-else-if="typeof icon == 'string'"
|
||||
:name="icon.replace('lucide-', '')"
|
||||
:name="icon"
|
||||
v-bind="$attrs"
|
||||
/>
|
||||
<component v-else :is="icon" v-bind="$attrs" />
|
||||
</template>
|
||||
<script setup>
|
||||
import { isEmoji } from "@/utils";
|
||||
// Sprite, not a `lucide-*` class: these names come from stored view icons, and
|
||||
// Tailwind only emits CSS for class names it can see in source.
|
||||
import { Icon as SpriteIcon } from "frappe-ui/experimental";
|
||||
|
||||
const props = defineProps({
|
||||
icon: {
|
||||
|
||||
118
desk/src/components/IconPicker.vue
Normal file
118
desk/src/components/IconPicker.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<Popover transition="default">
|
||||
<template #target="{ togglePopover, isOpen }">
|
||||
<slot v-bind="{ isOpen, togglePopover }">
|
||||
<span class="text-base"> {{ modelValue || "" }} </span>
|
||||
</slot>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
v-if="reaction"
|
||||
class="px-2 py-1 flex items-center justify-center gap-2 rounded-full bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div
|
||||
class="size-5 cursor-pointer rounded-full bg-surface-transparent text-xl"
|
||||
v-for="r in reactionEmojis"
|
||||
:key="r"
|
||||
@click="() => (emoji = r) && togglePopover()"
|
||||
>
|
||||
<button>
|
||||
{{ r }}
|
||||
</button>
|
||||
</div>
|
||||
<Button
|
||||
class="rounded-full"
|
||||
icon="plus"
|
||||
@click.stop="() => (reaction = false)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="my-3 max-w-max transform bg-surface-white px-4 sm:px-0"
|
||||
>
|
||||
<div
|
||||
class="relative max-h-96 pb-3 overflow-y-auto min-w-40 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div class="flex gap-2 px-3 pb-1 pt-3">
|
||||
<div class="flex-1">
|
||||
<FormControl
|
||||
type="text"
|
||||
placeholder="Search by keyword"
|
||||
v-model="search"
|
||||
:debounce="300"
|
||||
/>
|
||||
</div>
|
||||
<Button @click="setRandom">Random</Button>
|
||||
</div>
|
||||
<div class="w-96"></div>
|
||||
<div class="px-3" v-for="(emojis, group) in emojiGroups" :key="group">
|
||||
<div
|
||||
class="sticky top-0 bg-surface-modal pb-2 pt-3 text-sm text-ink-gray-7"
|
||||
>
|
||||
{{ group }}
|
||||
</div>
|
||||
<div class="grid w-96 grid-cols-12 place-items-center">
|
||||
<button
|
||||
class="h-8 w-8 rounded-md p-1 text-2xl hover:bg-surface-gray-2 focus:outline-none focus:ring focus:ring-blue-200"
|
||||
v-for="_emoji in emojis"
|
||||
:key="_emoji.description"
|
||||
@click="() => (emoji = _emoji.emoji) && togglePopover()"
|
||||
:title="_emoji.description"
|
||||
>
|
||||
{{ _emoji.emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import { Popover } from "frappe-ui";
|
||||
import { gemoji } from "gemoji";
|
||||
|
||||
const search = ref("");
|
||||
const emoji = defineModel();
|
||||
const reaction = defineModel("reaction");
|
||||
|
||||
const reactionEmojis = ref(["👍", "❤️", "😂", "😮", "😢", "🙏"]);
|
||||
|
||||
const emojiGroups = computed(() => {
|
||||
let groups = {};
|
||||
for (let _emoji of gemoji) {
|
||||
if (search.value) {
|
||||
let keywords = [_emoji.description, ..._emoji.names, ..._emoji.tags]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (!keywords.includes(search.value.toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let group = groups[_emoji.category];
|
||||
if (!group) {
|
||||
groups[_emoji.category] = [];
|
||||
group = groups[_emoji.category];
|
||||
}
|
||||
group.push(_emoji);
|
||||
}
|
||||
if (!Object.keys(groups).length) {
|
||||
groups["No results"] = [];
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
function setRandom() {
|
||||
let total = gemoji.length;
|
||||
let index = randomInt(0, total - 1);
|
||||
emoji.value = gemoji[index].emoji;
|
||||
}
|
||||
|
||||
function randomInt(min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
|
||||
defineExpose({ setRandom });
|
||||
</script>
|
||||
@@ -1,91 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<FileUploader :fileTypes="['image/*']" @success="onSuccess">
|
||||
<template #default="{ openFileSelector, uploading }">
|
||||
<!-- Filled: image with hover-to-replace + remove badge -->
|
||||
<div v-if="image" class="group relative size-11.5 shrink-0">
|
||||
<Avatar
|
||||
:image="image"
|
||||
:label="fallbackLabel"
|
||||
:shape="shape"
|
||||
size="3xl"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute inset-0 flex cursor-pointer items-center justify-center bg-black/40 text-white opacity-0 outline-none transition focus:outline-none focus-visible:outline-none group-hover:opacity-100"
|
||||
:class="roundedClass"
|
||||
:aria-label="__('Replace image')"
|
||||
@click.prevent="openFileSelector()"
|
||||
>
|
||||
<LucidePencil class="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute -right-2.5 -top-2.5 flex size-4.5 cursor-pointer items-center justify-center rounded-full bg-surface-base text-ink-gray-4 opacity-0 outline outline-black-overlay-50 duration-300 ease-in-out focus:outline-none focus-visible:outline-none hover:bg-surface-gray-2 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
|
||||
:aria-label="__('Remove image')"
|
||||
@click.prevent="image = ''"
|
||||
>
|
||||
<LucideX class="size-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty: upload box -->
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="flex size-11.5 shrink-0 cursor-pointer items-center justify-center border border-outline-gray-2 text-ink-gray-4 outline-none transition focus:outline-none focus-visible:outline-none hover:border-outline-gray-3"
|
||||
:class="roundedClass"
|
||||
:disabled="uploading"
|
||||
:aria-label="label || __('Upload image')"
|
||||
@click.prevent="openFileSelector()"
|
||||
>
|
||||
<LucideUpload class="size-4.5" />
|
||||
</button>
|
||||
</template>
|
||||
</FileUploader>
|
||||
|
||||
<div v-if="label || description" class="flex flex-col gap-0.5">
|
||||
<span v-if="label" class="text-p-sm-medium text-ink-gray-8">
|
||||
{{ __(label) }}
|
||||
</span>
|
||||
<span v-if="description" class="text-p-sm text-ink-gray-5">
|
||||
{{ __(description) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { __ } from "@/translation";
|
||||
import type { File as FileType } from "@/types";
|
||||
import { Avatar, FileUploader } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
import LucidePencil from "~icons/lucide/pencil";
|
||||
import LucideUpload from "~icons/lucide/upload";
|
||||
import LucideX from "~icons/lucide/x";
|
||||
|
||||
const image = defineModel<string>({ default: "" });
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string;
|
||||
description?: string;
|
||||
fallbackLabel?: string;
|
||||
shape?: "circle" | "square";
|
||||
}>(),
|
||||
{
|
||||
label: "",
|
||||
description: __("Upload an image in PNG or JPG format"),
|
||||
fallbackLabel: "",
|
||||
shape: "square",
|
||||
}
|
||||
);
|
||||
|
||||
const roundedClass = computed(() =>
|
||||
props.shape === "circle" ? "rounded-full" : "rounded-[10px]"
|
||||
);
|
||||
|
||||
function onSuccess(file: FileType) {
|
||||
image.value = file.file_url;
|
||||
}
|
||||
</script>
|
||||
@@ -1,13 +1,11 @@
|
||||
<template>
|
||||
<Teleport to="#app-header" v-if="showHeader">
|
||||
<slot>
|
||||
<header
|
||||
class="flex h-10.5 items-center justify-between mx-4 md:ms-5 md:me-0"
|
||||
>
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<header class="flex h-10.5 items-center justify-between mx-4 md:mr-0">
|
||||
<div class="flex items-center gap-2 max-w-[50%]">
|
||||
<slot name="left-header" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<slot name="right-header" class="flex items-center gap-2" />
|
||||
</div>
|
||||
</header>
|
||||
@@ -15,7 +13,7 @@
|
||||
</Teleport>
|
||||
</template>
|
||||
<script setup>
|
||||
import { nextTick, ref } from "vue";
|
||||
import { ref, nextTick } from "vue";
|
||||
const showHeader = ref(false);
|
||||
|
||||
nextTick(() => {
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden">
|
||||
<CardBase
|
||||
:title="title"
|
||||
:text="chartData.text"
|
||||
:percentageChange="chartData.percentageChange"
|
||||
:chartConfig="chartConfig"
|
||||
:currentDuration="currentDuration"
|
||||
:orientation="orientation"
|
||||
:timelineFilter="timelineFilter"
|
||||
@changeDuration="changeDuration"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { __ } from "@/translation";
|
||||
import { buildPercentageChange, formatTime } from "@/utils";
|
||||
import { EChartsOption } from "echarts";
|
||||
import { createResource } from "frappe-ui";
|
||||
import { computed, onMounted, ref, type PropType } from "vue";
|
||||
import CardBase from "./ChartCardBase.vue";
|
||||
|
||||
interface AverageResponseData {
|
||||
percentage_change: number;
|
||||
average?: number;
|
||||
total?: number;
|
||||
|
||||
data: { date: string; avg_time?: number; count?: number }[];
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
apiUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
timelineFilter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
data: {
|
||||
type: Object as PropType<AverageResponseData>,
|
||||
required: true,
|
||||
},
|
||||
chartColor: {
|
||||
type: Object as PropType<{
|
||||
lineColor: string;
|
||||
gradientColor: { start: string; end: string };
|
||||
}>,
|
||||
required: true,
|
||||
},
|
||||
dt: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
dn: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
orientation: {
|
||||
type: String as PropType<"vertical" | "horizontal">,
|
||||
default: "vertical",
|
||||
},
|
||||
type: {
|
||||
type: String as PropType<"Time" | "Count">,
|
||||
default: "Time",
|
||||
},
|
||||
});
|
||||
|
||||
// the untranslated key: it is lowercased straight into the API period
|
||||
const currentDuration = ref("Last month");
|
||||
|
||||
const chartData = computed(() => {
|
||||
const isDataFetched = resource.fetched;
|
||||
const _data: AverageResponseData = isDataFetched ? resource.data : props.data;
|
||||
|
||||
const dates = _data?.data?.map((item) => item.date) || [];
|
||||
|
||||
const seriesData =
|
||||
_data?.data?.map((item) =>
|
||||
props.type === "Time" ? item.avg_time : item.count
|
||||
) || [];
|
||||
const _percentageChange = _data?.percentage_change || 0;
|
||||
const percentageChange = buildPercentageChange(_percentageChange);
|
||||
|
||||
// for time take average and for count take total
|
||||
const text =
|
||||
props.type === "Time"
|
||||
? formatTime(_data?.average || 0, {
|
||||
day: true,
|
||||
hour: true,
|
||||
minute: true,
|
||||
maxUnits: 2,
|
||||
}) || "0m"
|
||||
: _data?.total || 0;
|
||||
|
||||
return {
|
||||
data: seriesData,
|
||||
dates,
|
||||
percentageChange,
|
||||
text,
|
||||
};
|
||||
});
|
||||
|
||||
const chartConfig = computed<EChartsOption>(() => {
|
||||
const color = props.chartColor.lineColor;
|
||||
const gradientColor = props.chartColor.gradientColor;
|
||||
return {
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: chartData.value.dates,
|
||||
show: false,
|
||||
boundaryGap: false,
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
show: false,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: chartData.value.data,
|
||||
type: "line",
|
||||
symbol: "none",
|
||||
lineStyle: {
|
||||
width: 1.25,
|
||||
},
|
||||
areaStyle: {
|
||||
opacity: 0.8,
|
||||
color: {
|
||||
type: "linear",
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0,
|
||||
color: gradientColor.start,
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: gradientColor.end,
|
||||
},
|
||||
],
|
||||
global: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
color: color,
|
||||
grid: {
|
||||
left: 2,
|
||||
right: 2,
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
},
|
||||
tooltip: {
|
||||
show: true,
|
||||
trigger: "axis",
|
||||
appendToBody: true,
|
||||
// position: (point: number[]) => [point[0] + 12, point[1] - 50],
|
||||
axisPointer: { type: "none" },
|
||||
formatter: (params: any) => {
|
||||
const p = Array.isArray(params) ? params[0] : params;
|
||||
if (p.value === undefined || p.value === null) return "";
|
||||
const value =
|
||||
props.type === "Time"
|
||||
? formatTime(Number(p.value) || 0, {
|
||||
day: true,
|
||||
hour: true,
|
||||
minute: true,
|
||||
maxUnits: 2,
|
||||
}) || "0m"
|
||||
: p.value;
|
||||
return `<span style="font-size:12px;color:#6b7280">${p.name}: <b style="color:#374151">${value}</b></span>`;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const resource = createResource({
|
||||
url: props.apiUrl || "",
|
||||
type: "GET",
|
||||
makeParams: () => {
|
||||
const params: Record<string, any> = {
|
||||
period: currentDuration.value.toLowerCase(),
|
||||
};
|
||||
if (props.dt && props.dn) {
|
||||
params.dt = props.dt;
|
||||
params.dn = props.dn;
|
||||
}
|
||||
return params;
|
||||
},
|
||||
});
|
||||
|
||||
const changeDuration = (period: string) => {
|
||||
currentDuration.value = period;
|
||||
if (props.apiUrl) resource.submit();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (props.timelineFilter && props.apiUrl && !props.data?.data) {
|
||||
resource.submit();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<div v-for="group in groupedRows" :key="group.group">
|
||||
<ListGroupHeader :group="group">
|
||||
<div
|
||||
class="my-2 flex items-center gap-2 text-base-medium text-ink-gray-8 justify-between w-full me-1"
|
||||
class="my-2 flex items-center gap-2 text-base font-medium text-ink-gray-8 justify-between w-full mr-1"
|
||||
>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<component v-if="group.icon" :is="group.icon" />
|
||||
@@ -11,7 +11,7 @@
|
||||
v-if="group.group.label != ''"
|
||||
class="flex items-end gap-1 w-full"
|
||||
>
|
||||
<span>{{ __(group.group.label) }}</span>
|
||||
<span>{{ group.group.label }}</span>
|
||||
<span class="text-xs text-ink-gray-5"
|
||||
>{{
|
||||
group.rows.length +
|
||||
@@ -57,13 +57,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, Dropdown } from "frappe-ui";
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
ListGroupHeader,
|
||||
ListGroupRows,
|
||||
ListRow,
|
||||
ListRows,
|
||||
} from "frappe-ui/experimental";
|
||||
} from "frappe-ui";
|
||||
import { computed, ref, watch } from "vue";
|
||||
|
||||
import IconMoreHorizontal from "~icons/lucide/more-horizontal";
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
<template>
|
||||
<!-- View Controls -->
|
||||
<div
|
||||
:class="[
|
||||
'flex items-center justify-between gap-2 px-5 pb-4 pt-4',
|
||||
list?.data?.data?.length > 0 ? 'relative' : 'absolute inset-x-0',
|
||||
]"
|
||||
class="flex items-center justify-between gap-2 px-5 pb-4 pt-3 pl-6"
|
||||
v-if="showViewControls"
|
||||
>
|
||||
<QuickFilters v-if="!isMobileView" />
|
||||
<div v-if="!isMobileView" class="-ms-2 h-5 border-s"></div>
|
||||
<div
|
||||
class="flex items-start gap-2 justify-end h-full py-1 ps-0.5"
|
||||
v-if="!isMobileView"
|
||||
>
|
||||
<QuickFilters v-if="!isMobileView" class="flex-1" />
|
||||
<div class="flex items-start gap-2 justify-end h-full" v-if="!isMobileView">
|
||||
<Button
|
||||
:label="__('Save Changes')"
|
||||
label="Save Changes"
|
||||
v-if="isViewUpdated && canSaveView"
|
||||
@click="handleViewUpdate"
|
||||
/>
|
||||
<Reload @click="handleReload" :loading="list.loading" />
|
||||
<Filter />
|
||||
<Filter :default_filters="defaultParams.filters" />
|
||||
<SortBy :hide-label="isMobileView" />
|
||||
<ColumnSettings
|
||||
:hide-label="isMobileView"
|
||||
@@ -27,7 +20,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex justify-between items-center w-full">
|
||||
<Filter />
|
||||
<Filter :default_filters="defaultParams.filters" />
|
||||
<div class="flex items-center gap-2">
|
||||
<Reload @click="handleReload" :loading="list.loading" />
|
||||
<SortBy :hide-label="isMobileView" />
|
||||
@@ -35,16 +28,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="list.loading && !list.data?.data?.length"
|
||||
class="flex items-center justify-center h-full w-full absolute top-0 z-100"
|
||||
>
|
||||
<LoadingIndicator class="size-6 text-ink-gray-4" />
|
||||
</div>
|
||||
<!-- List View -->
|
||||
<ListView
|
||||
v-else-if="list.data?.data.length > 0"
|
||||
v-if="list.data?.data.length > 0"
|
||||
class="flex-1"
|
||||
:columns="columns"
|
||||
:rows="rows"
|
||||
@@ -52,7 +38,7 @@
|
||||
:options="{
|
||||
selectable: options.selectable,
|
||||
showTooltip: false,
|
||||
resizeColumn: true,
|
||||
resizeColumn: false,
|
||||
getRowRoute: (row) => ({
|
||||
name: options.rowRoute?.name,
|
||||
params: { [options.rowRoute?.prop]: row.name },
|
||||
@@ -66,7 +52,7 @@
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:item="column"
|
||||
@columnWidthUpdated="handleColumnResize"
|
||||
@columnWidthUpdated="(width) => console.log(width)"
|
||||
/>
|
||||
</ListHeader>
|
||||
<ListRows
|
||||
@@ -86,19 +72,9 @@
|
||||
</ListRows>
|
||||
<ListSelectBanner v-if="options.showSelectBanner">
|
||||
<template #actions="{ selections, unselectAll }">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
v-for="action in selectBannerOptions(selections, unselectAll, true)"
|
||||
:key="action.label"
|
||||
:label="action.label"
|
||||
:icon-left="action.icon"
|
||||
variant="ghost"
|
||||
@click="action.onClick"
|
||||
/>
|
||||
<Dropdown :options="selectBannerOptions(selections, unselectAll)">
|
||||
<Button icon="lucide-more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Dropdown :options="selectBannerOptions(selections, unselectAll)">
|
||||
<Button icon="more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</template>
|
||||
</ListSelectBanner>
|
||||
</ListView>
|
||||
@@ -121,24 +97,21 @@
|
||||
handlePageLength(count);
|
||||
}
|
||||
"
|
||||
>
|
||||
<!-- frappe-ui's ListFooter still feeds TabButtons the removed `buttons`
|
||||
prop, so its own page-length picker renders nothing. -->
|
||||
<template #left>
|
||||
<TabButtons
|
||||
:model-value="defaultParams.page_length_count"
|
||||
:options="[20, 50, 100].map((o) => ({ label: String(o), value: o }))"
|
||||
@update:model-value="(count) => handlePageLength(count)"
|
||||
/>
|
||||
</template>
|
||||
</ListFooter>
|
||||
/>
|
||||
</div>
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-else-if="list.loading"
|
||||
class="w-full h-full flex items-center justify-center -mt-48"
|
||||
>
|
||||
<LoadingIndicator :scale="10" />
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<EmptyState
|
||||
v-else-if="!list.loading"
|
||||
v-else
|
||||
:title="emptyState.title"
|
||||
:icon="emptyState.icon"
|
||||
:description="emptyState.description"
|
||||
@emptyStateAction="emit('emptyStateAction')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -146,11 +119,11 @@
|
||||
import { MultipleAvatar, StarRating } from "@/components";
|
||||
import {
|
||||
ColumnSettings,
|
||||
Filter,
|
||||
QuickFilters,
|
||||
Reload,
|
||||
SortBy,
|
||||
} from "@/components/view-controls";
|
||||
import { Filter, normalizeFilters } from "@/components/view-controls/filter";
|
||||
import { useScreenSize } from "@/composables/screen";
|
||||
import {
|
||||
currentView as headerView,
|
||||
@@ -159,35 +132,30 @@ import {
|
||||
} from "@/composables/useView";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { globalStore } from "@/stores/globalStore";
|
||||
import { useTicketStatusStore } from "@/stores/ticketStatus";
|
||||
import { capture } from "@/telemetry";
|
||||
import { __ } from "@/translation";
|
||||
import { View, ViewType } from "@/types";
|
||||
import { getIcon } from "@/utils";
|
||||
import { formatTimeShort, getIcon } from "@/utils";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
|
||||
import { useTicketStatusStore } from "@/stores/ticketStatus";
|
||||
import {
|
||||
call,
|
||||
createResource,
|
||||
dayjsLocal,
|
||||
Dropdown,
|
||||
frappeRequest,
|
||||
LoadingIndicator,
|
||||
TabButtons,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import {
|
||||
Icon as SpriteIcon,
|
||||
FeatherIcon,
|
||||
ListFooter,
|
||||
ListHeader,
|
||||
ListHeaderItem,
|
||||
ListRowItem,
|
||||
ListSelectBanner,
|
||||
ListView,
|
||||
} from "frappe-ui/experimental";
|
||||
LoadingIndicator,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import {
|
||||
computed,
|
||||
h,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
provide,
|
||||
reactive,
|
||||
ref,
|
||||
@@ -195,9 +163,7 @@ import {
|
||||
watch,
|
||||
} from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import EmptyState from "./EmptyState.vue";
|
||||
import { listFilters } from "./listViewFilters";
|
||||
import ListRows from "./ListRows.vue";
|
||||
|
||||
interface P {
|
||||
@@ -209,7 +175,6 @@ interface P {
|
||||
// type of a h componnt
|
||||
icon?: string | VNode;
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
hideViewControls?: boolean;
|
||||
hideColumnSetting?: boolean;
|
||||
@@ -225,6 +190,7 @@ interface P {
|
||||
}
|
||||
|
||||
interface E {
|
||||
(event: "emptyStateAction"): void;
|
||||
(event: "rowClick", row: any): void;
|
||||
}
|
||||
const props = defineProps<P>();
|
||||
@@ -232,7 +198,7 @@ const emit = defineEmits<E>();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { isManager } = useAuthStore();
|
||||
const { $dialog, $socket } = globalStore();
|
||||
const { $dialog } = globalStore();
|
||||
const { getStatus } = useTicketStatusStore();
|
||||
|
||||
const listSelections = ref(new Set());
|
||||
@@ -255,20 +221,16 @@ const defaultOptions = reactive({
|
||||
},
|
||||
selectBannerActions: [
|
||||
{
|
||||
label: __("Delete"),
|
||||
icon: "lucide-trash-2",
|
||||
label: "Delete",
|
||||
icon: "trash-2",
|
||||
onClick: (selections: Set<string>) => {
|
||||
$dialog({
|
||||
title: __("Delete"),
|
||||
message: __("Are you sure you want to delete {0} item(s)?", [
|
||||
selections.size,
|
||||
]),
|
||||
title: "Delete",
|
||||
message: `Are you sure you want to delete ${selections.size} item(s)?`,
|
||||
actions: [
|
||||
{
|
||||
label: __("Delete"),
|
||||
label: "Confirm",
|
||||
variant: "solid",
|
||||
theme: "red",
|
||||
iconLeft: "lucide-trash-2",
|
||||
onClick({ close }) {
|
||||
handleBulkDelete(close, selections);
|
||||
},
|
||||
@@ -283,69 +245,11 @@ const defaultOptions = reactive({
|
||||
|
||||
function handleBulkDelete(hide: Function, selections: Set<string>) {
|
||||
capture("bulk_delete" + props.options.doctype);
|
||||
const requested = Array.from(selections);
|
||||
const requestedCount = requested.length;
|
||||
|
||||
const failureMessages: string[] = [];
|
||||
let successMessage = "";
|
||||
let failedCount = 0;
|
||||
|
||||
const onBulkResult = (data: { message: string; title: string }) => {
|
||||
const isFailure =
|
||||
data.title === __("Bulk Operation Failed") ||
|
||||
data.title === "Bulk Operation Failed";
|
||||
const isSuccess =
|
||||
data.title === __("Bulk Operation Successful") ||
|
||||
data.title === "Bulk Operation Successful";
|
||||
if (!isFailure && !isSuccess) return;
|
||||
|
||||
if (isFailure) {
|
||||
// Parse how many items failed from the message (Frappe includes the count)
|
||||
const match = data.message.match(/Failed to delete (\d+) documents?/);
|
||||
if (match) {
|
||||
failedCount = parseInt(match[1], 10);
|
||||
}
|
||||
failureMessages.push(data.message);
|
||||
} else {
|
||||
successMessage = data.message;
|
||||
}
|
||||
};
|
||||
|
||||
$socket.on("msgprint", onBulkResult);
|
||||
|
||||
// Use frappeRequest (not `call`) so per-item delete errors surfaced in
|
||||
// `_server_messages` get routed through the app's serverMessagesHandler.
|
||||
// `call` silently drops them on 200 responses.
|
||||
frappeRequest({
|
||||
url: "frappe.desk.reportview.delete_items",
|
||||
params: {
|
||||
items: JSON.stringify(requested),
|
||||
doctype: props.options.doctype,
|
||||
},
|
||||
}).finally(() => {
|
||||
$socket.off("msgprint", onBulkResult);
|
||||
|
||||
const deletedCount = requestedCount - failedCount;
|
||||
|
||||
if (failureMessages.length > 0 && deletedCount > 0) {
|
||||
// Partial success: some deleted, some failed — show both toasts
|
||||
toast.success(__("{0} item(s) deleted successfully", [deletedCount]));
|
||||
for (const msg of failureMessages) {
|
||||
toast.error(msg);
|
||||
}
|
||||
} else if (failureMessages.length > 0) {
|
||||
// All failed
|
||||
for (const msg of failureMessages) {
|
||||
toast.error(msg);
|
||||
}
|
||||
} else if (successMessage) {
|
||||
// All succeeded
|
||||
toast.success(successMessage);
|
||||
} else {
|
||||
// Fallback: no socket messages received (e.g. enqueued for >10 items)
|
||||
toast.success(__("{0} item(s) queued for deletion", [requestedCount]));
|
||||
}
|
||||
|
||||
call("frappe.desk.reportview.delete_items", {
|
||||
items: JSON.stringify(Array.from(selections)),
|
||||
doctype: props.options.doctype,
|
||||
}).then(() => {
|
||||
toast.success("Item(s) deleted successfully");
|
||||
hide();
|
||||
reset();
|
||||
});
|
||||
@@ -367,21 +271,16 @@ const { isMobileView } = useScreenSize();
|
||||
|
||||
const defaultEmptyState = {
|
||||
icon: "",
|
||||
title: __("No Data Found"),
|
||||
title: "No Data Found",
|
||||
};
|
||||
|
||||
const pageLengthCount = useStorage(
|
||||
`list_page_length_count+${props.options.doctype}`,
|
||||
options.value.default_page_length
|
||||
);
|
||||
|
||||
const defaultParams = reactive({
|
||||
doctype: options.value.doctype,
|
||||
filters: {},
|
||||
default_filters: options.value.defaultFilters,
|
||||
order_by: "modified desc",
|
||||
page_length: pageLengthCount.value,
|
||||
page_length_count: pageLengthCount.value,
|
||||
page_length: options.value.default_page_length,
|
||||
page_length_count: options.value.default_page_length,
|
||||
view: options.value.view,
|
||||
columns: [],
|
||||
rows: [],
|
||||
@@ -417,12 +316,7 @@ const exposeFunctions = {
|
||||
unselectAll: () => {},
|
||||
};
|
||||
|
||||
/** Banner actions. `inline` picks the ones shown as buttons; the rest fill the "..." menu. */
|
||||
function selectBannerOptions(
|
||||
selections: Set<string>,
|
||||
unselectAll = () => {},
|
||||
inline = false
|
||||
) {
|
||||
function selectBannerOptions(selections: Set<string>, unselectAll = () => {}) {
|
||||
exposeFunctions["unselectAll"] = unselectAll;
|
||||
|
||||
// Get the user-provided actions
|
||||
@@ -445,10 +339,8 @@ function selectBannerOptions(
|
||||
onClick: () => action.onClick?.(selections),
|
||||
}));
|
||||
|
||||
return [...userActions, ...defaultActions].filter(
|
||||
(action) =>
|
||||
Boolean(action.inline) === inline && (action.condition?.() ?? true)
|
||||
);
|
||||
// Return combined actions
|
||||
return [...userActions, ...defaultActions];
|
||||
}
|
||||
|
||||
const rows = computed(() => {
|
||||
@@ -478,7 +370,7 @@ function getGroupedByRows(listRows, groupByField) {
|
||||
group: option || " ",
|
||||
collapsed: false,
|
||||
rows: filteredRows,
|
||||
icon: h(SpriteIcon, {
|
||||
icon: h(FeatherIcon, {
|
||||
name: "folder",
|
||||
class: "h-4 w-4 flex-shrink-0 text-ink-gray-6",
|
||||
}),
|
||||
@@ -561,15 +453,15 @@ function listCell(column: any, row: any, item: any, idx: number) {
|
||||
}
|
||||
if (column.type === "Datetime") {
|
||||
return h("span", {
|
||||
class: "text-base",
|
||||
textContent: dayjsLocal(item).fromNow(),
|
||||
class: "text-p-xs",
|
||||
textContent: formatTimeShort(item),
|
||||
});
|
||||
}
|
||||
if (column.type === "MultipleAvatar") {
|
||||
return h(MultipleAvatar, {
|
||||
avatars: item,
|
||||
hideName: false,
|
||||
class: "flex items-center flex-1 min-w-0",
|
||||
hideName: true,
|
||||
class: "flex items-center truncate flex-1 flex-row-reverse justify-end",
|
||||
});
|
||||
}
|
||||
if (column.type === "Rating") {
|
||||
@@ -601,22 +493,19 @@ function handleFieldClick(e: MouseEvent, column, row, item) {
|
||||
}
|
||||
|
||||
if (column.type === "MultipleAvatar") {
|
||||
const avatar = (e.target as HTMLElement).closest(".user-avatar");
|
||||
// the gap between the faces, and the "+n" chip, name nobody to filter by
|
||||
const name = avatar?.getAttribute("data-name");
|
||||
if (!name) return;
|
||||
applyColumnFilter(column.key, "LIKE", `%${name}%`);
|
||||
if (item.length > 1) {
|
||||
let target = e.target as HTMLElement;
|
||||
target = target.closest(".user-avatar");
|
||||
if (target) {
|
||||
item = target.getAttribute("data-name");
|
||||
}
|
||||
} else {
|
||||
item = item[0].name;
|
||||
}
|
||||
applyFilters({ ...defaultParams.filters, [column.key]: ["LIKE", item] });
|
||||
return;
|
||||
}
|
||||
applyColumnFilter(column.key, "=", item);
|
||||
}
|
||||
|
||||
function applyColumnFilter(key: string, operator: string, value: any) {
|
||||
const conditions = normalizeFilters(defaultParams.filters).filter(
|
||||
(condition) => condition[0] !== key
|
||||
);
|
||||
conditions.push([key, operator, value]);
|
||||
applyFilters(conditions);
|
||||
applyFilters({ ...defaultParams.filters, [column.key]: item });
|
||||
}
|
||||
|
||||
const showViewControls = computed(() => {
|
||||
@@ -644,17 +533,9 @@ provide("listViewActions", {
|
||||
reload,
|
||||
});
|
||||
|
||||
// Also published module-scope, for the command palette: it renders in the
|
||||
// sidebar, outside this provide chain.
|
||||
listFilters.value = {
|
||||
current: () => normalizeFilters(list?.params?.filters),
|
||||
apply: applyFilters,
|
||||
};
|
||||
onUnmounted(() => (listFilters.value = null));
|
||||
|
||||
function applyFilters(filters) {
|
||||
isViewUpdated.value = true;
|
||||
defaultParams.filters = normalizeFilters(filters);
|
||||
defaultParams.filters = { ...filters };
|
||||
list.submit({ ...defaultParams });
|
||||
|
||||
// automatically update filters for default view
|
||||
@@ -667,9 +548,6 @@ function applySort(order_by: string) {
|
||||
isViewUpdated.value = true;
|
||||
defaultParams.order_by = order_by;
|
||||
list.submit({ ...defaultParams, order_by });
|
||||
if (!defaultParams.is_default) return;
|
||||
handleViewUpdate();
|
||||
isViewUpdated.value = false;
|
||||
}
|
||||
|
||||
function updateColumns(obj) {
|
||||
@@ -686,11 +564,10 @@ function updateColumns(obj) {
|
||||
|
||||
function reload(reset: boolean = false) {
|
||||
if (reset) {
|
||||
defaultParams.filters = normalizeFilters(options.value.defaultFilters);
|
||||
defaultParams.filters = options.value.defaultFilters || {};
|
||||
defaultParams.order_by = "modified desc";
|
||||
defaultParams.page_length = options.value.default_page_length;
|
||||
pageLengthCount.value = options.value.default_page_length;
|
||||
defaultParams.page_length_count = pageLengthCount.value;
|
||||
defaultParams.page_length_count = options.value.default_page_length;
|
||||
defaultParams.columns = [];
|
||||
defaultParams.rows = [];
|
||||
defaultParams.is_default = true;
|
||||
@@ -699,8 +576,7 @@ function reload(reset: boolean = false) {
|
||||
}
|
||||
|
||||
function handlePageLength(count: number, loadMore: boolean = false) {
|
||||
pageLengthCount.value = count;
|
||||
defaultParams.page_length_count = pageLengthCount.value;
|
||||
defaultParams.page_length_count = count;
|
||||
if (loadMore) {
|
||||
defaultParams.page_length += count;
|
||||
} else {
|
||||
@@ -727,45 +603,15 @@ function handleViewUpdate() {
|
||||
route_name: route.name,
|
||||
is_customer_portal: options.value.isCustomerPortal,
|
||||
};
|
||||
const currentView = findView(route.query.view as string).value;
|
||||
if (currentView && currentView.public) {
|
||||
$dialog({
|
||||
title: __("Confirm Changes"),
|
||||
message: __(
|
||||
"This view is public. Changes made will be visible to everyone."
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
label: __("Save"),
|
||||
variant: "solid",
|
||||
onClick({ close }) {
|
||||
updateView(view, () => {
|
||||
isViewUpdated.value = false;
|
||||
});
|
||||
close();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Cancel"),
|
||||
variant: "outline",
|
||||
onClick({ close }) {
|
||||
close();
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
updateView(view, () => {
|
||||
isViewUpdated.value = false;
|
||||
});
|
||||
}
|
||||
updateView(view, () => {
|
||||
isViewUpdated.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
const { findView, updateView, defaultView } = useView(options.value.doctype);
|
||||
|
||||
const canSaveView = computed(() => {
|
||||
let currentView: View = findView(route.query.view as string).value;
|
||||
if (currentView?.is_standard) return false;
|
||||
if (!currentView || !currentView.public) return true;
|
||||
if (currentView.public && isManager) {
|
||||
return true;
|
||||
@@ -779,66 +625,18 @@ function handleReload() {
|
||||
}
|
||||
|
||||
function handleViewChanges() {
|
||||
if (!switchToView(route.query.view as string)) return;
|
||||
applyUrlFilters();
|
||||
list.submit({ ...defaultParams });
|
||||
}
|
||||
|
||||
/** Base for URL filters, so repeated pushes layer on the view, not each other. */
|
||||
let viewFilters = [];
|
||||
|
||||
/** Owns sort, columns and rows. False means a redirect is in flight. */
|
||||
function switchToView(view: string): boolean {
|
||||
defaultParams.view.name = view;
|
||||
const currentView: View = findCurrentView();
|
||||
if (currentView) {
|
||||
// normalize so legacy dict-format saved views become list conditions
|
||||
viewFilters = normalizeFilters(currentView.filters);
|
||||
defaultParams.order_by = currentView.order_by || "modified desc";
|
||||
defaultParams.columns = currentView.columns;
|
||||
defaultParams.rows = currentView.rows;
|
||||
return true;
|
||||
}
|
||||
if (view) {
|
||||
// Stale ?view: drop it but keep the filters, unlike a push to the bare route.
|
||||
router.replace({
|
||||
name: route.name,
|
||||
query: { ...route.query, view: undefined },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
viewFilters = normalizeFilters(options.value.defaultFilters);
|
||||
defaultParams.order_by = "modified desc";
|
||||
defaultParams.columns = [];
|
||||
defaultParams.rows = [];
|
||||
defaultParams.is_default = true;
|
||||
headerView.value.label = __("List");
|
||||
headerView.value.icon = LucideAlignJustify;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Touches `filters` only, so filtering never resets the user's sort. */
|
||||
function applyUrlFilters() {
|
||||
const urlFilters = parseUrlFilters();
|
||||
if (!urlFilters) {
|
||||
defaultParams.filters = viewFilters;
|
||||
let currentView: View = findCurrentView();
|
||||
if (!currentView) {
|
||||
router.push({ name: route.name });
|
||||
reload(true);
|
||||
return;
|
||||
}
|
||||
const overriddenFields = new Set(urlFilters.map((c) => c[0]));
|
||||
defaultParams.filters = urlFilters.length
|
||||
? [...viewFilters.filter((c) => !overriddenFields.has(c[0])), ...urlFilters]
|
||||
: [];
|
||||
}
|
||||
defaultParams.filters = currentView.filters;
|
||||
defaultParams.order_by = currentView.order_by || "modified desc";
|
||||
defaultParams.columns = currentView.columns;
|
||||
defaultParams.rows = currentView.rows;
|
||||
|
||||
/** null when the URL carries no filters; [] means "clear them". */
|
||||
function parseUrlFilters() {
|
||||
if (!route.query.filters) return null;
|
||||
try {
|
||||
return normalizeFilters(JSON.parse(route.query.filters as string));
|
||||
} catch (e) {
|
||||
console.error("Failed to parse filters from URL", e);
|
||||
return null;
|
||||
}
|
||||
list.submit({ ...defaultParams });
|
||||
}
|
||||
|
||||
function findCurrentView() {
|
||||
@@ -853,14 +651,15 @@ function findCurrentView() {
|
||||
return currentView;
|
||||
}
|
||||
|
||||
// The view is re-read only when it changes; re-reading it on every filter
|
||||
// change is what let a filter silently reset the sort.
|
||||
watch(
|
||||
[() => route.query.view as string, () => route.query.filters as string],
|
||||
([view], [previousView]) => {
|
||||
if (view !== previousView && !switchToView(view)) return;
|
||||
applyUrlFilters();
|
||||
list.submit({ ...defaultParams });
|
||||
() => route.query.view,
|
||||
(val: string) => {
|
||||
defaultParams.view.name = val;
|
||||
handleViewChanges();
|
||||
if (!val) {
|
||||
headerView.value.label = "List";
|
||||
headerView.value.icon = "lucide:align-justify";
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -879,17 +678,6 @@ function handleScrollPosition() {
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleColumnResize({ key, width, save } = {}) {
|
||||
const column = columns.value.find((c) => c.key === key);
|
||||
if (column) column.width = width;
|
||||
if (!save) return;
|
||||
isViewUpdated.value = true;
|
||||
defaultParams.columns = columns.value;
|
||||
if (!defaultParams.is_default) return;
|
||||
handleViewUpdate();
|
||||
isViewUpdated.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
handleScrollPosition();
|
||||
|
||||
@@ -903,7 +691,7 @@ onMounted(async () => {
|
||||
if (route.query.view) {
|
||||
const currentView = findCurrentView();
|
||||
if (!currentView) return;
|
||||
headerView.value.label = currentView.label || __("List");
|
||||
headerView.value.label = currentView.label || "List";
|
||||
headerView.value.icon = getIcon(currentView.icon);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 rounded-6 bg-surface-gray-2 p-2">
|
||||
<div class="flex flex-wrap gap-2 rounded-lg bg-gray-100 p-2">
|
||||
<Pill
|
||||
v-for="item in items"
|
||||
:key="item.value"
|
||||
@@ -7,7 +7,7 @@
|
||||
@click="(i) => remove(i)"
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
<Input
|
||||
v-model="input"
|
||||
class="w-full"
|
||||
:placeholder="placeholder"
|
||||
@@ -17,7 +17,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TextInput, toast } from "frappe-ui";
|
||||
import { Input, toast } from "frappe-ui";
|
||||
import { ref, toRefs } from "vue";
|
||||
import Pill from "./Pill.vue";
|
||||
|
||||
|
||||
233
desk/src/components/MultiSelectInput.vue
Normal file
233
desk/src/components/MultiSelectInput.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button
|
||||
v-for="value in values"
|
||||
ref="emails"
|
||||
:key="value"
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
class="rounded-full"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
class="h-3.5"
|
||||
name="x"
|
||||
@click.stop="removeValue(value)"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex-1">
|
||||
<Combobox v-model="selectedValue" nullable>
|
||||
<Popover v-model:show="showOptions" class="w-full">
|
||||
<template #target="{ togglePopover }">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="search-input form-input w-full border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||
type="text"
|
||||
:value="query"
|
||||
autocomplete="off"
|
||||
@change="
|
||||
(e) => {
|
||||
query = e.target.value;
|
||||
showOptions = true;
|
||||
}
|
||||
"
|
||||
@focus="() => togglePopover()"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
/>
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
static
|
||||
>
|
||||
<ComboboxOption
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
v-slot="{ active }"
|
||||
:value="option"
|
||||
>
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-gray-100': active },
|
||||
]"
|
||||
>
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:name="option.value"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 p-1 text-gray-800">
|
||||
<div class="text-base font-medium">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
</ComboboxOptions>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="mt-2 pl-2" :message="error" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOptions,
|
||||
ComboboxOption,
|
||||
} from "@headlessui/vue";
|
||||
import { UserAvatar } from "@/components/";
|
||||
import { Popover, createResource } from "frappe-ui";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
|
||||
const props = defineProps({
|
||||
validate: {
|
||||
type: Function,
|
||||
default: null,
|
||||
},
|
||||
errorMessage: {
|
||||
type: Function,
|
||||
default: (value) => `${value} is an Invalid value`,
|
||||
},
|
||||
});
|
||||
|
||||
const values = defineModel();
|
||||
|
||||
const emails = ref([]);
|
||||
const search = ref(null);
|
||||
const error = ref(null);
|
||||
const query = ref("");
|
||||
const text = ref("");
|
||||
const showOptions = ref(false);
|
||||
|
||||
const selectedValue = computed({
|
||||
get: () => query.value || "",
|
||||
set: (val) => {
|
||||
query.value = "";
|
||||
if (val) {
|
||||
showOptions.value = false;
|
||||
}
|
||||
val?.value && addValue(val.value);
|
||||
},
|
||||
});
|
||||
|
||||
watchDebounced(
|
||||
query,
|
||||
(val) => {
|
||||
val = val || "";
|
||||
if (text.value === val) return;
|
||||
text.value = val;
|
||||
reload(val);
|
||||
},
|
||||
{ debounce: 300, immediate: true }
|
||||
);
|
||||
|
||||
const filterOptions = createResource({
|
||||
url: "helpdesk.api.contact.search_contacts",
|
||||
method: "GET",
|
||||
cache: ["Contact", text.value],
|
||||
params: {
|
||||
txt: text.value,
|
||||
},
|
||||
transform: (data: Record<"full_name" | "name" | "email_id", string>[]) => {
|
||||
return data.map(({ full_name, email_id, name }) => ({
|
||||
label: full_name || name || email_id,
|
||||
value: email_id,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const options = computed(() => {
|
||||
let searchedContacts = filterOptions.data || [];
|
||||
if (!searchedContacts.length && query.value) {
|
||||
searchedContacts.push({
|
||||
label: query.value,
|
||||
value: query.value,
|
||||
});
|
||||
}
|
||||
return searchedContacts;
|
||||
});
|
||||
|
||||
function reload(val) {
|
||||
filterOptions.update({
|
||||
params: {
|
||||
txt: val,
|
||||
},
|
||||
});
|
||||
filterOptions.reload();
|
||||
}
|
||||
|
||||
const addValue = (value) => {
|
||||
error.value = null;
|
||||
if (value) {
|
||||
const splitValues = value.split(",");
|
||||
splitValues.forEach((value) => {
|
||||
value = value.trim();
|
||||
if (value) {
|
||||
// check if value is not already in the values array
|
||||
if (!values.value?.includes(value)) {
|
||||
// check if value is valid
|
||||
if (value && props.validate && !props.validate(value)) {
|
||||
error.value = props.errorMessage(value);
|
||||
return;
|
||||
}
|
||||
// add value to values array
|
||||
if (!values.value) {
|
||||
values.value = [value];
|
||||
} else {
|
||||
values.value.push(value);
|
||||
}
|
||||
value = value.replace(value, "");
|
||||
}
|
||||
}
|
||||
});
|
||||
!error.value && (value = "");
|
||||
}
|
||||
};
|
||||
|
||||
const removeValue = (value) => {
|
||||
values.value = values.value.filter((v) => v !== value);
|
||||
};
|
||||
|
||||
const removeLastValue = () => {
|
||||
if (query.value) return;
|
||||
|
||||
let emailRef = emails.value[emails.value.length - 1]?.$el;
|
||||
if (document.activeElement === emailRef) {
|
||||
values.value.pop();
|
||||
nextTick(() => {
|
||||
if (values.value.length) {
|
||||
emailRef = emails.value[emails.value.length - 1].$el;
|
||||
emailRef?.focus();
|
||||
} else {
|
||||
setFocus();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
emailRef?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
function setFocus() {
|
||||
search.value.$el.focus();
|
||||
}
|
||||
|
||||
defineExpose({ setFocus });
|
||||
</script>
|
||||
@@ -1,13 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="_avatars?.length"
|
||||
class="isolate me-1.5 flex min-w-0 cursor-pointer items-center"
|
||||
>
|
||||
<div
|
||||
v-if="_avatars?.length == 1"
|
||||
class="user-avatar flex min-w-0 items-center gap-2 text-base line-clamp-1"
|
||||
:data-name="_avatars[0].name"
|
||||
>
|
||||
<div v-if="_avatars?.length" class="mr-1.5 flex cursor-pointer items-center">
|
||||
<div v-if="_avatars?.length == 1" class="flex items-center gap-2 text-base">
|
||||
<Tooltip :text="_avatars[0].name">
|
||||
<Avatar
|
||||
shape="circle"
|
||||
@@ -15,34 +8,24 @@
|
||||
:label="_avatars[0].label"
|
||||
size="sm"
|
||||
/>
|
||||
<div class="min-w-0 truncate" v-if="!hideName">
|
||||
{{ _avatars[0].label }}
|
||||
</div>
|
||||
<div class="truncate" v-if="!hideName">{{ _avatars[0].label }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<template v-else>
|
||||
<Tooltip
|
||||
v-for="avatar in visibleAvatars"
|
||||
:key="avatar.name"
|
||||
:text="avatar.name"
|
||||
>
|
||||
<Avatar
|
||||
class="user-avatar -mr-1.5 ring-2 ring-[var(--surface-base)] transition hover:z-20 hover:scale-110"
|
||||
shape="circle"
|
||||
:image="avatar.image"
|
||||
:label="avatar.label"
|
||||
:size="size"
|
||||
:data-name="avatar.name"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="overflowCount" :text="overflowNames">
|
||||
<div
|
||||
class="user-avatar relative z-10 -mr-1.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-surface-gray-3 text-xs text-ink-gray-7 ring-2 ring-[var(--surface-base)]"
|
||||
>
|
||||
+{{ overflowCount }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
<Tooltip
|
||||
v-for="avatar in _avatars"
|
||||
v-else
|
||||
:key="avatar.name"
|
||||
:text="avatar.name"
|
||||
>
|
||||
<Avatar
|
||||
class="user-avatar -mr-1.5 ring-2 ring-white transition hover:z-10 hover:scale-110"
|
||||
shape="circle"
|
||||
:image="avatar.image"
|
||||
:label="avatar.label"
|
||||
:size="size"
|
||||
:data-name="avatar.name"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
@@ -51,25 +34,17 @@ import { Avatar, Tooltip } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
// Frappe hands `_assign` over as a JSON string; callers with the list
|
||||
// already parsed pass the array itself.
|
||||
avatars: {
|
||||
type: [String, Array],
|
||||
type: String,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "sm",
|
||||
default: "md",
|
||||
},
|
||||
hideName: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
// Cap the number of avatars shown; the rest collapse into a "+n" chip.
|
||||
// 0 (default) shows all, so existing callers are unaffected.
|
||||
max: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
const { getUser } = useUserStore();
|
||||
const _avatars = computed(() => {
|
||||
@@ -93,25 +68,4 @@ const _avatars = computed(() => {
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
const capped = computed(
|
||||
() => props.max > 0 && (_avatars.value?.length || 0) > props.max
|
||||
);
|
||||
|
||||
const visibleAvatars = computed(() =>
|
||||
capped.value ? _avatars.value.slice(0, props.max) : _avatars.value
|
||||
);
|
||||
|
||||
const overflowCount = computed(() =>
|
||||
capped.value ? _avatars.value.length - props.max : 0
|
||||
);
|
||||
|
||||
const overflowNames = computed(() =>
|
||||
capped.value
|
||||
? _avatars.value
|
||||
.slice(props.max)
|
||||
.map((a: { name: string }) => a.name)
|
||||
.join(", ")
|
||||
: ""
|
||||
);
|
||||
</script>
|
||||
|
||||
60
desk/src/components/NestedPopover.vue
Normal file
60
desk/src/components/NestedPopover.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<Popover v-slot="{ open }">
|
||||
<PopoverButton
|
||||
ref="reference"
|
||||
v-slot="{ open: o }"
|
||||
as="div"
|
||||
@click="updatePosition"
|
||||
@focusin="updatePosition"
|
||||
@keydown="updatePosition"
|
||||
>
|
||||
<slot name="target" :open="o" />
|
||||
</PopoverButton>
|
||||
<div v-show="open">
|
||||
<PopoverPanel
|
||||
v-slot="{ open: o, close }"
|
||||
ref="popover"
|
||||
static
|
||||
class="z-[100]"
|
||||
>
|
||||
<slot name="body" :open="o" :close="close" />
|
||||
</PopoverPanel>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, ref, onBeforeUnmount } from "vue";
|
||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/vue";
|
||||
import { createPopper } from "@popperjs/core";
|
||||
|
||||
const props = defineProps({
|
||||
placement: {
|
||||
type: String,
|
||||
default: "bottom-start",
|
||||
},
|
||||
});
|
||||
|
||||
const reference = ref(null);
|
||||
const popover = ref(null);
|
||||
|
||||
let popper = ref(null);
|
||||
|
||||
function setupPopper() {
|
||||
if (!popper.value) {
|
||||
popper.value = createPopper(reference.value.el, popover.value.el, {
|
||||
placement: props.placement,
|
||||
});
|
||||
} else {
|
||||
popper.value.update();
|
||||
}
|
||||
}
|
||||
|
||||
function updatePosition() {
|
||||
nextTick(() => setupPopper());
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
popper.value?.destroy();
|
||||
});
|
||||
</script>
|
||||
@@ -1,93 +0,0 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-5 pb-0">
|
||||
<div class="gap-x-3 flex h-full">
|
||||
<Avatar
|
||||
:size="avatar.size ?? '3xl'"
|
||||
:shape="avatar.shape ?? 'square'"
|
||||
:label="avatar.label"
|
||||
:image="avatar.image"
|
||||
class="h-[52px] w-[52px]"
|
||||
/>
|
||||
<div
|
||||
class="flex flex-col h-full gap-1.5"
|
||||
:class="[
|
||||
(isMobileView || !hasDocInfo) && 'flex-1 items-center justify-center',
|
||||
]"
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<p class="text-ink-gray-8 text-xl-medium">
|
||||
{{ avatar.label }}
|
||||
</p>
|
||||
<Tooltip v-if="badge" :text="badge.tooltip ?? ''">
|
||||
<Badge :label="badge.label" :theme="badge.theme ?? 'amber'" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div v-if="!isMobileView" class="flex items-center gap-x-1.5">
|
||||
<template v-for="(item, index) in docInfo" :key="index">
|
||||
<template v-if="item.condition !== false">
|
||||
<span
|
||||
v-if="
|
||||
docInfo.slice(0, index).some((i) => i.condition !== false)
|
||||
"
|
||||
class="text-ink-gray-4"
|
||||
>
|
||||
•
|
||||
</span>
|
||||
<div class="flex items-center gap-x-1">
|
||||
<component
|
||||
v-if="item.icon"
|
||||
:is="item.icon"
|
||||
class="h-4 w-4 text-ink-gray-6"
|
||||
/>
|
||||
<span v-if="item.value" class="text-sm text-ink-gray-8">{{
|
||||
item.value
|
||||
}}</span>
|
||||
<template v-else-if="item.component">
|
||||
<component :is="item.component" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useScreenSize } from "@/composables/screen";
|
||||
import { Avatar, Badge, Tooltip, type AvatarProps } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
|
||||
interface DocInfoItem {
|
||||
icon?: any;
|
||||
value?: string;
|
||||
component?: any;
|
||||
condition?: boolean;
|
||||
}
|
||||
|
||||
interface BadgeInfo {
|
||||
label: string;
|
||||
theme?: "gray" | "blue" | "green" | "amber" | "red";
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
avatar: AvatarProps;
|
||||
docInfo?: DocInfoItem[];
|
||||
badge?: BadgeInfo | null;
|
||||
}>(),
|
||||
{
|
||||
docInfo: () => [],
|
||||
badge: null,
|
||||
}
|
||||
);
|
||||
|
||||
const { isMobileView } = useScreenSize();
|
||||
|
||||
const hasDocInfo = computed(() => {
|
||||
return props.docInfo?.some((item) => item.value) ?? false;
|
||||
});
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<header class="border-b px-5 py-2.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<slot name="title">
|
||||
<div v-if="title" class="text-md-medium text-ink-gray-9">
|
||||
<div v-if="title" class="text-lg font-medium text-gray-900">
|
||||
{{ title }}
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<slot name="prefix" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<Tooltip bare>
|
||||
<template #content>
|
||||
<Tooltip>
|
||||
<template #body>
|
||||
<div
|
||||
class="rounded-4 bg-surface-gray-10 py-1.5 px-2 text-xs text-ink-base shadow-xl"
|
||||
class="rounded bg-surface-gray-7 py-1.5 px-2 text-xs text-ink-white shadow-xl"
|
||||
>
|
||||
<span class="flex items-center gap-1">
|
||||
{{ show ? "Hide Password" : "Show Password" }}
|
||||
@@ -24,7 +24,7 @@
|
||||
<component
|
||||
v-show="showEye"
|
||||
:is="show ? LucideEyeOff : LucideEye"
|
||||
class="h-3 cursor-pointer me-1"
|
||||
class="h-3 cursor-pointer mr-1"
|
||||
@click="show = !show"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<template>
|
||||
<Button
|
||||
ref="buttonRef"
|
||||
:key="label"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
@@ -14,9 +13,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
import IconX from "~icons/lucide/x";
|
||||
|
||||
defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
@@ -31,13 +28,7 @@ defineProps({
|
||||
|
||||
const emit = defineEmits(["click"]);
|
||||
|
||||
const buttonRef = ref(null);
|
||||
|
||||
const handleClick = (label) => {
|
||||
emit("click", label);
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
rootRef: computed(() => buttonRef.value?.$el),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
export interface Option {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
// A dropdown rendered beneath a text question (e.g. org name + company size).
|
||||
export interface DropdownConfig {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
options: Option[];
|
||||
}
|
||||
|
||||
export interface TextQuestion {
|
||||
key: string;
|
||||
title: string;
|
||||
type: "text";
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
required?: boolean;
|
||||
dropdown?: DropdownConfig;
|
||||
}
|
||||
|
||||
export interface ChoiceQuestion {
|
||||
key: string;
|
||||
title: string;
|
||||
type: "choice";
|
||||
options: Option[];
|
||||
multiple?: boolean;
|
||||
// Placeholder for the free-text box the "Other" option reveals.
|
||||
otherPlaceholder?: string;
|
||||
}
|
||||
|
||||
export type Question = TextQuestion | ChoiceQuestion;
|
||||
@@ -1,279 +0,0 @@
|
||||
<template>
|
||||
<div class="relative mx-auto w-full max-w-md flex flex-col gap-5">
|
||||
<HDLogo class="size-8" />
|
||||
|
||||
<!-- Fixed height keeps the panel from resizing as questions vary in length. -->
|
||||
<div class="relative">
|
||||
<Button
|
||||
v-if="current > 0"
|
||||
variant="ghost"
|
||||
icon="lucide-chevron-left"
|
||||
class="!size-7 shrink-0 !rounded-5 absolute -left-11 mt-4"
|
||||
@click="back"
|
||||
/>
|
||||
<div
|
||||
:key="question.key"
|
||||
class="mx-0 min-w-0 border-0 p-0 flex gap-5 flex-col"
|
||||
>
|
||||
<div>
|
||||
<p class="p-0 text-p-xl font-bold text-ink-gray-9">
|
||||
{{ question.title
|
||||
}}<span v-if="titleRequired" class="ms-0.5 text-ink-red-6">*</span>
|
||||
</p>
|
||||
|
||||
<p class="text-base pt-1.5">
|
||||
{{ __("This helps us personalize your Helpdesk experience.") }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="question.type === 'text'">
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Cap textarea resize: vertical only, between one row block and ~2x default. -->
|
||||
<div
|
||||
class="[&_textarea]:resize-y [&_textarea]:min-h-16 [&_textarea]:max-h-40"
|
||||
>
|
||||
<span
|
||||
v-if="question.label"
|
||||
class="mb-1.5 block text-p-sm text-ink-gray-5"
|
||||
>
|
||||
{{ question.label
|
||||
}}<span v-if="question.required" class="ms-0.5 text-ink-red-6"
|
||||
>*</span
|
||||
>
|
||||
</span>
|
||||
<Textarea
|
||||
v-if="question.multiline"
|
||||
v-model="answers[question.key]"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:rows="3"
|
||||
:placeholder="question.placeholder"
|
||||
/>
|
||||
<TextInput
|
||||
v-else
|
||||
v-model="answers[question.key]"
|
||||
size="sm"
|
||||
type="text"
|
||||
variant="outline"
|
||||
:placeholder="question.placeholder"
|
||||
@keyup.enter="proceed"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="dropdown">
|
||||
<span class="mb-1.5 block text-p-sm text-ink-gray-5">
|
||||
{{ dropdown.label }}
|
||||
</span>
|
||||
<Select
|
||||
v-model="answers[dropdown.key]"
|
||||
class="w-full"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:placeholder="dropdown.placeholder"
|
||||
:options="dropdown.options"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-wrap gap-3 !text-ink-gray-7 text-p-base">
|
||||
<Button
|
||||
v-for="option in choiceOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:variant="isSelected(option) ? 'subtle' : 'outline'"
|
||||
size="sm"
|
||||
class="!rounded-5 border border-solid"
|
||||
:class="
|
||||
isSelected(option)
|
||||
? '!bg-surface-gray-3 hover:!bg-surface-gray-4 !border-outline-gray-3'
|
||||
: 'hover:!bg-surface-alpha-gray-2'
|
||||
"
|
||||
@click="select(option)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="-mt-5 grid transition-[grid-template-rows,opacity] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
|
||||
:class="
|
||||
isOtherSelected
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div
|
||||
class="transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] [&_textarea]:resize-y [&_textarea]:min-h-16 [&_textarea]:max-h-40"
|
||||
:class="isOtherSelected ? 'translate-y-0' : '-translate-y-full'"
|
||||
>
|
||||
<Textarea
|
||||
ref="otherInput"
|
||||
v-model="answers[otherKey]"
|
||||
class="mt-5"
|
||||
variant="outline"
|
||||
:rows="3"
|
||||
:placeholder="otherPlaceholder"
|
||||
:tabindex="isOtherSelected ? 0 : -1"
|
||||
:error="otherError"
|
||||
@update:model-value="otherError = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Button
|
||||
class="w-full"
|
||||
variant="solid"
|
||||
size="sm"
|
||||
:label="isLast ? __('Finish') : __('Next')"
|
||||
:disabled="!canProceed"
|
||||
@click="proceed"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="mt-auto !bg-transparent !text-ink-gray-5 hover:!text-ink-gray-8"
|
||||
variant="ghost"
|
||||
:label="__('Skip for now')"
|
||||
@click="finish"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import HDLogo from "@/assets/logos/HDLogo.vue";
|
||||
import { __ } from "@/translation";
|
||||
import { Button, Select, Textarea, TextInput } from "frappe-ui";
|
||||
import { computed, nextTick, reactive, ref, watch } from "vue";
|
||||
import type { Option, Question } from "./Questionnaire.types";
|
||||
|
||||
type Answers = Record<string, string | string[]>;
|
||||
|
||||
const props = defineProps<{ questions: Question[] }>();
|
||||
const emit = defineEmits<{ submit: [answers: Answers] }>();
|
||||
|
||||
const current = ref(0);
|
||||
const completed = ref(false);
|
||||
const answers = reactive<Answers>({});
|
||||
const otherInput = ref<InstanceType<typeof Textarea>>();
|
||||
|
||||
const total = computed(() => props.questions.length);
|
||||
const question = computed(() => props.questions[current.value] as Question);
|
||||
const isLast = computed(() => current.value === total.value - 1);
|
||||
|
||||
// Next stays disabled until the current question has an answer.
|
||||
const canProceed = computed(() => {
|
||||
const q = question.value;
|
||||
const value = answers[q.key];
|
||||
if (q.type === "text")
|
||||
return !q.required || (typeof value === "string" && value.trim() !== "");
|
||||
if (q.multiple) return Array.isArray(value) && value.length > 0;
|
||||
return typeof value === "string" && value !== "";
|
||||
});
|
||||
|
||||
// Narrow the discriminated union so the template stays declarative: choice
|
||||
// questions render pills, text questions may carry a dropdown beneath them.
|
||||
const choiceOptions = computed(() =>
|
||||
question.value.type === "choice" ? question.value.options : []
|
||||
);
|
||||
const dropdown = computed(() =>
|
||||
question.value.type === "text" ? question.value.dropdown : undefined
|
||||
);
|
||||
|
||||
// "Other" free-text is stored under a `<key>_other` sibling answer key.
|
||||
const OTHER_SUFFIX = "_other";
|
||||
const otherKey = computed(() => `${question.value.key}${OTHER_SUFFIX}`);
|
||||
const isOtherSelected = computed(() => {
|
||||
const otherOption = choiceOptions.value.find((o) => o.value === "other");
|
||||
return otherOption ? isSelected(otherOption) : false;
|
||||
});
|
||||
|
||||
const otherPlaceholder = computed(
|
||||
() =>
|
||||
(question.value.type === "choice" && question.value.otherPlaceholder) ||
|
||||
__("Tell us more")
|
||||
);
|
||||
|
||||
// The asterisk normally rides on the field label; questions whose title is
|
||||
// itself the prompt have no label to hang it on.
|
||||
const titleRequired = computed(
|
||||
() =>
|
||||
question.value.type === "text" &&
|
||||
!!question.value.required &&
|
||||
!question.value.label
|
||||
);
|
||||
|
||||
const isOtherFilled = computed(() => {
|
||||
const other = answers[otherKey.value];
|
||||
return typeof other === "string" && other.trim() !== "";
|
||||
});
|
||||
|
||||
// Cleared on navigation and on toggling "Other" off, so re-opening the box
|
||||
// never shows a stale error the user hasn't earned yet.
|
||||
const otherError = ref("");
|
||||
watch([current, isOtherSelected], () => (otherError.value = ""));
|
||||
|
||||
function isSelected(option: Option) {
|
||||
const q = question.value;
|
||||
if (q.type !== "choice") return false;
|
||||
const value = answers[q.key];
|
||||
if (q.multiple) {
|
||||
return Array.isArray(value) && value.includes(option.value);
|
||||
}
|
||||
return value === option.value;
|
||||
}
|
||||
|
||||
function select(option: Option) {
|
||||
if (completed.value) return;
|
||||
const q = question.value;
|
||||
if (q.type !== "choice") return;
|
||||
const key = q.key;
|
||||
if (q.multiple) {
|
||||
const value = Array.isArray(answers[key])
|
||||
? [...(answers[key] as string[])]
|
||||
: [];
|
||||
const index = value.indexOf(option.value);
|
||||
if (index === -1) {
|
||||
value.push(option.value);
|
||||
} else {
|
||||
value.splice(index, 1);
|
||||
}
|
||||
answers[key] = value;
|
||||
} else {
|
||||
// A click only sets the selection; advancing is always via the Next button.
|
||||
answers[key] = option.value;
|
||||
}
|
||||
|
||||
if (!isOtherSelected.value) {
|
||||
delete answers[otherKey.value];
|
||||
} else if (option.value === "other") {
|
||||
nextTick(() => otherInput.value?.focus({ preventScroll: true }));
|
||||
}
|
||||
}
|
||||
|
||||
function advance() {
|
||||
if (current.value < total.value - 1) current.value += 1;
|
||||
}
|
||||
|
||||
function back() {
|
||||
if (current.value > 0) current.value -= 1;
|
||||
}
|
||||
|
||||
function proceed() {
|
||||
if (!canProceed.value) return;
|
||||
// "Other" stays selectable with Next enabled; the ask happens on click.
|
||||
if (isOtherSelected.value && !isOtherFilled.value) {
|
||||
otherError.value = __("Add a few words so we can continue");
|
||||
otherInput.value?.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
if (isLast.value) finish();
|
||||
else advance();
|
||||
}
|
||||
|
||||
function finish() {
|
||||
if (completed.value) return;
|
||||
completed.value = true;
|
||||
setTimeout(() => emit("submit", { ...answers }), 700);
|
||||
}
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="relative" :style="{ width: `${sidebarWidth}px` }">
|
||||
<slot v-bind="{ sidebarResizing, sidebarWidth }" />
|
||||
<div
|
||||
class="absolute z-10 h-full w-1 cursor-col-resize bg-surface-gray-4 opacity-0 transition-opacity hover:opacity-100"
|
||||
class="absolute z-1 h-full w-1 cursor-col-resize bg-surface-gray-4 opacity-0 transition-opacity hover:opacity-100"
|
||||
:class="{
|
||||
'opacity-100': sidebarResizing,
|
||||
'left-0': side == 'right',
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
<template>
|
||||
<Dialog v-model:open="show" size="4xl" bare @after-leave="onAfterLeave">
|
||||
<template #default>
|
||||
<div class="max-h-[575px]" :style="{ height: 'calc(100vh - 8rem)' }">
|
||||
<div class="flex items-center justify-between w-full p-4 pb-2">
|
||||
<div class="text-2xl-semibold">{{ __("Saved Replies") }}</div>
|
||||
<Button
|
||||
icon-left="lucide-plus"
|
||||
:label="__('New')"
|
||||
@click="onNewSavedReplyClick"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative w-full">
|
||||
<TextInput
|
||||
ref="searchInput"
|
||||
:model-value="search"
|
||||
@update:model-value="search = $event"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
class="focus:ring-0 border-outline-gray-2"
|
||||
:debounce="300"
|
||||
autofocus
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideSearch class="size-4" />
|
||||
</template>
|
||||
</TextInput>
|
||||
<Button
|
||||
v-if="search"
|
||||
icon="lucide-x"
|
||||
variant="ghost"
|
||||
@click="search = ''"
|
||||
class="absolute end-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown :options="filters" align="end">
|
||||
<Button :label="activeFilterLabel">
|
||||
<template #prefix><FilterIcon class="h-4" /></template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-4 h-full overflow-y-auto">
|
||||
<div
|
||||
v-if="savedReplyListResource?.list?.loading"
|
||||
class="flex items-center justify-center mt-24"
|
||||
>
|
||||
<LoadingIndicator class="size-4" />
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!savedReplyListResource?.list?.loading &&
|
||||
savedReplyListResource?.data?.length
|
||||
"
|
||||
class="grid grid-cols-1 md:grid-cols-3 gap-3 pb-36"
|
||||
>
|
||||
<div
|
||||
v-for="template in savedReplyListResource?.data"
|
||||
:key="template.name"
|
||||
class="flex h-56 cursor-pointer flex-col gap-2 rounded-6 border border-outline-gray-1 bg-surface-base p-3 hover:border-outline-gray-3 relative"
|
||||
@click="onTemplateSelect(template)"
|
||||
>
|
||||
<div class="flex items-center gap-2 border-b pb-2">
|
||||
<div class="text-base-semibold truncate max-w-[75%]">
|
||||
{{ template.title }}
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="actionCounts[template.name]"
|
||||
:text="actionTooltip(actionCounts[template.name])"
|
||||
>
|
||||
<Badge
|
||||
class="ms-auto shrink-0"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
:label="actionLabel(actionCounts[template.name])"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div
|
||||
v-if="template.message"
|
||||
class="flex-1 overflow-hidden pointer-events-none [mask-image:linear-gradient(to_bottom,black_80%,transparent)]"
|
||||
>
|
||||
<Editor
|
||||
:model-value="template.message"
|
||||
:extensions="extensions"
|
||||
:editable="false"
|
||||
>
|
||||
<template #default>
|
||||
<EditorContent
|
||||
class="max-w-none text-p-sm text-ink-gray-5 focus:outline-none"
|
||||
/>
|
||||
</template>
|
||||
</Editor>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
selectedTemplate.name === template.name &&
|
||||
selectedTemplate.isLoading
|
||||
"
|
||||
class="flex items-center justify-center absolute top-0 start-0 w-full h-full rounded-6"
|
||||
>
|
||||
<LoadingIndicator class="size-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-full flex justify-center">
|
||||
<Button
|
||||
v-if="
|
||||
!savedReplyListResource.list.loading &&
|
||||
savedReplyListResource.hasNextPage
|
||||
"
|
||||
:label="__('Load More')"
|
||||
icon-left="lucide-refresh-cw"
|
||||
@click="savedReplyListResource.next()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
!savedReplyListResource?.list?.loading &&
|
||||
!savedReplyListResource?.data?.length
|
||||
"
|
||||
class="mt-2"
|
||||
>
|
||||
<div class="flex h-56 flex-col items-center justify-center">
|
||||
<div class="text-p-sm text-ink-gray-4">
|
||||
{{ __("No saved replies found") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { recordSavedReplyUse } from "@/components/command-palette/savedReplyCommands";
|
||||
import { buildEditorExtensions } from "@/components/editor/config";
|
||||
import FilterIcon from "@/components/icons/FilterIcon.vue";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { capture } from "@/telemetry";
|
||||
import { __ } from "@/translation";
|
||||
import { RenderedSavedReply, SavedReply } from "@/types";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
createListResource,
|
||||
createResource,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
LoadingIndicator,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "frappe-ui";
|
||||
import { Editor, EditorContent } from "frappe-ui/editor";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, h, nextTick, ref, watch } from "vue";
|
||||
import {
|
||||
setActiveSettingsTab,
|
||||
showSettingsModal,
|
||||
} from "./Settings/settingsModal";
|
||||
const extensions = buildEditorExtensions();
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
ticketId: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
|
||||
const show = defineModel();
|
||||
const activeFilter = useStorage("saved-replies-filter", "Personal");
|
||||
const { disableGlobalScopeForSavedReplies, teamRestrictionApplied } =
|
||||
storeToRefs(useConfigStore());
|
||||
|
||||
const filters = computed(() => {
|
||||
const scopes = [
|
||||
{ label: __("All"), value: "All" },
|
||||
{ label: __("Personal"), value: "Personal" },
|
||||
{ label: __("My Team(s)"), value: "Team" },
|
||||
{ label: __("Global"), value: "Global" },
|
||||
];
|
||||
if (teamRestrictionApplied.value && disableGlobalScopeForSavedReplies.value) {
|
||||
scopes.pop();
|
||||
}
|
||||
return scopes.map((scope) => ({
|
||||
...scope,
|
||||
selected: activeFilter.value === scope.value,
|
||||
onClick: () => (activeFilter.value = scope.value),
|
||||
slots: {
|
||||
suffix: () =>
|
||||
h(
|
||||
"span",
|
||||
{ class: "text-sm text-ink-gray-5" },
|
||||
String(scopeCounts.data?.[scope.value] ?? 0)
|
||||
),
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
const activeFilterLabel = computed(() => {
|
||||
return (
|
||||
filters.value.find((filter) => filter.value === activeFilter.value)
|
||||
?.label ?? activeFilter.value
|
||||
);
|
||||
});
|
||||
|
||||
// Older sessions stored the label instead of the value
|
||||
if (activeFilter.value == "My Team") {
|
||||
activeFilter.value = "Team";
|
||||
}
|
||||
|
||||
// Set default filter to Personal if Global is disabled
|
||||
if (
|
||||
teamRestrictionApplied.value &&
|
||||
disableGlobalScopeForSavedReplies.value &&
|
||||
activeFilter.value == "Global"
|
||||
) {
|
||||
activeFilter.value = "Personal";
|
||||
}
|
||||
|
||||
const emit = defineEmits(["apply"]);
|
||||
|
||||
const search = ref("");
|
||||
const searchInput = ref<InstanceType<typeof TextInput>>();
|
||||
const selectedTemplate = ref({
|
||||
name: "",
|
||||
isLoading: false,
|
||||
});
|
||||
const pendingTemplate = ref<RenderedSavedReply | null>(null);
|
||||
|
||||
function onAfterLeave() {
|
||||
if (pendingTemplate.value !== null) {
|
||||
emit("apply", pendingTemplate.value);
|
||||
pendingTemplate.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
const scope = computed(() => {
|
||||
return filters.value.find((f) => f.value === activeFilter.value)?.value;
|
||||
});
|
||||
|
||||
const savedReplyListResource = createListResource({
|
||||
doctype: "HD Saved Reply",
|
||||
fields: ["name", "title", "owner", "scope", "message", "actions"],
|
||||
filters: {
|
||||
scope: scope.value == "All" ? undefined : ["=", scope.value],
|
||||
},
|
||||
cache: ["SavedReplyListForModal"],
|
||||
auto: true,
|
||||
orderBy: "modified desc",
|
||||
start: 0,
|
||||
pageLength: 20,
|
||||
});
|
||||
|
||||
// One grouped query answers every scope at once, so the menu can say where the
|
||||
// replies live before an agent switches to find out
|
||||
const scopeCounts = createResource({
|
||||
url: "frappe.client.get_list",
|
||||
params: {
|
||||
doctype: "HD Saved Reply",
|
||||
fields: ["scope", { COUNT: "name" }],
|
||||
group_by: "scope",
|
||||
limit_page_length: 0,
|
||||
},
|
||||
transform: (rows: { scope: string }[]) => {
|
||||
const counts: Record<string, number> = { All: 0 };
|
||||
for (const row of rows) {
|
||||
const count = row["COUNT(`name`)"] ?? 0;
|
||||
counts[row.scope] = count;
|
||||
counts.All += count;
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
});
|
||||
|
||||
const actionCounts = computed<Record<string, number>>(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of savedReplyListResource.data || []) {
|
||||
counts[row.name] = JSON.parse(row.actions || "[]").length;
|
||||
}
|
||||
return counts;
|
||||
});
|
||||
|
||||
const actionLabel = (count: number) =>
|
||||
count === 1 ? __("1 action") : __("{0} actions", count);
|
||||
|
||||
const actionTooltip = (count: number) =>
|
||||
count === 1
|
||||
? __("Applies 1 action when sent")
|
||||
: __("Applies {0} actions when sent", count);
|
||||
|
||||
const onTemplateSelect = (template: SavedReply) => {
|
||||
if (selectedTemplate.value.isLoading) return;
|
||||
selectedTemplate.value = {
|
||||
name: template.name,
|
||||
isLoading: true,
|
||||
};
|
||||
const renderResponse = createResource({
|
||||
url: "helpdesk.api.saved_replies.get_rendered_saved_reply",
|
||||
params: {
|
||||
saved_reply_id: template.name,
|
||||
ticket_id: props.ticketId,
|
||||
},
|
||||
onSuccess: (data: RenderedSavedReply) => {
|
||||
selectedTemplate.value = {
|
||||
name: "",
|
||||
isLoading: false,
|
||||
};
|
||||
// If user cancelled (Escape/outside click) while API was in flight, discard
|
||||
if (!show.value) return;
|
||||
pendingTemplate.value = data;
|
||||
show.value = false;
|
||||
// Shared with the palette: modal picks count toward its most-used rows.
|
||||
recordSavedReplyUse(template.name, template.title);
|
||||
capture("saved_reply_applied", { data: { source: "composer" } });
|
||||
},
|
||||
});
|
||||
renderResponse.submit().catch(() => {
|
||||
selectedTemplate.value = {
|
||||
name: "",
|
||||
isLoading: false,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const onNewSavedReplyClick = () => {
|
||||
show.value = false;
|
||||
showSettingsModal.value = true;
|
||||
setActiveSettingsTab("Saved Replies");
|
||||
};
|
||||
|
||||
// A reply is as findable by its wording as by its name, now that only a page loads
|
||||
watch(search, (query) => {
|
||||
savedReplyListResource.orFilters = query
|
||||
? { title: ["like", `%${query}%`], message: ["like", `%${query}%`] }
|
||||
: undefined;
|
||||
savedReplyListResource.start = 0;
|
||||
savedReplyListResource.list.reload();
|
||||
});
|
||||
|
||||
watch(activeFilter, () => {
|
||||
savedReplyListResource.filters = {
|
||||
...savedReplyListResource?.filters,
|
||||
scope: scope.value == "All" ? undefined : ["=", scope.value],
|
||||
};
|
||||
savedReplyListResource.start = 0;
|
||||
savedReplyListResource.list.reload();
|
||||
});
|
||||
|
||||
watch(
|
||||
show,
|
||||
(newValue) => {
|
||||
if (!newValue) return;
|
||||
if (search.value) {
|
||||
search.value = "";
|
||||
} else {
|
||||
savedReplyListResource.list.reload();
|
||||
}
|
||||
// Counts don't move with the selected scope, so this is the only refetch
|
||||
scopeCounts.reload();
|
||||
nextTick(() => searchInput.value?.focus());
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
@@ -1,132 +0,0 @@
|
||||
<template>
|
||||
<component
|
||||
:is="config.doctype ? Link : Combobox"
|
||||
v-bind="controlProps"
|
||||
:disabled="!isEditable"
|
||||
:model-value="action.value"
|
||||
:placeholder="__('Select {0}', label.toLowerCase())"
|
||||
@update:model-value="pick(($event ?? '') as string)"
|
||||
>
|
||||
<template #trigger="{ setOpen }">
|
||||
<!-- The anchor forwards its click to this element, so it has to be a plain
|
||||
one: Tooltip and Combobox both drop inherited attrs -->
|
||||
<div
|
||||
class="inline-flex"
|
||||
:role="isEditable ? 'button' : undefined"
|
||||
:tabindex="isEditable ? 0 : undefined"
|
||||
@keydown.enter.self.prevent="setOpen(true)"
|
||||
>
|
||||
<Tooltip :text="value ? `${label}: ${value}` : label">
|
||||
<Badge
|
||||
class="bg-surface-base transition-colors"
|
||||
:class="isEditable ? 'cursor-pointer hover:bg-surface-gray-2' : ''"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
>
|
||||
<template #prefix>
|
||||
<component
|
||||
:is="config.icon"
|
||||
class="size-3 shrink-0 text-ink-gray-6"
|
||||
/>
|
||||
</template>
|
||||
<span class="flex min-w-0 items-center gap-1">
|
||||
<span v-if="value" class="shrink-0 text-ink-gray-6">
|
||||
{{ label }}
|
||||
</span>
|
||||
<span class="max-w-40 truncate text-ink-gray-8">
|
||||
{{ value || label }}
|
||||
</span>
|
||||
</span>
|
||||
<template #suffix>
|
||||
<!-- Removing is not picking: the click stops here -->
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="__('Remove {0}', label)"
|
||||
class="flex items-center rounded-1 text-ink-gray-6 transition-colors hover:text-ink-gray-9 focus-visible:ring-2 focus-visible:ring-outline-gray-3 mt-[1px]"
|
||||
@click.stop="emit('remove')"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<LucideX class="size-3" />
|
||||
</button>
|
||||
</template>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ACTION_TYPES } from "@/components/Settings/SavedReplies/components/actionTypes";
|
||||
import { useSavedReplyActionOptions } from "@/composables/useSavedReplyActionOptions";
|
||||
import { Link } from "@framework/ui";
|
||||
import { __ } from "@/translation";
|
||||
import { SavedReplyAction } from "@/types";
|
||||
import { Badge, Combobox, Tooltip } from "frappe-ui";
|
||||
import { computed } from "vue";
|
||||
import LucideX from "~icons/lucide/x";
|
||||
|
||||
const props = defineProps<{
|
||||
action: SavedReplyAction;
|
||||
/** Values the other chips hold, so a repoint can't collide with them. */
|
||||
taken?: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [action: SavedReplyAction];
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
const { valueOptions, linkFilters } = useSavedReplyActionOptions();
|
||||
|
||||
const config = computed(() => ACTION_TYPES[props.action.action_type]);
|
||||
const label = computed(() => config.value.chipLabel);
|
||||
// Empty for no-value actions like "Assign to Me"; the noun then stands alone
|
||||
const value = computed(() => props.action.label || props.action.value);
|
||||
|
||||
// A tag chip holds one tag, so it picks like the rest. Excluded rather than
|
||||
// listed, so a new control type is editable by default instead of silently
|
||||
// read-only: no-value actions have nothing to pick, and a comment is too long
|
||||
// for a chip.
|
||||
const isEditable = computed(
|
||||
() => !["none", "editor"].includes(config.value.control)
|
||||
);
|
||||
|
||||
// Combobox takes string values; the shared options allow numbers too
|
||||
const pickerOptions = computed(() =>
|
||||
valueOptions(props.action.action_type)
|
||||
.filter((option) => !props.taken?.includes(String(option.value)))
|
||||
.map((option) => ({
|
||||
label: option.label,
|
||||
value: String(option.value),
|
||||
}))
|
||||
);
|
||||
|
||||
// A #trigger slot puts Combobox in button mode, and Link forwards that slot,
|
||||
// so the badge below serves both controls.
|
||||
const controlProps = computed(() =>
|
||||
config.value.doctype
|
||||
? {
|
||||
doctype: config.value.doctype,
|
||||
filters: linkFilters(props.action.action_type),
|
||||
title: value.value,
|
||||
}
|
||||
: { options: pickerOptions.value }
|
||||
);
|
||||
|
||||
/** `label` carries the display name (an agent's, not their id), so both move. */
|
||||
function pick(picked: string) {
|
||||
// A link picker offers no option list to read a label from; for these
|
||||
// doctypes the record name is the label.
|
||||
if (config.value.doctype) {
|
||||
emit("update", { ...props.action, value: picked, label: picked });
|
||||
return;
|
||||
}
|
||||
const option = pickerOptions.value.find(
|
||||
(candidate) => candidate.value === picked
|
||||
);
|
||||
if (!option) return;
|
||||
emit("update", { ...props.action, value: option.value, label: option.label });
|
||||
}
|
||||
</script>
|
||||
@@ -1,80 +0,0 @@
|
||||
<template>
|
||||
<!-- Full-bleed white section, so the block reads as one box with a gray top
|
||||
and a white bottom rather than a card floating inside another card -->
|
||||
<div class="border-t border-outline-gray-1 bg-surface-base">
|
||||
<div class="flex h-8 items-center gap-1.5 ps-3.5 pe-1.5 text-sm">
|
||||
<LucideMessageSquare class="size-3 shrink-0 text-ink-gray-6" />
|
||||
<span class="shrink-0 text-ink-gray-6">{{ __("Comment") }}</span>
|
||||
<Button
|
||||
class="ms-auto shrink-0"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="lucide-x"
|
||||
:label="__('Remove comment')"
|
||||
@click="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
<Editor
|
||||
:model-value="action.value"
|
||||
:extensions="extensions"
|
||||
:placeholder="__('Comment')"
|
||||
@update:model-value="emit('update:value', $event ?? '')"
|
||||
>
|
||||
<template #default>
|
||||
<EditorContent
|
||||
class="prose prose-v3 [--prose-font-size:13px] max-w-none max-h-[140px] min-h-16 overflow-y-auto px-3.5 py-2"
|
||||
/>
|
||||
</template>
|
||||
</Editor>
|
||||
<!-- Fixed height so the reset button appearing doesn't grow the footer -->
|
||||
<div
|
||||
class="flex h-9 items-center gap-2 ps-3.5 pe-1.5 pb-2 text-xs text-ink-gray-5"
|
||||
>
|
||||
<span class="cursor-default">
|
||||
{{ __("Visible to your team only, never sent to the customer.") }}
|
||||
</span>
|
||||
<Button
|
||||
v-if="isEdited"
|
||||
class="ms-auto"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:label="__('Reset to default')"
|
||||
@click="emit('update:value', action.original_value ?? '')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { buildEditorExtensions } from "@/components/editor/config";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { __ } from "@/translation";
|
||||
import { SavedReplyAction } from "@/types";
|
||||
import { Button } from "frappe-ui";
|
||||
import { Editor, EditorContent } from "frappe-ui/editor";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed } from "vue";
|
||||
import LucideMessageSquare from "~icons/lucide/message-square";
|
||||
|
||||
const props = defineProps<{
|
||||
action: SavedReplyAction;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:value": [value: string];
|
||||
remove: [];
|
||||
}>();
|
||||
|
||||
/** Only actions staged with the original text can report an edit. */
|
||||
const isEdited = computed(
|
||||
() =>
|
||||
props.action.original_value !== undefined &&
|
||||
props.action.original_value !== props.action.value
|
||||
);
|
||||
|
||||
// Same editor the Comment tab uses: rich text with @-mentions
|
||||
const { dropdown: agentDropdown } = storeToRefs(useAgentStore());
|
||||
const extensions = buildEditorExtensions({
|
||||
mentions: () => agentDropdown.value ?? [],
|
||||
});
|
||||
</script>
|
||||
@@ -1,264 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="pendingActions.length"
|
||||
ref="container"
|
||||
class="overflow-hidden rounded-6 border border-outline-gray-1 bg-surface-gray-1"
|
||||
>
|
||||
<div class="flex h-8 items-center gap-1.5 ps-3.5 pe-1.5">
|
||||
<LucideInfo class="size-3 shrink-0 text-ink-gray-6" />
|
||||
<template v-if="sourceLabel">
|
||||
<Tooltip :text="sourceLabel">
|
||||
<span class="min-w-0 truncate text-sm-medium text-ink-gray-7">
|
||||
{{ sourceLabel }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span class="shrink-0 text-ink-gray-3">·</span>
|
||||
</template>
|
||||
<span class="shrink-0 whitespace-nowrap text-sm text-ink-gray-5">
|
||||
{{ countSentence }}
|
||||
</span>
|
||||
<div class="ms-auto flex shrink-0 items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:aria-expanded="expanded"
|
||||
:icon="expanded ? 'lucide-chevron-down' : 'lucide-chevron-right'"
|
||||
:label="expanded ? __('Hide actions') : __('Show actions')"
|
||||
@click="toggleExpanded"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
icon="lucide-x"
|
||||
:label="__('Clear all actions')"
|
||||
@click="clear"
|
||||
:tooltip="__('Clear all actions')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expanded">
|
||||
<div
|
||||
v-if="chipActions.length"
|
||||
class="flex flex-wrap items-center gap-1.5 px-1.5 pb-2"
|
||||
>
|
||||
<SavedReplyActionChip
|
||||
v-for="action in visibleChips"
|
||||
:key="actionKey(action)"
|
||||
:action="action"
|
||||
:taken="takenTags(action)"
|
||||
@update="updateAction(action, $event)"
|
||||
@remove="removeAction(action)"
|
||||
/>
|
||||
<button v-if="overflowCount" type="button" @click="showAllChips = true">
|
||||
<Badge theme="gray" variant="outline" size="lg">
|
||||
{{ __("+{0} more", overflowCount) }}
|
||||
</Badge>
|
||||
</button>
|
||||
</div>
|
||||
<SavedReplyActionComment
|
||||
v-if="noteAction"
|
||||
:action="noteAction"
|
||||
@update:value="setNoteValue"
|
||||
@remove="removeAction(noteAction)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { isTagAction } from "@/components/Settings/SavedReplies/components/actionTypes";
|
||||
import { reloadTicket, reloadTicketFeed } from "@/composables/useTicket";
|
||||
import { userStorage } from "@/composables/userStorage";
|
||||
import { __ } from "@/translation";
|
||||
import { RenderedSavedReply, SavedReplyAction } from "@/types";
|
||||
import { useElementSize } from "@vueuse/core";
|
||||
import { Badge, Button, Tooltip, createResource, toast } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
import LucideInfo from "~icons/lucide/info";
|
||||
import SavedReplyActionChip from "./SavedReplyActionChip.vue";
|
||||
import SavedReplyActionComment from "./SavedReplyActionComment.vue";
|
||||
|
||||
/** Chips beyond this stay hidden until the agent asks for the rest. */
|
||||
const MAX_VISIBLE_CHIPS = 4;
|
||||
/** Failures hold long enough to read: one asks for a decision, the other for work. */
|
||||
const FAILURE_TOAST_DURATION = 15000;
|
||||
/** Below this the long sentence squeezes the reply name to nothing. */
|
||||
const NARROW_WIDTH = 470;
|
||||
|
||||
const props = defineProps<{
|
||||
ticketId: string;
|
||||
doctype: string;
|
||||
}>();
|
||||
|
||||
// Persisted like the draft body, so a refresh keeps text and actions in sync.
|
||||
// Scoped to the agent: a staged comment must not survive a shift handover.
|
||||
const pendingActions = userStorage<SavedReplyAction[]>(
|
||||
"pendingSavedReplyActions" + props.ticketId,
|
||||
[]
|
||||
);
|
||||
const expanded = userStorage(
|
||||
"pendingSavedReplyActionsExpanded" + props.ticketId,
|
||||
false
|
||||
);
|
||||
|
||||
const showAllChips = ref(false);
|
||||
// Not reactive: it only gates the next failure's retry
|
||||
let isRetry = false;
|
||||
const container = ref<HTMLElement>();
|
||||
const { width } = useElementSize(container);
|
||||
|
||||
const noteAction = computed(() =>
|
||||
pendingActions.value.find((action) => action.action_type === "Add Comment")
|
||||
);
|
||||
|
||||
const chipActions = computed(() =>
|
||||
pendingActions.value.filter((action) => action.action_type !== "Add Comment")
|
||||
);
|
||||
|
||||
const visibleChips = computed(() =>
|
||||
showAllChips.value || chipActions.value.length <= MAX_VISIBLE_CHIPS + 1
|
||||
? chipActions.value
|
||||
: chipActions.value.slice(0, MAX_VISIBLE_CHIPS)
|
||||
);
|
||||
|
||||
const overflowCount = computed(
|
||||
() => chipActions.value.length - visibleChips.value.length
|
||||
);
|
||||
|
||||
/** Collapsing puts the chip list back to its capped state. */
|
||||
function toggleExpanded() {
|
||||
expanded.value = !expanded.value;
|
||||
if (!expanded.value) showAllChips.value = false;
|
||||
}
|
||||
|
||||
// Only one reply is ever staged, so its title is the whole label
|
||||
const sourceLabel = computed(() => pendingActions.value[0]?.source ?? "");
|
||||
|
||||
const countSentence = computed(() => {
|
||||
const count = pendingActions.value.length;
|
||||
if (width.value && width.value < NARROW_WIDTH) {
|
||||
return __("{0} actions after sending", count);
|
||||
}
|
||||
return count === 1
|
||||
? __("1 action will be applied after sending the email")
|
||||
: __("{0} actions will be applied after sending the email", count);
|
||||
});
|
||||
|
||||
/** Stage the actions of an applied saved reply, replacing whatever was staged. */
|
||||
function add(reply: RenderedSavedReply) {
|
||||
pendingActions.value = (reply.actions ?? []).map((action) => ({
|
||||
...action,
|
||||
source: reply.title,
|
||||
// Kept so the staged comment can report edits and restore
|
||||
...(action.action_type === "Add Comment"
|
||||
? { original_value: action.value }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/** What is staged right now, for the replace confirmation. Null when nothing is. */
|
||||
function stagedSummary(): { count: number; source: string } | null {
|
||||
if (!pendingActions.value.length) return null;
|
||||
return { count: pendingActions.value.length, source: sourceLabel.value };
|
||||
}
|
||||
|
||||
// Tags share an action type, so they key on their value too
|
||||
function actionKey(action: SavedReplyAction): string {
|
||||
return isTagAction(action.action_type)
|
||||
? `${action.action_type}:${action.value}`
|
||||
: action.action_type;
|
||||
}
|
||||
|
||||
// Dont re-use duplicate tags
|
||||
function takenTags(action: SavedReplyAction): string[] {
|
||||
if (!isTagAction(action.action_type)) return [];
|
||||
return pendingActions.value
|
||||
.filter((other) => other !== action && isTagAction(other.action_type))
|
||||
.map((other) => other.value);
|
||||
}
|
||||
|
||||
/** Repoint an action, e.g. an escalation routed to a different team. */
|
||||
function updateAction(action: SavedReplyAction, updated: SavedReplyAction) {
|
||||
const index = pendingActions.value.indexOf(action);
|
||||
if (index !== -1) pendingActions.value[index] = updated;
|
||||
}
|
||||
|
||||
function removeAction(action: SavedReplyAction) {
|
||||
pendingActions.value = pendingActions.value.filter(
|
||||
(pending) => actionKey(pending) !== actionKey(action)
|
||||
);
|
||||
}
|
||||
|
||||
// Whatever is in the editor at send time is what gets applied
|
||||
function setNoteValue(value: string) {
|
||||
if (noteAction.value) noteAction.value.value = value;
|
||||
}
|
||||
|
||||
function reloadFeed() {
|
||||
reloadTicket(props.ticketId);
|
||||
reloadTicketFeed(props.ticketId);
|
||||
}
|
||||
|
||||
const applyActions = createResource({
|
||||
url: "helpdesk.api.saved_replies.apply_saved_reply_actions",
|
||||
onSuccess: (result: {
|
||||
applied: SavedReplyAction[];
|
||||
skipped: SavedReplyAction[];
|
||||
}) => {
|
||||
if (result.applied?.length) {
|
||||
toast.success(
|
||||
__("Applied {0} saved reply action(s)", result.applied.length)
|
||||
);
|
||||
}
|
||||
if (result.skipped?.length) {
|
||||
toast.warning(
|
||||
__(
|
||||
"Skipped {0} saved reply action(s) that are no longer valid",
|
||||
result.skipped.length
|
||||
)
|
||||
);
|
||||
}
|
||||
pendingActions.value = [];
|
||||
reloadFeed();
|
||||
},
|
||||
onError: (error: { status?: number }) => {
|
||||
const failed = [...pendingActions.value];
|
||||
// Only a response proves it rolled back, and one retry is enough
|
||||
const canRetry = Boolean(error?.status) && !isRetry;
|
||||
// Unstaged always: a kept batch would ride the next, unrelated send
|
||||
pendingActions.value = [];
|
||||
toast.error(__("Could not apply actions from the saved reply."), {
|
||||
duration: FAILURE_TOAST_DURATION,
|
||||
...(canRetry
|
||||
? { action: { label: __("Retry"), onClick: () => retry(failed) } }
|
||||
: { description: __("Please apply the actions manually") }),
|
||||
});
|
||||
reloadFeed();
|
||||
},
|
||||
});
|
||||
|
||||
/** Re-send a batch the agent kept from the failure toast. */
|
||||
function retry(actions: SavedReplyAction[]) {
|
||||
isRetry = true;
|
||||
applyActions.submit({ ticket_id: props.ticketId, actions });
|
||||
}
|
||||
|
||||
/** Apply the staged actions to the ticket; call after the email is sent. */
|
||||
function submit() {
|
||||
if (!pendingActions.value.length || props.doctype !== "HD Ticket") return;
|
||||
// Actions stay staged during the request, so guard the second call
|
||||
if (applyActions.loading) return;
|
||||
isRetry = false;
|
||||
applyActions.submit({
|
||||
ticket_id: props.ticketId,
|
||||
actions: [...pendingActions.value],
|
||||
});
|
||||
}
|
||||
|
||||
/** Drop the staged actions without applying them. */
|
||||
function clear() {
|
||||
pendingActions.value = [];
|
||||
}
|
||||
|
||||
defineExpose({ add, stagedSummary, submit, clear });
|
||||
</script>
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="Boolean(articles.data?.length) && query.length > 2"
|
||||
class="rounded-4 border p-4 text-base"
|
||||
v-if="!isEmpty(articles.data) && query.length > 2"
|
||||
class="rounded border p-4 text-base"
|
||||
>
|
||||
<div class="mb-2 font-medium ps-2" v-if="!hideViewAll">
|
||||
<div class="mb-2 font-medium pl-2" v-if="!hideViewAll">
|
||||
These articles may already cover what you are looking for
|
||||
<RouterLink
|
||||
class="group cursor-pointer space-x-1 hover:text-ink-gray-9"
|
||||
class="group cursor-pointer space-x-1 hover:text-gray-900"
|
||||
:to="{
|
||||
name: 'CustomerKnowledgeBase',
|
||||
}"
|
||||
@@ -22,10 +22,10 @@
|
||||
<div
|
||||
v-for="a in articles.data"
|
||||
:key="a.id"
|
||||
class="rounded-5 border-2 p-2 border-hidden hover:bg-surface-gray-2"
|
||||
class="rounded-md border-2 p-2 border-hidden hover:bg-surface-gray-2"
|
||||
>
|
||||
<RouterLink
|
||||
class="group cursor-pointer hover:text-ink-gray-9 flex flex-col gap-1"
|
||||
class="group cursor-pointer hover:text-gray-900 flex flex-col gap-1"
|
||||
:to="{
|
||||
name: 'ArticlePublic',
|
||||
params: {
|
||||
@@ -39,7 +39,7 @@
|
||||
<dt class="font-base">{{ a.subject }} - {{ a.headings }}</dt>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<dd
|
||||
class="font-base text-p-sm text-ink-gray-5 line-clamp-1"
|
||||
class="font-base text-p-sm text-gray-600 line-clamp-1"
|
||||
v-html="a.description"
|
||||
></dd>
|
||||
</RouterLink>
|
||||
@@ -50,24 +50,24 @@
|
||||
v-else-if="
|
||||
!articles.loading && articles.data?.length === 0 && query.length > 2
|
||||
"
|
||||
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded-4 border"
|
||||
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded border"
|
||||
>
|
||||
<LucideSearch class="size-8 text-ink-gray-3" />
|
||||
<Icon icon="heroicons-outline:search" class="h-8 w-8 text-gray-400" />
|
||||
<div class="flex items-center flex-col justify-center">
|
||||
<p class="font-base">No answers found</p>
|
||||
<span class="font-base text-p-sm text-ink-gray-5 text-center"
|
||||
<span class="font-base text-p-sm text-gray-600 text-center"
|
||||
>Rephrase the question and try again with some keywords</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="articles.loading"
|
||||
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded-4 border"
|
||||
class="flex flex-col items-center justify-center h-[240px] gap-2 rounded border"
|
||||
>
|
||||
<LucideSearch class="size-8 text-ink-gray-3" />
|
||||
<Icon icon="heroicons-outline:search" class="h-8 w-8 text-gray-400" />
|
||||
<div class="flex items-center flex-col justify-center">
|
||||
<p class="font-base">Searching...</p>
|
||||
<span class="font-base text-p-sm text-ink-gray-5 text-center"
|
||||
<span class="font-base text-p-sm text-gray-600 text-center"
|
||||
>Please wait while we search for the answers</span
|
||||
>
|
||||
</div>
|
||||
@@ -75,9 +75,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { capture } from "@/telemetry";
|
||||
import { createResource } from "frappe-ui";
|
||||
import { watch } from "vue";
|
||||
import { createResource } from "frappe-ui";
|
||||
import { isEmpty } from "lodash";
|
||||
import { Icon } from "@iconify/vue";
|
||||
import { capture } from "@/telemetry";
|
||||
interface P {
|
||||
query: string;
|
||||
hideViewAll?: boolean;
|
||||
@@ -99,9 +101,7 @@ watch(
|
||||
},
|
||||
});
|
||||
articles.reload();
|
||||
},
|
||||
// Mounted after the third character is typed.
|
||||
{ immediate: true }
|
||||
}
|
||||
);
|
||||
|
||||
function handleSearchArticleClick(article) {
|
||||
|
||||
101
desk/src/components/SearchComplete.vue
Normal file
101
desk/src/components/SearchComplete.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<Autocomplete
|
||||
placeholder="Select an option"
|
||||
:options="options"
|
||||
:value="selection"
|
||||
@update:query="(q) => onUpdateQuery(q)"
|
||||
@change="
|
||||
(v) => {
|
||||
if (!resetInput) {
|
||||
selection = v;
|
||||
}
|
||||
emit('change', v);
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Autocomplete } from "@/components";
|
||||
import { createListResource } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const emit = defineEmits(["change"]);
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
searchField: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "name",
|
||||
},
|
||||
labelField: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "name",
|
||||
},
|
||||
valueField: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "name",
|
||||
},
|
||||
pageLength: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 100,
|
||||
},
|
||||
resetInput: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
customFilters: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: {},
|
||||
},
|
||||
});
|
||||
|
||||
const r = createListResource({
|
||||
doctype: props.doctype,
|
||||
pageLength: props.pageLength,
|
||||
auto: true,
|
||||
fields: [props.labelField, props.searchField, props.valueField],
|
||||
filters: {
|
||||
[props.searchField]: ["like", `%${props.value}%`],
|
||||
...props.customFilters,
|
||||
},
|
||||
onSuccess: () => {
|
||||
selection.value = props.value
|
||||
? options.value.find((o) => o.value === props.value)
|
||||
: null;
|
||||
},
|
||||
});
|
||||
const options = computed(
|
||||
() =>
|
||||
r.data?.map((result) => ({
|
||||
label: result[props.labelField],
|
||||
value: result[props.valueField],
|
||||
})) || []
|
||||
);
|
||||
const selection = ref(null);
|
||||
|
||||
function onUpdateQuery(query: string) {
|
||||
r.update({
|
||||
filters: {
|
||||
[props.searchField]: ["like", `%${query}%`],
|
||||
...props.customFilters,
|
||||
},
|
||||
});
|
||||
|
||||
r.reload();
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="relative inline-block text-start">
|
||||
<div class="relative inline-block text-left">
|
||||
<div class="flex">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -12,7 +12,7 @@
|
||||
>
|
||||
<template v-if="selectedCount > 0">
|
||||
<!-- Stacked Avatars -->
|
||||
<div class="flex -space-x-2 rtl:space-x-reverse isolate">
|
||||
<div class="flex -space-x-2 isolate">
|
||||
<div
|
||||
v-for="(option, index) in selectedOptions.slice(0, 3)"
|
||||
:key="`avatar-${option.value}`"
|
||||
@@ -23,7 +23,7 @@
|
||||
v-if="option.image"
|
||||
:image="option.image"
|
||||
:label="option.label"
|
||||
class="border-2 border-[var(--surface-base)] flex-shrink-0"
|
||||
class="border-2 border-white flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
@@ -41,7 +41,7 @@
|
||||
</div>
|
||||
<template #suffix>
|
||||
<LucideChevronDown
|
||||
class="ms-2 h-4 w-4 transition-transform duration-200"
|
||||
class="ml-2 h-4 w-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
/>
|
||||
</template>
|
||||
@@ -50,12 +50,12 @@
|
||||
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="absolute z-50 mt-2 w-64 divide-y divide-outline-elevation-2 rounded-6 bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none start-0 origin-top-left"
|
||||
class="absolute z-50 mt-2 w-64 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none left-0 origin-top-left"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="py-1.5 px-1.5">
|
||||
<div
|
||||
class="flex h-7 items-center text-sm-medium text-ink-gray-6 justify-between"
|
||||
class="flex h-7 items-center text-sm font-medium text-ink-gray-6 justify-between"
|
||||
>
|
||||
<input
|
||||
ref="inputRef"
|
||||
@@ -85,13 +85,13 @@
|
||||
<button
|
||||
v-for="option in filteredIndividualOptions"
|
||||
:key="`individual-${option.value}`"
|
||||
class="group flex h-7 w-full items-center rounded-4 px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
|
||||
class="group flex h-7 w-full items-center rounded px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
|
||||
:class="{ 'bg-surface-gray-2': isHighlighted(option) }"
|
||||
@click="toggleOption(option)"
|
||||
>
|
||||
<Checkbox
|
||||
:modelValue="props.modelValue.includes(option.value)"
|
||||
class="me-4 flex-shrink-0"
|
||||
class="mr-2 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<component v-if="option.icon" :is="renderIcon(option.icon)" />
|
||||
@@ -100,17 +100,17 @@
|
||||
v-else-if="hasAnyVisualElements"
|
||||
:image="option.image"
|
||||
:label="option.label"
|
||||
class="me-2 flex-shrink-0"
|
||||
class="mr-2 flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<span class="text-ink-gray-7 flex-1 text-start truncate">
|
||||
<span class="text-ink-gray-7 flex-1 text-left truncate">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="(option.count ?? 0) > 0"
|
||||
class="text-xs text-ink-gray-5 ms-auto"
|
||||
class="text-xs text-ink-gray-5 ml-auto"
|
||||
>
|
||||
{{ option.count }}
|
||||
</span>
|
||||
@@ -121,7 +121,7 @@
|
||||
<div v-for="group in filteredGroups" :key="`group-${group.group}`">
|
||||
<!-- Group Header -->
|
||||
<div
|
||||
class="flex h-7 pt-1.5 sticky top-0 bg-surface-base items-center px-2 text-sm-medium text-ink-gray-6"
|
||||
class="flex h-7 pt-1.5 sticky top-0 bg-surface-white items-center px-2 text-sm font-medium text-ink-gray-6"
|
||||
>
|
||||
{{ group.group }}
|
||||
</div>
|
||||
@@ -130,13 +130,13 @@
|
||||
<button
|
||||
v-for="option in group.items"
|
||||
:key="`grouped-${option.value}`"
|
||||
class="group flex h-7 w-full items-center rounded-4 px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
|
||||
class="group flex h-7 w-full items-center rounded px-2 text-base hover:bg-surface-gray-3 text-ink-gray-6"
|
||||
:class="{ 'bg-surface-gray-2': isHighlighted(option) }"
|
||||
@click="toggleOption(option)"
|
||||
>
|
||||
<Checkbox
|
||||
:modelValue="props.modelValue.includes(option.value)"
|
||||
class="me-4 flex-shrink-0"
|
||||
class="mr-2 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<component v-if="option.icon" :is="renderIcon(option.icon)" />
|
||||
@@ -145,23 +145,23 @@
|
||||
v-else-if="option.image"
|
||||
:image="option.image"
|
||||
:label="option.label"
|
||||
class="me-2 flex-shrink-0"
|
||||
class="mr-2 flex-shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<!-- Spacer for alignment when other options have visual elements -->
|
||||
<div
|
||||
v-else-if="hasAnyVisualElements"
|
||||
class="me-2 w-6 h-6 flex-shrink-0"
|
||||
class="mr-2 w-6 h-6 flex-shrink-0"
|
||||
></div>
|
||||
|
||||
<span class="text-ink-gray-7 flex-1 text-start truncate">
|
||||
<span class="text-ink-gray-7 flex-1 text-left truncate">
|
||||
{{ option.label }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="(option.count ?? 0) > 0"
|
||||
class="text-xs text-ink-gray-5 ms-auto"
|
||||
class="text-xs text-ink-gray-5 ml-auto"
|
||||
>
|
||||
{{ option.count }}
|
||||
</span>
|
||||
@@ -391,7 +391,7 @@ function renderIcon(icon: string | any) {
|
||||
"span",
|
||||
{
|
||||
class:
|
||||
"flex-shrink-0 w-4 h-4 inline-flex items-center justify-center me-2",
|
||||
"flex-shrink-0 w-4 h-4 inline-flex items-center justify-center mr-2",
|
||||
},
|
||||
[iconContent]
|
||||
);
|
||||
|
||||
@@ -1,41 +1,33 @@
|
||||
<template>
|
||||
<Popover>
|
||||
<template #trigger="{ close, setOpen }">
|
||||
<!--
|
||||
Typing opens this, not clicking. reka turns the trigger's root into the
|
||||
toggle, so the field stops its own click before it reaches that root.
|
||||
-->
|
||||
<div>
|
||||
<div class="flex flex-col gap-1 w-full" @click.stop>
|
||||
<slot name="label"></slot>
|
||||
<FormControl
|
||||
type="text"
|
||||
class="w-full focus:outline-none outline-none border-inherit shadow-none"
|
||||
v-bind="$attrs"
|
||||
v-model="query"
|
||||
@update:model-value="
|
||||
(e: string) => {
|
||||
if (e.length >= 3) {
|
||||
setOpen(true);
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideSearch class="size-4 text-ink-gray-4" />
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
<Popover
|
||||
:popover-class="popoverClass.length > 0 ? popoverClass : ''"
|
||||
class="flex w-full"
|
||||
>
|
||||
<template #target="{ open, close }">
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<slot name="label"></slot>
|
||||
<FormControl
|
||||
type="text"
|
||||
class="w-full focus:outline-none outline-none border-inherit shadow-none"
|
||||
v-bind="$attrs"
|
||||
v-model="query"
|
||||
@update:model-value="(e:string)=>{
|
||||
if (e.length >= 3) {
|
||||
open();
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
}"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon icon="lucide:search" class="h-4 w-4 text-gray-500" />
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<template #body-main>
|
||||
<!-- Searched Articles -->
|
||||
<div
|
||||
class="max-h-[320px] md:max-h-[420px] overflow-scroll flex flex-col"
|
||||
:class="popoverClass"
|
||||
>
|
||||
<div class="max-h-[320px] md:max-h-[420px] overflow-scroll flex flex-col">
|
||||
<SearchArticles
|
||||
:query="query"
|
||||
:hideViewAll="true"
|
||||
@@ -48,8 +40,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FormControl, Popover } from "frappe-ui";
|
||||
import { ModelRef } from "vue";
|
||||
import SearchArticles from "./SearchArticles.vue";
|
||||
import { ModelRef } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
|
||||
interface P {
|
||||
popoverClass: Array<string>;
|
||||
|
||||
@@ -1,45 +1,42 @@
|
||||
<template>
|
||||
<!-- px-4 lives once here so the header and content share a single horizontal
|
||||
padding instead of each declaring their own. -->
|
||||
<div class="px-4">
|
||||
<div>
|
||||
<slot name="header" v-bind="{ opened, hide, open, close, toggle }">
|
||||
<div
|
||||
v-if="!hide"
|
||||
class="section-header sticky top-0 z-10 flex cursor-pointer select-none items-center gap-1 bg-surface-base py-3.5"
|
||||
class="section-header flex items-center justify-between"
|
||||
:class="headerClass"
|
||||
@click="collapsible && toggle()"
|
||||
>
|
||||
<!-- Figma: text/base/medium, ink-gray-8 -->
|
||||
<Tooltip v-if="tooltip" :text="__(tooltip)">
|
||||
<span
|
||||
class="text-base font-medium text-ink-gray-8"
|
||||
:class="labelClass"
|
||||
>
|
||||
{{ __(label) || "Untitled" }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span
|
||||
v-else
|
||||
class="text-base font-medium text-ink-gray-8"
|
||||
<div
|
||||
class="flex text-ink-gray-9 max-w-fit cursor-pointer items-center gap-2 text-base"
|
||||
:class="labelClass"
|
||||
@click="collapsible && toggle()"
|
||||
>
|
||||
{{ __(label) || "Untitled" }}
|
||||
</span>
|
||||
<LucideChevronRight
|
||||
v-if="collapsible"
|
||||
class="size-3.5 text-ink-gray-5 transition-transform"
|
||||
:class="{ 'rotate-90': opened }"
|
||||
/>
|
||||
<FeatherIcon
|
||||
v-if="collapsible && collapseIconPosition === 'left'"
|
||||
name="chevron-right"
|
||||
class="h-4 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': opened }"
|
||||
/>
|
||||
<span>
|
||||
{{ label || "Untitled" }}
|
||||
</span>
|
||||
<FeatherIcon
|
||||
v-if="collapsible && collapseIconPosition === 'right'"
|
||||
name="chevron-right"
|
||||
class="h-4 transition-all duration-300 ease-in-out"
|
||||
:class="{ 'rotate-90': opened }"
|
||||
/>
|
||||
</div>
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</slot>
|
||||
<transition
|
||||
enter-active-class="transition-all duration-300 ease-in"
|
||||
leave-active-class="transition-all duration-300 ease-[cubic-bezier(0,1,0.5,1)]"
|
||||
enter-to-class="max-h-[2000px] overflow-hidden"
|
||||
leave-from-class="max-h-[2000px] overflow-hidden"
|
||||
enter-from-class="max-h-0 opacity-0"
|
||||
leave-to-class="max-h-0 opacity-0"
|
||||
enter-active-class="duration-300 ease-in"
|
||||
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]"
|
||||
enter-to-class="max-h-[200px] overflow-hidden"
|
||||
leave-from-class="max-h-[200px] overflow-hidden"
|
||||
enter-from-class="max-h-0 overflow-hidden"
|
||||
leave-to-class="max-h-0 overflow-hidden"
|
||||
>
|
||||
<div class="columns" v-bind="$attrs" v-show="opened">
|
||||
<slot v-bind="{ opened, open, close, toggle }" />
|
||||
@@ -48,20 +45,13 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { __ } from "@/translation";
|
||||
import { Tooltip } from "frappe-ui";
|
||||
import { ref, watch } from "vue";
|
||||
import LucideChevronRight from "~icons/lucide/chevron-right";
|
||||
import { ref } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
tooltip: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
hideLabel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -74,6 +64,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
collapseIconPosition: {
|
||||
type: String,
|
||||
default: "left",
|
||||
},
|
||||
labelClass: {
|
||||
type: [String, Object, Array],
|
||||
default: "",
|
||||
@@ -84,33 +78,19 @@ const props = defineProps({
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:opened"]);
|
||||
|
||||
const hide = ref(props.hideLabel);
|
||||
const opened = ref(props.opened);
|
||||
|
||||
watch(
|
||||
() => props.opened,
|
||||
(val) => {
|
||||
opened.value = val;
|
||||
}
|
||||
);
|
||||
|
||||
function setOpened(value) {
|
||||
opened.value = value;
|
||||
emit("update:opened", value);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
setOpened(!opened.value);
|
||||
opened.value = !opened.value;
|
||||
}
|
||||
|
||||
function open() {
|
||||
setOpened(true);
|
||||
opened.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
setOpened(false);
|
||||
opened.value = false;
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<Popover
|
||||
bare
|
||||
:side="splitPlacement(props.placement).side"
|
||||
:align="splitPlacement(props.placement).align"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button
|
||||
class="flex items-center justify-between min-w-36"
|
||||
:class="targetClass"
|
||||
icon-right="lucide-chevron-down"
|
||||
>
|
||||
<div class="w-full truncate">
|
||||
{{
|
||||
options?.find((option) => option.value == model)?.label || "Select"
|
||||
}}
|
||||
</div>
|
||||
</Button>
|
||||
</template>
|
||||
<template #default="{ close: closePopover }">
|
||||
<div
|
||||
class="mt-1 p-1 text-ink-gray-6 w-[--reka-popper-anchor-width] bg-surface-base shadow-2xl rounded-4"
|
||||
:class="bodyClass"
|
||||
>
|
||||
<div class="max-h-52 overflow-y-auto">
|
||||
<div
|
||||
v-for="option in options"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-base flex items-center justify-between rounded-4"
|
||||
@click="
|
||||
() => {
|
||||
onChange(option.value);
|
||||
closePopover();
|
||||
}
|
||||
"
|
||||
>
|
||||
<div class="w-full truncate">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<LucideCheck v-if="model == option.value" class="size-4 ms-2" />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
:label="__('Reset')"
|
||||
icon-left="lucide-refresh-ccw"
|
||||
class="w-full focus-visible:ring-0"
|
||||
@click="onReset(closePopover)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { splitPlacement } from "@/utils";
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import { Button, Popover } from "frappe-ui";
|
||||
|
||||
const model = defineModel();
|
||||
|
||||
interface Props {
|
||||
options: Array<{ value: string; label: string }>;
|
||||
targetClass?: string;
|
||||
bodyClass?: string;
|
||||
placement?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placement: "bottom-start",
|
||||
defaultValue: undefined,
|
||||
});
|
||||
|
||||
const onReset = (closePopover: () => void) => {
|
||||
model.value = props.defaultValue !== undefined ? props.defaultValue : null;
|
||||
closePopover();
|
||||
};
|
||||
|
||||
const onChange = (value: string) => {
|
||||
model.value = value;
|
||||
};
|
||||
</script>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-between py-2" v-bind:class="$attrs">
|
||||
<div class="flex items-center gap-x-3 w-4/5">
|
||||
<div class="flex items-center space-x-3 w-4/5">
|
||||
<Avatar :image="agent.user_image" :label="agent.agent_name" size="xl" />
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,191 +1,158 @@
|
||||
<template>
|
||||
<SettingsLayoutBase
|
||||
:title="__('Agents')"
|
||||
:description="__('Add, manage agents and assign roles to them.')"
|
||||
>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
@click="() => setActiveSettingsTab('Invite Agents')"
|
||||
label="New"
|
||||
variant="solid"
|
||||
class="rtl:flex-row-reverse"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucidePlus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #header-bottom>
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<div class="relative grow">
|
||||
<TextInput
|
||||
:model-value="search"
|
||||
@update:model-value="search = $event"
|
||||
:placeholder="__('Search')"
|
||||
<div v-bind:class="$attrs.class" class="px-10 py-8">
|
||||
<!-- Header -->
|
||||
<SettingsLayoutHeader
|
||||
:title="__('Agents')"
|
||||
:description="__('Add, manage agents and assign roles to them.')"
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
@click="() => (showNewAgentsDialog = !showNewAgentsDialog)"
|
||||
label="New"
|
||||
variant="solid"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucidePlus class="h-4 w-4 stroke-1.5" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #bottom-section>
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<FormControl
|
||||
v-model="search"
|
||||
:placeholder="'Search'"
|
||||
type="text"
|
||||
class="focus:ring-0 border-outline-gray-2"
|
||||
:debounce="300"
|
||||
class="w-60"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideSearch class="size-4" />
|
||||
<LucideSearch class="h-4 w-4 text-gray-500" />
|
||||
</template>
|
||||
</TextInput>
|
||||
<Button
|
||||
v-if="search"
|
||||
icon="lucide-x"
|
||||
variant="ghost"
|
||||
@click="search = ''"
|
||||
class="absolute end-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown :options="dropdownOptions" align="end">
|
||||
<template #default="{ open }">
|
||||
<Button :label="activeFilter">
|
||||
<template #suffix>
|
||||
<component
|
||||
:is="open ? LucideChevronUp : LucideChevronDown"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #item-label="{ item }">
|
||||
<button
|
||||
class="group flex text-ink-gray-6 gap-4 w-full justify-between items-center rounded-4 text-base"
|
||||
@click="item.onClick"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-1">
|
||||
<span class="whitespace-nowrap">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<LucideCheck
|
||||
v-if="activeFilter === item.label"
|
||||
class="size-4 text-ink-gray-7"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="grow">
|
||||
<!-- loading state -->
|
||||
<div
|
||||
v-if="agents.loading"
|
||||
class="flex mt-28 justify-between w-full h-full"
|
||||
>
|
||||
<Button
|
||||
:loading="agents.loading"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
size="lg"
|
||||
/>
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<EmptyState
|
||||
v-if="!agents.loading && !agents.data?.length"
|
||||
variant="badge"
|
||||
:icon="AgentIcon"
|
||||
title="No agent found"
|
||||
:description="
|
||||
activeFilter.length
|
||||
? 'Change your search terms or filters'
|
||||
: 'Add one to get started.'
|
||||
"
|
||||
/>
|
||||
<!-- Agent List -->
|
||||
<div
|
||||
class="w-full"
|
||||
v-if="!agents.loading && Boolean(agents.data?.length)"
|
||||
>
|
||||
<div
|
||||
class="grid grid-cols-8 items-center gap-3 text-sm text-ink-gray-5"
|
||||
>
|
||||
<div class="col-span-6 text-p-sm">{{ __("Agent name") }}</div>
|
||||
</div>
|
||||
<hr class="mt-2" />
|
||||
<div v-for="(agent, index) in agents.data" :key="agent.agent_name">
|
||||
<div class="flex items-center justify-between h-14 group rounded-4">
|
||||
<div class="flex items-center gap-x-3 grow">
|
||||
<Avatar
|
||||
:image="agent.user_image"
|
||||
:label="agent.agent_name"
|
||||
size="xl"
|
||||
/>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-base">
|
||||
{{ agent.agent_name }}
|
||||
</p>
|
||||
<Badge
|
||||
:label="__('Inactive')"
|
||||
:theme="'gray'"
|
||||
:class="!agent.is_active ? 'opacity-100' : 'opacity-0'"
|
||||
variant="subtle"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-base text-ink-gray-6 mt-1">
|
||||
{{ agent.name }}
|
||||
</div>
|
||||
</FormControl>
|
||||
<Dropdown :options="dropdownOptions" placement="right">
|
||||
<template #default="{ open }">
|
||||
<Button
|
||||
:label="activeFilter"
|
||||
class="flex items-center justify-between w-[90px]"
|
||||
>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
<template #item="{ item, active }">
|
||||
<button
|
||||
class="group flex text-ink-gray-6 gap-4 h-7 w-full justify-between items-center rounded px-2 text-base"
|
||||
:class="{ 'bg-surface-gray-3': active }"
|
||||
@click="item.onClick"
|
||||
>
|
||||
<div class="flex items-center justify-between flex-1">
|
||||
<span class="whitespace-nowrap">
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<FeatherIcon
|
||||
v-if="activeFilter === item.label"
|
||||
name="check"
|
||||
class="size-4 text-ink-gray-7"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Dropdown
|
||||
v-if="isManager"
|
||||
class="flex justify-end items-center"
|
||||
:options="getRoles(agent.name)"
|
||||
:label="getUserRole(agent.name)"
|
||||
:button="{
|
||||
label: getUserRole(agent.name),
|
||||
iconRight: 'lucide-chevron-down',
|
||||
iconLeft:
|
||||
getUserRole(agent.name) === 'Agent'
|
||||
? 'lucide-user'
|
||||
: getUserRole(agent.name) === 'Manager'
|
||||
? 'lucide-briefcase'
|
||||
: null,
|
||||
}"
|
||||
align="end"
|
||||
/>
|
||||
<Dropdown :options="getOptions(agent)" :key="agent" align="end">
|
||||
<Button icon="lucide-more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="index !== agents.data.length - 1" />
|
||||
</div>
|
||||
<!-- Load More Button -->
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
v-if="!agents.loading && agents.hasNextPage"
|
||||
class="mt-3.5 p-2"
|
||||
@click="() => agents.next()"
|
||||
:loading="agents.loading"
|
||||
:label="__('Load More')"
|
||||
icon-left="lucide-refresh-cw"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutHeader>
|
||||
|
||||
<!-- loading state -->
|
||||
<div v-if="agents.loading" class="flex mt-28 justify-between w-full h-full">
|
||||
<Button
|
||||
:loading="agents.loading"
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
size="2xl"
|
||||
/>
|
||||
</div>
|
||||
<!-- Empty State -->
|
||||
<div
|
||||
v-if="!agents.loading && !agents.data?.length"
|
||||
class="flex mt-28 justify-between w-full h-full"
|
||||
>
|
||||
<p class="text-sm text-gray-500 w-full flex justify-center">
|
||||
No agents found
|
||||
</p>
|
||||
</div>
|
||||
<!-- Agent List -->
|
||||
<div
|
||||
class="overflow-y-auto w-full hide-scrollbar mt-4"
|
||||
v-if="!agents.loading && Boolean(agents.data?.length)"
|
||||
>
|
||||
<div v-for="(agent, idx) in agents.data" :key="agent.agent_name">
|
||||
<AgentCard
|
||||
:agent="agent"
|
||||
:show-status="true"
|
||||
:class="idx !== agents.data.length - 1 && 'border-b '"
|
||||
>
|
||||
<template #right>
|
||||
<Dropdown
|
||||
v-if="isManager"
|
||||
class="w-1/5 flex justify-end items-center"
|
||||
:options="getRoles(agent.name)"
|
||||
:label="getUserRole(agent.name)"
|
||||
:button="{
|
||||
label: getUserRole(agent.name),
|
||||
iconRight: 'chevron-down',
|
||||
}"
|
||||
placement="right"
|
||||
/>
|
||||
<Dropdown
|
||||
:options="getOptions(agent)"
|
||||
placement="right"
|
||||
:key="agent"
|
||||
class="ml-2"
|
||||
>
|
||||
<Button>
|
||||
<template #icon>
|
||||
<IconMoreHorizontal class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</template>
|
||||
</AgentCard>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
<!-- Load More Button -->
|
||||
<div class="flex justify-center">
|
||||
<Button
|
||||
v-if="!agents.loading && agents.hasNextPage"
|
||||
class="mt-3.5 p-2"
|
||||
@click="() => agents.next()"
|
||||
:loading="agents.loading"
|
||||
label="Load More"
|
||||
icon-left="refresh-cw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddNewAgentsDialog
|
||||
:title="__('Add Agents')"
|
||||
@close="showNewAgentsDialog = false"
|
||||
:modelValue="showNewAgentsDialog"
|
||||
:show="showNewAgentsDialog"
|
||||
@update:modelValue="showNewAgentsDialog = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideChevronUp from "~icons/lucide/chevron-up";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import { __ } from "@/translation";
|
||||
import { Avatar, Button, call, Dropdown, toast } from "frappe-ui";
|
||||
import { h, onUnmounted } from "vue";
|
||||
import AgentIcon from "../icons/AgentIcon.vue";
|
||||
import { activeFilter, useAgents } from "./agents";
|
||||
import { setActiveSettingsTab } from "./settingsModal";
|
||||
import { call, FormControl, toast } from "frappe-ui";
|
||||
import { h } from "vue";
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import IconMoreHorizontal from "~icons/lucide/more-horizontal";
|
||||
import AgentCard from "./AgentCard.vue";
|
||||
import { activeFilter, showNewAgentsDialog, useAgents } from "./agents";
|
||||
|
||||
const { getUserRole, updateUserRoleCache } = useUserStore();
|
||||
const { isManager } = useAuthStore();
|
||||
@@ -196,20 +163,58 @@ const agents = agentStore.agents;
|
||||
|
||||
function getRoles(agent: string) {
|
||||
const agentRole = getUserRole(agent);
|
||||
const role = (label: string, icon: string) => ({
|
||||
label,
|
||||
icon,
|
||||
selected: agentRole === label,
|
||||
onClick: () => updateRole(agent, label),
|
||||
});
|
||||
const roles = [role("Agent", "lucide-user")];
|
||||
const roles = [
|
||||
{
|
||||
label: "Agent",
|
||||
component: (props) =>
|
||||
RoleOption({
|
||||
role: "Agent",
|
||||
active: props.active,
|
||||
selected: agentRole === "Agent",
|
||||
onClick: () => {
|
||||
updateRole(agent, "Agent");
|
||||
},
|
||||
}),
|
||||
},
|
||||
];
|
||||
if (isManager) {
|
||||
roles.unshift(role("Manager", "lucide-briefcase"));
|
||||
roles.unshift({
|
||||
label: "Manager",
|
||||
component: (props) =>
|
||||
RoleOption({
|
||||
role: "Manager",
|
||||
active: props.active,
|
||||
selected: agentRole === "Manager",
|
||||
onClick: () => {
|
||||
updateRole(agent, "Manager");
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
return roles;
|
||||
}
|
||||
|
||||
function RoleOption({ active, role, onClick, selected }) {
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
class: [
|
||||
active ? "bg-surface-gray-2" : "text-ink-gray-7",
|
||||
"group flex w-full justify-between items-center rounded-md px-2 py-2 text-base",
|
||||
],
|
||||
onClick: !selected ? onClick : null,
|
||||
},
|
||||
[
|
||||
h("span", { class: "whitespace-nowrap" }, role),
|
||||
selected
|
||||
? h(LucideCheck, {
|
||||
class: ["h-4 w-4 shrink-0 text-ink-gray-7"],
|
||||
"aria-hidden": true,
|
||||
})
|
||||
: null,
|
||||
]
|
||||
);
|
||||
}
|
||||
function updateRole(agent: string, newRole: string) {
|
||||
const currentRole = getUserRole(agent);
|
||||
if (currentRole === newRole) {
|
||||
@@ -221,7 +226,7 @@ function updateRole(agent: string, newRole: string) {
|
||||
new_role: newRole,
|
||||
}).then(() => {
|
||||
updateUserRoleCache(agent, newRole);
|
||||
toast.success(__(`Role updated to ${newRole} successfully.`));
|
||||
toast.success(`Role updated to ${newRole}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,7 +235,7 @@ function getOptions(agent) {
|
||||
return [
|
||||
{
|
||||
label: "Disable Agent",
|
||||
icon: "lucide-x-circle",
|
||||
icon: "x-circle",
|
||||
onClick: async () => {
|
||||
await agentStore.updateAgent(agent.name, 0);
|
||||
agents.reload({ ...filters, search: search.value });
|
||||
@@ -239,7 +244,7 @@ function getOptions(agent) {
|
||||
},
|
||||
{
|
||||
label: "Enable Agent",
|
||||
icon: "lucide-check-circle",
|
||||
icon: "check-circle",
|
||||
onClick: async () => {
|
||||
await agentStore.updateAgent(agent.name, 1);
|
||||
agents.reload({ ...filters, search: search.value });
|
||||
@@ -272,8 +277,6 @@ const dropdownOptions = [
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
onUnmounted(() => {
|
||||
agents.filters = {};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-md-semibold text-ink-gray-8">{{
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Assignee Rules")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<div class="mt-8 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-base-medium text-ink-gray-8">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{ __("Ticket Routing") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-6 mt-1">
|
||||
@@ -24,10 +24,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Popover bare side="bottom" align="end">
|
||||
<template #trigger>
|
||||
<Popover placement="bottom-end">
|
||||
<template #target="{ togglePopover }">
|
||||
<div
|
||||
class="flex items-center justify-between text-base rounded-4 h-7 py-1.5 ps-2 pe-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-elevation-2 hover:bg-surface-gray-3 focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-44"
|
||||
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] select-none min-w-40"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<div>
|
||||
{{
|
||||
@@ -36,29 +37,30 @@
|
||||
)?.label
|
||||
}}
|
||||
</div>
|
||||
<LucideChevronDown class="size-4" />
|
||||
<FeatherIcon name="chevron-down" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ close: closePopover }">
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
class="p-1 text-ink-gray-7 mt-1 bg-surface-base shadow-xl rounded-4 w-[--reka-popper-anchor-width]"
|
||||
class="p-1 text-ink-gray-7 mt-1 w-48 bg-white shadow-xl rounded"
|
||||
>
|
||||
<div
|
||||
v-for="option in ticketRoutingOptions"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-surface-gray-3 text-sm flex items-center justify-between rounded-4"
|
||||
class="p-2 cursor-pointer hover:bg-gray-50 text-sm flex items-center justify-between rounded"
|
||||
@click="
|
||||
() => {
|
||||
assignmentRuleData.rule = option.value;
|
||||
closePopover();
|
||||
togglePopover();
|
||||
}
|
||||
"
|
||||
>
|
||||
<span>
|
||||
{{ option.label }}
|
||||
</span>
|
||||
<LucideCheck
|
||||
<FeatherIcon
|
||||
v-if="assignmentRuleData.rule == option.value"
|
||||
name="check"
|
||||
class="size-4"
|
||||
/>
|
||||
</div>
|
||||
@@ -69,7 +71,7 @@
|
||||
</div>
|
||||
<div class="mt-7 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<div class="text-base-medium text-ink-gray-8">
|
||||
<div class="text-base font-medium text-ink-gray-8">
|
||||
{{ __("Assignees") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-6 mt-1">
|
||||
@@ -79,56 +81,44 @@
|
||||
<AssigneeSearch @addAssignee="validateAssignmentRule('users')" />
|
||||
</div>
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.name"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:label="user.full_name"
|
||||
class="flex items-center gap-2 text-sm bg-surface-gray-2 rounded-md p-1 w-max px-2 select-none"
|
||||
>
|
||||
<template #prefix>
|
||||
<Avatar :image="user.user_image" :label="user.full_name" size="sm" />
|
||||
</template>
|
||||
<template #suffix>
|
||||
<Tooltip
|
||||
v-if="user.email == assignmentRuleData.lastUser"
|
||||
:text="__('Last user assigned by this rule')"
|
||||
:hover-delay="350"
|
||||
side="top"
|
||||
<Avatar :image="user.user_image" :label="user.full_name" size="sm" />
|
||||
<div class="text-ink-gray-7">
|
||||
{{ user.full_name }}
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="user.email == assignmentRuleData.lastUser"
|
||||
:text="__('Last user assigned by this rule')"
|
||||
:hover-delay="0.35"
|
||||
:placement="'top'"
|
||||
>
|
||||
<div
|
||||
class="text-xs rounded-full select-none bg-blue-600 text-white p-0.5 px-2"
|
||||
>
|
||||
<Badge theme="blue" variant="solid" :label="__('Last')" />
|
||||
</Tooltip>
|
||||
<LucideX
|
||||
class="size-4 text-ink-gray-4 transition-colors hover:text-ink-gray-7"
|
||||
@click.stop="removeAssignedUser(user)"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
{{ __("Last") }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<Button variant="ghost" icon="x" @click="removeAssignedUser(user)" />
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage :message="assignmentRulesErrors.users" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import { Avatar, Button, ErrorMessage, Popover, Tooltip } from "frappe-ui";
|
||||
import {
|
||||
assignmentRuleData,
|
||||
assignmentRulesErrors,
|
||||
validateAssignmentRule,
|
||||
} from "@/stores/assignmentRules";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
ErrorMessage,
|
||||
Popover,
|
||||
Tooltip,
|
||||
} from "frappe-ui";
|
||||
import LucideX from "~icons/lucide/x";
|
||||
import { computed } from "vue";
|
||||
} from "../../../stores/assignmentRules";
|
||||
import AssigneeSearch from "./AssigneeSearch.vue";
|
||||
import { computed } from "vue";
|
||||
import { useUserStore } from "@/stores/user";
|
||||
|
||||
const { getUser } = useUserStore();
|
||||
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
<template>
|
||||
<Combobox :multiple="true">
|
||||
<Popover bare side="bottom" align="end">
|
||||
<template #trigger>
|
||||
<Popover placement="bottom-end">
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
variant="subtle"
|
||||
icon-left="lucide-plus"
|
||||
icon-left="plus"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Assignee')"
|
||||
class="rtl:flex-row-reverse"
|
||||
/>
|
||||
</template>
|
||||
<template #default>
|
||||
<div
|
||||
class="mt-1 rounded-6 bg-surface-base py-1 text-base shadow-2xl w-60"
|
||||
>
|
||||
<template #body="{ togglePopover }">
|
||||
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl w-60">
|
||||
<div class="relative px-1.5 pt-0.5">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="form-input w-full bg-transparent"
|
||||
class="form-input w-full"
|
||||
type="text"
|
||||
@change="
|
||||
(e) => {
|
||||
@@ -28,17 +26,17 @@
|
||||
:placeholder="__('Search')"
|
||||
/>
|
||||
<button
|
||||
class="absolute end-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
class="absolute right-1.5 inline-flex h-7 w-7 items-center justify-center"
|
||||
@click="query = ''"
|
||||
>
|
||||
<LucideX class="w-4" />
|
||||
<FeatherIcon name="x" class="w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<ComboboxOptions class="my-2 max-h-64 overflow-y-auto px-1.5" static>
|
||||
<ComboboxOption
|
||||
v-show="users.length > 0"
|
||||
v-for="user in users"
|
||||
:key="user.name"
|
||||
:key="user.username"
|
||||
:value="user"
|
||||
as="template"
|
||||
v-slot="{ active }"
|
||||
@@ -50,30 +48,28 @@
|
||||
"
|
||||
>
|
||||
<li
|
||||
class="flex items-center rounded-4 p-1.5 w-full text-base"
|
||||
:class="{ 'bg-surface-gray-2': active }"
|
||||
class="flex items-center rounded p-1.5 w-full text-base"
|
||||
:class="{ 'bg-gray-100': active }"
|
||||
>
|
||||
<div class="flex gap-2 items-center w-full select-none">
|
||||
<Avatar
|
||||
:shape="'circle'"
|
||||
:image="user.user_image"
|
||||
:label="user.agent_name"
|
||||
:label="user.full_name"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 truncate">
|
||||
<div class="font-semibold text-ink-gray-7 truncate">
|
||||
{{ user.agent_name }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 truncate">
|
||||
{{ user.user }}
|
||||
{{ user.full_name }}
|
||||
</div>
|
||||
<div class="text-ink-gray-6 truncate">{{ user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ComboboxOption>
|
||||
<li
|
||||
v-if="users.length == 0"
|
||||
class="mt-1.5 rounded-5 p-1.5 text-base text-ink-gray-5"
|
||||
class="mt-1.5 rounded-md p-1.5 text-base text-gray-600"
|
||||
>
|
||||
{{ __("No results found") }}
|
||||
</li>
|
||||
@@ -81,26 +77,34 @@
|
||||
<div class="border-t p-1.5 pb-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="w-full rtl:flex-row-reverse"
|
||||
icon-left="lucide-plus"
|
||||
class="w-full"
|
||||
icon-left="plus"
|
||||
:label="__('Invite agent')"
|
||||
@click="inviteAgents"
|
||||
@click="
|
||||
() => {
|
||||
showNewAgentsDialog = true;
|
||||
togglePopover();
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</Combobox>
|
||||
<ConfirmDialog
|
||||
v-model="showConfirmDialog.show"
|
||||
:title="showConfirmDialog.title"
|
||||
:message="showConfirmDialog.message"
|
||||
:onConfirm="showConfirmDialog.onConfirm"
|
||||
|
||||
<AddNewAgentsDialog
|
||||
:title="__('Add Agents')"
|
||||
@close="showNewAgentsDialog = false"
|
||||
:modelValue="showNewAgentsDialog"
|
||||
:show="showNewAgentsDialog"
|
||||
@update:modelValue="showNewAgentsDialog = $event"
|
||||
@onAgentsInvited="addInvitedAgents"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideX from "~icons/lucide/x";
|
||||
import AddNewAgentsDialog from "@/components/desk/global/AddNewAgentsDialog.vue";
|
||||
import { assignmentRuleData } from "@/stores/assignmentRules";
|
||||
import {
|
||||
Combobox,
|
||||
@@ -108,76 +112,87 @@ import {
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
} from "@headlessui/vue";
|
||||
import { Avatar, Popover } from "frappe-ui";
|
||||
import { computed, inject, Ref, ref } from "vue";
|
||||
import { setActiveSettingsTab } from "../settingsModal";
|
||||
import { useAgentStore } from "@/stores/agent";
|
||||
import { onMounted } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
import { Avatar, createListResource, Popover } from "frappe-ui";
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const emit = defineEmits(["addAssignee"]);
|
||||
const query = ref("");
|
||||
const { agents } = useAgentStore();
|
||||
const isAssignmentRuleFormDirty = inject<Ref<boolean>>(
|
||||
"isAssignmentRuleFormDirty"
|
||||
const showNewAgentsDialog = ref(false);
|
||||
|
||||
watchDebounced(
|
||||
() => query.value,
|
||||
(val) => {
|
||||
const filters = {
|
||||
full_name: undefined,
|
||||
email: undefined,
|
||||
};
|
||||
const isEmail = val.includes("@") || val.includes(".");
|
||||
if (isEmail) {
|
||||
filters.email = ["like", `%${val}%`];
|
||||
} else {
|
||||
filters.full_name = ["like", `%${val}%`];
|
||||
}
|
||||
usersList.update({
|
||||
filters,
|
||||
});
|
||||
usersList.reload();
|
||||
},
|
||||
{ debounce: 500 }
|
||||
);
|
||||
const showConfirmDialog = ref({
|
||||
show: false,
|
||||
title: "",
|
||||
message: "",
|
||||
onConfirm: () => {},
|
||||
|
||||
const usersList = createListResource({
|
||||
doctype: "User",
|
||||
fields: ["name", "email", "full_name", "user_image"],
|
||||
start: 0,
|
||||
pageLength: 5,
|
||||
auto: true,
|
||||
transform: (data) => {
|
||||
return data
|
||||
.map((user) => {
|
||||
if (user.full_name == "Administrator") {
|
||||
return {
|
||||
...user,
|
||||
email: "Administrator",
|
||||
user: "Administrator",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...user,
|
||||
user: user.email,
|
||||
};
|
||||
})
|
||||
.filter((user) => user.full_name != "Guest");
|
||||
},
|
||||
});
|
||||
|
||||
const users = computed(() => {
|
||||
let filteredAgents =
|
||||
agents.data?.filter(
|
||||
return (
|
||||
usersList.data?.filter(
|
||||
(user) =>
|
||||
!assignmentRuleData.value.users.some((u) => u.user === user.user)
|
||||
) || [];
|
||||
|
||||
if (query.value) {
|
||||
const val = query.value.toLowerCase();
|
||||
const isEmail = val.includes("@") || val.includes(".");
|
||||
filteredAgents = filteredAgents.filter((user) => {
|
||||
if (isEmail) {
|
||||
return user.user?.toLowerCase().includes(val);
|
||||
} else {
|
||||
return user.agent_name?.toLowerCase().includes(val);
|
||||
}
|
||||
});
|
||||
}
|
||||
return filteredAgents;
|
||||
!assignmentRuleData.value.users.some((u) => u.user === user.email)
|
||||
) || []
|
||||
);
|
||||
});
|
||||
|
||||
const addInvitedAgents = (users) => {
|
||||
users.forEach((user) => {
|
||||
addAssignee({ user });
|
||||
});
|
||||
};
|
||||
|
||||
const addAssignee = (user) => {
|
||||
assignmentRuleData.value.users.push({
|
||||
user: user.user,
|
||||
});
|
||||
emit("addAssignee", user);
|
||||
};
|
||||
|
||||
const inviteAgents = () => {
|
||||
if (isAssignmentRuleFormDirty.value) {
|
||||
showConfirmDialog.value = {
|
||||
show: true,
|
||||
title: __("Unsaved changes"),
|
||||
message: __(
|
||||
"Are you sure you want to leave? Unsaved changes will be lost."
|
||||
),
|
||||
onConfirm: () => {
|
||||
setActiveSettingsTab("Invite Agents");
|
||||
},
|
||||
};
|
||||
} else {
|
||||
setActiveSettingsTab("Invite Agents");
|
||||
const userExists = assignmentRuleData.value.users.some(
|
||||
(u) => u.user === user.user
|
||||
);
|
||||
if (!userExists) {
|
||||
assignmentRuleData.value.users.push({
|
||||
full_name: user.full_name,
|
||||
email: user.email,
|
||||
user_image: user.user_image,
|
||||
user: user.email,
|
||||
});
|
||||
emit("addAssignee", user);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
if (agents.loading || agents.data?.length || agents.list.promise) {
|
||||
return;
|
||||
}
|
||||
agents.fetch();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,36 +1,28 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-cols-12 items-center gap-4 cursor-pointer hover:bg-surface-sidebar rounded-4"
|
||||
class="grid grid-cols-11 items-center gap-4 cursor-pointer hover:bg-gray-50 rounded"
|
||||
>
|
||||
<div
|
||||
@click="assignmentRulesActiveScreen = { screen: 'view', data: data }"
|
||||
class="w-full ps-2 col-span-7 h-14 flex flex-col justify-center"
|
||||
class="w-full py-3 pl-2 col-span-7"
|
||||
>
|
||||
<div class="text-base-medium text-ink-gray-7">{{ data.name }}</div>
|
||||
<div class="text-base text-ink-gray-7 font-medium">{{ data.name }}</div>
|
||||
<div
|
||||
v-if="data.description && data.description.length > 0"
|
||||
class="text-sm w-full text-ink-gray-5 mt-1 truncate"
|
||||
class="text-sm w-full text-ink-gray-5 mt-1 whitespace-nowrap overflow-ellipsis overflow-hidden"
|
||||
>
|
||||
{{ data.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<select
|
||||
class="w-full h-7 text-base hover:bg-surface-gray-3 rounded-5 p-0 ps-2 pe-5 bg-transparent -ms-2 border-0 text-ink-gray-8 focus-visible:!ring-0 bg-none truncate"
|
||||
<div class="col-span-2">
|
||||
<Select
|
||||
class="w-max bg-transparent -ml-2 border-0 text-ink-gray-6 focus-visible:!ring-0 bg-none"
|
||||
:options="priorityOptions"
|
||||
v-model="data.priority"
|
||||
@update:modelValue="onPriorityChange"
|
||||
@change="onPriorityChange"
|
||||
>
|
||||
<option
|
||||
v-for="option in priorityOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center w-full pe-2 col-span-2">
|
||||
<div class="flex justify-between items-center w-full pr-2 col-span-2">
|
||||
<div>
|
||||
<Switch
|
||||
size="sm"
|
||||
@@ -39,26 +31,26 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Dropdown
|
||||
align="end"
|
||||
:options="dropdownOptions"
|
||||
@update:open="(open) => open && (isConfirmingDelete = false)"
|
||||
>
|
||||
<Button icon="lucide-more-horizontal" variant="ghost" />
|
||||
<Dropdown placement="right" :options="dropdownOptions">
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
variant="ghost"
|
||||
@click="isConfirmingDelete = false"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Dialog
|
||||
:title="__('Duplicate Assignment Rule')"
|
||||
v-model:open="duplicateDialog.show"
|
||||
:options="{ title: __('Duplicate Assignment Rule') }"
|
||||
v-model="duplicateDialog.show"
|
||||
>
|
||||
<template #default>
|
||||
<template #body-content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FormControl
|
||||
:label="__('New Assignment Rule Name')"
|
||||
type="text"
|
||||
v-model="duplicateDialog.newName"
|
||||
v-model="duplicateDialog.name"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -77,22 +69,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { assignmentRulesActiveScreen } from "@/stores/assignmentRules";
|
||||
import { __ } from "@/translation";
|
||||
import { AssignmentRuleListResourceSymbol } from "@/types";
|
||||
import { AssignmentRule } from "@/types/doctypes";
|
||||
import { ConfirmDelete } from "@/utils";
|
||||
import {
|
||||
Button,
|
||||
createResource,
|
||||
Dialog,
|
||||
Dropdown,
|
||||
FormControl,
|
||||
Select,
|
||||
Switch,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { inject, ref } from "vue";
|
||||
|
||||
const assignmentRulesListData = inject(AssignmentRuleListResourceSymbol);
|
||||
const assignmentRulesList = inject<any>("assignmentRulesList");
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
@@ -111,7 +100,6 @@ const priorityOptions = [
|
||||
|
||||
const duplicateDialog = ref({
|
||||
show: false,
|
||||
newName: "",
|
||||
name: "",
|
||||
});
|
||||
|
||||
@@ -125,9 +113,9 @@ const deleteAssignmentRule = () => {
|
||||
name: props.data.name,
|
||||
},
|
||||
onSuccess: () => {
|
||||
assignmentRulesListData?.reload();
|
||||
assignmentRulesList.reload();
|
||||
isConfirmingDelete.value = false;
|
||||
toast.success(__("Assignment rule deleted successfully."));
|
||||
toast.success("Assignment rule deleted");
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
@@ -135,15 +123,14 @@ const deleteAssignmentRule = () => {
|
||||
|
||||
const dropdownOptions = [
|
||||
{
|
||||
label: __("Duplicate"),
|
||||
label: "Duplicate",
|
||||
onClick: () => {
|
||||
duplicateDialog.value = {
|
||||
show: true,
|
||||
newName: props.data.name + " (Copy)",
|
||||
name: props.data.name,
|
||||
name: props.data.name + " (Copy)",
|
||||
};
|
||||
},
|
||||
icon: "lucide-copy",
|
||||
icon: "copy",
|
||||
},
|
||||
...ConfirmDelete({
|
||||
onConfirmDelete: () => deleteAssignmentRule(),
|
||||
@@ -153,37 +140,22 @@ const dropdownOptions = [
|
||||
|
||||
const duplicate = () => {
|
||||
createResource({
|
||||
url: "frappe.client.get",
|
||||
url: "helpdesk.api.assignment_rule.duplicate_assignment_rule",
|
||||
params: {
|
||||
doctype: "Assignment Rule",
|
||||
name: duplicateDialog.value.name,
|
||||
docname: props.data.name,
|
||||
new_name: duplicateDialog.value.name,
|
||||
},
|
||||
onSuccess: (data: AssignmentRule) => {
|
||||
createResource({
|
||||
url: "frappe.client.insert",
|
||||
params: {
|
||||
doc: {
|
||||
...data,
|
||||
name: duplicateDialog.value.newName,
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
assignmentRulesList.reload();
|
||||
toast.success("Assignment rule duplicated");
|
||||
duplicateDialog.value.show = false;
|
||||
duplicateDialog.value.name = "";
|
||||
assignmentRulesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: {
|
||||
name: data.name,
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(newAssignmentRuleData: AssignmentRule) {
|
||||
assignmentRulesListData?.reload();
|
||||
toast.success(__("Assignment rule duplicated successfully."));
|
||||
duplicateDialog.value = {
|
||||
show: false,
|
||||
newName: "",
|
||||
name: "",
|
||||
};
|
||||
setTimeout(() => {
|
||||
assignmentRulesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: newAssignmentRuleData,
|
||||
};
|
||||
}, 250);
|
||||
},
|
||||
});
|
||||
};
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
@@ -193,26 +165,15 @@ const onPriorityChange = () => {
|
||||
setAssignmentRuleValue("priority", props.data.priority);
|
||||
};
|
||||
|
||||
const onToggle = (enabled: boolean) => {
|
||||
const onToggle = () => {
|
||||
if (!props.data.users_exists && props.data.disabled) {
|
||||
toast.error(__("Cannot enable rule without adding users in it"));
|
||||
toast.error("Cannot enable rule without adding users in it");
|
||||
return;
|
||||
}
|
||||
// Optimistically flip so the switch reflects the new state immediately;
|
||||
// revert if the backend update fails.
|
||||
const previous = props.data.disabled;
|
||||
props.data.disabled = enabled ? 0 : 1;
|
||||
setAssignmentRuleValue("disabled", props.data.disabled, "status", () => {
|
||||
props.data.disabled = previous;
|
||||
});
|
||||
setAssignmentRuleValue("disabled", !props.data.disabled, "status");
|
||||
};
|
||||
|
||||
const setAssignmentRuleValue = (
|
||||
key: string,
|
||||
value: any,
|
||||
fieldName?: string,
|
||||
onError?: () => void
|
||||
) => {
|
||||
const setAssignmentRuleValue = (key, value, fieldName = undefined) => {
|
||||
createResource({
|
||||
url: "frappe.client.set_value",
|
||||
params: {
|
||||
@@ -222,15 +183,8 @@ const setAssignmentRuleValue = (
|
||||
value: value,
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
__("Assignment rule {0} updated successfully.", fieldName || key)
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
onError?.();
|
||||
toast.error(
|
||||
__("Failed to update assignment rule {0}.", fieldName || key)
|
||||
);
|
||||
assignmentRulesList.reload();
|
||||
toast.success(`Assignment rule ${fieldName || key} updated`);
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
<template>
|
||||
<SettingsLayoutBase
|
||||
:back-label="
|
||||
assignmentRuleData.assignmentRuleName || __('New Assignment Rule')
|
||||
"
|
||||
:on-back="goBack"
|
||||
:dirty="isDirty"
|
||||
<div
|
||||
v-if="getAssignmentRuleData.loading"
|
||||
class="flex items-center h-full justify-center"
|
||||
>
|
||||
<template #header-actions>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div
|
||||
v-if="!getAssignmentRuleData.loading"
|
||||
class="sticky top-0 z-10 bg-white pb-6 px-10 py-8"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon-left="chevron-left"
|
||||
:label="
|
||||
assignmentRuleData.assignmentRuleName || 'New Assignment Rule'
|
||||
"
|
||||
size="md"
|
||||
@click="goBack()"
|
||||
class="cursor-pointer -ml-4 hover:bg-transparent focus:bg-transparent focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:none active:bg-transparent active:outline-none active:ring-0 active:ring-offset-0 active:text-ink-gray-5 font-semibold text-xl hover:opacity-70 !pr-0 !max-w-96 !justify-start"
|
||||
/>
|
||||
<Badge
|
||||
:variant="'subtle'"
|
||||
:theme="'orange'"
|
||||
size="sm"
|
||||
:label="__('Unsaved')"
|
||||
v-if="isDirty"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2"
|
||||
@@ -21,297 +43,268 @@
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="saveAssignmentRule()"
|
||||
:loading="
|
||||
isLoading ||
|
||||
getAssignmentRuleData.loading ||
|
||||
createAssignmentRuleResource.loading
|
||||
"
|
||||
:loading="isLoading || getAssignmentRuleData.loading"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div
|
||||
v-if="getAssignmentRuleData.loading"
|
||||
class="flex items-center justify-center absolute inset-x-0 top-5.5 bottom-0"
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!getAssignmentRuleData.loading" class="overflow-y-auto px-10 pb-8">
|
||||
<div class="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'text'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
placeholder="Name"
|
||||
label="Name"
|
||||
v-model="assignmentRuleData.assignmentRuleName"
|
||||
required
|
||||
maxlength="50"
|
||||
@change="validateAssignmentRule('assignmentRuleName')"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.assignmentRuleName"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!getAssignmentRuleData.loading">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'text'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
:placeholder="__('Name')"
|
||||
:label="__('Name')"
|
||||
v-model="assignmentRuleData.assignmentRuleName"
|
||||
required
|
||||
maxlength="50"
|
||||
@change="validateAssignmentRule('assignmentRuleName')"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.assignmentRuleName"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<FormLabel :label="__('Priority')" />
|
||||
<Popover bare>
|
||||
<template #trigger>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<FormLabel label="Priority" />
|
||||
<Popover>
|
||||
<template #target="{ togglePopover }">
|
||||
<div
|
||||
class="flex items-center justify-between text-base rounded h-7 py-1.5 pl-2 pr-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
|
||||
@click="togglePopover()"
|
||||
>
|
||||
<div>
|
||||
{{
|
||||
priorityOptions.find(
|
||||
(option) => option.value == assignmentRuleData.priority
|
||||
)?.label
|
||||
}}
|
||||
</div>
|
||||
<FeatherIcon name="chevron-down" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
class="p-1 text-ink-gray-6 top-1 absolute w-full bg-white shadow-2xl rounded"
|
||||
>
|
||||
<div
|
||||
v-for="option in priorityOptions"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-gray-50 text-base flex items-center justify-between rounded"
|
||||
@click="
|
||||
assignmentRuleData.priority = option.value;
|
||||
togglePopover();
|
||||
"
|
||||
>
|
||||
{{ option.label }}
|
||||
<FeatherIcon
|
||||
v-if="assignmentRuleData.priority == option.value"
|
||||
name="check"
|
||||
class="size-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'textarea'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
placeholder="Description"
|
||||
label="Description"
|
||||
required
|
||||
maxlength="250"
|
||||
@change="validateAssignmentRule('description')"
|
||||
v-model="assignmentRuleData.description"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.description"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Assignment condition")
|
||||
}}</span>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__("Choose which tickets are affected by this assignment rule.")
|
||||
}}
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.frappe.io/helpdesk/assignment-rule"
|
||||
target="_blank"
|
||||
>{{ __("Learn about conditions") }}</a
|
||||
>
|
||||
</span>
|
||||
<div v-if="isAssignConditionOld && assignmentRulesActiveScreen.data">
|
||||
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<div
|
||||
class="flex items-center justify-between text-base rounded-4 h-7 py-1.5 ps-2 pe-2 border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-elevation-2 hover:bg-surface-gray-3 focus:bg-surface-base focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full dark:[color-scheme:dark] cursor-default"
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
|
||||
>
|
||||
<div>
|
||||
{{
|
||||
priorityOptions.find(
|
||||
(option) => option.value == assignmentRuleData.priority
|
||||
)?.label
|
||||
}}
|
||||
</div>
|
||||
<LucideChevronDown class="size-4" />
|
||||
<span>{{ __("Old Condition") }}</span>
|
||||
<FeatherIcon name="info" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ close: closePopover }">
|
||||
<template #body-main>
|
||||
<div
|
||||
class="p-1 text-ink-gray-6 top-1 absolute bg-surface-base shadow-2xl rounded-4 w-[--reka-popper-anchor-width]"
|
||||
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<div
|
||||
v-for="option in priorityOptions"
|
||||
:key="option.value"
|
||||
class="p-2 cursor-pointer hover:bg-surface-sidebar text-base flex items-center justify-between rounded-4"
|
||||
@click="
|
||||
assignmentRuleData.priority = option.value;
|
||||
closePopover();
|
||||
"
|
||||
>
|
||||
{{ option.label }}
|
||||
<LucideCheck
|
||||
v-if="assignmentRuleData.priority == option.value"
|
||||
class="size-4"
|
||||
/>
|
||||
</div>
|
||||
<code>{{ assignmentRuleData.assignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
<div>
|
||||
<FormControl
|
||||
:type="'textarea'"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
:placeholder="__('Description')"
|
||||
:label="__('Description')"
|
||||
required
|
||||
maxlength="140"
|
||||
@change="validateAssignmentRule('description')"
|
||||
v-model="assignmentRuleData.description"
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.description"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-md-semibold text-ink-gray-8">{{
|
||||
__("Assignment Condition")
|
||||
}}</span>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Choose which tickets are affected by this assignment rule."
|
||||
)
|
||||
}}
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.frappe.io/helpdesk/assignment-rule"
|
||||
target="_blank"
|
||||
>{{ __("Learn about conditions") }}</a
|
||||
>
|
||||
</span>
|
||||
<div
|
||||
v-if="isAssignConditionOld && assignmentRulesActiveScreen.data"
|
||||
>
|
||||
<HoverCard :hoverDelay="250" side="top" align="end">
|
||||
<template #trigger>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
|
||||
>
|
||||
<span>{{ __("Old Condition") }}</span>
|
||||
<LucideInfo class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-5 max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ assignmentRuleData.assignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-5 p-3 py-4"
|
||||
v-if="
|
||||
!useNewUIForAssignCondition &&
|
||||
assignmentRuleData.assignCondition
|
||||
"
|
||||
>
|
||||
<span class="text-p-sm">
|
||||
Conditions for this rule were created from
|
||||
<a :href="deskUrl" target="_blank" class="underline">desk</a>
|
||||
which are not compatible with this UI, you will need to recreate
|
||||
the conditions here if you want to manage and add new conditions
|
||||
from this UI.
|
||||
</span>
|
||||
<Button
|
||||
label="I understand, add conditions"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUIForAssignCondition = true"
|
||||
/>
|
||||
</div>
|
||||
<AssignmentRulesSection
|
||||
:conditions="assignmentRuleData.assignConditionJson"
|
||||
name="assignCondition"
|
||||
:errors="assignmentRulesErrors.assignConditionError"
|
||||
v-else
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.assignCondition"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-md-semibold text-ink-gray-8">{{
|
||||
__("Unassignment Condition")
|
||||
}}</span>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Choose which tickets are affected by this un-assignment rule."
|
||||
)
|
||||
}}
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.frappe.io/helpdesk/assignment-rule"
|
||||
target="_blank"
|
||||
>{{ __("Learn about conditions") }}</a
|
||||
>
|
||||
</span>
|
||||
<div
|
||||
v-if="
|
||||
isUnassignConditionOld &&
|
||||
assignmentRulesActiveScreen.data &&
|
||||
assignmentRuleData.unassignCondition
|
||||
"
|
||||
>
|
||||
<HoverCard :hoverDelay="250" side="top" align="end">
|
||||
<template #trigger>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
|
||||
>
|
||||
<span> {{ __("Old Condition") }} </span>
|
||||
<LucideInfo class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #default>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-surface-base rounded-5 max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ assignmentRuleData.unassignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-outline-gray-2 rounded-5 p-3 py-4"
|
||||
v-if="
|
||||
!useNewUIForUnassignCondition &&
|
||||
assignmentRuleData.unassignCondition
|
||||
"
|
||||
>
|
||||
<span class="text-p-sm">
|
||||
Conditions for this rule were created from
|
||||
<a :href="deskUrl" target="_blank" class="underline">desk</a>
|
||||
which are not compatible with this UI, you will need to recreate
|
||||
the conditions here if you want to manage and add new conditions
|
||||
from this UI.
|
||||
</span>
|
||||
<Button
|
||||
label="I understand, add conditions"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUIForUnassignCondition = true"
|
||||
/>
|
||||
</div>
|
||||
<AssignmentRulesSection
|
||||
:conditions="assignmentRuleData.unassignConditionJson"
|
||||
name="unassignCondition"
|
||||
:errors="assignmentRulesErrors.unassignConditionError"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-md-semibold text-ink-gray-8">{{
|
||||
__("Assignment Schedule")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Choose the days of the week when this rule should be active."
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<AssignmentSchedule />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<AssigneeRules />
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
|
||||
v-if="
|
||||
!useNewUIForAssignCondition && assignmentRuleData.assignCondition
|
||||
"
|
||||
>
|
||||
<span class="text-p-sm">
|
||||
Conditions for this rule were created from
|
||||
<a :href="deskUrl" target="_blank" class="underline">desk</a> which
|
||||
are not compatible with this UI, you will need to recreate the
|
||||
conditions here if you want to manage and add new conditions from
|
||||
this UI.
|
||||
</span>
|
||||
<Button
|
||||
label="I understand, add conditions"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUIForAssignCondition = true"
|
||||
/>
|
||||
</div>
|
||||
<AssignmentRulesSection
|
||||
:conditions="assignmentRuleData.assignConditionJson"
|
||||
name="assignCondition"
|
||||
:errors="assignmentRulesErrors.assignConditionError"
|
||||
v-else
|
||||
/>
|
||||
<ErrorMessage
|
||||
:message="assignmentRulesErrors.assignCondition"
|
||||
class="mt-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Unassignment condition")
|
||||
}}</span>
|
||||
<div class="flex items-center justify-between gap-6">
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Choose which tickets are affected by this un-assignment rule."
|
||||
)
|
||||
}}
|
||||
<a
|
||||
class="font-medium underline"
|
||||
href="https://docs.frappe.io/helpdesk/assignment-rule"
|
||||
target="_blank"
|
||||
>{{ __("Learn about conditions") }}</a
|
||||
>
|
||||
</span>
|
||||
<div
|
||||
v-if="
|
||||
isUnassignConditionOld &&
|
||||
assignmentRulesActiveScreen.data &&
|
||||
assignmentRuleData.unassignCondition
|
||||
"
|
||||
>
|
||||
<Popover trigger="hover" :hoverDelay="0.25" placement="top-end">
|
||||
<template #target>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 flex gap-1 cursor-default text-nowrap flex items-center"
|
||||
>
|
||||
<span> {{ __("Old Condition") }} </span>
|
||||
<FeatherIcon name="info" class="size-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template #body-main>
|
||||
<div
|
||||
class="text-sm text-ink-gray-6 p-2 bg-white rounded-md max-w-96 text-wrap whitespace-pre-wrap leading-5"
|
||||
>
|
||||
<code>{{ assignmentRuleData.unassignCondition }}</code>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-5">
|
||||
<div
|
||||
class="flex flex-col gap-3 items-center text-center text-ink-gray-7 text-sm mb-2 border border-gray-300 rounded-md p-3 py-4"
|
||||
v-if="
|
||||
!useNewUIForUnassignCondition &&
|
||||
assignmentRuleData.unassignCondition
|
||||
"
|
||||
>
|
||||
<span class="text-p-sm">
|
||||
Conditions for this rule were created from
|
||||
<a :href="deskUrl" target="_blank" class="underline">desk</a> which
|
||||
are not compatible with this UI, you will need to recreate the
|
||||
conditions here if you want to manage and add new conditions from
|
||||
this UI.
|
||||
</span>
|
||||
<Button
|
||||
label="I understand, add conditions"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
@click="useNewUIForUnassignCondition = true"
|
||||
/>
|
||||
</div>
|
||||
<AssignmentRulesSection
|
||||
:conditions="assignmentRuleData.unassignConditionJson"
|
||||
name="unassignCondition"
|
||||
:errors="assignmentRulesErrors.unassignConditionError"
|
||||
v-else
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-ink-gray-8">{{
|
||||
__("Assignment Schedule")
|
||||
}}</span>
|
||||
<span class="text-p-sm text-ink-gray-6">
|
||||
{{
|
||||
__("Choose the days of the week when this rule should be active.")
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<AssignmentSchedule />
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-8" />
|
||||
<AssigneeRules />
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
v-model="showConfirmDialog.show"
|
||||
:title="showConfirmDialog.title"
|
||||
:message="showConfirmDialog.message"
|
||||
:onConfirm="showConfirmDialog.onConfirm"
|
||||
:onCancel="
|
||||
() => {
|
||||
if (showConfirmDialog.onCancel) {
|
||||
showConfirmDialog.onCancel();
|
||||
} else {
|
||||
showConfirmDialog.show = false;
|
||||
}
|
||||
}
|
||||
"
|
||||
:onCancel="() => (showConfirmDialog.show = false)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucideCheck from "~icons/lucide/check";
|
||||
import LucideChevronDown from "~icons/lucide/chevron-down";
|
||||
import LucideInfo from "~icons/lucide/info";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||
import {
|
||||
Badge,
|
||||
@@ -321,13 +314,12 @@ import {
|
||||
ErrorMessage,
|
||||
FormControl,
|
||||
FormLabel,
|
||||
HoverCard,
|
||||
LoadingIndicator,
|
||||
Popover,
|
||||
Switch,
|
||||
toast,
|
||||
} from "frappe-ui";
|
||||
import { onMounted, onUnmounted, provide, ref, watch } from "vue";
|
||||
import { onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import {
|
||||
assignmentRuleData,
|
||||
assignmentRulesActiveScreen,
|
||||
@@ -335,33 +327,23 @@ import {
|
||||
resetAssignmentRuleData,
|
||||
resetAssignmentRuleErrors,
|
||||
validateAssignmentRule,
|
||||
} from "@/stores/assignmentRules";
|
||||
} from "../../../stores/assignmentRules";
|
||||
import AssigneeRules from "./AssigneeRules.vue";
|
||||
import AssignmentRulesSection from "./AssignmentRulesSection.vue";
|
||||
import AssignmentSchedule from "./AssignmentSchedule.vue";
|
||||
import { convertToConditions } from "@/utils";
|
||||
import { disableSettingModalOutsideClick } from "../settingsModal";
|
||||
import { __ } from "@/translation";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
|
||||
const isDirty = ref(false);
|
||||
const initialData = ref(null);
|
||||
const isLoading = ref(false);
|
||||
provide("isAssignmentRuleFormDirty", isDirty);
|
||||
|
||||
const showConfirmDialog = ref<{
|
||||
show: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
onConfirm: () => void;
|
||||
onCancel?: () => void;
|
||||
}>({
|
||||
const showConfirmDialog = ref({
|
||||
show: false,
|
||||
title: "",
|
||||
message: "",
|
||||
onConfirm: () => {},
|
||||
});
|
||||
|
||||
const useNewUIForAssignCondition = ref(true);
|
||||
const useNewUIForUnassignCondition = ref(true);
|
||||
const isAssignConditionOld = ref(false);
|
||||
@@ -442,22 +424,26 @@ const getAssignmentRuleData = createResource({
|
||||
|
||||
if (!assignmentRulesActiveScreen.value.data) {
|
||||
disableSettingModalOutsideClick.value = true;
|
||||
initialData.value = JSON.stringify(assignmentRuleData.value);
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
const confirmDialogInfo = {
|
||||
show: true,
|
||||
title: __("Unsaved changes"),
|
||||
message: __(
|
||||
"Are you sure you want to go back? Unsaved changes will be lost."
|
||||
),
|
||||
title: "Unsaved changes",
|
||||
message: "Are you sure you want to go back? Unsaved changes will be lost.",
|
||||
onConfirm: goBack,
|
||||
};
|
||||
if (isDirty.value && !showConfirmDialog.value.show) {
|
||||
showConfirmDialog.value = confirmDialogInfo;
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!assignmentRulesActiveScreen.value.data &&
|
||||
!showConfirmDialog.value.show
|
||||
) {
|
||||
showConfirmDialog.value = confirmDialogInfo;
|
||||
return;
|
||||
}
|
||||
// Workaround fix for settings modal not closing after going back
|
||||
setTimeout(() => {
|
||||
assignmentRulesActiveScreen.value = {
|
||||
@@ -477,7 +463,7 @@ const saveAssignmentRule = () => {
|
||||
|
||||
if (Object.values(validationErrors).some((error) => error)) {
|
||||
toast.error(
|
||||
__("Invalid fields, check if all are filled in and values are correct.")
|
||||
"Invalid fields, check if all are filled in and values are correct."
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -511,51 +497,59 @@ const showOverwriteConfirm = () => {
|
||||
};
|
||||
|
||||
const createAssignmentRule = () => {
|
||||
createAssignmentRuleResource.submit({
|
||||
doc: {
|
||||
doctype: "Assignment Rule",
|
||||
document_type: "HD Ticket",
|
||||
rule: assignmentRuleData.value.rule,
|
||||
priority: assignmentRuleData.value.priority,
|
||||
users: assignmentRuleData.value.users,
|
||||
disabled: assignmentRuleData.value.disabled,
|
||||
description: assignmentRuleData.value.description,
|
||||
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
|
||||
day: day,
|
||||
})),
|
||||
name: assignmentRuleData.value.assignmentRuleName,
|
||||
assignment_rule_name: assignmentRuleData.value.assignmentRuleName,
|
||||
assign_condition: convertToConditions({
|
||||
conditions: assignmentRuleData.value.assignConditionJson,
|
||||
}),
|
||||
unassign_condition: convertToConditions({
|
||||
conditions: assignmentRuleData.value.unassignConditionJson,
|
||||
}),
|
||||
assign_condition_json: JSON.stringify(
|
||||
assignmentRuleData.value.assignConditionJson
|
||||
),
|
||||
unassign_condition_json: JSON.stringify(
|
||||
assignmentRuleData.value.unassignConditionJson
|
||||
),
|
||||
isLoading.value = true;
|
||||
createResource({
|
||||
url: "frappe.client.insert",
|
||||
params: {
|
||||
doc: {
|
||||
doctype: "Assignment Rule",
|
||||
document_type: "HD Ticket",
|
||||
rule: assignmentRuleData.value.rule,
|
||||
priority: assignmentRuleData.value.priority,
|
||||
users: assignmentRuleData.value.users,
|
||||
disabled: assignmentRuleData.value.disabled,
|
||||
description: assignmentRuleData.value.description,
|
||||
assignment_days: assignmentRuleData.value.assignmentDays.map((day) => ({
|
||||
day: day,
|
||||
})),
|
||||
name: assignmentRuleData.value.assignmentRuleName,
|
||||
assignment_rule_name: assignmentRuleData.value.assignmentRuleName,
|
||||
assign_condition: convertToConditions({
|
||||
conditions: assignmentRuleData.value.assignConditionJson,
|
||||
}),
|
||||
unassign_condition: convertToConditions({
|
||||
conditions: assignmentRuleData.value.unassignConditionJson,
|
||||
}),
|
||||
assign_condition_json: JSON.stringify(
|
||||
assignmentRuleData.value.assignConditionJson
|
||||
),
|
||||
unassign_condition_json: JSON.stringify(
|
||||
assignmentRuleData.value.unassignConditionJson
|
||||
),
|
||||
},
|
||||
},
|
||||
auto: true,
|
||||
onSuccess(data) {
|
||||
getAssignmentRuleData
|
||||
.submit({
|
||||
doctype: "Assignment Rule",
|
||||
name: data.name,
|
||||
})
|
||||
.then(() => {
|
||||
isLoading.value = false;
|
||||
toast.success("Assignment rule created");
|
||||
});
|
||||
assignmentRulesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: data,
|
||||
};
|
||||
},
|
||||
onError: () => {
|
||||
isLoading.value = false;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createAssignmentRuleResource = createResource({
|
||||
url: "frappe.client.insert",
|
||||
onSuccess(data) {
|
||||
toast.success(__("Assignment rule created successfully."));
|
||||
assignmentRulesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: data,
|
||||
};
|
||||
getAssignmentRuleData.submit({
|
||||
doctype: "Assignment Rule",
|
||||
name: data.name,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const priorityOptions = [
|
||||
{ label: "Low", value: "0" },
|
||||
{ label: "Low-Medium", value: "1" },
|
||||
@@ -599,8 +593,7 @@ const updateAssignmentRule = async () => {
|
||||
},
|
||||
}).catch((er) => {
|
||||
const error =
|
||||
er?.messages?.[0] ||
|
||||
__("Some error occurred while updating assignment rule");
|
||||
er?.messages?.[0] || "Some error occurred while updating assignment rule";
|
||||
toast.error(error);
|
||||
isLoading.value = false;
|
||||
});
|
||||
@@ -616,7 +609,7 @@ const updateAssignmentRule = async () => {
|
||||
}).catch(async (er) => {
|
||||
const error =
|
||||
er?.messages?.[0] ||
|
||||
__("Some error occurred while renaming assignment rule");
|
||||
"Some error occurred while renaming assignment rule";
|
||||
toast.error(error);
|
||||
// Reset assignment rule to previous state
|
||||
await getAssignmentRuleData.reload();
|
||||
@@ -631,7 +624,7 @@ const updateAssignmentRule = async () => {
|
||||
}
|
||||
|
||||
isLoading.value = false;
|
||||
toast.success(__("Assignment rule updated successfully."));
|
||||
toast.success("Assignment rule updated");
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
<template>
|
||||
<AssignmentRulesList v-if="assignmentRulesActiveScreen.screen === 'list'" />
|
||||
<AssignmentRuleView v-if="assignmentRulesActiveScreen.screen === 'view'" />
|
||||
<div class="pb-8">
|
||||
<AssignmentRulesList v-if="assignmentRulesActiveScreen.screen === 'list'" />
|
||||
<AssignmentRuleView v-if="assignmentRulesActiveScreen.screen === 'view'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { provide, ref } from "vue";
|
||||
import { assignmentRulesActiveScreen } from "@/stores/assignmentRules";
|
||||
import { assignmentRulesActiveScreen } from "../../../stores/assignmentRules";
|
||||
import AssignmentRulesList from "./AssignmentRulesList.vue";
|
||||
import AssignmentRuleView from "./AssignmentRuleView.vue";
|
||||
|
||||
const assignmentRuleSearchQuery = ref("");
|
||||
|
||||
provide("assignmentRuleSearchQuery", assignmentRuleSearchQuery);
|
||||
</script>
|
||||
|
||||
@@ -1,83 +1,48 @@
|
||||
<template>
|
||||
<SettingsLayoutBase>
|
||||
<template #title>
|
||||
<h1 class="text-md-semibold text-ink-gray-8">
|
||||
{{ __("Assignment Rules") }}
|
||||
</h1>
|
||||
</template>
|
||||
<template #description>
|
||||
<p class="text-p-sm max-w-md text-ink-gray-6">
|
||||
{{
|
||||
__(
|
||||
"Assignment Rules automatically route tickets to the right team members based on predefined conditions."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
<template #header-actions>
|
||||
<div class="px-10 py-8 sticky top-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="text-lg font-semibold text-ink-gray-8">
|
||||
{{ __("Assignment rules") }}
|
||||
</h1>
|
||||
<p class="text-p-sm text-ink-gray-6 max-w-md">
|
||||
{{
|
||||
__(
|
||||
"Assignment Rules automatically route tickets to the right team members based on predefined conditions."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
:label="__('Create new')"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="goToNew()"
|
||||
icon-left="lucide-plus"
|
||||
class="rtl:flex-row-reverse"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
assignmentRulesListData.data?.length > 9 ||
|
||||
assignmentRuleSearchQuery.length
|
||||
"
|
||||
#header-bottom
|
||||
>
|
||||
<div class="relative">
|
||||
<TextInput
|
||||
:model-value="assignmentRuleSearchQuery"
|
||||
@update:model-value="assignmentRuleSearchQuery = $event"
|
||||
:placeholder="__('Search')"
|
||||
type="text"
|
||||
class="focus:ring-0 border-outline-gray-2"
|
||||
:debounce="300"
|
||||
>
|
||||
<template #prefix>
|
||||
<LucideSearch class="size-4" />
|
||||
</template>
|
||||
</TextInput>
|
||||
<Button
|
||||
v-if="assignmentRuleSearchQuery"
|
||||
icon="lucide-x"
|
||||
variant="ghost"
|
||||
@click="assignmentRuleSearchQuery = ''"
|
||||
class="absolute end-1 top-1/2 -translate-y-1/2"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<AssignmentRulesListView />
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-y-auto px-10 pb-8">
|
||||
<AssignmentRulesListView />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button, createResource } from "frappe-ui";
|
||||
import { inject, provide, Ref } from "vue";
|
||||
import { provide } from "vue";
|
||||
import {
|
||||
assignmentRulesActiveScreen,
|
||||
resetAssignmentRuleData,
|
||||
} from "@/stores/assignmentRules";
|
||||
} from "../../../stores/assignmentRules";
|
||||
import AssignmentRulesListView from "./AssignmentRulesListView.vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { AssignmentRuleListResourceSymbol } from "@/types";
|
||||
|
||||
const assignmentRulesListData = createResource({
|
||||
url: "helpdesk.api.assignment_rule.get_assignment_rules_list",
|
||||
cache: ["assignmentRules", "get_assignment_rules_list"],
|
||||
auto: true,
|
||||
});
|
||||
const assignmentRuleSearchQuery = inject<Ref>("assignmentRuleSearchQuery");
|
||||
|
||||
provide(AssignmentRuleListResourceSymbol, assignmentRulesListData);
|
||||
provide("assignmentRulesList", assignmentRulesListData);
|
||||
|
||||
const goToNew = () => {
|
||||
resetAssignmentRuleData();
|
||||
|
||||
@@ -1,42 +1,32 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="assignmentRulesListData.loading && !assignmentRulesListData.data"
|
||||
class="flex items-center justify-center absolute inset-x-0 top-5.5 bottom-0"
|
||||
v-if="assignmentRulesList.loading && !assignmentRulesList.data"
|
||||
class="flex items-center justify-center mt-12"
|
||||
>
|
||||
<LoadingIndicator class="w-4" />
|
||||
</div>
|
||||
<div v-else class="-ms-2 grow">
|
||||
<div v-else>
|
||||
<div
|
||||
v-if="!assignmentRulesListData.loading && !assignmentRulesList?.length"
|
||||
class="flex flex-col items-center justify-center gap-4 h-full"
|
||||
v-if="assignmentRulesList.data?.length === 0"
|
||||
class="flex items-center justify-center rounded-md border border-gray-200 p-4"
|
||||
>
|
||||
<div
|
||||
class="p-4 size-14.5 rounded-full bg-surface-gray-1 flex justify-center items-center"
|
||||
>
|
||||
<Settings class="size-6 text-ink-gray-6 rotate-90" />
|
||||
</div>
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<div class="text-base-medium text-ink-gray-6">
|
||||
{{ __("No assignment rule found") }}
|
||||
</div>
|
||||
<div class="text-p-sm text-ink-gray-5 max-w-60 text-center">
|
||||
{{ __("Add one to get started.") }}
|
||||
</div>
|
||||
<div class="text-sm text-ink-gray-7">
|
||||
{{ __("No items in the list") }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="grid grid-cols-12 items-center gap-4 text-sm text-ink-gray-5">
|
||||
<div class="col-span-7 ms-2">{{ __("Assignment rule") }}</div>
|
||||
<div class="col-span-3">{{ __("Priority") }}</div>
|
||||
<div class="grid grid-cols-11 items-center gap-4 text-sm text-gray-600">
|
||||
<div class="col-span-7 ml-2">{{ __("Assignment rule") }}</div>
|
||||
<div class="col-span-2">{{ __("Priority") }}</div>
|
||||
<div class="col-span-2">{{ __("Enabled") }}</div>
|
||||
</div>
|
||||
<hr class="mt-2 mx-2" />
|
||||
<div
|
||||
v-for="(assignmentRule, index) in assignmentRulesList"
|
||||
v-for="assignmentRule in assignmentRulesList.data"
|
||||
:key="assignmentRule.name"
|
||||
>
|
||||
<AssignmentRuleListItem :data="assignmentRule" />
|
||||
<hr v-if="index !== assignmentRulesList.length - 1" class="mx-2" />
|
||||
<hr class="mx-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,43 +34,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoadingIndicator } from "frappe-ui";
|
||||
import { inject, Ref, ref, watch } from "vue";
|
||||
import { inject } from "vue";
|
||||
import AssignmentRuleListItem from "./AssignmentRuleListItem.vue";
|
||||
import {
|
||||
assignmentRulesActiveScreen,
|
||||
resetAssignmentRuleData,
|
||||
} from "@/stores/assignmentRules";
|
||||
import Settings from "~icons/lucide/settings-2";
|
||||
import { AssignmentRuleListResourceSymbol } from "@/types";
|
||||
|
||||
const assignmentRulesListData = inject(AssignmentRuleListResourceSymbol);
|
||||
|
||||
const assignmentRulesList = ref(assignmentRulesListData.data || []);
|
||||
|
||||
const assignmentRuleSearchQuery = inject<Ref>("assignmentRuleSearchQuery");
|
||||
|
||||
const goToNew = () => {
|
||||
resetAssignmentRuleData();
|
||||
assignmentRulesActiveScreen.value = {
|
||||
screen: "view",
|
||||
data: null,
|
||||
};
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [assignmentRuleSearchQuery.value, assignmentRulesListData.data],
|
||||
([query, data]) => {
|
||||
if (!query) {
|
||||
assignmentRulesList.value = data || [];
|
||||
return;
|
||||
}
|
||||
assignmentRulesList.value =
|
||||
data?.filter((item) => {
|
||||
return (
|
||||
item.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.description.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}) || [];
|
||||
}
|
||||
);
|
||||
const assignmentRulesList = inject<any>("assignmentRulesList");
|
||||
</script>
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
/>
|
||||
<div
|
||||
v-if="props.conditions.length == 0"
|
||||
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-outline-gray-2 text-ink-gray-5 rounded-5"
|
||||
class="flex p-4 items-center cursor-pointer justify-center gap-2 text-sm border border-gray-300 text-gray-600 rounded-md"
|
||||
@click="
|
||||
props.conditions.push(['', '', '']);
|
||||
validateAssignmentRule(props.name);
|
||||
"
|
||||
>
|
||||
<LucidePlus class="size-4" />
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
{{ __("Add a condition") }}
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
@@ -25,7 +25,7 @@
|
||||
>
|
||||
<Button
|
||||
:disabled="props.errors !== ''"
|
||||
:icon-right="open ? 'lucide-chevron-up' : 'lucide-chevron-down'"
|
||||
:icon-right="open ? 'chevron-up' : 'chevron-down'"
|
||||
:label="__('Add condition')"
|
||||
/>
|
||||
</Dropdown>
|
||||
@@ -38,13 +38,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LucidePlus from "~icons/lucide/plus";
|
||||
import CFConditions from "@/components/conditions-filter/CFConditions.vue";
|
||||
import { validateConditions } from "@/utils";
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
import { Button, Dropdown, ErrorMessage } from "frappe-ui";
|
||||
import { validateAssignmentRule } from "@/stores/assignmentRules";
|
||||
import { __ } from "@/translation";
|
||||
import { Button, Dropdown, ErrorMessage, FeatherIcon } from "frappe-ui";
|
||||
import { validateAssignmentRule } from "../../../stores/assignmentRules";
|
||||
|
||||
const props = defineProps({
|
||||
conditions: Array<any>,
|
||||
@@ -64,13 +62,13 @@ const getConjunction = () => {
|
||||
|
||||
const dropdownOptions = [
|
||||
{
|
||||
label: __("Add condition"),
|
||||
label: "Add condition",
|
||||
onClick: () => {
|
||||
addCondition();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: __("Add condition group"),
|
||||
label: "Add condition group",
|
||||
onClick: () => {
|
||||
const conjunction = getConjunction();
|
||||
props.conditions.push(conjunction, [[]]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="rounded-5 border px-2 border-outline-gray-2 text-sm">
|
||||
<div class="rounded-md border px-2 border-gray-300 text-sm">
|
||||
<div
|
||||
class="grid p-2 px-4 items-center"
|
||||
style="grid-template-columns: 3fr 1fr"
|
||||
@@ -7,7 +7,7 @@
|
||||
<div
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-ink-gray-5 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
class="text-gray-600 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ column.label }}
|
||||
</div>
|
||||
@@ -29,17 +29,16 @@ import { onMounted, ref } from "vue";
|
||||
import {
|
||||
assignmentRuleData,
|
||||
assignmentRulesErrors,
|
||||
} from "@/stores/assignmentRules";
|
||||
} from "../../../stores/assignmentRules";
|
||||
import AssignmentScheduleItem from "./AssignmentScheduleItem.vue";
|
||||
import { __ } from "@/translation";
|
||||
|
||||
const columns = [
|
||||
{
|
||||
label: __("Days"),
|
||||
label: "Days",
|
||||
key: "day",
|
||||
},
|
||||
{
|
||||
label: __("Active"),
|
||||
label: "Active",
|
||||
key: "active",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,15 +5,7 @@
|
||||
>
|
||||
<div class="text-ink-gray-7 font-medium">{{ data.day }}</div>
|
||||
<div class="flex justify-start">
|
||||
<Switch
|
||||
:model-value="Boolean(data.active)"
|
||||
@update:model-value="
|
||||
(value) => {
|
||||
data.active = value;
|
||||
toggleDay(value);
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Switch v-model="data.active" @update:model-value="toggleDay" />
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="!isLast" />
|
||||
|
||||
140
desk/src/components/Settings/Branding.vue
Normal file
140
desk/src/components/Settings/Branding.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-10 py-8">
|
||||
<SettingsLayoutHeader
|
||||
title="Customise your Helpdesk"
|
||||
description="Customise Helpdesk with your own branding."
|
||||
/>
|
||||
|
||||
<!-- Brand Logo & Favicon -->
|
||||
<div v-for="config in brandingConfig" class="flex flex-col gap-2">
|
||||
<p class="text-sm text-gray-600">{{ config.title }}</p>
|
||||
<div class="flex gap-4 items-center">
|
||||
<Avatar
|
||||
v-if="config.image && !config.loading"
|
||||
size="3xl"
|
||||
:image="config.image"
|
||||
/>
|
||||
<FileUploader
|
||||
v-if="!config.image"
|
||||
:fileTypes="['image/*']"
|
||||
@success="
|
||||
(file) => {
|
||||
update(file.file_url, config.doctype, config.fieldname);
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #default="{ openFileSelector }">
|
||||
<Button
|
||||
@click="openFileSelector()"
|
||||
iconLeft="upload"
|
||||
label="Upload Image"
|
||||
:loading="config.loading"
|
||||
/>
|
||||
</template>
|
||||
</FileUploader>
|
||||
|
||||
<div v-else>
|
||||
<Button
|
||||
label="Remove"
|
||||
@click="update('', config.doctype, config.fieldname)"
|
||||
iconLeft="trash"
|
||||
:loading="config.loading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import { Avatar, createResource, FileUploader, toast } from "frappe-ui";
|
||||
import { computed, reactive } from "vue";
|
||||
import SettingsLayoutHeader from "./SettingsLayoutHeader.vue";
|
||||
|
||||
const config = useConfigStore();
|
||||
|
||||
const websiteSettings = createResource({
|
||||
url: "frappe.client.get_value",
|
||||
cache: true,
|
||||
params: {
|
||||
doctype: "Website Settings",
|
||||
fieldname: "favicon",
|
||||
},
|
||||
onSuccess(data) {
|
||||
state.brandFavicon = data.favicon;
|
||||
},
|
||||
auto: true,
|
||||
});
|
||||
|
||||
const state = reactive({
|
||||
brandLogo: config.brandLogo,
|
||||
brandFavicon: websiteSettings.data?.favicon || "",
|
||||
});
|
||||
|
||||
const loadingState = reactive({
|
||||
logoLoading: false,
|
||||
faviconLoading: false,
|
||||
});
|
||||
|
||||
const brandingConfig = computed(() => [
|
||||
{
|
||||
title: "Brand Logo",
|
||||
image: state.brandLogo,
|
||||
doctype: "HD Settings",
|
||||
fieldname: "brand_logo",
|
||||
loading: loadingState.logoLoading,
|
||||
},
|
||||
{
|
||||
title: "Brand Favicon",
|
||||
image: state.brandFavicon,
|
||||
doctype: "Website Settings",
|
||||
fieldname: "favicon",
|
||||
loading: loadingState.faviconLoading,
|
||||
},
|
||||
]);
|
||||
|
||||
const settingsResource = createResource({
|
||||
url: "frappe.client.set_value",
|
||||
debounce: 1000,
|
||||
onSuccess(data) {
|
||||
if (data.doctype === "HD Settings") {
|
||||
handleLogoChange(data.brand_logo);
|
||||
} else {
|
||||
handleFaviconChange(data.favicon);
|
||||
}
|
||||
},
|
||||
onError() {
|
||||
toast.error("Failed to update, please try again");
|
||||
loadingState.logoLoading = false;
|
||||
loadingState.faviconLoading = false;
|
||||
},
|
||||
});
|
||||
|
||||
function update(file: string, doctype: string, fieldname: string) {
|
||||
settingsResource.submit({
|
||||
doctype: doctype,
|
||||
name: doctype,
|
||||
fieldname: fieldname,
|
||||
value: file,
|
||||
});
|
||||
doctype === "HD Settings"
|
||||
? (loadingState.logoLoading = true)
|
||||
: (loadingState.faviconLoading = true);
|
||||
}
|
||||
|
||||
function handleLogoChange(url: string) {
|
||||
state.brandLogo = url;
|
||||
loadingState.logoLoading = false;
|
||||
|
||||
toast.success("Brand logo updated");
|
||||
}
|
||||
|
||||
function handleFaviconChange(url: string) {
|
||||
state.brandFavicon = url;
|
||||
loadingState.faviconLoading = false;
|
||||
toast.success("Favicon updated, please refresh the page to see the changes");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,17 +1,15 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between items-center border-outline-elevation-2 p-2 cursor-pointer hover:bg-surface-sidebar rounded-4 h-14"
|
||||
class="flex justify-between items-center border-gray-200 p-2 pl-0 cursor-pointer"
|
||||
>
|
||||
<!-- avatar and name -->
|
||||
<div class="flex justify-between items-center gap-2">
|
||||
<EmailProviderIcon :logo="emailIcon[emailAccount.service]" />
|
||||
<div class="rtl:text-right">
|
||||
<p class="text-p-base-medium text-ink-gray-7">
|
||||
{{ __(emailAccount.email_account_name) }}
|
||||
<div>
|
||||
<p class="text-gray-700 font-semibold">
|
||||
{{ emailAccount.email_account_name }}
|
||||
</p>
|
||||
<div class="text-p-sm w-full text-ink-gray-5 mt-1">
|
||||
{{ __(emailAccount.email_id) }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">{{ emailAccount.email_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -30,7 +28,6 @@ import { EmailAccount } from "@/types";
|
||||
import { emailIcon } from "./emailConfig";
|
||||
import EmailProviderIcon from "./EmailProviderIcon.vue";
|
||||
import { computed } from "vue";
|
||||
import { __ } from "@/translation";
|
||||
|
||||
interface P {
|
||||
emailAccount: EmailAccount;
|
||||
@@ -47,16 +44,16 @@ const badgeTitleColor = computed(() => {
|
||||
props.emailAccount.enable_incoming && props.emailAccount.enable_outgoing
|
||||
? "blue"
|
||||
: "gray";
|
||||
return [__("Default Sending and Inbox"), color];
|
||||
return ["Default Sending and Inbox", color];
|
||||
} else if (props.emailAccount.default_incoming) {
|
||||
const color = props.emailAccount.enable_incoming ? "blue" : "gray";
|
||||
return [__("Default Inbox"), color];
|
||||
return ["Default Inbox", color];
|
||||
} else if (props.emailAccount.default_outgoing) {
|
||||
const color = props.emailAccount.enable_outgoing ? "blue" : "gray";
|
||||
return [__("Default Sending"), color];
|
||||
return ["Default Sending", color];
|
||||
} else {
|
||||
const color = props.emailAccount.enable_incoming ? "blue" : "gray";
|
||||
return [__("Inbox"), color];
|
||||
return ["Inbox", color];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,68 +1,44 @@
|
||||
<template>
|
||||
<SettingsLayoutBase
|
||||
:title="__('Email Accounts')"
|
||||
:description="
|
||||
__(
|
||||
'Manage your email accounts and configure incoming and outgoing settings.'
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
:label="__('New')"
|
||||
theme="gray"
|
||||
class="rtl:flex-row-reverse"
|
||||
variant="solid"
|
||||
@click="emit('update:step', 'email-add')"
|
||||
icon-left="lucide-plus"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<!-- list accounts -->
|
||||
<div
|
||||
class="-ms-2 grow"
|
||||
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
|
||||
>
|
||||
<div class="flex text-sm text-ink-gray-5">
|
||||
<div class="ms-2">{{ __("Email account name") }}</div>
|
||||
</div>
|
||||
<hr class="mx-2 mt-2" />
|
||||
<div
|
||||
v-for="emailAccount in emailAccounts.data"
|
||||
:key="emailAccount.name"
|
||||
>
|
||||
<EmailAccountCard
|
||||
:emailAccount="emailAccount"
|
||||
@click="emit('update:step', 'email-edit', emailAccount)"
|
||||
/>
|
||||
<hr
|
||||
class="mx-2"
|
||||
v-if="
|
||||
emailAccount !==
|
||||
emailAccounts?.data[emailAccounts?.data?.length - 1]
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<!-- header -->
|
||||
<SettingsLayoutHeader
|
||||
title="Email Accounts"
|
||||
description="Manage your email accounts and configure incoming and outgoing settings."
|
||||
>
|
||||
<template #actions>
|
||||
<Button
|
||||
label="Add Account"
|
||||
theme="gray"
|
||||
variant="solid"
|
||||
@click="emit('update:step', 'email-add')"
|
||||
icon-left="plus"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayoutHeader>
|
||||
<!-- list accounts -->
|
||||
<div
|
||||
v-if="!emailAccounts.loading && Boolean(emailAccounts.data?.length)"
|
||||
class="mt-4 divide-y"
|
||||
>
|
||||
<div v-for="emailAccount in emailAccounts.data" :key="emailAccount.name">
|
||||
<EmailAccountCard
|
||||
:emailAccount="emailAccount"
|
||||
@click="emit('update:step', 'email-edit', emailAccount)"
|
||||
/>
|
||||
</div>
|
||||
<!-- fallback if no email accounts -->
|
||||
<EmptyState
|
||||
v-else-if="!emailAccounts.loading"
|
||||
variant="badge"
|
||||
:icon="EmailIcon"
|
||||
:title="__('No email account found')"
|
||||
:description="__('Add one to get started.')"
|
||||
/>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
</div>
|
||||
<!-- fallback if no email accounts -->
|
||||
<div v-else class="flex items-center justify-center h-64 text-gray-500">
|
||||
Please add an email account to continue.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import EmptyState from "@/components/EmptyState.vue";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { EmailAccount } from "@/types";
|
||||
import { createListResource } from "frappe-ui";
|
||||
import { EmailIcon } from "../icons";
|
||||
import EmailAccountCard from "./EmailAccountCard.vue";
|
||||
import SettingsLayoutHeader from "./SettingsLayoutHeader.vue";
|
||||
|
||||
const emit = defineEmits(["update:step"]);
|
||||
|
||||
|
||||
@@ -1,180 +1,106 @@
|
||||
<template>
|
||||
<SettingsLayoutBase
|
||||
:title="__('Setup Email')"
|
||||
:description="
|
||||
__('Choose the email service provider you want to configure.')
|
||||
"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex h-full flex-col gap-4">
|
||||
<div class="flex flex-col gap-4 overflow-y-auto p-0.5">
|
||||
<!-- email service provider selection -->
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div
|
||||
v-for="s in services"
|
||||
:key="s.name"
|
||||
class="min-w-3 flex flex-col items-center gap-1"
|
||||
@click="handleSelect(s)"
|
||||
>
|
||||
<EmailProviderIcon
|
||||
:service-name="s.name"
|
||||
:logo="s.icon"
|
||||
:selected="selectedService?.name === s?.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedService" class="flex flex-col gap-4">
|
||||
<!-- email service provider info -->
|
||||
<div>
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-5 p-2 ring-1 ring-outline-elevation-2"
|
||||
>
|
||||
<CircleAlert
|
||||
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-ink-blue-5"
|
||||
/>
|
||||
<div class="text-wrap text-xs text-ink-gray-7">
|
||||
{{ selectedService.info }}
|
||||
<a
|
||||
:href="selectedService.link"
|
||||
target="_blank"
|
||||
class="text-ink-blue-5 underline"
|
||||
>here</a
|
||||
>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- service provider fields -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<div
|
||||
v-if="field.name === 'domain'"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<Link
|
||||
v-model="customState.domain"
|
||||
:label="field.label"
|
||||
:placeholder="field.placeholder"
|
||||
doctype="Email Domain"
|
||||
:onCreate="handleCreateDomainClick"
|
||||
/>
|
||||
<div v-if="customState.domain" class="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
theme="gray"
|
||||
:label="__('Edit Domain')"
|
||||
@click="handleEditDomainClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<FormControl
|
||||
v-else
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isCustomSelected"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="field in customIncomingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="customState[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="field in customOutgoingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="customState[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in incomingOutgoingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
/>
|
||||
<p class="text-ink-gray-4 text-p-sm">
|
||||
{{ field.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="ms-1" :message="error" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- action button -->
|
||||
<div class="mt-auto flex justify-between -mb-8">
|
||||
<Button
|
||||
:label="__('Back')"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
:disabled="addEmailRes.loading"
|
||||
@click="emit('update:step', 'email-list')"
|
||||
/>
|
||||
<Button
|
||||
:label="__('Create')"
|
||||
variant="solid"
|
||||
:loading="addEmailRes.loading"
|
||||
@click="createEmailAccount"
|
||||
/>
|
||||
<div class="flex h-full flex-col gap-4">
|
||||
<!-- title and desc -->
|
||||
<div role="heading" aria-level="1" class="flex flex-col gap-1">
|
||||
<h5 class="text-lg font-semibold pt-[5px]">Setup Email</h5>
|
||||
<p class="text-sm text-gray-600">
|
||||
Choose the email service provider you want to configure.
|
||||
</p>
|
||||
</div>
|
||||
<!-- email service provider selection -->
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div
|
||||
v-for="s in services"
|
||||
:key="s.name"
|
||||
class="min-w-3 flex flex-col items-center gap-1"
|
||||
@click="handleSelect(s)"
|
||||
>
|
||||
<EmailProviderIcon
|
||||
:service-name="s.name"
|
||||
:logo="s.icon"
|
||||
:selected="selectedService?.name === s?.name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedService" class="flex flex-col gap-4">
|
||||
<!-- email service provider info -->
|
||||
<div class="flex items-center gap-2 rounded-md p-2 ring-1 ring-gray-200">
|
||||
<CircleAlert
|
||||
class="h-6 w-5 w-min-5 w-max-5 min-h-5 max-w-5 text-blue-500"
|
||||
/>
|
||||
<div class="text-wrap text-xs text-gray-700">
|
||||
{{ selectedService.info }}
|
||||
<a
|
||||
:href="selectedService.link"
|
||||
target="_blank"
|
||||
class="text-blue-500 underline"
|
||||
>here</a
|
||||
>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</SettingsLayoutBase>
|
||||
<!-- service provider fields -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in incomingOutgoingFields"
|
||||
:key="field.name"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<FormControl
|
||||
v-model="state[field.name]"
|
||||
:label="field.label"
|
||||
:name="field.name"
|
||||
:type="field.type"
|
||||
/>
|
||||
<p class="text-gray-500 text-p-sm">{{ field.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage v-if="error" class="ml-1" :message="error" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- action button -->
|
||||
<div v-if="selectedService" class="mt-auto flex justify-between">
|
||||
<Button
|
||||
label="Back"
|
||||
theme="gray"
|
||||
variant="outline"
|
||||
:disabled="addEmailRes.loading"
|
||||
@click="emit('update:step', 'email-list')"
|
||||
/>
|
||||
<Button
|
||||
label="Create"
|
||||
variant="solid"
|
||||
:loading="addEmailRes.loading"
|
||||
@click="createEmailAccount"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Link } from "@/components";
|
||||
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
|
||||
import { capture } from "@/telemetry";
|
||||
import { __ } from "@/translation";
|
||||
import { EmailAccount, EmailService, EmailStep } from "@/types";
|
||||
import { call, createResource, toast } from "frappe-ui";
|
||||
import { useOnboarding } from "@framework/ui";
|
||||
import { computed, reactive, Ref, ref, watch } from "vue";
|
||||
import { createResource, toast } from "frappe-ui";
|
||||
import { useOnboarding } from "frappe-ui/frappe";
|
||||
import { computed, Reactive, reactive, Ref, ref } from "vue";
|
||||
import CircleAlert from "~icons/lucide/circle-alert";
|
||||
import {
|
||||
customIncomingFields,
|
||||
customOutgoingFields,
|
||||
customProviderTopFields,
|
||||
frappeMailFields,
|
||||
customProviderFields,
|
||||
incomingOutgoingFields,
|
||||
popularProviderFields,
|
||||
services,
|
||||
@@ -182,49 +108,15 @@ import {
|
||||
} from "./emailConfig";
|
||||
import EmailProviderIcon from "./EmailProviderIcon.vue";
|
||||
|
||||
interface EmailAccountBaseState {
|
||||
email_account_name: string;
|
||||
email_id: string;
|
||||
service: string;
|
||||
enable_incoming: boolean;
|
||||
enable_outgoing: boolean;
|
||||
default_incoming: boolean;
|
||||
default_outgoing: boolean;
|
||||
}
|
||||
|
||||
interface EmailAccountProviderAuthState extends EmailAccountBaseState {
|
||||
password?: string;
|
||||
api_key?: string;
|
||||
api_secret?: string;
|
||||
frappe_mail_site?: string;
|
||||
}
|
||||
|
||||
type CustomEmailAccountState = {
|
||||
domain?: string;
|
||||
email_server?: string;
|
||||
incoming_port?: string | number;
|
||||
smtp_server?: string;
|
||||
smtp_port?: string | number;
|
||||
use_ssl?: boolean | number;
|
||||
use_starttls?: boolean | number;
|
||||
use_tls?: boolean | number;
|
||||
use_ssl_for_outgoing?: boolean | number;
|
||||
validate_ssl_certificate?: boolean | number;
|
||||
validate_ssl_certificate_for_outgoing?: boolean | number;
|
||||
attachment_limit?: string | number;
|
||||
append_emails_to_sent_folder?: boolean | number;
|
||||
sent_folder_name?: string;
|
||||
};
|
||||
|
||||
interface E {
|
||||
(event: "update:step", value: EmailStep): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<E>();
|
||||
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk") ?? {};
|
||||
const { updateOnboardingStep } = useOnboarding("helpdesk");
|
||||
|
||||
const state = reactive<EmailAccountProviderAuthState>({
|
||||
const state: Reactive<EmailAccount> = reactive({
|
||||
service: "",
|
||||
email_account_name: "",
|
||||
email_id: "",
|
||||
@@ -238,174 +130,39 @@ const state = reactive<EmailAccountProviderAuthState>({
|
||||
default_outgoing: false,
|
||||
});
|
||||
|
||||
const getDefaultCustomState = (): CustomEmailAccountState => ({
|
||||
domain: "",
|
||||
email_server: "",
|
||||
smtp_server: "",
|
||||
incoming_port: "",
|
||||
smtp_port: "",
|
||||
use_ssl: false,
|
||||
use_starttls: false,
|
||||
use_tls: false,
|
||||
use_ssl_for_outgoing: false,
|
||||
validate_ssl_certificate: true,
|
||||
validate_ssl_certificate_for_outgoing: true,
|
||||
attachment_limit: "",
|
||||
append_emails_to_sent_folder: false,
|
||||
sent_folder_name: "",
|
||||
});
|
||||
|
||||
const customState = reactive<CustomEmailAccountState>(getDefaultCustomState());
|
||||
|
||||
function resetCustomState() {
|
||||
Object.assign(customState, getDefaultCustomState());
|
||||
}
|
||||
|
||||
const selectedService: Ref<EmailService> = ref(null);
|
||||
const fields = computed(() => {
|
||||
if (!selectedService.value) return [];
|
||||
if (selectedService.value.name === "Frappe Mail") {
|
||||
return frappeMailFields;
|
||||
}
|
||||
if (selectedService.value.custom) {
|
||||
return customProviderTopFields;
|
||||
}
|
||||
return popularProviderFields;
|
||||
});
|
||||
|
||||
const isCustomSelected = computed(
|
||||
() => selectedService.value?.name === "Custom"
|
||||
);
|
||||
|
||||
watch(
|
||||
() => customState.domain,
|
||||
async (val) => {
|
||||
if (state.service !== "Custom") return;
|
||||
if (!isCustomSelected.value || !val) return;
|
||||
try {
|
||||
const doc = await call("frappe.client.get", {
|
||||
doctype: "Email Domain",
|
||||
name: val,
|
||||
});
|
||||
syncAccountFieldsFromDomain(doc);
|
||||
} catch (err) {
|
||||
console.warn("Failed to load Email Domain", err);
|
||||
}
|
||||
}
|
||||
const fields = computed(() =>
|
||||
selectedService.value.custom ? customProviderFields : popularProviderFields
|
||||
);
|
||||
|
||||
function handleSelect(service: EmailService) {
|
||||
selectedService.value = service;
|
||||
state.service = service.name;
|
||||
if (service.name !== "Custom") {
|
||||
resetCustomState();
|
||||
}
|
||||
}
|
||||
|
||||
const addEmailRes = createResource({
|
||||
url: "helpdesk.api.settings.email.create_email_account",
|
||||
makeParams: (val: EmailAccount) => ({ ...val }),
|
||||
makeParams: (val: EmailAccount) => {
|
||||
return {
|
||||
...val,
|
||||
};
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(__("Email account created"));
|
||||
toast.success("Email account created");
|
||||
emit("update:step", "email-list");
|
||||
updateOnboardingStep?.("setup_email_account");
|
||||
capture("email_account_created", { data: { service: state.service } });
|
||||
updateOnboardingStep("setup_email_account");
|
||||
},
|
||||
onError: () => {
|
||||
error.value = __("Failed to create email account, Invalid credentials");
|
||||
error.value = "Failed to create email account, Invalid credentials";
|
||||
},
|
||||
});
|
||||
|
||||
const error = ref<string | undefined>();
|
||||
function createEmailAccount() {
|
||||
const validationState = { ...state, ...customState };
|
||||
error.value = validateInputs(validationState, state.service);
|
||||
error.value = validateInputs(state, selectedService.value.custom);
|
||||
if (error.value) return;
|
||||
|
||||
addEmailRes.submit({ data: buildCreatePayload() });
|
||||
}
|
||||
|
||||
function buildCreatePayload() {
|
||||
const commonPayload = {
|
||||
email_account_name: state.email_account_name,
|
||||
email_id: state.email_id,
|
||||
service: state.service,
|
||||
enable_incoming: state.enable_incoming,
|
||||
enable_outgoing: state.enable_outgoing,
|
||||
default_incoming: state.default_incoming,
|
||||
default_outgoing: state.default_outgoing,
|
||||
};
|
||||
|
||||
if (state.service === "Frappe Mail") {
|
||||
return {
|
||||
...commonPayload,
|
||||
frappe_mail_site: state.frappe_mail_site,
|
||||
api_key: state.api_key,
|
||||
api_secret: state.api_secret,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.service === "Custom") {
|
||||
return {
|
||||
...commonPayload,
|
||||
password: state.password,
|
||||
...customState,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...commonPayload,
|
||||
password: state.password,
|
||||
};
|
||||
}
|
||||
|
||||
function syncAccountFieldsFromDomain(payload: Record<string, any>) {
|
||||
customState.email_server = payload.email_server ?? customState.email_server;
|
||||
customState.smtp_server = payload.smtp_server ?? customState.smtp_server;
|
||||
customState.incoming_port =
|
||||
payload.incoming_port ?? customState.incoming_port;
|
||||
customState.smtp_port = payload.smtp_port ?? customState.smtp_port;
|
||||
customState.use_ssl = Boolean(payload.use_ssl ?? customState.use_ssl);
|
||||
customState.use_starttls = Boolean(
|
||||
payload.use_starttls ?? customState.use_starttls
|
||||
);
|
||||
customState.use_tls = Boolean(payload.use_tls ?? customState.use_tls);
|
||||
customState.use_ssl_for_outgoing = Boolean(
|
||||
payload.use_ssl_for_outgoing ?? customState.use_ssl_for_outgoing
|
||||
);
|
||||
customState.validate_ssl_certificate = Boolean(
|
||||
payload.validate_ssl_certificate ?? customState.validate_ssl_certificate
|
||||
);
|
||||
customState.validate_ssl_certificate_for_outgoing = Boolean(
|
||||
payload.validate_ssl_certificate_for_outgoing ??
|
||||
customState.validate_ssl_certificate_for_outgoing
|
||||
);
|
||||
customState.append_emails_to_sent_folder = Boolean(
|
||||
payload.append_emails_to_sent_folder ??
|
||||
customState.append_emails_to_sent_folder
|
||||
);
|
||||
customState.sent_folder_name =
|
||||
payload.sent_folder_name || customState.sent_folder_name;
|
||||
customState.attachment_limit =
|
||||
payload.attachment_limit || customState.attachment_limit;
|
||||
}
|
||||
|
||||
function handleCreateDomainClick(value: any, close?: () => void) {
|
||||
close?.();
|
||||
window.open(
|
||||
"/desk/email-domain/new-email-domain",
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
}
|
||||
|
||||
function handleEditDomainClick() {
|
||||
if (!customState.domain) return;
|
||||
window.open(
|
||||
`/desk/email-domain/${encodeURIComponent(customState.domain)}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
addEmailRes.submit({ data: state });
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user