first load
Some checks failed
Build, Push, Publish / Build & Release (push) Failing after 2s
Sync Repo / sync (push) Failing after 2s

This commit is contained in:
2025-12-16 04:40:00 -03:00
parent 9f33a94e0e
commit 6fa41a771d
856 changed files with 70411 additions and 1 deletions

23
.a11yrc.json.example Normal file
View File

@@ -0,0 +1,23 @@
{
"subdomain": "",
"username": "",
"password": "",
"urls": [
"https://***.zendesk.com/hc/en-us",
"https://***.zendesk.com/hc/en-us/categories/:id",
"https://***.zendesk.com/hc/en-us/sections/:id",
"https://***.zendesk.com/hc/en-us/articles/:id",
"https://***.zendesk.com/hc/en-us/requests/new",
"https://***.zendesk.com/hc/en-us/search?utf8=%E2%9C%93&query=Help+Center",
"https://***.zendesk.com/hc/en-us/community/topics",
"https://***.zendesk.com/hc/en-us/community/topics/:id",
"https://***.zendesk.com/hc/en-us/community/posts",
"https://***.zendesk.com/hc/en-us/community/posts/:id",
"https://***.zendesk.com/hc/en-us/profiles/:id",
"https://***.zendesk.com/hc/contributions/posts?locale=en-us",
"https://***.zendesk.com/hc/en-us/subscriptions",
"https://***.zendesk.com/hc/en-us/requests",
"https://***.zendesk.com/hc/en-us/requests/:id",
"https://***.zendesk.com/hc/en-us/community/posts/new"
]
}

63
.eslintrc.js Normal file
View File

@@ -0,0 +1,63 @@
/* eslint-env node */
const PASCAL_CASE = "*([A-Z]*([a-z0-9]))";
const CAMEL_CASE = "+([a-z])*([a-z0-9])*([A-Z]*([a-z0-9]))";
module.exports = {
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/typescript",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:react-hooks/recommended",
"plugin:prettier/recommended",
],
env: {
browser: true,
},
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint", "check-file", "@shopify"],
root: true,
settings: {
"import/resolver": {
typescript: true,
node: true,
},
react: {
version: "detect",
},
},
ignorePatterns: ["assets/**"],
rules: {
"@typescript-eslint/consistent-type-imports": "error",
"check-file/folder-naming-convention": [
"error",
{ "src/**/": "KEBAB_CASE" },
],
"check-file/filename-naming-convention": [
"error",
{
"src/**/*.{js,ts,tsx}": `@(${CAMEL_CASE}|${PASCAL_CASE})`,
},
{
ignoreMiddleExtensions: true,
},
],
"@shopify/jsx-no-hardcoded-content": [
"warn",
{
checkProps: ["title", "aria-label"],
modules: {
"@zendeskgarden/react-tooltips": {
Tooltip: { checkProps: ["content"] },
},
"react-i18next": {
Trans: { allowStrings: true },
},
},
},
],
},
};

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
script.js linguist-generated
style.css linguist-generated
assets/** linguist-generated

240
.github/workflows/release_build.yml vendored Normal file
View File

@@ -0,0 +1,240 @@
name: Build, Push, Publish
on:
push:
branches:
- main
workflow_dispatch:
schedule:
- cron: '28 5 * * *'
workflow_run:
workflows: ["Sync Repo"]
types:
- completed
jobs:
release:
name: Build & Release
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: 📥 Checkout code with full history and tags
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if any tags exist
id: check_tags_exist
run: |
git fetch --tags
TAG_COUNT=$(git tag | wc -l)
if [ "$TAG_COUNT" -eq 0 ]; then
echo "has_tags=false" >> "$GITHUB_OUTPUT"
echo "latest_tag=v0.0.0" >> "$GITHUB_OUTPUT"
else
echo "has_tags=true" >> "$GITHUB_OUTPUT"
LATEST_TAG=$(git describe --tags --abbrev=0)
echo "latest_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
fi
- name: Check if meaningful commits exist since latest tag
id: check_commits
run: |
if [ "${{ steps.check_tags_exist.outputs.has_tags }}" = "false" ]; then
# No tags exist, so we should create first release
echo "commit_count=1" >> "$GITHUB_OUTPUT"
CHANGED_FILES=$(git ls-files | grep -v '^manifest.json$' || true)
if [ -n "$CHANGED_FILES" ]; then
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
printf '%s\n' "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
else
echo "changed_files=Initial release" >> "$GITHUB_OUTPUT"
fi
else
LATEST_TAG="${{ steps.check_tags_exist.outputs.latest_tag }}"
CHANGED_FILES="$(git diff --name-only "${LATEST_TAG}..HEAD" | grep -v '^manifest.json$' || true)"
if [ -n "$CHANGED_FILES" ]; then
echo "commit_count=1" >> "$GITHUB_OUTPUT"
echo "changed_files<<EOF" >> "$GITHUB_OUTPUT"
printf '%s\n' "$CHANGED_FILES" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
else
echo "commit_count=0" >> "$GITHUB_OUTPUT"
fi
fi
- name: Get latest release tag (from GitHub API)
id: get_latest_release
run: |
LATEST_RELEASE_TAG=$(curl -sL -H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest" | jq -r .tag_name)
if [ -z "$LATEST_RELEASE_TAG" ] || [ "$LATEST_RELEASE_TAG" = "null" ]; then
LATEST_RELEASE_TAG="v1.0.0"
fi
echo "latest_release_tag=$LATEST_RELEASE_TAG" >> "$GITHUB_OUTPUT"
echo "latest_release_version=${LATEST_RELEASE_TAG#v}" >> "$GITHUB_OUTPUT"
# -------------------------------
# Sync manifest.json to last release version if behind (only when no meaningful commits)
# -------------------------------
- name: 🛠 Ensure manifest.json matches latest release version
if: steps.check_commits.outputs.commit_count == '0'
run: |
if [ -f manifest.json ]; then
MANIFEST_VERSION=$(jq -r '.version // empty' manifest.json)
else
MANIFEST_VERSION=""
fi
LATEST_RELEASE_VERSION="${{ steps.get_latest_release.outputs.latest_release_version }}"
PYTHON_CODE="from packaging import version; \
print(version.parse('$LATEST_RELEASE_VERSION') > version.parse('$MANIFEST_VERSION') if '$MANIFEST_VERSION' else True)"
NEED_UPDATE=$(python3 -c "$PYTHON_CODE")
if [ "$NEED_UPDATE" = "True" ]; then
echo "Updating manifest.json to version $LATEST_RELEASE_VERSION (sync with release)"
jq --arg v "$LATEST_RELEASE_VERSION" '.version = $v' manifest.json > tmp.json && mv tmp.json manifest.json
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add manifest.json
git commit -m "Sync manifest.json to release $LATEST_RELEASE_VERSION [🔄]" || echo "Nothing to commit"
git push origin main || true
else
echo "Manifest.json is already up-to-date with the latest release."
fi
# -------------------------------
# Continue normal workflow if commits exist
# -------------------------------
- name: 📃 Get list of changed files (Markdown bullet list)
if: steps.check_commits.outputs.commit_count != '0'
id: changed_files
run: |
BULLET_LIST="$(printf '%s\n' "${{ steps.check_commits.outputs.changed_files }}" | sed 's/^/- /')"
echo "CHANGED<<EOF" >> "$GITHUB_OUTPUT"
printf '%s\n' "$BULLET_LIST" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
COUNT="$(printf '%s\n' "${{ steps.check_commits.outputs.changed_files }}" | wc -l)"
echo "COUNT=$COUNT" >> "$GITHUB_OUTPUT"
- name: Get manifest version
if: steps.check_commits.outputs.commit_count != '0'
id: get_manifest_version
run: |
if [ -f manifest.json ]; then
MANIFEST_VERSION=$(jq -r '.version // empty' manifest.json)
if [ -z "$MANIFEST_VERSION" ] || [ "$MANIFEST_VERSION" = "null" ]; then
MANIFEST_VERSION="1.0.0"
fi
else
MANIFEST_VERSION="1.0.0"
fi
echo "manifest_version=$MANIFEST_VERSION" >> "$GITHUB_OUTPUT"
- name: Pick base version
if: steps.check_commits.outputs.commit_count != '0'
id: pick_base_version
run: |
LATEST_RELEASE="${{ steps.get_latest_release.outputs.latest_release_version }}"
MANIFEST="${{ steps.get_manifest_version.outputs.manifest_version }}"
BASE_VERSION=$(python3 -c "from packaging import version; \
print(str(max(version.parse('$LATEST_RELEASE'), version.parse('$MANIFEST'))))")
echo "base_version=$BASE_VERSION" >> "$GITHUB_OUTPUT"
- name: 🔢 Determine version
if: steps.check_commits.outputs.commit_count != '0'
id: version
run: |
BASE_VERSION="${{ steps.pick_base_version.outputs.base_version }}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE_VERSION"
COUNT="${{ steps.changed_files.outputs.COUNT }}"
if [ "$COUNT" -ge 5 ]; then
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif [ "$COUNT" -ge 3 ]; then
MINOR=$((MINOR + 1))
PATCH=0
else
PATCH=$((PATCH + 1))
fi
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
REPO_NAME="$(basename "$GITHUB_REPOSITORY")"
ZIP_NAME="${REPO_NAME}-${NEW_VERSION}.zip"
echo "VERSION=$NEW_VERSION" >> "$GITHUB_OUTPUT"
echo "ZIP_NAME=$ZIP_NAME" >> "$GITHUB_OUTPUT"
echo "REPO_NAME=$REPO_NAME" >> "$GITHUB_OUTPUT"
- name: 🛠 Update or create manifest.json
if: steps.check_commits.outputs.commit_count != '0'
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
AUTHOR="Ivan Carlos"
VERSION_FILE="manifest.json"
if [ -f "$VERSION_FILE" ]; then
jq --arg v "$VERSION" --arg a "$AUTHOR" \
'.version = $v | .author = $a' "$VERSION_FILE" > tmp.json && mv tmp.json "$VERSION_FILE"
else
echo "{ \"version\": \"$VERSION\", \"author\": \"$AUTHOR\" }" > "$VERSION_FILE"
fi
- name: 💾 Commit and push updated manifest.json
if: steps.check_commits.outputs.commit_count != '0'
run: |
git config user.name "github-actions"
git config user.email "github-actions@github.com"
git add manifest.json
git commit -m "Update manifest version to ${{ steps.version.outputs.VERSION }} [▶️]" || echo "Nothing to commit"
git push origin main
- name: 📦 Create ZIP package (excluding certain files)
if: steps.check_commits.outputs.commit_count != '0'
run: |
ZIP_NAME="${{ steps.version.outputs.ZIP_NAME }}"
zip -r "$ZIP_NAME" . -x ".git/*" ".github/*" "docker/*" ".dockerignore" "CNAME" "Dockerfile" "README.md" "LICENSE"
- name: 🚀 Create GitHub Release
if: steps.check_commits.outputs.commit_count != '0'
uses: softprops/action-gh-release@v2
with:
tag_name: "v${{ steps.version.outputs.VERSION }}"
name: "${{ steps.version.outputs.REPO_NAME }} v${{ steps.version.outputs.VERSION }}"
body: |
### Changelog
Files changed in this release:
${{ steps.changed_files.outputs.CHANGED }}
files: ${{ steps.version.outputs.ZIP_NAME }}
# ----- Docker steps -----
- name: 🔍 Check if Dockerfile exists
if: steps.check_commits.outputs.commit_count != '0'
id: dockerfile_check
run: |
if [ -f Dockerfile ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: 🛠 Set up Docker Buildx
if: steps.check_commits.outputs.commit_count != '0' && steps.dockerfile_check.outputs.exists == 'true'
uses: docker/setup-buildx-action@v3
- name: 🔐 Login to GitHub Container Registry
if: steps.check_commits.outputs.commit_count != '0' && steps.dockerfile_check.outputs.exists == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: 🐳 Build and Push Docker image
if: steps.check_commits.outputs.commit_count != '0' && steps.dockerfile_check.outputs.exists == 'true'
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest

128
.github/workflows/sync_repo.yml vendored Normal file
View File

@@ -0,0 +1,128 @@
name: Sync Repo
on:
workflow_dispatch:
schedule:
- cron: '38 */12 * * *'
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
actions: write # needed to call other workflows
steps:
- name: Checkout your repository
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: main
repository: ivancarlosti/copenlight
clean: true
persist-credentials: true
- name: Reset Git remote
run: |
git remote remove origin || true
git remote add origin https://github.com/ivancarlosti/copenlight.git
git remote -v
- name: Download CopenhagenTheme content (from master)
run: |
mkdir -p /tmp/copenhagen_temp
cd /tmp/copenhagen_temp
git clone --branch master https://github.com/zendesk/copenhagen_theme.git
echo "== Checking manifest.json version =="
MANIFEST="/tmp/copenhagen_temp/copenhagen_theme/manifest.json"
if [ ! -f "$MANIFEST" ]; then
echo "No manifest.json file found, aborting sync."
exit 0
fi
cat "$MANIFEST"
VERSION=$(jq -r '.version // empty' "$MANIFEST")
if [ -z "$VERSION" ]; then
echo "No version defined in manifest.json, aborting sync."
exit 0
fi
if echo "$VERSION" | grep -i 'beta'; then
echo "Version is beta ($VERSION), aborting sync."
exit 0
fi
echo "Version is $VERSION. Proceeding with sync."
rsync -av \
--exclude='.github' \
--exclude='.git' \
--exclude='README.md' \
--exclude='LICENSE' \
/tmp/copenhagen_temp/copenhagen_theme/ "$GITHUB_WORKSPACE/"
- name: Debug manifest.json in workspace
run: |
echo "= manifest.json in your repo after sync ="
cat "$GITHUB_WORKSPACE/manifest.json" || echo "manifest.json missing!"
- name: Modify manifest.json
run: |
echo "== Updating manifest.json with custom name and author =="
MANIFEST="$GITHUB_WORKSPACE/manifest.json"
if [ -f "$MANIFEST" ]; then
jq '.name = "CopenLight" | .author = "Ivan Carlos"' "$MANIFEST" > "$MANIFEST.tmp" && mv "$MANIFEST.tmp" "$MANIFEST"
echo "Updated manifest.json:"
cat "$MANIFEST"
else
echo "manifest.json not found!"
exit 1
fi
- name: Append custom CSS from template
run: |
STYLE_CSS="$GITHUB_WORKSPACE/style.css"
CUSTOM_CSS="$GITHUB_WORKSPACE/custom/style.css.template"
if [ -f "$STYLE_CSS" ] && [ -f "$CUSTOM_CSS" ]; then
echo "== Appending custom CSS from template to style.css =="
echo "" >> "$STYLE_CSS"
echo "/* ### BEGIN part to custom style from template ### */" >> "$STYLE_CSS"
cat "$CUSTOM_CSS" >> "$STYLE_CSS"
echo "/* ### END part to custom style from template ### */" >> "$STYLE_CSS"
echo "Custom CSS appended successfully."
else
echo "Either style.css or style.css.template not found!"
exit 1
fi
- name: Append custom JS from template
run: |
SCRIPT_JS="$GITHUB_WORKSPACE/script.js"
CUSTOM_JS="$GITHUB_WORKSPACE/custom/script.js.template"
if [ -f "$SCRIPT_JS" ] && [ -f "$CUSTOM_JS" ]; then
echo "== Appending custom JS from template to script.js =="
echo "" >> "$SCRIPT_JS"
echo "/* ### BEGIN part to custom JS from template ### */" >> "$SCRIPT_JS"
cat "$CUSTOM_JS" >> "$SCRIPT_JS"
echo "/* ### END part to custom JS from template ### */" >> "$SCRIPT_JS"
echo "Custom JS appended successfully."
else
echo "Either script.js or script.js.template not found!"
exit 1
fi
- name: Cleanup temp files
run: rm -rf /tmp/copenhagen_temp
- name: Commit changes
id: commit_step
run: |
cd "$GITHUB_WORKSPACE"
git config --global user.email "ivan@ivancarlos.com.br"
git config --global user.name "ivancarlosti"
git add .
if git diff-index --quiet HEAD --; then
echo "No changes to commit"
echo "changes_committed=false" >> $GITHUB_OUTPUT
else
git commit -m "Sync CopenhagenTheme content [▶️]"
echo "Commit created"
git push https://${{ github.actor }}:${{ secrets.GITHUB_TOKEN }}@github.com/ivancarlosti/copenlight.git HEAD:main
echo "Push successful"
echo "changes_committed=true" >> $GITHUB_OUTPUT
fi

78
.github/workflows/update_readme.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Update README
# Allow GitHub Actions to commit and push changes
permissions:
contents: write
on:
workflow_dispatch:
schedule:
- cron: '0 4 * * *' # Every day at 4 AM UTC
jobs:
update-readme:
runs-on: ubuntu-latest
env:
SOURCE_REPO: ivancarlosti/.github
SOURCE_BRANCH: main
steps:
- name: Checkout current repository
uses: actions/checkout@v4
- name: Checkout source README template
uses: actions/checkout@v4
with:
repository: ${{ env.SOURCE_REPO }}
ref: ${{ env.SOURCE_BRANCH }}
path: source_readme
- name: Update README.md (buttons and footer)
run: |
set -e
REPO_NAME="${GITHUB_REPOSITORY##*/}"
# --- Extract buttons block from source ---
BUTTONS=$(awk '/<!-- buttons -->/{flag=1;next}/<!-- endbuttons -->/{flag=0}flag' source_readme/README.md)
BUTTONS_UPDATED=$(echo "$BUTTONS" | sed "s/\.github/${REPO_NAME}/g")
# --- Extract footer block from source (everything from <!-- footer --> onward) ---
FOOTER=$(awk '/<!-- footer -->/{flag=1}flag' source_readme/README.md)
# --- Replace buttons section in README.md ---
UPDATED=$(awk -v buttons="$BUTTONS_UPDATED" '
BEGIN { skip=0 }
/<!-- buttons -->/ {
print
print buttons
skip=1
next
}
/<!-- endbuttons -->/ && skip {
print
skip=0
next
}
!skip { print }
' README.md)
# --- Replace everything after <!-- footer --> with FOOTER ---
echo "$UPDATED" | awk -v footer="$FOOTER" '
/<!-- footer -->/ {
print footer
found=1
exit
}
{ print }
' > README.tmp && mv README.tmp README.md
- name: Remove source_readme from git index
run: git rm --cached -r source_readme || true
- name: Commit and push changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
file_pattern: README.md
commit_message: "Sync README from template [▶️]"
branch: ${{ github.ref_name }}

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
.sass-cache/
style.css.map
node_modules/
.a11yrc.json
*.DS_Store
coverage/
.idea/
# yarn
.yarn/*
!.yarn/cache
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions

4
.husky/commit-msg Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no -- commitlint --edit ${1}

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
@zendesk:registry=https://registry.npmjs.org/

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v22.15.1

21
.releaserc Normal file
View File

@@ -0,0 +1,21 @@
{
"branches": [
"master",
{
"name": "beta", "prerelease": true
}
],
"plugins": [
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/changelog",
["@semantic-release/exec", {
"prepareCmd": "./bin/update-manifest-version.sh ${nextRelease.version}"
}],
["@semantic-release/git", {
"assets": ["manifest.json", "script.js", "style.css", "assets", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}"
}],
"@semantic-release/github"
]
}

942
.yarn/releases/yarn-4.10.3.cjs vendored Normal file

File diff suppressed because one or more lines are too long

5
.yarnrc.yml Normal file
View File

@@ -0,0 +1,5 @@
enableTelemetry: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.10.3.cjs

1617
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Ivan Carlos de Almeida
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,2 +1,53 @@
# copenlight
# CopenLight theme by Ivan Carlos
The Copenhagen theme is the default Zendesk Guide theme. This fork is built to allow insertion of code on style.css, script.js files preserving Copenhagen updates.
<!-- buttons -->
[![Stars](https://img.shields.io/github/stars/ivancarlosti/copenlight?label=⭐%20Stars&color=gold&style=flat)](https://github.com/ivancarlosti/copenlight/stargazers)
[![Watchers](https://img.shields.io/github/watchers/ivancarlosti/copenlight?label=Watchers&style=flat&color=red)](https://github.com/sponsors/ivancarlosti)
[![Forks](https://img.shields.io/github/forks/ivancarlosti/copenlight?label=Forks&style=flat&color=ff69b4)](https://github.com/sponsors/ivancarlosti)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/ivancarlosti/copenlight?label=Activity)](https://github.com/ivancarlosti/copenlight/pulse)
[![GitHub Issues](https://img.shields.io/github/issues/ivancarlosti/copenlight?label=Issues&color=orange)](https://github.com/ivancarlosti/copenlight/issues)
[![License](https://img.shields.io/github/license/ivancarlosti/copenlight?label=License)](LICENSE)
[![GitHub last commit](https://img.shields.io/github/last-commit/ivancarlosti/copenlight?label=Last%20Commit)](https://github.com/ivancarlosti/copenlight/commits)
[![Security](https://img.shields.io/badge/Security-View%20Here-purple)](https://github.com/ivancarlosti/copenlight/security)
[![Code of Conduct](https://img.shields.io/badge/Code%20of%20Conduct-2.1-4baaaa)](https://github.com/ivancarlosti/copenlight?tab=coc-ov-file)
[![GitHub Sponsors](https://img.shields.io/github/sponsors/ivancarlosti?label=GitHub%20Sponsors&color=ffc0cb)][sponsor]
<!-- endbuttons -->
## Instructions
* Fork this repository
* Edit files `/custom/script.js.template`, `/custom/style.css.template`
* Run action `Sync CopenhagenTheme Repository` to pull updated Copenhagen files and add content of `/custom/script.js.template`, `/custom/style.css.template` on related `script.js`, `style.css` files
* (optional) Run action `Build, Push, Publish` to create an updated release of your repository
* Add or update the theme from your repository to Zendesk on `yourzendeskdomain/theming/workbench`
## Notes
* This repository have some customizations for better KB viewing
* Please share your progress with the community
<!-- footer -->
---
## 🧑‍💻 Consulting and technical support
* For personal support and queries, please submit a new issue to have it addressed.
* For commercial related questions, please [**contact me**][ivancarlos] for consulting costs.
## 🩷 Project support
| If you found this project helpful, consider |
| :---: |
[**buying me a coffee**][buymeacoffee], [**donate by paypal**][paypal], [**sponsor this project**][sponsor] or just [**leave a star**](../..)⭐
|Thanks for your support, it is much appreciated!|
[cc]: https://docs.github.com/en/communities/setting-up-your-project-for-healthy-contributions/adding-a-code-of-conduct-to-your-project
[contributing]: https://docs.github.com/en/articles/setting-guidelines-for-repository-contributors
[security]: https://docs.github.com/en/code-security/getting-started/adding-a-security-policy-to-your-repository
[support]: https://docs.github.com/en/articles/adding-support-resources-to-your-project
[it]: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository#configuring-the-template-chooser
[prt]: https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/creating-a-pull-request-template-for-your-repository
[funding]: https://docs.github.com/en/articles/displaying-a-sponsor-button-in-your-repository
[ivancarlos]: https://ivancarlos.it
[buymeacoffee]: https://www.buymeacoffee.com/ivancarlos
[paypal]: https://icc.gg/donate
[sponsor]: https://github.com/sponsors/ivancarlosti

0
assets/.gitkeep generated Normal file
View File

269
assets/approval-requests-bundle.js generated Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2
assets/es-module-shims.js generated Normal file

File diff suppressed because one or more lines are too long

1
assets/flash-notifications-bundle.js generated Normal file

File diff suppressed because one or more lines are too long

111
assets/new-request-form-bundle.js generated Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

198
assets/service-catalog-bundle.js generated Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

65
assets/shared-bundle.js generated Normal file

File diff suppressed because one or more lines are too long

12
assets/ticket-fields-bundle.js generated Normal file

File diff suppressed because one or more lines are too long

1
assets/wysiwyg-bundle.js generated Normal file

File diff suppressed because one or more lines are too long

256
bin/extract-strings.mjs Normal file
View File

@@ -0,0 +1,256 @@
/* eslint-env node */
/**
* This script is used for the internal Zendesk translation system, and it creates or updates the source YAML file for translations,
* extracting the strings from the source code.
*
* It searches for i18next calls (`{t("my-key", "My value")}`) and it adds the strings to the specified YAML file
* (if not already present) with empty title and screenshot.
* ```yaml
* - translation:
* key: "my-key"
* title: ""
* screenshot: ""
* value: "My value"
* ```
*
* If a string present in the source YAML file is not found in the source code, it will be marked as obsolete if the
* `--mark-obsolete` flag is passed.
*
* If the value in the YAML file differs from the value in the source code, a warning will be printed in the console,
* since the script cannot know which one is correct and cannot write back in the source code files. This can happen for
* example after a "reverse string sweep", and can be eventually fixed manually.
*
* The script uses the i18next-parser library for extracting the strings and it adds a custom transformer for creating
* the file in the required YAML format.
*
* For usage instructions, run `node extract-strings.mjs --help`
*/
import vfs from "vinyl-fs";
import Vinyl from "vinyl";
import { transform as I18NextTransform } from "i18next-parser";
import { Transform } from "node:stream";
import { readFileSync } from "node:fs";
import { load, dump } from "js-yaml";
import { resolve } from "node:path";
import { glob } from "glob";
import { parseArgs } from "node:util";
const { values: args } = parseArgs({
options: {
"mark-obsolete": {
type: "boolean",
},
module: {
type: "string",
},
help: {
type: "boolean",
},
},
});
if (args.help) {
const helpMessage = `
Usage: extract-strings.mjs [options]
Options:
--mark-obsolete Mark removed strings as obsolete in the source YAML file
--module Extract strings only for the specified module. The module name should match the folder name in the src/modules folder
If not specified, the script will extract strings for all modules
--help Display this help message
Examples:
node extract-strings.mjs
node extract-strings.mjs --mark-obsolete
node extract-strings.mjs --module=ticket-fields
`;
console.log(helpMessage);
process.exit(0);
}
const OUTPUT_YML_FILE_NAME = "en-us.yml";
const OUTPUT_BANNER = `#
# This file is used for the internal Zendesk translation system, and it is generated from the extract-strings.mjs script.
# It contains the English strings to be translated, which are used for generating the JSON files containing the translation strings
# for each language.
#
# If you are building your own theme, you can remove this file and just load the translation JSON files, or provide
# your translations with a different method.
#
`;
/** @type {import("i18next-parser").UserConfig} */
const config = {
// Our translation system requires that we add all 6 forms (zero, one, two, few, many, other) keys for plurals
// i18next-parser extracts plural keys based on the target locale, so we are passing a
// locale that need exactly the 6 forms, even if we are extracting English strings
locales: ["ar"],
keySeparator: false,
namespaceSeparator: false,
pluralSeparator: ".",
// This path is used only as a Virtual FS path by i18next-parser, and it doesn't get written on the FS
output: "locales/en.json",
};
class SourceYmlTransform extends Transform {
#parsedInitialContent;
#outputDir;
#counters = {
added: 0,
obsolete: 0,
mismatch: 0,
};
constructor(outputDir) {
super({ objectMode: true });
this.#outputDir = outputDir;
this.#parsedInitialContent = this.#getSourceYmlContent();
}
_transform(file, encoding, done) {
try {
const strings = JSON.parse(file.contents.toString(encoding));
const outputContent = {
...this.#parsedInitialContent,
parts: this.#parsedInitialContent.parts || [],
};
// Find obsolete keys
for (const { translation } of outputContent.parts) {
if (!(translation.key in strings) && !translation.obsolete) {
this.#counters.obsolete++;
if (args["mark-obsolete"]) {
translation.obsolete = this.#getObsoleteDate();
}
}
}
// Add new keys to the source YAML or log mismatched value
for (let [key, value] of Object.entries(strings)) {
value = this.#fixPluralValue(key, value, strings);
const existingPart = outputContent.parts.find(
(part) => part.translation.key === key
);
if (existingPart === undefined) {
outputContent.parts.push({
translation: {
key,
title: "",
screenshot: "",
value,
},
});
this.#counters.added++;
} else if (value !== existingPart.translation.value) {
console.warn(
`\nFound a mismatch value for the key "${key}".\n\tSource code value: ${value}\n\tTranslation file value: ${existingPart.translation.value}`
);
this.#counters.mismatch++;
}
}
const virtualFile = new Vinyl({
path: OUTPUT_YML_FILE_NAME,
contents: Buffer.from(
OUTPUT_BANNER +
"\n" +
dump(outputContent, { quotingType: `"`, forceQuotes: true })
),
});
this.push(virtualFile);
this.#printInfo();
done();
} catch (e) {
console.error(e);
process.exit(1);
}
}
#getSourceYmlContent() {
const outputPath = resolve(this.#outputDir, OUTPUT_YML_FILE_NAME);
return load(readFileSync(outputPath, "utf-8"));
}
#getObsoleteDate() {
const today = new Date();
const obsolete = new Date(today.setMonth(today.getMonth() + 3));
return obsolete.toISOString().split("T")[0];
}
#printInfo() {
const message = `Package ${this.#parsedInitialContent.packages[0]}
Added strings: ${this.#counters.added}
${this.#getObsoleteInfoMessage()}
Strings with mismatched value: ${this.#counters.mismatch}
`;
console.log(message);
}
#getObsoleteInfoMessage() {
if (args["mark-obsolete"]) {
return `Removed strings (marked as obsolete): ${this.#counters.obsolete}`;
}
let result = `Obsolete strings: ${this.#counters.obsolete}`;
if (this.#counters.obsolete > 0) {
result += " - Use --mark-obsolete to mark them as obsolete";
}
return result;
}
// if the key ends with .zero, .one, .two, .few, .many, .other and the value is empty
// find the same key with the `.other` suffix in strings and return the value
#fixPluralValue(key, value, strings) {
if (key.endsWith(".zero") && value === "") {
return strings[key.replace(".zero", ".other")] || "";
}
if (key.endsWith(".one") && value === "") {
return strings[key.replace(".one", ".other")] || "";
}
if (key.endsWith(".two") && value === "") {
return strings[key.replace(".two", ".other")] || "";
}
if (key.endsWith(".few") && value === "") {
return strings[key.replace(".few", ".other")] || "";
}
if (key.endsWith(".many") && value === "") {
return strings[key.replace(".many", ".other")] || "";
}
return value;
}
}
const sourceFilesGlob = args.module
? `src/modules/${args.module}/translations/en-us.yml`
: "src/modules/**/translations/en-us.yml";
const sourceFiles = await glob(sourceFilesGlob);
for (const sourceFile of sourceFiles) {
const moduleName = sourceFile.split("/")[2];
const inputGlob = `src/modules/${moduleName}/**/*.{ts,tsx}`;
const outputDir = resolve(
process.cwd(),
`src/modules/${moduleName}/translations`
);
vfs
.src([inputGlob])
.pipe(new I18NextTransform(config))
.pipe(new SourceYmlTransform(outputDir))
.pipe(vfs.dest(outputDir));
}

36
bin/lighthouse/account.js Normal file
View File

@@ -0,0 +1,36 @@
/**
* Reads account information from env variables or .a11yrc.json file
*/
const fs = require("fs");
let a11yAccount = {};
const a11yrcFilePath = ".a11yrc.json";
if (fs.existsSync(a11yrcFilePath)) {
a11yAccount = JSON.parse(fs.readFileSync(a11yrcFilePath));
}
function isValid(account) {
return account.subdomain && account.email && account.password;
}
function getAccount() {
// Reads account from the env or .a11yrc.json file if present
let account = {
subdomain: process.env.subdomain || a11yAccount.subdomain,
email: process.env.end_user_email || a11yAccount.username,
password: process.env.end_user_password || a11yAccount.password,
urls: process.env?.urls?.trim()?.split(/\s+/) || a11yAccount.urls
};
if (!isValid(account)) {
console.error(
"No account specified. Please create a .a11yrc.json file or set subdomain, end_user_email and end_user_password as environment variables"
);
process.exit(1);
}
return account;
}
module.exports = getAccount;

78
bin/lighthouse/config.js Normal file
View File

@@ -0,0 +1,78 @@
/**
* Sets lighthouse configuration
*/
module.exports = {
lighthouse: {
extends: "lighthouse:default",
settings: {
maxWaitForLoad: 10000,
onlyCategories: ["accessibility"],
disableFullPageScreenshot: true,
},
},
// custom properties
custom: {
// turn audit errors into warnings
ignore: {
tabindex: [
{
path: "*",
selector: "body.community-enabled > a.skip-navigation",
},
],
"link-in-text-block": [
{
path: "/hc/:locale/community/topics/360000644279",
selector:
"li > section.striped-list-item > span.striped-list-info > a#title-360006766859",
},
{
path: "/hc/:locale/community/topics/360000644279",
selector:
"li > section.striped-list-item > span.striped-list-info > a#title-360006766799",
},
{
path: "/hc/:locale/community/posts",
selector:
"li > section.striped-list-item > span.striped-list-info > a#title-360006766799",
},
{
path: "/hc/:locale/community/posts",
selector:
"li > section.striped-list-item > span.striped-list-info > a#title-360006766859",
},
],
"aria-allowed-attr": [
{
path: "/hc/:locale/community/posts/new",
selector:
"div#main-content > form.new_community_post > div.form-field > a.nesty-input",
},
],
"td-has-header": [
{
path: "/hc/:locale/subscriptions",
selector: "main > div.container > div#main-content > table.table",
},
],
"label-content-name-mismatch": [
{
path: "/hc/:locale/articles/:id",
selector:
"footer > div.article-votes > div.article-votes-controls > button.button",
},
],
"target-size": [
{
path: "/hc/:locale/search",
selector:
"header > div.search-result-title-container > h2.search-result-title > a",
},
{
path: "/hc/:locale/search",
selector: "nav > ol.breadcrumbs > li > a",
},
],
},
},
};

View File

@@ -0,0 +1,7 @@
module.exports = Object.freeze({
ERROR: 0,
SUCCESS: 1,
WARNING: 2,
SKIPPED: 3,
UNKNOWN: 4
});

119
bin/lighthouse/index.js Normal file
View File

@@ -0,0 +1,119 @@
require("dotenv").config();
const lighthouse = require("lighthouse/core/index.cjs");
const puppeteer = require("puppeteer");
const config = require("./config");
const getAccount = require("./account");
const buildUrlsFromAPI = require("./urls");
const login = require("./login");
const processResults = require("./processor");
const { ERROR, WARNING } = require("./constants");
const outputAudit = (
{ title, description, url, id, selector, snippet, explanation },
emoji
) => {
console.log("");
console.log(`${emoji} ${id}: ${title}`);
console.log(description, "\n");
console.log({ url, selector, snippet }, "\n");
console.log(explanation, "\n");
}
(async () => {
// TODO: If dev, check if zcli is running
const isDev = process.argv[2] === "-d";
const results = {
stats: {},
audits: [],
};
// Reading account
console.log("Reading account...", "\n");
const account = getAccount();
// Build list of urls to audit
if (!account.urls || account.urls.length === 0) {
console.log(
"No urls were found in .a11yrc.json or as env variable. Building urls from the API...",
"\n"
);
account.urls = await buildUrlsFromAPI(account);
}
// Set login URL
account.loginUrl = isDev
? `https://${account.subdomain}.zendesk.com/hc/admin/local_preview/start`
: `https://${account.subdomain}.zendesk.com/hc/en-us/signin`;
// Output account
console.log("Account:");
console.log(
{
subdomain: account.subdomain,
email: account.email,
urls: account.urls,
loginUrl: account.loginUrl,
},
"\n"
);
// Use Puppeteer to launch headless Chrome
console.log("Starting headless Chrome...");
const browser = await puppeteer.launch({ headless: true });
// Login
console.log(`Logging in using ${account.loginUrl}...`, "\n");
await login(browser, account);
// Run lighthouse on all pages
for (let url of account.urls) {
console.log(`Running lighthouse in ${url}`);
const page = await browser.newPage();
const { lhr } = await lighthouse(
url,
{ port: new URL(browser.wsEndpoint()).port, logLevel: "silent" },
config.lighthouse,
page
);
// Output run warnings
lhr.runWarnings.forEach((message) => console.warn(message));
// Analyze / process results
const { stats, audits } = processResults(lhr);
results.stats[url] = stats;
results.audits = [...results.audits, ...audits];
console.log("");
}
// Close browser
await browser.close();
// Output table with stats for each url
console.table(results.stats);
// Output warnings
const warnings = results.audits.filter((audit) => audit.result === WARNING);
warnings.forEach((audit) => outputAudit(audit, "⚠️"));
// Output errors
const errors = results.audits.filter((audit) => audit.result === ERROR);
errors.forEach((audit) => outputAudit(audit, "❌"));
// Output totals
console.log(
"\n",
`Total of ${errors.length} errors and ${warnings.length} warnings`,
"\n"
);
// Exit with error if there is at least one audit with an error
if (errors.length > 0) {
process.exit(1);
} else {
process.exit(0);
}
})();

16
bin/lighthouse/login.js Normal file
View File

@@ -0,0 +1,16 @@
async function login(browser, account) {
const { email, password, loginUrl } = account;
const page = await browser.newPage();
await page.goto(loginUrl);
await page.waitForSelector("input#user_email", { visible: true });
await page.type("input#user_email", email);
await page.type("input#user_password", password);
await Promise.all([
page.click("#sign-in-submit-button"),
page.waitForNavigation(),
]);
await page.close();
}
module.exports = login;

View File

@@ -0,0 +1,84 @@
/**
* Filters and maps lighthouse results for a simplified output
* If an error should be ignored, it will be converted into a warning
*/
const UrlPattern = require("url-pattern");
const config = require("./config");
const { ERROR, WARNING, SKIPPED, SUCCESS, UNKNOWN } = require("./constants");
function shouldIgnoreError(auditId, url, selector) {
const path = new URL(url).pathname;
return config.custom.ignore[auditId]?.some((ignore) => {
const pattern = new UrlPattern(ignore.path);
return Boolean(pattern.match(path)) && selector === ignore.selector;
});
}
function processResults(lhr) {
const url = lhr.mainDocumentUrl;
const pageScore = lhr.categories.accessibility.score;
const audits = Object.values(lhr.audits)
// filter and flatten data
.map(({ id, title, description, score, details }) => {
const newItem = {
id,
url,
title,
description,
score,
};
return details === undefined || details.items.length === 0
? newItem
: details.items.map((item) => ({
...newItem,
selector: item.node.selector,
snippet: item.node.snippet,
explanation: item.node.explanation,
}));
})
.flat()
// map lighthouse score to a result
.map(({ score, ...audit }) => {
const newItem = { ...audit };
switch (score) {
case 1:
newItem.result = SUCCESS;
break;
case 0:
newItem.result = shouldIgnoreError(
audit.id,
audit.url,
audit.selector
)
? WARNING
: ERROR;
break;
case null:
newItem.result = SKIPPED;
break;
default:
console.error(`Error: unexpected score for audit ${audit.id}`);
newItem.result = UNKNOWN;
}
return newItem;
});
return {
audits,
stats: {
success: audits.filter((audit) => audit.result === SUCCESS).length,
error: audits.filter((audit) => audit.result === ERROR).length,
skipped: audits.filter((audit) => audit.result === SKIPPED).length,
warning: audits.filter((audit) => audit.result === WARNING).length,
unknown: audits.filter((audit) => audit.result === UNKNOWN).length,
score: pageScore
},
};
}
module.exports = processResults;

167
bin/lighthouse/urls.js Normal file
View File

@@ -0,0 +1,167 @@
/**
* Builds urls to audit by fetching the required ids
* Account's API should have basic authentication enabled
*/
const fetch = require("node-fetch");
const fetchCategoryId = async (subdomain) => {
const response = await fetch(
`https://${subdomain}.zendesk.com/api/v2/help_center/categories`
);
const data = await response.json();
const categoryId = data?.categories[0]?.id;
if (!categoryId) {
throw new Error(
"No category found. Please make sure the account has at least one category."
);
}
return categoryId;
};
const fetchSectionId = async (subdomain) => {
const response = await fetch(
`https://${subdomain}.zendesk.com/api/v2/help_center/sections`
);
const data = await response.json();
const sectionId = data?.sections[0]?.id;
if (!sectionId) {
throw new Error(
"No section found. Please make sure the account has at least one section."
);
}
return sectionId;
};
const fetchArticleId = async (subdomain) => {
const response = await fetch(
`https://${subdomain}.zendesk.com/api/v2/help_center/articles`
);
const data = await response.json();
const articleId = data?.articles[0]?.id;
if (!articleId) {
throw new Error(
"No article found. Please make sure the account has at least one article."
);
}
return articleId;
};
const fetchTopicId = async (subdomain) => {
const response = await fetch(
`https://${subdomain}.zendesk.com/api/v2/community/topics`
);
const data = await response.json();
const topicId = data?.topics[0]?.id;
if (!topicId) {
throw new Error(
"No community topic found. Please make sure the account has at least one community topic."
);
}
return topicId;
};
const fetchPostId = async (subdomain) => {
const response = await fetch(
`https://${subdomain}.zendesk.com/api/v2/community/posts`
);
const data = await response.json();
const postId = data?.posts[0]?.id;
if (!postId) {
throw new Error(
"No community post found. Please make sure the account has at least one community post."
);
}
return postId;
};
const fetchUserId = async (subdomain, email, password) => {
const response = await fetch(
`https://${subdomain}.zendesk.com/api/v2/users/me`,
{
headers: {
authorization: `Basic ${Buffer.from(
`${email}:${password}`,
"binary"
).toString("base64")}`,
},
}
);
const data = await response.json();
const userId = data?.user?.id;
if (!userId) {
throw new Error(
"Fetching the user id failed. Please make sure this account's API has password access enabled. [Learn more](https://developer.zendesk.com/api-reference/introduction/security-and-auth/#basic-authentication)"
);
}
return userId;
};
const fetchRequestId = async (subdomain, email, password) => {
const response = await fetch(
`https://${subdomain}.zendesk.com/api/v2/requests`,
{
headers: {
authorization: `Basic ${Buffer.from(
`${email}:${password}`,
"binary"
).toString("base64")}`,
},
}
);
const data = await response.json();
const requestId = data?.requests?.[0]?.id;
if (!requestId) {
throw new Error(
"No request id found. Please make sure the user has at least one request and this account's API has password access enabled. [Learn more](https://developer.zendesk.com/api-reference/introduction/security-and-auth/#basic-authentication)"
);
}
return requestId;
};
const buildUrlsFromAPI = async ({ subdomain, email, password }) => {
const [categoryId, sectionId, articleId, topicId, postId, userId, requestId] =
await Promise.all([
fetchCategoryId(subdomain),
fetchSectionId(subdomain),
fetchArticleId(subdomain),
fetchTopicId(subdomain),
fetchPostId(subdomain),
fetchUserId(subdomain, email, password),
fetchRequestId(subdomain, email, password),
]);
return [
`https://${subdomain}.zendesk.com/hc/en-us`,
`https://${subdomain}.zendesk.com/hc/en-us/categories/${categoryId}`,
`https://${subdomain}.zendesk.com/hc/en-us/sections/${sectionId}`,
`https://${subdomain}.zendesk.com/hc/en-us/articles/${articleId}`,
`https://${subdomain}.zendesk.com/hc/en-us/requests/new`,
`https://${subdomain}.zendesk.com/hc/en-us/search?utf8=%E2%9C%93&query=Help+Center`,
`https://${subdomain}.zendesk.com/hc/en-us/community/topics`,
`https://${subdomain}.zendesk.com/hc/en-us/community/topics/${topicId}`,
`https://${subdomain}.zendesk.com/hc/en-us/community/posts`,
`https://${subdomain}.zendesk.com/hc/en-us/community/posts/${postId}`,
`https://${subdomain}.zendesk.com/hc/en-us/profiles/${userId}`,
`https://${subdomain}.zendesk.com/hc/contributions/posts?locale=en-us`,
`https://${subdomain}.zendesk.com/hc/en-us/subscriptions`,
`https://${subdomain}.zendesk.com/hc/en-us/requests`,
`https://${subdomain}.zendesk.com/hc/en-us/requests/${requestId}`,
`https://${subdomain}.zendesk.com/hc/en-us/community/posts/new`,
];
};
module.exports = buildUrlsFromAPI;

25
bin/theme-upload.js Normal file
View File

@@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-env node */
const brandId = process.env.BRAND_ID;
const { execSync } = require("child_process");
function zcli(command) {
try {
const data = execSync(`yarn zcli ${command} --json`);
return JSON.parse(data.toString());
} catch (e) {
console.error(e.message);
console.error(e.stdout.toString());
process.exit(1);
}
}
const { themeId } = zcli(`themes:import --brandId=${brandId}`);
zcli(`themes:publish --themeId=${themeId}`);
const { themes } = zcli(`themes:list --brandId=${brandId}`);
for (const { live, id } of themes) {
if (!live) zcli(`themes:delete --themeId=${id}`);
}

View File

@@ -0,0 +1,10 @@
#!/bin/sh
# EXIT ON ERROR
set -e
# UPDATE MANIFEST VERSION
NEW_VERSION=$1
mv manifest.json manifest.temp.json
jq -r '.version |= "'${NEW_VERSION}'"' manifest.temp.json > manifest.json
rm manifest.temp.json

View File

@@ -0,0 +1,81 @@
/* eslint-env node */
/**
* This script is used for downloading the latest Zendesk official translation files for the modules in the `src/module` folder.
*
*/
import { writeFile, readFile, mkdir } from "node:fs/promises";
import { resolve } from "node:path";
import { glob } from "glob";
import { load } from "js-yaml";
import { parseArgs } from "node:util";
const BASE_URL = `https://static.zdassets.com/translations`;
const { values: args } = parseArgs({
options: {
module: {
type: "string",
},
},
});
async function writeLocaleFile(name, filePath, outputDir) {
const response = await fetch(`${BASE_URL}${filePath}`);
const { translations } = await response.json();
const outputPath = resolve(outputDir, `${name.toLocaleLowerCase()}.json`);
await writeFile(
outputPath,
JSON.stringify(translations, null, 2) + "\n",
"utf-8"
);
}
async function fetchModuleTranslations(moduleName, packageName) {
try {
const manifestResponse = await fetch(
`${BASE_URL}/${packageName}/manifest.json`
);
console.log(`Downloading translations for ${moduleName}...`);
const outputDir = resolve(
process.cwd(),
`src/modules/${moduleName}/translations/locales`
);
await mkdir(outputDir, { recursive: true });
const { json } = await manifestResponse.json();
await Promise.all(
json.map(({ name, path }) => writeLocaleFile(name, path, outputDir))
);
console.log(`Downloaded ${json.length} files.`);
} catch (e) {
console.log(`Error downloading translations for ${moduleName}: ${e}`);
}
}
// search for `src/modules/**/translations/en-us.yml` files, read it contents and return a map of module names and package names
async function getModules() {
const result = {};
const sourceFilesGlob = args.module
? `src/modules/${args.module}/translations/en-us.yml`
: "src/modules/**/translations/en-us.yml";
const files = await glob(sourceFilesGlob);
for (const file of files) {
const content = await readFile(file);
const parsedContent = load(content);
const moduleName = file.split("/")[2];
result[moduleName] = parsedContent.packages[0];
}
return result;
}
const modules = await getModules();
for (const [moduleName, packageName] of Object.entries(modules)) {
await fetchModuleTranslations(moduleName, packageName);
}

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env node
const fetch = require("node-fetch");
const path = require("path");
const fs = require("fs");
const ZSAP_BASE = `https://static.zdassets.com/translations`;
const SUPPORTED_LOCALES = [
"ar",
"bg",
"cs",
"da",
"de",
"el",
"en-US",
"en-ca",
"en-gb",
"es",
"es-419",
"es-es",
"fi",
"fr",
"fr-ca",
"he",
"hi",
"hu",
"id",
"it",
"ja",
"ko",
"nl",
"no",
"pl",
"pt",
"pt-br",
"ro",
"ru",
"sk",
"sv",
"th",
"tr",
"uk",
"vi",
"zh-cn",
"zh-tw",
];
(async function () {
const resp = await fetch(
`${ZSAP_BASE}/help_center_copenhagen_theme/manifest.json`
);
const manifest_json = await resp.json();
for (const targetLocale of SUPPORTED_LOCALES) {
const locale = manifest_json.json.find(
(entry) => entry.name === targetLocale
);
console.log(`Downloading ${locale.name}...`);
const resp = await fetch(`${ZSAP_BASE}${locale.path}`);
const translations = await resp.json();
const formattedTranslations = Object.entries(
translations.translations
).reduce((accumulator, [key, value]) => {
accumulator[key.replace(/.+\./, "")] = value;
return accumulator;
}, {});
fs.writeFileSync(
path.join("translations", `${locale.name}.json`),
JSON.stringify(formattedTranslations, null, 2) + "\n",
"utf8"
);
}
})();

65
custom/script.js.template Normal file
View File

@@ -0,0 +1,65 @@
document.addEventListener("DOMContentLoaded", function() {
document.querySelectorAll('pre').forEach(function(pre) {
// Evita duplicar o botão copiar
if (pre.querySelector('.copy-btn')) return;
pre.style.position = 'relative'; // importante para posicionamento absoluto do botão
// Criar botão copiar com ícone SVG
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.type = 'button';
copyBtn.setAttribute('aria-label', 'Copiar código');
// Ícone clipboard SVG inline
copyBtn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z" />
</svg>
`;
copyBtn.addEventListener('click', function() {
const codeElement = pre.querySelector('code');
let textToCopy = '';
if (codeElement) {
textToCopy = codeElement.innerText;
} else {
// Copiar apenas texto puro do <pre> ignorando o botão
textToCopy = Array.from(pre.childNodes)
.filter(node => node.nodeType === Node.TEXT_NODE)
.map(node => node.textContent)
.join('');
}
navigator.clipboard.writeText(textToCopy).then(() => {
// Feedback visual simples
copyBtn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" >
<path fill="green" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>`;
setTimeout(() => {
copyBtn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z" />
</svg>
`;
}, 1500);
}).catch(() => {
// Se erro, exibe ícone de erro (vermelho)
copyBtn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" >
<path fill="red" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.29 13.29-1.41 1.41L12 13.41l-2.88 2.88-1.41-1.41L10.59 12 7.71 9.12l1.41-1.41L12 10.59l2.88-2.88 1.41 1.41L13.41 12l2.88 2.88z"/>
</svg>`;
setTimeout(() => {
copyBtn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z" />
</svg>`;
}, 1500);
});
});
pre.appendChild(copyBtn);
});
});

268
custom/style.css.template Normal file
View File

@@ -0,0 +1,268 @@
/* ===== Hiding social content ===== */
.recent-activity-item-comment {
display: none;
}
.article-votes {
display: none;
}
.share {
display: none;
}
.comment-overview {
display: none;
}
.comment-callout {
display: none;
}
/* ===== Collumns size ===== */
.container {
max-width: 1300px !important;
}
.header {
max-width: 1300px !important;
}
#main-content.article, .article, .article-body, #article-body {
max-width: 100% !important;
}
.article-container a:hover,
#article-container a:hover {
text-decoration: underline !important;
}
/* ===== Article page container ===== */
.article-container,
#article-container {
display: flex !important;
flex-wrap: wrap !important;
justify-content: center !important;
align-items: flex-start !important;
max-width: 1300px !important;
margin: 0 auto !important;
box-sizing: border-box !important;
background: transparent !important;
gap: 40px !important;
width: 100% !important;
overflow-x: hidden !important;
}
/* ===== Main article ===== */
#main-content.article,
.article,
.article-body,
#article-body {
flex: 1 1 700px !important;
max-width: 100% !important;
min-width: 300px !important;
width: 100% !important;
background: #fff !important;
border-radius: 12px !important;
margin: 40px 0 !important;
font-size: 15px !important;
line-height: 1.7 !important;
box-sizing: border-box !important;
overflow-wrap: break-word !important;
}
/* ===== Sidebar ===== */
.zd-sidebar,
.article-sidebar {
max-width: 260px !important;
width: 100% !important;
background: #f1f5f9 !important;
padding: 20px !important;
border-radius: 10px !important;
font-size: 15px !important;
line-height: 1.5 !important;
margin: 40px 0 !important;
box-sizing: border-box !important;
overflow-wrap: break-word !important;
}
/* ===== Responsiveness ===== */
@media (max-width: 1150px) {
.article-container,
#article-container {
flex-direction: column !important;
gap: 0 !important;
max-width: 100% !important;
}
#main-content.article,
.article,
.article-body,
#article-body {
max-width: 100% !important;
padding: 18px 1vw !important;
margin: 10px 0 !important;
border-radius: 8px !important;
font-size: 16px !important;
}
.zd-sidebar,
.article-sidebar {
max-width: 100% !important;
margin: 0 0 24px 0 !important;
border-radius: 8px !important;
}
}
/* ===== Prevent horizontal page scroll ===== */
html,
body {
overflow-x: hidden !important;
margin: 0 !important;
padding: 0 !important;
}
/* ===== General styles ===== */
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif !important;
background: #fafafa !important;
color: #222 !important;
margin: 0 !important;
}
h1,
h2,
h3,
h4 {
color: #004080 !important;
font-weight: 700 !important;
margin-top: 0.5em !important;
margin-bottom: 0.5em !important;
}
a {
color: #007acc !important;
text-decoration: none !important;
transition: color 0.3s ease !important;
}
a:hover,
a:focus {
color: #005a99 !important;
text-decoration: none !important;
}
p {
margin-bottom: 1.15em !important;
font-size: 15px !important;
word-break: break-word !important;
}
ul,
ol {
margin-left: 0.5em !important;
margin-bottom: 1.1em !important;
font-size: 15px !important;
}
ul li strong {
color: #d9534f !important;
}
table {
border-collapse: collapse !important;
width: 100% !important;
margin-bottom: 1.5em !important;
table-layout: fixed !important;
word-wrap: break-word !important;
}
th,
td {
border: 1px solid #ddd !important;
padding: 10px !important;
text-align: left !important;
}
th {
background-color: #007acc !important;
color: white !important;
}
img,
.article-body img {
max-width: 100% !important;
height: auto !important;
border-radius: 7px !important;
box-shadow: 0 2px 10px rgb(0 0 0 / 0.1) !important;
margin: 1.5em 0 !important;
}
/* ===== Styles for <pre> blocks with copy button ===== */
pre {
position: relative !important;
background: #f7f9fc !important;
border-left: 5px solid #007acc !important;
padding: 17px 20px 17px 20px !important;
border-radius: 5px !important;
margin-bottom: 2em !important;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1) !important;
font-family: Consolas, Monaco, 'Courier New', monospace !important;
font-size: 14px !important;
line-height: 1.6 !important;
overflow-x: auto !important;
white-space: pre !important;
word-break: break-word !important;
}
/* Copy button inside <pre> */
pre .copy-btn {
position: absolute !important;
top: 12px !important;
right: 12px !important;
background: none !important;
border: none !important;
padding: 6px !important;
cursor: pointer !important;
z-index: 2 !important;
transition: background-color 0.3s ease !important;
color: #007acc !important;
border-radius: 4px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 16px !important;
}
pre .copy-btn:hover,
pre .copy-btn:focus {
background-color: rgba(0, 122, 204, 0.2) !important;
outline: none !important;
color: #005a99 !important;
}
/* SVG icon inside the button, size and color */
pre .copy-btn svg {
width: 20px !important;
height: 20px !important;
fill: currentColor !important;
}
/* ===== Responsiveness for small devices ===== */
@media (max-width: 900px) {
#main-content.article,
.article,
.article-body,
#article-body {
padding: 10px 2vw !important;
font-size: 16px !important;
}
pre {
padding: 14px 14px 14px 14px !important;
font-size: 14px !important;
}
pre .copy-btn {
top: 8px !important;
right: 8px !important;
padding: 5px !important;
font-size: 14px !important;
}
}

86
generate-import-map.mjs Normal file
View File

@@ -0,0 +1,86 @@
import path from "path";
import fs from "fs";
/**
* When assets are deployed to Theming Center, their file name is changed and this
* breaks the rollup bundles, where bundled files are referencing each other with
* relative paths (e.g. file a.js has an `import something from "./b.js"`)
*
* This plugin solves the issue by creating an importmap to be used in the browser,
* specifically:
* - it replaces relative imports with bare imports (`import something from "./b.js"`
* is replaced with `import something from "b"`)
* - it creates an importmap that maps each bare import to the asset file, using the
* Curlybars asset helper. This import map is then injected into the document_head.hbs
* template and used by the browser to resolve the bare imports.
*
* Note: you need to have an importmap in the document_head that gets replaced
* after each build. The first time you can just put an empty importmap:
* <script type="importmap"></script>
* @returns {import("rollup").Plugin}
*/
export function generateImportMap() {
return {
name: "rollup-plugin-generate-import-map",
writeBundle({ dir }, bundle) {
const outputAssets = Object.values(bundle);
const importMap = { imports: {} };
for (const { name, fileName } of outputAssets) {
replaceImports(fileName, outputAssets, dir);
importMap.imports[name] = `{{asset '${fileName}'}}`;
}
injectImportMap(importMap);
},
};
}
/**
* Replace relative imports with bare imports
* @param {string} fileName Name of the current output asset
* @param {import("rollup").OutputAsset} outputAssets Array of all output assets generated during the build
* @param {string} outputPath Path of the output directory
*/
function replaceImports(fileName, outputAssets, outputPath) {
const filePath = path.resolve(outputPath, fileName);
let content = fs.readFileSync(filePath, "utf-8");
for (const { name, fileName } of outputAssets) {
// Takes into account both single and double quotes
const regex = new RegExp(`(['"])./${fileName}\\1`, "g");
content = content.replaceAll(regex, `$1${name}$1`);
}
fs.writeFileSync(filePath, content, "utf-8");
}
/**
* Injects the importmap in the document_head.hbs template, replacing the existing one
* @param {object} importMap
*/
function injectImportMap(importMap) {
const headTemplatePath = path.resolve("templates", "document_head.hbs");
const content = fs.readFileSync(headTemplatePath, "utf-8");
const importMapStart = content.indexOf(`<script type="importmap">`);
const importMapEnd = content.indexOf(`</script>`, importMapStart);
if (importMapStart === -1 || importMapEnd === -1) {
throw new Error(
`Cannot inject importmap in templates/document_head.hbs. Please provide an empty importmap like <script type="importmap"></script>`
);
}
const existingImportMap = content.substring(
importMapStart,
importMapEnd + `</script>`.length
);
const newImportMap = `<script type="importmap">
${JSON.stringify(importMap, null, 2)}
</script>`;
const newContent = content.replace(existingImportMap, newImportMap);
fs.writeFileSync(headTemplatePath, newContent, "utf-8");
}

12
jest.config.mjs Normal file
View File

@@ -0,0 +1,12 @@
const config = {
coverageProvider: "v8",
testEnvironment: "jsdom",
preset: "rollup-jest",
transform: {
"^.+.tsx?$": ["ts-jest", {}],
},
transformIgnorePatterns: ["node_modules/(?!(react-merge-refs)/)"],
setupFilesAfterEnv: ["@testing-library/jest-dom", "<rootDir>/jest.setup.js"],
};
export default config;

34
jest.setup.js Normal file
View File

@@ -0,0 +1,34 @@
import { jest } from "@jest/globals";
Object.defineProperty(window, "matchMedia", {
writable: true,
value: jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
});
if (typeof window.matchMedia !== "function") {
window.matchMedia = jest.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}));
}
// Mock for document.dir which is used in createTheme
Object.defineProperty(document, "dir", {
writable: true,
value: "ltr",
});

408
manifest.json Normal file
View File

@@ -0,0 +1,408 @@
{
"name": "CopenLight",
"author": "Ivan Carlos",
"version": "17.1.2",
"api_version": 4,
"default_locale": "en-us",
"settings": [
{
"label": "colors_group_label",
"variables": [
{
"identifier": "brand_color",
"type": "color",
"description": "brand_color_description",
"label": "brand_color_label",
"value": "#17494D"
},
{
"identifier": "brand_text_color",
"type": "color",
"description": "brand_text_color_description",
"label": "brand_text_color_label",
"value": "#FFFFFF"
},
{
"identifier": "text_color",
"type": "color",
"description": "text_color_description",
"label": "text_color_label",
"value": "#2F3941"
},
{
"identifier": "link_color",
"type": "color",
"description": "link_color_description",
"label": "link_color_label",
"value": "#1F73B7"
},
{
"identifier": "hover_link_color",
"type": "color",
"description": "hover_link_color_description",
"label": "hover_link_color_label",
"value": "#0F3554"
},
{
"identifier": "visited_link_color",
"type": "color",
"description": "visited_link_color_description",
"label": "visited_link_color_label",
"value": "#9358B0"
},
{
"identifier": "background_color",
"type": "color",
"description": "background_color_description",
"label": "background_color_label",
"value": "#FFFFFF"
}
]
},
{
"label": "fonts_group_label",
"variables": [
{
"identifier": "heading_font",
"type": "list",
"description": "heading_font_description",
"label": "heading_font_label",
"options": [
{
"label": "System",
"value": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
},
{
"label": "Arial",
"value": "Arial, 'Helvetica Neue', Helvetica, sans-serif"
},
{
"label": "Arial Black",
"value": "'Arial Black', Arial, 'Helvetica Neue', Helvetica, sans-serif"
},
{
"label": "Baskerville",
"value": "Baskerville, 'Times New Roman', Times, serif"
},
{
"label": "Century Gothic",
"value": "'Century Gothic', sans-serif"
},
{
"label": "Copperplate Light",
"value": "'Copperplate Light', 'Copperplate Gothic Light', serif"
},
{
"label": "Courier New",
"value": "'Courier New', Courier, monospace"
},
{
"label": "Futura",
"value": "Futura, 'Century Gothic', sans-serif"
},
{
"label": "Garamond",
"value": "Garamond, 'Hoefler Text', 'Times New Roman', Times, serif"
},
{
"label": "Geneva",
"value": "Geneva, 'Lucida Sans', 'Lucida Grande', 'Lucida Sans Unicode', Verdana, sans-serif"
},
{
"label": "Georgia",
"value": "Georgia, Palatino, 'Palatino Linotype', Times, 'Times New Roman', serif"
},
{
"label": "Helvetica",
"value": "Helvetica, Arial, sans-serif"
},
{
"label": "Helvetica Neue",
"value": "'Helvetica Neue', Arial, Helvetica, sans-serif"
},
{
"label": "Impact",
"value": "Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif"
},
{
"label": "Lucida Grande",
"value": "'Lucida Grande', 'Lucida Sans', 'Lucida Sans Unicode', sans-serif"
},
{
"label": "Trebuchet MS",
"value": "'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif"
}
],
"value": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
},
{
"identifier": "text_font",
"type": "list",
"description": "text_font_description",
"label": "text_font_label",
"options": [
{
"label": "System",
"value": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
},
{
"label": "Arial",
"value": "Arial, 'Helvetica Neue', Helvetica, sans-serif"
},
{
"label": "Arial Black",
"value": "'Arial Black', Arial, 'Helvetica Neue', Helvetica, sans-serif"
},
{
"label": "Baskerville",
"value": "Baskerville, 'Times New Roman', Times, serif"
},
{
"label": "Century Gothic",
"value": "'Century Gothic', sans-serif"
},
{
"label": "Copperplate Light",
"value": "'Copperplate Light', 'Copperplate Gothic Light', serif"
},
{
"label": "Courier New",
"value": "'Courier New', Courier, monospace"
},
{
"label": "Futura",
"value": "Futura, 'Century Gothic', sans-serif"
},
{
"label": "Garamond",
"value": "Garamond, 'Hoefler Text', 'Times New Roman', Times, serif"
},
{
"label": "Geneva",
"value": "Geneva, 'Lucida Sans', 'Lucida Grande', 'Lucida Sans Unicode', Verdana, sans-serif"
},
{
"label": "Georgia",
"value": "Georgia, Palatino, 'Palatino Linotype', Times, 'Times New Roman', serif"
},
{
"label": "Helvetica",
"value": "Helvetica, Arial, sans-serif"
},
{
"label": "Helvetica Neue",
"value": "'Helvetica Neue', Arial, Helvetica, sans-serif"
},
{
"label": "Impact",
"value": "Impact, Haettenschweiler, 'Arial Narrow Bold', sans-serif"
},
{
"label": "Lucida Grande",
"value": "'Lucida Grande', 'Lucida Sans', 'Lucida Sans Unicode', sans-serif"
},
{
"label": "Trebuchet MS",
"value": "'Trebuchet MS', 'Lucida Sans Unicode', 'Lucida Grande', 'Lucida Sans', Arial, sans-serif"
}
],
"value": "-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"
}
]
},
{
"label": "brand_group_label",
"variables": [
{
"identifier": "logo",
"type": "file",
"description": "logo_description",
"label": "logo_label"
},
{
"identifier": "show_brand_name",
"type": "checkbox",
"description": "show_brand_name_description",
"label": "show_brand_name_label",
"value": true
},
{
"identifier": "favicon",
"type": "file",
"description": "favicon_description",
"label": "favicon_label"
}
]
},
{
"label": "images_group_label",
"variables": [
{
"identifier": "homepage_background_image",
"type": "file",
"description": "homepage_background_image_description",
"label": "homepage_background_image_label"
},
{
"identifier": "community_background_image",
"type": "file",
"description": "community_background_image_description",
"label": "community_background_image_label"
},
{
"identifier": "community_image",
"type": "file",
"description": "community_image_description",
"label": "community_image_label"
}
]
},
{
"label": "search_group_label",
"variables": [
{
"identifier": "instant_search",
"type": "checkbox",
"description": "instant_search_description",
"label": "instant_search_label",
"value": true
},
{
"identifier": "scoped_kb_search",
"type": "checkbox",
"description": "scoped_knowledge_base_search_description",
"label": "scoped_knowledge_base_search_label_v2",
"value": true
},
{
"identifier": "scoped_community_search",
"type": "checkbox",
"description": "scoped_community_search_description",
"label": "scoped_community_search_label",
"value": true
}
]
},
{
"label": "home_page_group_label",
"variables": [
{
"identifier": "show_recent_activity",
"type": "checkbox",
"description": "recent_activity_description",
"label": "recent_activity_label",
"value": true
}
]
},
{
"label": "article_page_group_label",
"variables": [
{
"identifier": "show_articles_in_section",
"type": "checkbox",
"description": "articles_in_section_description",
"label": "articles_in_section_label",
"value": true
},
{
"identifier": "show_article_author",
"type": "checkbox",
"description": "article_author_description",
"label": "article_author_label",
"value": true
},
{
"identifier": "show_article_comments",
"type": "checkbox",
"description": "article_comments_description",
"label": "article_comments_label",
"value": true
},
{
"identifier": "show_follow_article",
"type": "checkbox",
"description": "follow_article_description",
"label": "follow_article_label",
"value": true
},
{
"identifier": "show_recently_viewed_articles",
"type": "checkbox",
"description": "recently_viewed_articles_description",
"label": "recently_viewed_articles_label",
"value": true
},
{
"identifier": "show_related_articles",
"type": "checkbox",
"description": "related_articles_description",
"label": "related_articles_label",
"value": true
},
{
"identifier": "show_article_sharing",
"type": "checkbox",
"description": "article_sharing_description",
"label": "article_sharing_label",
"value": true
}
]
},
{
"label": "section_page_group_label",
"variables": [
{
"identifier": "show_follow_section",
"type": "checkbox",
"description": "follow_section_description",
"label": "follow_section_label",
"value": true
}
]
},
{
"label": "community_post_group_label",
"variables": [
{
"identifier": "show_follow_post",
"type": "checkbox",
"description": "follow_post_description",
"label": "follow_post_label",
"value": true
},
{
"identifier": "show_post_sharing",
"type": "checkbox",
"description": "post_sharing_description",
"label": "post_sharing_label",
"value": true
}
]
},
{
"label": "community_topic_group_label",
"variables": [
{
"identifier": "show_follow_topic",
"type": "checkbox",
"description": "follow_topic_description",
"label": "follow_topic_label",
"value": true
}
]
},
{
"label": "request_list_group_label",
"variables": [
{
"identifier": "request_list_beta",
"type": "checkbox",
"description": "request_list_beta_description",
"label": "request_list_beta_label",
"value": false
}
]
}
]
}

122
package.json Normal file
View File

@@ -0,0 +1,122 @@
{
"name": "copenhagen_theme",
"version": "1.0.0",
"repository": "git@github.com:zendesk/copenhagen_theme.git",
"scripts": {
"start": "concurrently -k -r 'rollup -c -w' 'wait-on script.js style.css && zcli themes:preview'",
"build": "NODE_ENV=production rollup -c",
"prebuild": "rm -f assets/*-bundle.js",
"eslint": "eslint src",
"prepare": "husky install",
"download-locales": "node ./bin/update-translations",
"test": "jest",
"test-a11y": "node bin/lighthouse/index.js",
"i18n:extract": "node bin/extract-strings.mjs",
"i18n:update-translations": "node bin/update-modules-translations.mjs",
"zcli": "zcli"
},
"dependencies": {
"@zendesk/help-center-wysiwyg": "0.1.0",
"@zendeskgarden/container-grid": "^3.0.14",
"@zendeskgarden/container-utilities": "^2.0.2",
"@zendeskgarden/react-accordions": "9.12.0",
"@zendeskgarden/react-avatars": "9.12.0",
"@zendeskgarden/react-breadcrumbs": "9.12.0",
"@zendeskgarden/react-buttons": "9.12.0",
"@zendeskgarden/react-datepickers": "9.12.0",
"@zendeskgarden/react-dropdowns": "9.12.0",
"@zendeskgarden/react-forms": "9.12.0",
"@zendeskgarden/react-grid": "9.12.0",
"@zendeskgarden/react-loaders": "9.12.0",
"@zendeskgarden/react-modals": "9.12.0",
"@zendeskgarden/react-notifications": "9.12.0",
"@zendeskgarden/react-pagination": "9.12.0",
"@zendeskgarden/react-tables": "9.12.0",
"@zendeskgarden/react-tags": "9.12.0",
"@zendeskgarden/react-theming": "9.12.0",
"@zendeskgarden/react-tooltips": "9.12.0",
"@zendeskgarden/react-typography": "9.12.0",
"@zendeskgarden/svg-icons": "8.0.0",
"dompurify": "3.2.5",
"eslint-plugin-check-file": "^2.6.2",
"i18next": "^23.10.1",
"lodash.debounce": "^4.0.8",
"node-fetch": "2.6.9",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-dropzone": "^14.2.3",
"react-i18next": "^14.1.0",
"react-is": "^17.0.2",
"styled-components": "^5.3.11"
},
"devDependencies": {
"@commitlint/cli": "17.3.0",
"@commitlint/config-conventional": "17.3.0",
"@jest/globals": "^29.6.4",
"@rollup/plugin-commonjs": "^25.0.2",
"@rollup/plugin-dynamic-import-vars": "^2.1.2",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^15.1.0",
"@rollup/plugin-replace": "^5.0.2",
"@rollup/plugin-terser": "^0.4.4",
"@rollup/plugin-typescript": "^11.1.2",
"@semantic-release/changelog": "6.0.2",
"@semantic-release/exec": "6.0.3",
"@semantic-release/git": "10.0.1",
"@shopify/eslint-plugin": "^44.0.0",
"@svgr/rollup": "^8.1.0",
"@testing-library/dom": "^9.3.1",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "7.0.2",
"@testing-library/user-event": "^14.6.1",
"@types/lodash.debounce": "^4.0.9",
"@types/react": "^17.0.62",
"@types/react-dom": "^17.0.20",
"@types/styled-components": "^5.1.26",
"@typescript-eslint/eslint-plugin": "^6.1.0",
"@typescript-eslint/parser": "^6.1.0",
"@zendesk/zcli": "1.0.0-beta.52",
"concurrently": "8.0.1",
"dotenv": "16.0.3",
"eslint": "8.35.0",
"eslint-config-prettier": "8.6.0",
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-jest": "^27.2.2",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"glob": "^10.4.5",
"husky": "8.0.2",
"i18next-parser": "^9.3.0",
"jest": "^29.6.1",
"jest-environment-jsdom": "^29.6.1",
"js-yaml": "^4.1.0",
"lighthouse": "12.6.0",
"prettier": "2.8.4",
"puppeteer": "24.9.0",
"rollup": "3.29.5",
"rollup-jest": "^3.1.0",
"rollup-plugin-sass": "1.12.18",
"sass": "1.58.3",
"semantic-release": "19.0.5",
"ts-jest": "^29.2.4",
"typescript": "^5.1.6",
"url-pattern": "1.0.3",
"vinyl": "^3.0.0",
"vinyl-fs": "^4.0.0",
"wait-on": "8.0.3"
},
"resolutions": {
"@types/react": "^17.x",
"nwsapi": "^2.2.20",
"@zendeskgarden/container-utilities": "2.0.2"
},
"commitlint": {
"extends": [
"@commitlint/config-conventional"
]
},
"packageManager": "yarn@4.10.3"
}

105
rollup.config.mjs Normal file
View File

@@ -0,0 +1,105 @@
/* eslint-env node */
import zass from "./zass.mjs";
import { nodeResolve } from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
import json from "@rollup/plugin-json";
import dynamicImportVars from "@rollup/plugin-dynamic-import-vars";
import typescript from "@rollup/plugin-typescript";
import replace from "@rollup/plugin-replace";
import terser from "@rollup/plugin-terser";
import svgr from "@svgr/rollup";
import { generateImportMap } from "./generate-import-map.mjs";
import { defineConfig } from "rollup";
const fileNames = "[name]-bundle.js";
const isProduction = process.env.NODE_ENV === "production";
const TRANSLATION_FILE_REGEX =
/src\/modules\/(.+?)\/translations\/locales\/.+?\.json$/;
export default defineConfig([
// Configuration for bundling the script.js file
{
input: "src/index.js",
output: {
file: "script.js",
format: "iife",
},
plugins: [zass()],
watch: {
clearScreen: false,
},
},
// Configuration for bundling modules in the src/modules directory
{
context: "this",
input: {
"new-request-form": "src/modules/new-request-form/index.tsx",
"flash-notifications": "src/modules/flash-notifications/index.ts",
"service-catalog": "src/modules/service-catalog/index.tsx",
"approval-requests": "src/modules/approval-requests/index.tsx",
},
output: {
dir: "assets",
format: "es",
manualChunks: (id) => {
if (
id.includes("node_modules/@zendesk/help-center-wysiwyg") ||
id.includes("node_modules/@ckeditor5")
) {
return "wysiwyg";
}
if (id.includes("node_modules") || id.includes("src/modules/shared")) {
return "shared";
}
if (id.includes("src/modules/ticket-fields")) {
return "ticket-fields";
}
// Bundle all files from `src/modules/MODULE_NAME/translations/locales/*.json to `${MODULE_NAME}-translations.js`
const translationFileMatch = id.match(TRANSLATION_FILE_REGEX);
if (translationFileMatch) {
return `${translationFileMatch[1]}-translations`;
}
},
entryFileNames: fileNames,
chunkFileNames: fileNames,
},
plugins: [
nodeResolve({
extensions: [".js"],
}),
commonjs(),
typescript(),
replace({
preventAssignment: true,
"process.env.NODE_ENV": '"production"',
}),
svgr({
svgo: true,
svgoConfig: {
plugins: [
{
name: "preset-default",
params: {
overrides: {
removeTitle: false,
convertPathData: false,
removeViewBox: false,
},
},
},
],
},
}),
json(),
dynamicImportVars(),
isProduction && terser(),
generateImportMap(),
],
watch: {
clearScreen: false,
},
},
]);

742
script.js generated Normal file
View File

@@ -0,0 +1,742 @@
(function () {
'use strict';
// Key map
const ENTER = 13;
const ESCAPE = 27;
function toggleNavigation(toggle, menu) {
const isExpanded = menu.getAttribute("aria-expanded") === "true";
menu.setAttribute("aria-expanded", !isExpanded);
toggle.setAttribute("aria-expanded", !isExpanded);
}
function closeNavigation(toggle, menu) {
menu.setAttribute("aria-expanded", false);
toggle.setAttribute("aria-expanded", false);
toggle.focus();
}
// Navigation
window.addEventListener("DOMContentLoaded", () => {
const menuButton = document.querySelector(".header .menu-button-mobile");
const menuList = document.querySelector("#user-nav-mobile");
menuButton.addEventListener("click", (event) => {
event.stopPropagation();
toggleNavigation(menuButton, menuList);
});
menuList.addEventListener("keyup", (event) => {
if (event.keyCode === ESCAPE) {
event.stopPropagation();
closeNavigation(menuButton, menuList);
}
});
// Toggles expanded aria to collapsible elements
const collapsible = document.querySelectorAll(
".collapsible-nav, .collapsible-sidebar"
);
collapsible.forEach((element) => {
const toggle = element.querySelector(
".collapsible-nav-toggle, .collapsible-sidebar-toggle"
);
element.addEventListener("click", () => {
toggleNavigation(toggle, element);
});
element.addEventListener("keyup", (event) => {
console.log("escape");
if (event.keyCode === ESCAPE) {
closeNavigation(toggle, element);
}
});
});
// If multibrand search has more than 5 help centers or categories collapse the list
const multibrandFilterLists = document.querySelectorAll(
".multibrand-filter-list"
);
multibrandFilterLists.forEach((filter) => {
if (filter.children.length > 6) {
// Display the show more button
const trigger = filter.querySelector(".see-all-filters");
trigger.setAttribute("aria-hidden", false);
// Add event handler for click
trigger.addEventListener("click", (event) => {
event.stopPropagation();
trigger.parentNode.removeChild(trigger);
filter.classList.remove("multibrand-filter-list--collapsed");
});
}
});
});
const isPrintableChar = (str) => {
return str.length === 1 && str.match(/^\S$/);
};
function Dropdown(toggle, menu) {
this.toggle = toggle;
this.menu = menu;
this.menuPlacement = {
top: menu.classList.contains("dropdown-menu-top"),
end: menu.classList.contains("dropdown-menu-end"),
};
this.toggle.addEventListener("click", this.clickHandler.bind(this));
this.toggle.addEventListener("keydown", this.toggleKeyHandler.bind(this));
this.menu.addEventListener("keydown", this.menuKeyHandler.bind(this));
document.body.addEventListener("click", this.outsideClickHandler.bind(this));
const toggleId = this.toggle.getAttribute("id") || crypto.randomUUID();
const menuId = this.menu.getAttribute("id") || crypto.randomUUID();
this.toggle.setAttribute("id", toggleId);
this.menu.setAttribute("id", menuId);
this.toggle.setAttribute("aria-controls", menuId);
this.menu.setAttribute("aria-labelledby", toggleId);
if (!this.toggle.hasAttribute("aria-haspopup")) {
this.toggle.setAttribute("aria-haspopup", "true");
}
if (!this.toggle.hasAttribute("aria-expanded")) {
this.toggle.setAttribute("aria-expanded", "false");
}
this.toggleIcon = this.toggle.querySelector(".dropdown-chevron-icon");
if (this.toggleIcon) {
this.toggleIcon.setAttribute("aria-hidden", "true");
}
this.menu.setAttribute("tabindex", -1);
this.menuItems.forEach((menuItem) => {
menuItem.tabIndex = -1;
});
this.focusedIndex = -1;
}
Dropdown.prototype = {
get isExpanded() {
return this.toggle.getAttribute("aria-expanded") === "true";
},
get menuItems() {
return Array.prototype.slice.call(
this.menu.querySelectorAll("[role='menuitem'], [role='menuitemradio']")
);
},
dismiss: function () {
if (!this.isExpanded) return;
this.toggle.setAttribute("aria-expanded", "false");
this.menu.classList.remove("dropdown-menu-end", "dropdown-menu-top");
this.focusedIndex = -1;
},
open: function () {
if (this.isExpanded) return;
this.toggle.setAttribute("aria-expanded", "true");
this.handleOverflow();
},
handleOverflow: function () {
var rect = this.menu.getBoundingClientRect();
var overflow = {
right: rect.left < 0 || rect.left + rect.width > window.innerWidth,
bottom: rect.top < 0 || rect.top + rect.height > window.innerHeight,
};
if (overflow.right || this.menuPlacement.end) {
this.menu.classList.add("dropdown-menu-end");
}
if (overflow.bottom || this.menuPlacement.top) {
this.menu.classList.add("dropdown-menu-top");
}
if (this.menu.getBoundingClientRect().top < 0) {
this.menu.classList.remove("dropdown-menu-top");
}
},
focusByIndex: function (index) {
if (!this.menuItems.length) return;
this.menuItems.forEach((item, itemIndex) => {
if (itemIndex === index) {
item.tabIndex = 0;
item.focus();
} else {
item.tabIndex = -1;
}
});
this.focusedIndex = index;
},
focusFirstMenuItem: function () {
this.focusByIndex(0);
},
focusLastMenuItem: function () {
this.focusByIndex(this.menuItems.length - 1);
},
focusNextMenuItem: function (currentItem) {
if (!this.menuItems.length) return;
const currentIndex = this.menuItems.indexOf(currentItem);
const nextIndex = (currentIndex + 1) % this.menuItems.length;
this.focusByIndex(nextIndex);
},
focusPreviousMenuItem: function (currentItem) {
if (!this.menuItems.length) return;
const currentIndex = this.menuItems.indexOf(currentItem);
const previousIndex =
currentIndex <= 0 ? this.menuItems.length - 1 : currentIndex - 1;
this.focusByIndex(previousIndex);
},
focusByChar: function (currentItem, char) {
char = char.toLowerCase();
const itemChars = this.menuItems.map((menuItem) =>
menuItem.textContent.trim()[0].toLowerCase()
);
const startIndex =
(this.menuItems.indexOf(currentItem) + 1) % this.menuItems.length;
// look up starting from current index
let index = itemChars.indexOf(char, startIndex);
// if not found, start from start
if (index === -1) {
index = itemChars.indexOf(char, 0);
}
if (index > -1) {
this.focusByIndex(index);
}
},
outsideClickHandler: function (e) {
if (
this.isExpanded &&
!this.toggle.contains(e.target) &&
!e.composedPath().includes(this.menu)
) {
this.dismiss();
this.toggle.focus();
}
},
clickHandler: function (event) {
event.stopPropagation();
event.preventDefault();
if (this.isExpanded) {
this.dismiss();
this.toggle.focus();
} else {
this.open();
this.focusFirstMenuItem();
}
},
toggleKeyHandler: function (e) {
const key = e.key;
switch (key) {
case "Enter":
case " ":
case "ArrowDown":
case "Down": {
e.stopPropagation();
e.preventDefault();
this.open();
this.focusFirstMenuItem();
break;
}
case "ArrowUp":
case "Up": {
e.stopPropagation();
e.preventDefault();
this.open();
this.focusLastMenuItem();
break;
}
case "Esc":
case "Escape": {
e.stopPropagation();
e.preventDefault();
this.dismiss();
this.toggle.focus();
break;
}
}
},
menuKeyHandler: function (e) {
const key = e.key;
const currentElement = this.menuItems[this.focusedIndex];
if (e.ctrlKey || e.altKey || e.metaKey) {
return;
}
switch (key) {
case "Esc":
case "Escape": {
e.stopPropagation();
e.preventDefault();
this.dismiss();
this.toggle.focus();
break;
}
case "ArrowDown":
case "Down": {
e.stopPropagation();
e.preventDefault();
this.focusNextMenuItem(currentElement);
break;
}
case "ArrowUp":
case "Up": {
e.stopPropagation();
e.preventDefault();
this.focusPreviousMenuItem(currentElement);
break;
}
case "Home":
case "PageUp": {
e.stopPropagation();
e.preventDefault();
this.focusFirstMenuItem();
break;
}
case "End":
case "PageDown": {
e.stopPropagation();
e.preventDefault();
this.focusLastMenuItem();
break;
}
case "Tab": {
if (e.shiftKey) {
e.stopPropagation();
e.preventDefault();
this.dismiss();
this.toggle.focus();
} else {
this.dismiss();
}
break;
}
default: {
if (isPrintableChar(key)) {
e.stopPropagation();
e.preventDefault();
this.focusByChar(currentElement, key);
}
}
}
},
};
// Drodowns
window.addEventListener("DOMContentLoaded", () => {
const dropdowns = [];
const dropdownToggles = document.querySelectorAll(".dropdown-toggle");
dropdownToggles.forEach((toggle) => {
const menu = toggle.nextElementSibling;
if (menu && menu.classList.contains("dropdown-menu")) {
dropdowns.push(new Dropdown(toggle, menu));
}
});
});
// Share
window.addEventListener("DOMContentLoaded", () => {
const links = document.querySelectorAll(".share a");
links.forEach((anchor) => {
anchor.addEventListener("click", (event) => {
event.preventDefault();
window.open(anchor.href, "", "height = 500, width = 500");
});
});
});
// Vanilla JS debounce function, by Josh W. Comeau:
// https://www.joshwcomeau.com/snippets/javascript/debounce/
function debounce(callback, wait) {
let timeoutId = null;
return (...args) => {
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
callback.apply(null, args);
}, wait);
};
}
// Define variables for search field
let searchFormFilledClassName = "search-has-value";
let searchFormSelector = "form[role='search']";
// Clear the search input, and then return focus to it
function clearSearchInput(event) {
event.target
.closest(searchFormSelector)
.classList.remove(searchFormFilledClassName);
let input;
if (event.target.tagName === "INPUT") {
input = event.target;
} else if (event.target.tagName === "BUTTON") {
input = event.target.previousElementSibling;
} else {
input = event.target.closest("button").previousElementSibling;
}
input.value = "";
input.focus();
}
// Have the search input and clear button respond
// when someone presses the escape key, per:
// https://twitter.com/adambsilver/status/1152452833234554880
function clearSearchInputOnKeypress(event) {
const searchInputDeleteKeys = ["Delete", "Escape"];
if (searchInputDeleteKeys.includes(event.key)) {
clearSearchInput(event);
}
}
// Create an HTML button that all users -- especially keyboard users --
// can interact with, to clear the search input.
// To learn more about this, see:
// https://adrianroselli.com/2019/07/ignore-typesearch.html#Delete
// https://www.scottohara.me/blog/2022/02/19/custom-clear-buttons.html
function buildClearSearchButton(inputId) {
const button = document.createElement("button");
button.setAttribute("type", "button");
button.setAttribute("aria-controls", inputId);
button.classList.add("clear-button");
const buttonLabel = window.searchClearButtonLabelLocalized;
const icon = `<svg xmlns='http://www.w3.org/2000/svg' width='12' height='12' focusable='false' role='img' viewBox='0 0 12 12' aria-label='${buttonLabel}'><path stroke='currentColor' stroke-linecap='round' stroke-width='2' d='M3 9l6-6m0 6L3 3'/></svg>`;
button.innerHTML = icon;
button.addEventListener("click", clearSearchInput);
button.addEventListener("keyup", clearSearchInputOnKeypress);
return button;
}
// Append the clear button to the search form
function appendClearSearchButton(input, form) {
const searchClearButton = buildClearSearchButton(input.id);
form.append(searchClearButton);
if (input.value.length > 0) {
form.classList.add(searchFormFilledClassName);
}
}
// Add a class to the search form when the input has a value;
// Remove that class from the search form when the input doesn't have a value.
// Do this on a delay, rather than on every keystroke.
const toggleClearSearchButtonAvailability = debounce((event) => {
const form = event.target.closest(searchFormSelector);
form.classList.toggle(
searchFormFilledClassName,
event.target.value.length > 0
);
}, 200);
// Search
window.addEventListener("DOMContentLoaded", () => {
// Set up clear functionality for the search field
const searchForms = [...document.querySelectorAll(searchFormSelector)];
const searchInputs = searchForms.map((form) =>
form.querySelector("input[type='search']")
);
searchInputs.forEach((input) => {
appendClearSearchButton(input, input.closest(searchFormSelector));
input.addEventListener("keyup", clearSearchInputOnKeypress);
input.addEventListener("keyup", toggleClearSearchButtonAvailability);
});
});
const key = "returnFocusTo";
function saveFocus() {
const activeElementId = document.activeElement.getAttribute("id");
sessionStorage.setItem(key, "#" + activeElementId);
}
function returnFocus() {
const returnFocusTo = sessionStorage.getItem(key);
if (returnFocusTo) {
sessionStorage.removeItem("returnFocusTo");
const returnFocusToEl = document.querySelector(returnFocusTo);
returnFocusToEl && returnFocusToEl.focus && returnFocusToEl.focus();
}
}
// Forms
window.addEventListener("DOMContentLoaded", () => {
// In some cases we should preserve focus after page reload
returnFocus();
// show form controls when the textarea receives focus or back button is used and value exists
const commentContainerTextarea = document.querySelector(
".comment-container textarea"
);
const commentContainerFormControls = document.querySelector(
".comment-form-controls, .comment-ccs"
);
if (commentContainerTextarea) {
commentContainerTextarea.addEventListener(
"focus",
function focusCommentContainerTextarea() {
commentContainerFormControls.style.display = "block";
commentContainerTextarea.removeEventListener(
"focus",
focusCommentContainerTextarea
);
}
);
if (commentContainerTextarea.value !== "") {
commentContainerFormControls.style.display = "block";
}
}
// Expand Request comment form when Add to conversation is clicked
const showRequestCommentContainerTrigger = document.querySelector(
".request-container .comment-container .comment-show-container"
);
const requestCommentFields = document.querySelectorAll(
".request-container .comment-container .comment-fields"
);
const requestCommentSubmit = document.querySelector(
".request-container .comment-container .request-submit-comment"
);
if (showRequestCommentContainerTrigger) {
showRequestCommentContainerTrigger.addEventListener("click", () => {
showRequestCommentContainerTrigger.style.display = "none";
Array.prototype.forEach.call(requestCommentFields, (element) => {
element.style.display = "block";
});
requestCommentSubmit.style.display = "inline-block";
if (commentContainerTextarea) {
commentContainerTextarea.focus();
}
});
}
// Mark as solved button
const requestMarkAsSolvedButton = document.querySelector(
".request-container .mark-as-solved:not([data-disabled])"
);
const requestMarkAsSolvedCheckbox = document.querySelector(
".request-container .comment-container input[type=checkbox]"
);
const requestCommentSubmitButton = document.querySelector(
".request-container .comment-container input[type=submit]"
);
if (requestMarkAsSolvedButton) {
requestMarkAsSolvedButton.addEventListener("click", () => {
requestMarkAsSolvedCheckbox.setAttribute("checked", true);
requestCommentSubmitButton.disabled = true;
requestMarkAsSolvedButton.setAttribute("data-disabled", true);
requestMarkAsSolvedButton.form.submit();
});
}
// Change Mark as solved text according to whether comment is filled
const requestCommentTextarea = document.querySelector(
".request-container .comment-container textarea"
);
const usesWysiwyg =
requestCommentTextarea &&
requestCommentTextarea.dataset.helper === "wysiwyg";
function isEmptyPlaintext(s) {
return s.trim() === "";
}
function isEmptyHtml(xml) {
const doc = new DOMParser().parseFromString(`<_>${xml}</_>`, "text/xml");
const img = doc.querySelector("img");
return img === null && isEmptyPlaintext(doc.children[0].textContent);
}
const isEmpty = usesWysiwyg ? isEmptyHtml : isEmptyPlaintext;
if (requestCommentTextarea) {
requestCommentTextarea.addEventListener("input", () => {
if (isEmpty(requestCommentTextarea.value)) {
if (requestMarkAsSolvedButton) {
requestMarkAsSolvedButton.innerText =
requestMarkAsSolvedButton.getAttribute("data-solve-translation");
}
} else {
if (requestMarkAsSolvedButton) {
requestMarkAsSolvedButton.innerText =
requestMarkAsSolvedButton.getAttribute(
"data-solve-and-submit-translation"
);
}
}
});
}
const selects = document.querySelectorAll(
"#request-status-select, #request-organization-select"
);
selects.forEach((element) => {
element.addEventListener("change", (event) => {
event.stopPropagation();
saveFocus();
element.form.submit();
});
});
// Submit requests filter form on search in the request list page
const quickSearch = document.querySelector("#quick-search");
if (quickSearch) {
quickSearch.addEventListener("keyup", (event) => {
if (event.keyCode === ENTER) {
event.stopPropagation();
saveFocus();
quickSearch.form.submit();
}
});
}
// Submit organization form in the request page
const requestOrganisationSelect = document.querySelector(
"#request-organization select"
);
if (requestOrganisationSelect) {
requestOrganisationSelect.addEventListener("change", () => {
requestOrganisationSelect.form.submit();
});
requestOrganisationSelect.addEventListener("click", (e) => {
// Prevents Ticket details collapsible-sidebar to close on mobile
e.stopPropagation();
});
}
// If there are any error notifications below an input field, focus that field
const notificationElm = document.querySelector(".notification-error");
if (
notificationElm &&
notificationElm.previousElementSibling &&
typeof notificationElm.previousElementSibling.focus === "function"
) {
notificationElm.previousElementSibling.focus();
}
});
})();
/* ### BEGIN part to custom JS from template ### */
document.addEventListener("DOMContentLoaded", function() {
document.querySelectorAll('pre').forEach(function(pre) {
// Evita duplicar o botão copiar
if (pre.querySelector('.copy-btn')) return;
pre.style.position = 'relative'; // importante para posicionamento absoluto do botão
// Criar botão copiar com ícone SVG
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.type = 'button';
copyBtn.setAttribute('aria-label', 'Copiar código');
// Ícone clipboard SVG inline
copyBtn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z" />
</svg>
`;
copyBtn.addEventListener('click', function() {
const codeElement = pre.querySelector('code');
let textToCopy = '';
if (codeElement) {
textToCopy = codeElement.innerText;
} else {
// Copiar apenas texto puro do <pre> ignorando o botão
textToCopy = Array.from(pre.childNodes)
.filter(node => node.nodeType === Node.TEXT_NODE)
.map(node => node.textContent)
.join('');
}
navigator.clipboard.writeText(textToCopy).then(() => {
// Feedback visual simples
copyBtn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" >
<path fill="green" d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>`;
setTimeout(() => {
copyBtn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z" />
</svg>
`;
}, 1500);
}).catch(() => {
// Se erro, exibe ícone de erro (vermelho)
copyBtn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" >
<path fill="red" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.29 13.29-1.41 1.41L12 13.41l-2.88 2.88-1.41-1.41L10.59 12 7.71 9.12l1.41-1.41L12 10.59l2.88-2.88 1.41 1.41L13.41 12l2.88 2.88z"/>
</svg>`;
setTimeout(() => {
copyBtn.innerHTML = `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2zm0 16H8V7h11v14z" />
</svg>`;
}, 1500);
});
});
pre.appendChild(copyBtn);
});
});
/* ### END part to custom JS from template ### */

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
settings/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

BIN
settings/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

288
src/Dropdown.js Normal file
View File

@@ -0,0 +1,288 @@
const isPrintableChar = (str) => {
return str.length === 1 && str.match(/^\S$/);
};
export default function Dropdown(toggle, menu) {
this.toggle = toggle;
this.menu = menu;
this.menuPlacement = {
top: menu.classList.contains("dropdown-menu-top"),
end: menu.classList.contains("dropdown-menu-end"),
};
this.toggle.addEventListener("click", this.clickHandler.bind(this));
this.toggle.addEventListener("keydown", this.toggleKeyHandler.bind(this));
this.menu.addEventListener("keydown", this.menuKeyHandler.bind(this));
document.body.addEventListener("click", this.outsideClickHandler.bind(this));
const toggleId = this.toggle.getAttribute("id") || crypto.randomUUID();
const menuId = this.menu.getAttribute("id") || crypto.randomUUID();
this.toggle.setAttribute("id", toggleId);
this.menu.setAttribute("id", menuId);
this.toggle.setAttribute("aria-controls", menuId);
this.menu.setAttribute("aria-labelledby", toggleId);
if (!this.toggle.hasAttribute("aria-haspopup")) {
this.toggle.setAttribute("aria-haspopup", "true");
}
if (!this.toggle.hasAttribute("aria-expanded")) {
this.toggle.setAttribute("aria-expanded", "false");
}
this.toggleIcon = this.toggle.querySelector(".dropdown-chevron-icon");
if (this.toggleIcon) {
this.toggleIcon.setAttribute("aria-hidden", "true");
}
this.menu.setAttribute("tabindex", -1);
this.menuItems.forEach((menuItem) => {
menuItem.tabIndex = -1;
});
this.focusedIndex = -1;
}
Dropdown.prototype = {
get isExpanded() {
return this.toggle.getAttribute("aria-expanded") === "true";
},
get menuItems() {
return Array.prototype.slice.call(
this.menu.querySelectorAll("[role='menuitem'], [role='menuitemradio']")
);
},
dismiss: function () {
if (!this.isExpanded) return;
this.toggle.setAttribute("aria-expanded", "false");
this.menu.classList.remove("dropdown-menu-end", "dropdown-menu-top");
this.focusedIndex = -1;
},
open: function () {
if (this.isExpanded) return;
this.toggle.setAttribute("aria-expanded", "true");
this.handleOverflow();
},
handleOverflow: function () {
var rect = this.menu.getBoundingClientRect();
var overflow = {
right: rect.left < 0 || rect.left + rect.width > window.innerWidth,
bottom: rect.top < 0 || rect.top + rect.height > window.innerHeight,
};
if (overflow.right || this.menuPlacement.end) {
this.menu.classList.add("dropdown-menu-end");
}
if (overflow.bottom || this.menuPlacement.top) {
this.menu.classList.add("dropdown-menu-top");
}
if (this.menu.getBoundingClientRect().top < 0) {
this.menu.classList.remove("dropdown-menu-top");
}
},
focusByIndex: function (index) {
if (!this.menuItems.length) return;
this.menuItems.forEach((item, itemIndex) => {
if (itemIndex === index) {
item.tabIndex = 0;
item.focus();
} else {
item.tabIndex = -1;
}
});
this.focusedIndex = index;
},
focusFirstMenuItem: function () {
this.focusByIndex(0);
},
focusLastMenuItem: function () {
this.focusByIndex(this.menuItems.length - 1);
},
focusNextMenuItem: function (currentItem) {
if (!this.menuItems.length) return;
const currentIndex = this.menuItems.indexOf(currentItem);
const nextIndex = (currentIndex + 1) % this.menuItems.length;
this.focusByIndex(nextIndex);
},
focusPreviousMenuItem: function (currentItem) {
if (!this.menuItems.length) return;
const currentIndex = this.menuItems.indexOf(currentItem);
const previousIndex =
currentIndex <= 0 ? this.menuItems.length - 1 : currentIndex - 1;
this.focusByIndex(previousIndex);
},
focusByChar: function (currentItem, char) {
char = char.toLowerCase();
const itemChars = this.menuItems.map((menuItem) =>
menuItem.textContent.trim()[0].toLowerCase()
);
const startIndex =
(this.menuItems.indexOf(currentItem) + 1) % this.menuItems.length;
// look up starting from current index
let index = itemChars.indexOf(char, startIndex);
// if not found, start from start
if (index === -1) {
index = itemChars.indexOf(char, 0);
}
if (index > -1) {
this.focusByIndex(index);
}
},
outsideClickHandler: function (e) {
if (
this.isExpanded &&
!this.toggle.contains(e.target) &&
!e.composedPath().includes(this.menu)
) {
this.dismiss();
this.toggle.focus();
}
},
clickHandler: function (event) {
event.stopPropagation();
event.preventDefault();
if (this.isExpanded) {
this.dismiss();
this.toggle.focus();
} else {
this.open();
this.focusFirstMenuItem();
}
},
toggleKeyHandler: function (e) {
const key = e.key;
switch (key) {
case "Enter":
case " ":
case "ArrowDown":
case "Down": {
e.stopPropagation();
e.preventDefault();
this.open();
this.focusFirstMenuItem();
break;
}
case "ArrowUp":
case "Up": {
e.stopPropagation();
e.preventDefault();
this.open();
this.focusLastMenuItem();
break;
}
case "Esc":
case "Escape": {
e.stopPropagation();
e.preventDefault();
this.dismiss();
this.toggle.focus();
break;
}
}
},
menuKeyHandler: function (e) {
const key = e.key;
const currentElement = this.menuItems[this.focusedIndex];
if (e.ctrlKey || e.altKey || e.metaKey) {
return;
}
switch (key) {
case "Esc":
case "Escape": {
e.stopPropagation();
e.preventDefault();
this.dismiss();
this.toggle.focus();
break;
}
case "ArrowDown":
case "Down": {
e.stopPropagation();
e.preventDefault();
this.focusNextMenuItem(currentElement);
break;
}
case "ArrowUp":
case "Up": {
e.stopPropagation();
e.preventDefault();
this.focusPreviousMenuItem(currentElement);
break;
}
case "Home":
case "PageUp": {
e.stopPropagation();
e.preventDefault();
this.focusFirstMenuItem();
break;
}
case "End":
case "PageDown": {
e.stopPropagation();
e.preventDefault();
this.focusLastMenuItem();
break;
}
case "Tab": {
if (e.shiftKey) {
e.stopPropagation();
e.preventDefault();
this.dismiss();
this.toggle.focus();
} else {
this.dismiss();
}
break;
}
default: {
if (isPrintableChar(key)) {
e.stopPropagation();
e.preventDefault();
this.focusByChar(currentElement, key);
}
}
}
},
};

471
src/Dropdown.spec.js Normal file
View File

@@ -0,0 +1,471 @@
import { expect, jest, describe, it, beforeEach } from "@jest/globals";
import crypto from "crypto";
import { fireEvent, screen, within } from "@testing-library/dom";
import { userEvent } from "@testing-library/user-event";
import Dropdown from "./Dropdown";
expect.extend({
toHaveMenuOpen(targetElement) {
const isOpen = targetElement.getAttribute("aria-expanded") === "true";
return isOpen
? {
pass: true,
message: () =>
`${targetElement} has aria-expanded attribute set to true`,
}
: {
pass: false,
message: () =>
`${targetElement} does not have aria-expanded attribute set to true`,
};
},
toHaveMenuClosed(targetElement) {
const isClosed = targetElement.getAttribute("aria-expanded") === "false";
return isClosed
? {
pass: true,
message: () =>
`${targetElement} has aria-expanded attribute set to false`,
}
: {
pass: false,
message: () =>
`${targetElement} does not have aria-expanded attribute set to false`,
};
},
});
const menuHtml = `
<div class="dropdown">
<button class="dropdown-toggle" aria-haspopup="true" aria-expanded="false">Sort by</button>
<span class="dropdown-menu" role="menu">
<a role="menuitem" href="http://example.tld/first">First</a>
<a role="menuitem" href="http://example.tld/second">Second</a>
<a role="menuitem" href="http://example.tld/third">Third</a>
</span>
</div>
`;
const createMenu = (html = menuHtml) => {
document.body.innerHTML = html;
const targetElement = screen.getByRole("button");
const menuElement = screen.getByRole("menu");
return {
targetElement,
menuElement,
menu: new Dropdown(targetElement, menuElement),
};
};
describe("Dropdown", () => {
beforeEach(() => {
Object.defineProperty(window, "crypto", {
value: {
randomUUID: () => crypto.randomUUID(),
},
});
});
describe("attributes", () => {
it("preserves existing ids", () => {
const { targetElement, menuElement } = createMenu(`
<div class="dropdown">
<button id="targetId" class="dropdown-toggle" aria-haspopup="true" aria-expanded="false">Sort by</button>
<span id="menuId" class="dropdown-menu" role="menu">
<a role="menuitem" href="http://example.tld/first">First</a>
<a role="menuitem" href="http://example.tld/second">Second</a>
<a role="menuitem" href="http://example.tld/third">Third</a>
</span>
</div>
`);
expect(targetElement).toHaveAttribute("id", "targetId");
expect(menuElement).toHaveAttribute("id", "menuId");
expect(targetElement).toHaveAttribute("aria-controls", "menuId");
expect(targetElement).toHaveAttribute("aria-expanded", "false");
expect(menuElement).toHaveAttribute("aria-labelledby", "targetId");
});
it("assigns a default id to target and menu", () => {
const { targetElement, menuElement } = createMenu();
expect(targetElement).toHaveAttribute("id");
expect(menuElement).toHaveAttribute("id");
});
it("sets aria-controls attribute to target element", () => {
const { targetElement, menuElement } = createMenu();
expect(targetElement).toHaveAttribute("aria-controls", menuElement.id);
expect(menuElement).toHaveAttribute("aria-labelledby", targetElement.id);
});
it("sets aria-haspopup to the button that opens the menu", () => {
const { targetElement } = createMenu();
expect(targetElement).toHaveAttribute("aria-haspopup", "true");
});
it("sets aria-expanded", () => {
const { targetElement } = createMenu();
expect(targetElement).toHaveMenuClosed();
fireEvent.keyDown(targetElement, { key: "Enter" });
expect(targetElement).toHaveMenuOpen();
fireEvent.keyDown(targetElement, { key: "Escape" });
expect(targetElement).toHaveMenuClosed();
});
it("hides default target icon from assistive technology, if it's present and isn't hidden already", () => {
const { targetElement } = createMenu(`
<div class="dropdown">
<button id="targetId" class="dropdown-toggle" aria-haspopup="true" aria-expanded="false">
Sort by
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" focusable="false" viewBox="0 0 12 12" class="dropdown-chevron-icon" data-testid="icon">
<path fill="none" stroke="currentColor" stroke-linecap="round" d="M3 4.5l2.6 2.6c.2.2.5.2.7 0L9 4.5"/>
</svg>
</button>
<span id="menuId" class="dropdown-menu" role="menu">
<a role="menuitem" href="http://example.tld/first">First</a>
<a role="menuitem" href="http://example.tld/second">Second</a>
<a role="menuitem" href="http://example.tld/third">Third</a>
</span>
</div>
`);
const iconElement = within(targetElement).getByTestId("icon");
expect(iconElement).toHaveClass("dropdown-chevron-icon");
expect(iconElement).toHaveAttribute("aria-hidden", "true");
});
});
describe("Keyboard Support", () => {
describe("Menu Button", () => {
[" ", "Enter", "ArrowDown", "Down"].forEach((key) => {
it(`pressing "${key}" opens the menu and moves focus to first menuitem`, () => {
const { targetElement } = createMenu();
expect(targetElement).toHaveMenuClosed();
fireEvent.keyDown(targetElement, { key });
fireEvent.keyUp(targetElement, { key });
expect(targetElement).toHaveMenuOpen();
expect(document.activeElement).toHaveTextContent("First");
});
});
["ArrowUp", "Up"].forEach((key) => {
it(`pressing "${key}" opens the menu and moves focus to last menuitem`, () => {
const { targetElement } = createMenu();
expect(targetElement).toHaveMenuClosed();
fireEvent.keyDown(targetElement, { key });
fireEvent.keyUp(targetElement, { key });
expect(targetElement).toHaveMenuOpen();
expect(document.activeElement).toHaveTextContent("Third");
});
});
["Escape", "Esc"].forEach((key) => {
it(`pressing "${key}" closes the menu and moves focus to target`, () => {
const { targetElement } = createMenu();
expect(targetElement).toHaveMenuClosed();
fireEvent.keyDown(targetElement, { key: "Enter" });
fireEvent.keyUp(targetElement, { key: "Enter" });
expect(targetElement).toHaveMenuOpen();
fireEvent.keyDown(targetElement, { key });
fireEvent.keyUp(targetElement, { key });
expect(targetElement).toHaveMenuClosed();
expect(document.activeElement).toEqual(targetElement);
});
});
it(`clicking it opens and closes the menu`, () => {
const { targetElement } = createMenu();
expect(targetElement).toHaveMenuClosed();
fireEvent.click(targetElement);
expect(targetElement).toHaveMenuOpen();
fireEvent.click(targetElement);
expect(targetElement).toHaveMenuClosed();
});
});
describe("Menu", () => {
["Esc", "Escape"].forEach((key) => {
it(`pressing '${key}' closes the menu and focuses target`, () => {
const { targetElement, menuElement } = createMenu();
fireEvent.keyDown(targetElement, { key: "Enter" });
fireEvent.keyDown(menuElement, { key });
fireEvent.keyUp(menuElement, { key });
expect(targetElement).toHaveMenuClosed();
expect(document.activeElement).toEqual(targetElement);
});
});
["Up", "ArrowUp"].forEach((key) => {
it(`pressing '${key}' moves focus to the previous item`, () => {
const { targetElement, menuElement } = createMenu();
fireEvent.keyDown(targetElement, { key: "Enter" });
expect(document.activeElement).toHaveTextContent("First");
fireEvent.keyDown(menuElement, { key: "ArrowDown" });
expect(document.activeElement).toHaveTextContent("Second");
fireEvent.keyDown(menuElement, { key });
expect(document.activeElement).toHaveTextContent("First");
});
describe("when focus is on first menu item", () => {
it(`pressing '${key}' moves focus to the last item`, () => {
const { targetElement, menuElement } = createMenu();
fireEvent.keyDown(targetElement, { key: "Enter" });
expect(document.activeElement).toHaveTextContent("First");
fireEvent.keyDown(menuElement, { key });
expect(document.activeElement).toHaveTextContent("Third");
});
});
});
["Down", "ArrowDown"].forEach((key) => {
it(`pressing '${key}' moves focus to the next item`, () => {
const { targetElement, menuElement } = createMenu();
fireEvent.keyDown(targetElement, { key: "Enter" });
expect(document.activeElement).toHaveTextContent("First");
fireEvent.keyDown(menuElement, { key: "ArrowDown" });
expect(document.activeElement).toHaveTextContent("Second");
});
describe("when focus is on last menu item", () => {
it(`pressing '${key}' moves focus to the first item`, () => {
const { targetElement, menuElement } = createMenu();
fireEvent.keyDown(targetElement, { key: "Enter" });
expect(document.activeElement).toHaveTextContent("First");
fireEvent.keyDown(menuElement, { key });
expect(document.activeElement).toHaveTextContent("Second");
fireEvent.keyDown(menuElement, { key });
expect(document.activeElement).toHaveTextContent("Third");
fireEvent.keyDown(menuElement, { key });
expect(document.activeElement).toHaveTextContent("First");
});
});
});
["Home", "PageUp"].forEach((key) => {
it(`pressing '${key}' moves focus to the first item`, () => {
const { targetElement, menuElement } = createMenu();
fireEvent.keyDown(targetElement, { key: "Enter" });
fireEvent.keyDown(menuElement, { key: "ArrowUp" });
expect(document.activeElement).toHaveTextContent("Third");
fireEvent.keyDown(menuElement, { key });
expect(document.activeElement).toHaveTextContent("First");
});
});
["End", "PageDown"].forEach((key) => {
it(`pressing '${key}' moves focus to the last item`, () => {
const { targetElement, menuElement } = createMenu();
fireEvent.keyDown(targetElement, { key: "Enter" });
expect(document.activeElement).toHaveTextContent("First");
fireEvent.keyDown(menuElement, { key });
expect(document.activeElement).toHaveTextContent("Third");
});
});
["Control", "Meta", "Alt"].forEach((key) => {
it(`pressing '${key}' ignores any other key`, async () => {
const { targetElement } = createMenu();
fireEvent.keyDown(targetElement, { key: "Enter" });
expect(document.activeElement).toHaveTextContent("First");
await userEvent.keyboard(`{${key}>}{ArrowDown}{/${key}}`);
expect(document.activeElement).toHaveTextContent("First");
});
});
it("pressing 'Tab' closes the menu and moves focus to the next focusable element", async () => {
const { targetElement } = createMenu(`
<div class="dropdown">
<button class="dropdown-toggle" aria-haspopup="true" aria-expanded="false">Sort by</button>
<span class="dropdown-menu" role="menu">
<a role="menuitem" href="http://example.tld/first">First</a>
<a role="menuitem" href="http://example.tld/second">Second</a>
<a role="menuitem" href="http://example.tld/third">Third</a>
</span>
</div>
<input type="text" value="" />
`);
fireEvent.keyDown(targetElement, { key: "Enter" });
await userEvent.tab();
expect(targetElement).toHaveMenuClosed();
expect(document.activeElement).toEqual(screen.getByRole("textbox"));
});
it("pressing 'Shift+Tab' closes the menu and returns focus to the target element", async () => {
const { targetElement } = createMenu(`
<div class="dropdown">
<button class="dropdown-toggle" aria-haspopup="true" aria-expanded="false">Sort by</button>
<span class="dropdown-menu" role="menu">
<a role="menuitem" href="http://example.tld/first">First</a>
<a role="menuitem" href="http://example.tld/second">Second</a>
<a role="menuitem" href="http://example.tld/third">Third</a>
</span>
</div>
<input type="text" value="" />
`);
fireEvent.keyDown(targetElement, { key: "Enter" });
await userEvent.tab({ shift: true });
expect(targetElement).toHaveMenuClosed();
expect(document.activeElement).toEqual(screen.getByRole("button"));
});
describe('pressing "[A-z]"', () => {
it("moves focus to the next menu item with a label that starts with such char", async () => {
const { targetElement } = createMenu(`
<div class="dropdown">
<button class="dropdown-toggle" aria-haspopup="true" aria-expanded="false">Sort by</button>
<span class="dropdown-menu" role="menu">
<a role="menuitem" href="http://example.tld/apricots">Apricots</a>
<a role="menuitem" href="http://example.tld/asparagus">Asparagus</a>
<a role="menuitem" href="http://example.tld/tomato">Tomato</a>
</span>
</div>
<input type="text" value="" />
`);
fireEvent.keyDown(targetElement, { key: "Enter" });
expect(document.activeElement).toHaveTextContent("Apricots");
await userEvent.keyboard("a");
expect(document.activeElement).toHaveTextContent("Asparagus");
await userEvent.keyboard("a");
expect(document.activeElement).toHaveTextContent("Apricots");
await userEvent.keyboard("t");
expect(document.activeElement).toHaveTextContent("Tomato");
});
it("keeps focus when no menu item starts with such char", async () => {
const { targetElement } = createMenu(`
<div class="dropdown">
<button class="dropdown-toggle" aria-haspopup="true" aria-expanded="false">Sort by</button>
<span class="dropdown-menu" role="menu">
<a role="menuitem" href="http://example.tld/apricots">Apricots</a>
<a role="menuitem" href="http://example.tld/asparagus">Asparagus</a>
<a role="menuitem" href="http://example.tld/tomato">Tomato</a>
</span>
</div>
<input type="text" value="" />
`);
fireEvent.keyDown(targetElement, { key: "Enter" });
expect(document.activeElement).toHaveTextContent("Apricots");
await userEvent.keyboard("b");
expect(document.activeElement).toHaveTextContent("Apricots");
});
});
});
});
describe("outside click", () => {
describe("when clicking on the target", () => {
it("toggles the menu", async () => {
const { targetElement } = createMenu();
await userEvent.click(targetElement);
expect(targetElement).toHaveMenuOpen();
await userEvent.click(targetElement);
expect(targetElement).toHaveMenuClosed();
});
});
describe("when clicking on the menu", () => {
it("does not close the menu", async () => {
const { targetElement } = createMenu();
await userEvent.click(targetElement);
expect(targetElement).toHaveMenuOpen();
const menuItem = screen.getAllByRole("menuitem")[0];
const menuItemSpy = jest.fn();
// add custom click handler to prevent navigation
menuItem.addEventListener(
"click",
(event) => {
event.preventDefault();
menuItemSpy();
},
false
);
await userEvent.click(menuItem);
expect(targetElement).toHaveMenuOpen();
expect(menuItemSpy).toHaveBeenCalled();
});
});
describe("when clicking outside", () => {
it("closes the menu", async () => {
const { targetElement } = createMenu();
await userEvent.click(targetElement);
expect(targetElement).toHaveMenuOpen();
await userEvent.click(document.body);
expect(targetElement).toHaveMenuClosed();
});
});
});
describe("with role='menuitemradio'", () => {
it("attaches keyboard event handlers", async () => {
const { targetElement } = createMenu(`
<div class="dropdown">
<button class="dropdown-toggle" aria-haspopup="true" aria-expanded="false">Sort by</button>
<ul class="dropdown-menu" role="menu">
<li role="none"><a role="menuitemradio" aria-checked="true" href="http://example.tld/apricots">Apricots</a></li>
<li role="none"><a role="menuitemradio" href="http://example.tld/asparagus">Asparagus</a></li>
<li role="none"><a role="menuitemradio" href="http://example.tld/tomato">Tomato</a></li>
</ul>
</div>
<input type="text" value="" />
`);
fireEvent.keyDown(targetElement, { key: "Enter" });
expect(document.activeElement).toHaveTextContent("Apricots");
await userEvent.keyboard("t");
expect(document.activeElement).toHaveTextContent("Tomato");
});
});
});

7
src/Keys.js Normal file
View File

@@ -0,0 +1,7 @@
// Key map
export const ENTER = 13;
export const ESCAPE = 27;
export const SPACE = 32;
export const UP = 38;
export const DOWN = 40;
export const TAB = 9;

15
src/dropdowns.js Normal file
View File

@@ -0,0 +1,15 @@
import Dropdown from "./Dropdown";
// Drodowns
window.addEventListener("DOMContentLoaded", () => {
const dropdowns = [];
const dropdownToggles = document.querySelectorAll(".dropdown-toggle");
dropdownToggles.forEach((toggle) => {
const menu = toggle.nextElementSibling;
if (menu && menu.classList.contains("dropdown-menu")) {
dropdowns.push(new Dropdown(toggle, menu));
}
});
});

15
src/focus.js Normal file
View File

@@ -0,0 +1,15 @@
const key = "returnFocusTo";
export function saveFocus() {
const activeElementId = document.activeElement.getAttribute("id");
sessionStorage.setItem(key, "#" + activeElementId);
}
export function returnFocus() {
const returnFocusTo = sessionStorage.getItem(key);
if (returnFocusTo) {
sessionStorage.removeItem("returnFocusTo");
const returnFocusToEl = document.querySelector(returnFocusTo);
returnFocusToEl && returnFocusToEl.focus && returnFocusToEl.focus();
}
}

168
src/forms.js Normal file
View File

@@ -0,0 +1,168 @@
import { ENTER } from "./Keys";
import { saveFocus, returnFocus } from "./focus";
// Forms
window.addEventListener("DOMContentLoaded", () => {
// In some cases we should preserve focus after page reload
returnFocus();
// show form controls when the textarea receives focus or back button is used and value exists
const commentContainerTextarea = document.querySelector(
".comment-container textarea"
);
const commentContainerFormControls = document.querySelector(
".comment-form-controls, .comment-ccs"
);
if (commentContainerTextarea) {
commentContainerTextarea.addEventListener(
"focus",
function focusCommentContainerTextarea() {
commentContainerFormControls.style.display = "block";
commentContainerTextarea.removeEventListener(
"focus",
focusCommentContainerTextarea
);
}
);
if (commentContainerTextarea.value !== "") {
commentContainerFormControls.style.display = "block";
}
}
// Expand Request comment form when Add to conversation is clicked
const showRequestCommentContainerTrigger = document.querySelector(
".request-container .comment-container .comment-show-container"
);
const requestCommentFields = document.querySelectorAll(
".request-container .comment-container .comment-fields"
);
const requestCommentSubmit = document.querySelector(
".request-container .comment-container .request-submit-comment"
);
if (showRequestCommentContainerTrigger) {
showRequestCommentContainerTrigger.addEventListener("click", () => {
showRequestCommentContainerTrigger.style.display = "none";
Array.prototype.forEach.call(requestCommentFields, (element) => {
element.style.display = "block";
});
requestCommentSubmit.style.display = "inline-block";
if (commentContainerTextarea) {
commentContainerTextarea.focus();
}
});
}
// Mark as solved button
const requestMarkAsSolvedButton = document.querySelector(
".request-container .mark-as-solved:not([data-disabled])"
);
const requestMarkAsSolvedCheckbox = document.querySelector(
".request-container .comment-container input[type=checkbox]"
);
const requestCommentSubmitButton = document.querySelector(
".request-container .comment-container input[type=submit]"
);
if (requestMarkAsSolvedButton) {
requestMarkAsSolvedButton.addEventListener("click", () => {
requestMarkAsSolvedCheckbox.setAttribute("checked", true);
requestCommentSubmitButton.disabled = true;
requestMarkAsSolvedButton.setAttribute("data-disabled", true);
requestMarkAsSolvedButton.form.submit();
});
}
// Change Mark as solved text according to whether comment is filled
const requestCommentTextarea = document.querySelector(
".request-container .comment-container textarea"
);
const usesWysiwyg =
requestCommentTextarea &&
requestCommentTextarea.dataset.helper === "wysiwyg";
function isEmptyPlaintext(s) {
return s.trim() === "";
}
function isEmptyHtml(xml) {
const doc = new DOMParser().parseFromString(`<_>${xml}</_>`, "text/xml");
const img = doc.querySelector("img");
return img === null && isEmptyPlaintext(doc.children[0].textContent);
}
const isEmpty = usesWysiwyg ? isEmptyHtml : isEmptyPlaintext;
if (requestCommentTextarea) {
requestCommentTextarea.addEventListener("input", () => {
if (isEmpty(requestCommentTextarea.value)) {
if (requestMarkAsSolvedButton) {
requestMarkAsSolvedButton.innerText =
requestMarkAsSolvedButton.getAttribute("data-solve-translation");
}
} else {
if (requestMarkAsSolvedButton) {
requestMarkAsSolvedButton.innerText =
requestMarkAsSolvedButton.getAttribute(
"data-solve-and-submit-translation"
);
}
}
});
}
const selects = document.querySelectorAll(
"#request-status-select, #request-organization-select"
);
selects.forEach((element) => {
element.addEventListener("change", (event) => {
event.stopPropagation();
saveFocus();
element.form.submit();
});
});
// Submit requests filter form on search in the request list page
const quickSearch = document.querySelector("#quick-search");
if (quickSearch) {
quickSearch.addEventListener("keyup", (event) => {
if (event.keyCode === ENTER) {
event.stopPropagation();
saveFocus();
quickSearch.form.submit();
}
});
}
// Submit organization form in the request page
const requestOrganisationSelect = document.querySelector(
"#request-organization select"
);
if (requestOrganisationSelect) {
requestOrganisationSelect.addEventListener("change", () => {
requestOrganisationSelect.form.submit();
});
requestOrganisationSelect.addEventListener("click", (e) => {
// Prevents Ticket details collapsible-sidebar to close on mobile
e.stopPropagation();
});
}
// If there are any error notifications below an input field, focus that field
const notificationElm = document.querySelector(".notification-error");
if (
notificationElm &&
notificationElm.previousElementSibling &&
typeof notificationElm.previousElementSibling.focus === "function"
) {
notificationElm.previousElementSibling.focus();
}
});

7
src/index.js Normal file
View File

@@ -0,0 +1,7 @@
import "../styles/index.scss";
import "./navigation";
import "./dropdowns";
import "./share";
import "./search";
import "./forms";

View File

@@ -0,0 +1,163 @@
import { render } from "../test/render";
import { screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import ApprovalRequestListPage from "./ApprovalRequestListPage";
import { useSearchApprovalRequests } from "./hooks/useSearchApprovalRequests";
jest.mock(
"@zendeskgarden/svg-icons/src/16/search-stroke.svg",
() => "svg-mock"
);
jest.mock("./hooks/useSearchApprovalRequests");
const mockUseSearchApprovalRequests = useSearchApprovalRequests as jest.Mock;
const mockApprovalRequests = [
{
id: 123,
subject: "Hardware request",
requester_name: "Jane Doe",
created_by_name: "John Doe",
created_at: "2024-02-20T10:00:00Z",
status: "active",
},
{
id: 456,
subject: "Software license",
requester_name: "Jane Smith",
created_by_name: "Bob Smith",
created_at: "2024-02-19T15:00:00Z",
status: "approved",
},
];
describe("ApprovalRequestListPage", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("renders the loading state initially", () => {
mockUseSearchApprovalRequests.mockReturnValue({
approvalRequests: [],
errorFetchingApprovalRequests: null,
approvalRequestStatus: "any",
setApprovalRequestStatus: jest.fn(),
isLoading: true,
});
render(
<ApprovalRequestListPage baseLocale="en-US" helpCenterPath="/hc/en-us" />
);
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("renders the approval requests list page with data", () => {
mockUseSearchApprovalRequests.mockReturnValue({
approvalRequests: mockApprovalRequests,
errorFetchingApprovalRequests: null,
approvalRequestStatus: "any",
setApprovalRequestStatus: jest.fn(),
isLoading: false,
});
render(
<ApprovalRequestListPage baseLocale="en-US" helpCenterPath="/hc/en-us" />
);
expect(screen.getByText("Approval requests")).toBeInTheDocument();
expect(screen.getByText("Hardware request")).toBeInTheDocument();
expect(screen.getByText("Software license")).toBeInTheDocument();
});
it("renders the empty state when there are no approval requests", () => {
mockUseSearchApprovalRequests.mockReturnValue({
approvalRequests: [],
errorFetchingApprovalRequests: null,
approvalRequestStatus: "any",
setApprovalRequestStatus: jest.fn(),
isLoading: false,
});
render(
<ApprovalRequestListPage baseLocale="en-US" helpCenterPath="/hc/en-us" />
);
expect(screen.getByText("No approval requests found.")).toBeInTheDocument();
});
it("filters the approval requests by search term", async () => {
const user = userEvent.setup();
mockUseSearchApprovalRequests.mockReturnValue({
approvalRequests: mockApprovalRequests,
errorFetchingApprovalRequests: null,
approvalRequestStatus: "any",
setApprovalRequestStatus: jest.fn(),
isLoading: false,
});
render(
<ApprovalRequestListPage baseLocale="en-US" helpCenterPath="/hc/en-us" />
);
const searchInput = screen.getByPlaceholderText(
/search approval requests/i
);
await user.type(searchInput, "Hardware");
waitFor(() => {
expect(screen.getByText("Hardware request")).toBeInTheDocument();
expect(screen.queryByText("Software license")).not.toBeInTheDocument();
});
});
it("sorts the approval requests by the sent on date", async () => {
const user = userEvent.setup();
mockUseSearchApprovalRequests.mockReturnValue({
approvalRequests: mockApprovalRequests,
errorFetchingApprovalRequests: null,
approvalRequestStatus: "any",
setApprovalRequestStatus: jest.fn(),
isLoading: false,
});
render(
<ApprovalRequestListPage baseLocale="en-US" helpCenterPath="/hc/en-us" />
);
const createdHeader = screen.getByText("Sent on");
await user.click(createdHeader);
// Wait for re-render after sort
await waitFor(() => {
const rows = screen.getAllByRole("row").slice(1); // Skip header row
expect(rows[0]).toHaveTextContent("Software license");
expect(rows[1]).toHaveTextContent("Hardware request");
});
});
it("throws an error when the request fails", () => {
const consoleSpy = jest
.spyOn(console, "error")
.mockImplementation(() => {});
const error = new Error("Failed to fetch");
mockUseSearchApprovalRequests.mockReturnValue({
approvalRequests: [],
errorFetchingApprovalRequests: error,
approvalRequestStatus: "any",
setApprovalRequestStatus: jest.fn(),
isLoading: false,
});
expect(() =>
render(
<ApprovalRequestListPage
baseLocale="en-US"
helpCenterPath="/hc/en-us"
/>
)
).toThrow("Failed to fetch");
consoleSpy.mockRestore();
});
});

View File

@@ -0,0 +1,106 @@
import { memo, useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { XXL } from "@zendeskgarden/react-typography";
import { Spinner } from "@zendeskgarden/react-loaders";
import { useSearchApprovalRequests } from "./hooks/useSearchApprovalRequests";
import ApprovalRequestListFilters from "./components/approval-request-list/ApprovalRequestListFilters";
import ApprovalRequestListTable from "./components/approval-request-list/ApprovalRequestListTable";
import NoApprovalRequestsText from "./components/approval-request-list/NoApprovalRequestsText";
const Container = styled.div`
display: flex;
flex-direction: column;
gap: ${(props) => props.theme.space.lg};
margin-top: ${(props) => props.theme.space.xl}; /* 40px */
`;
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
export interface ApprovalRequestListPageProps {
baseLocale: string;
helpCenterPath: string;
}
type SortDirection = "asc" | "desc" | undefined;
function ApprovalRequestListPage({
baseLocale,
helpCenterPath,
}: ApprovalRequestListPageProps) {
const { t } = useTranslation();
const [searchTerm, setSearchTerm] = useState("");
const [sortDirection, setSortDirection] = useState<SortDirection>(undefined);
const {
approvalRequests,
errorFetchingApprovalRequests: error,
approvalRequestStatus,
setApprovalRequestStatus,
isLoading,
} = useSearchApprovalRequests();
const sortedAndFilteredApprovalRequests = useMemo(() => {
let results = [...approvalRequests];
// Apply search filter
if (searchTerm) {
const term = searchTerm.toLowerCase();
results = results.filter((request) =>
request.subject.toLowerCase().includes(term)
);
}
// Apply sorting
if (sortDirection) {
results.sort((a, b) => {
const dateA = new Date(a.created_at).getTime();
const dateB = new Date(b.created_at).getTime();
return sortDirection === "asc" ? dateA - dateB : dateB - dateA;
});
}
return results;
}, [approvalRequests, searchTerm, sortDirection]);
if (error) {
throw error;
}
if (isLoading) {
return (
<LoadingContainer>
<Spinner size="64" />
</LoadingContainer>
);
}
return (
<Container>
<XXL isBold>
{t("approval-requests.list.header", "Approval requests")}
</XXL>
<ApprovalRequestListFilters
approvalRequestStatus={approvalRequestStatus}
setApprovalRequestStatus={setApprovalRequestStatus}
setSearchTerm={setSearchTerm}
/>
{approvalRequests.length === 0 ? (
<NoApprovalRequestsText />
) : (
<ApprovalRequestListTable
approvalRequests={sortedAndFilteredApprovalRequests}
baseLocale={baseLocale}
helpCenterPath={helpCenterPath}
sortDirection={sortDirection}
onSortChange={setSortDirection}
/>
)}
</Container>
);
}
export default memo(ApprovalRequestListPage);

View File

@@ -0,0 +1,179 @@
import { screen } from "@testing-library/react";
import { render } from "../test/render";
import ApprovalRequestPage from "./ApprovalRequestPage";
import { useApprovalRequest } from "./hooks/useApprovalRequest";
import type { ApprovalRequest } from "./types";
jest.mock("@zendeskgarden/svg-icons/src/16/headset-fill.svg", () => "svg-mock");
jest.mock(
"@zendeskgarden/svg-icons/src/12/circle-sm-fill.svg",
() => "svg-mock"
);
jest.mock("./hooks/useApprovalRequest");
const mockUseApprovalRequest = useApprovalRequest as jest.Mock;
const mockUserAvatarUrl = "https://example.com/avatar.jpg";
const mockUserName = "Test User";
const mockApprovalRequest: ApprovalRequest = {
id: "123",
subject: "Test Request",
message: "Please approve this request",
status: "active",
created_at: "2024-02-20T10:00:00Z",
created_by_user: {
id: 1,
name: "Creator User",
photo: { content_url: null },
},
assignee_user: {
id: 2,
name: "Approver User",
photo: { content_url: null },
},
decided_at: null,
decisions: [],
withdrawn_reason: null,
ticket_details: {
id: "T123",
priority: "normal",
requester: {
id: 1,
name: "Creator User",
photo: { content_url: null },
},
custom_fields: [],
},
};
const baseProps = {
approvalWorkflowInstanceId: "456",
approvalRequestId: "123",
baseLocale: "en-US",
helpCenterPath: "/hc/en-us",
organizations: [],
userAvatarUrl: mockUserAvatarUrl,
userName: mockUserName,
};
describe("ApprovalRequestPage", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("renders the loading state initially", () => {
mockUseApprovalRequest.mockReturnValue({
approvalRequest: null,
setApprovalRequest: jest.fn(),
isLoading: true,
errorFetchingApprovalRequest: null,
});
render(<ApprovalRequestPage {...baseProps} userId={1} />);
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("renders the approval request details", async () => {
mockUseApprovalRequest.mockReturnValue({
approvalRequest: mockApprovalRequest,
setApprovalRequest: jest.fn(),
isLoading: false,
errorFetchingApprovalRequest: null,
});
render(
<ApprovalRequestPage
{...baseProps}
organizations={[{ id: 1, name: "Test Org" }]}
userId={1}
/>
);
expect(screen.getByText("Test Request")).toBeInTheDocument();
expect(screen.getByText("Please approve this request")).toBeInTheDocument();
});
it("shows the approver actions when the user is the assignee and the request is active", () => {
mockUseApprovalRequest.mockReturnValue({
approvalRequest: mockApprovalRequest,
setApprovalRequest: jest.fn(),
isLoading: false,
errorFetchingApprovalRequest: null,
});
render(<ApprovalRequestPage {...baseProps} userId={2} />);
expect(screen.getAllByText("Approve request").length).toBeGreaterThan(0);
expect(screen.getAllByText("Deny request").length).toBeGreaterThan(0);
});
it("does not show the approver actions when the user is not the assignee", () => {
mockUseApprovalRequest.mockReturnValue({
approvalRequest: mockApprovalRequest,
setApprovalRequest: jest.fn(),
isLoading: false,
errorFetchingApprovalRequest: null,
});
render(
<ApprovalRequestPage
{...baseProps}
userId={3} // Different from assignee_user.id
/>
);
expect(screen.queryAllByText("Approve request").length).toBe(0);
expect(screen.queryAllByText("Deny request").length).toBe(0);
});
it("throws an error when the request fails", () => {
const consoleSpy = jest
.spyOn(console, "error")
.mockImplementation(jest.fn());
const error = new Error("Failed to fetch");
mockUseApprovalRequest.mockReturnValue({
approvalRequest: null,
setApprovalRequest: jest.fn(),
isLoading: false,
errorFetchingApprovalRequest: error,
});
expect(() =>
render(<ApprovalRequestPage {...baseProps} userId={1} />)
).toThrow("Failed to fetch");
consoleSpy.mockRestore();
});
it("renders Clarification comment section when clarification_flow_messages is present (effectively arturo `approvals_clarification_flow_end_users` enabled)", () => {
const approvalRequestWithClarification = {
...mockApprovalRequest,
clarification_flow_messages: [],
};
mockUseApprovalRequest.mockReturnValue({
approvalRequest: approvalRequestWithClarification,
setApprovalRequest: jest.fn(),
isLoading: false,
errorFetchingApprovalRequest: null,
});
render(<ApprovalRequestPage {...baseProps} userId={1} />);
expect(screen.getByText(/Comments/i)).toBeInTheDocument();
});
it("renders without Clarification comment section when clarification_flow_messages is absent", () => {
mockUseApprovalRequest.mockReturnValue({
approvalRequest: mockApprovalRequest,
setApprovalRequest: jest.fn(),
isLoading: false,
errorFetchingApprovalRequest: null,
});
render(<ApprovalRequestPage {...baseProps} userId={1} />);
expect(screen.queryByText(/clarification/i)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,193 @@
import { memo, useEffect } from "react";
import styled from "styled-components";
import { MD, XXL } from "@zendeskgarden/react-typography";
import { Spinner } from "@zendeskgarden/react-loaders";
import ApprovalRequestDetails from "./components/approval-request/ApprovalRequestDetails";
import ApprovalTicketDetails from "./components/approval-request/ApprovalTicketDetails";
import ApproverActions from "./components/approval-request/ApproverActions";
import { useApprovalRequest } from "./hooks/useApprovalRequest";
import type { Organization } from "../ticket-fields/data-types/Organization";
import ApprovalRequestBreadcrumbs from "./components/approval-request/ApprovalRequestBreadcrumbs";
import ClarificationContainer from "./components/approval-request/clarification/ClarificationContainer";
import { useUserViewedApprovalStatus } from "./hooks/useUserViewedApprovalStatus";
const Container = styled.div`
display: grid;
grid-template-columns: 2fr 1fr;
grid-template-areas:
"left right"
"approverActions right"
"clarification right";
grid-gap: ${(props) => props.theme.space.lg};
margin-top: ${(props) => props.theme.space.xl}; /* 40px */
margin-bottom: ${(props) => props.theme.space.lg}; /* 32px */
@media (max-width: ${(props) => props.theme.breakpoints.md}) {
grid-template-columns: 1fr;
grid-template-areas:
"left"
"right"
"approverActions"
"clarification";
margin-bottom: ${(props) => props.theme.space.xl};
}
`;
const LoadingContainer = styled.div`
display: flex;
justify-content: center;
align-items: center;
`;
const LeftColumn = styled.div`
grid-area: left;
& > *:first-child {
margin-bottom: ${(props) => props.theme.space.base * 4}px; /* 16px */
}
& > *:not(:first-child) {
margin-bottom: ${(props) => props.theme.space.lg}; /* 32px */
}
& > *:last-child {
margin-bottom: 0;
}
`;
const RightColumn = styled.div`
grid-area: right;
`;
const ClarificationArea = styled.div`
grid-area: clarification;
`;
const ApproverActionsWrapper = styled.div`
grid-area: approverActions;
margin-top: ${(props) => props.theme.space.lg};
`;
export interface ApprovalRequestPageProps {
approvalWorkflowInstanceId: string;
approvalRequestId: string;
baseLocale: string;
helpCenterPath: string;
organizations: Array<Organization>;
userId: number;
userAvatarUrl: string;
userName: string;
}
function ApprovalRequestPage({
approvalWorkflowInstanceId,
approvalRequestId,
baseLocale,
helpCenterPath,
organizations,
userId,
userAvatarUrl,
userName,
}: ApprovalRequestPageProps) {
const {
approvalRequest,
setApprovalRequest,
errorFetchingApprovalRequest: error,
isLoading,
} = useApprovalRequest(approvalWorkflowInstanceId, approvalRequestId);
const { hasUserViewedBefore, markUserViewed } = useUserViewedApprovalStatus({
approvalRequestId: approvalRequest?.id,
currentUserId: userId,
});
useEffect(() => {
const handleBeforeUnload = () => {
markUserViewed();
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [markUserViewed]);
if (error) {
throw error;
}
if (isLoading || !approvalRequest) {
return (
<LoadingContainer>
<Spinner size="64" />
</LoadingContainer>
);
}
const showApproverActions =
userId === approvalRequest.assignee_user.id &&
approvalRequest.status === "active";
// The `clarification_flow_messages` field is only present when arturo `approvals_clarification_flow_end_users` is enabled
const hasClarificationEnabled =
approvalRequest?.clarification_flow_messages !== undefined;
return (
<>
<ApprovalRequestBreadcrumbs
helpCenterPath={helpCenterPath}
organizations={organizations}
/>
<Container>
<LeftColumn>
<XXL isBold>{approvalRequest.subject}</XXL>
<MD>{approvalRequest.message}</MD>
{approvalRequest.ticket_details && (
<ApprovalTicketDetails ticket={approvalRequest.ticket_details} />
)}
</LeftColumn>
<RightColumn>
{approvalRequest && (
<ApprovalRequestDetails
approvalRequest={approvalRequest}
baseLocale={baseLocale}
/>
)}
</RightColumn>
{showApproverActions && (
<ApproverActionsWrapper>
<ApproverActions
approvalWorkflowInstanceId={approvalWorkflowInstanceId}
approvalRequestId={approvalRequestId}
setApprovalRequest={setApprovalRequest}
assigneeUser={approvalRequest.assignee_user}
/>
</ApproverActionsWrapper>
)}
{hasClarificationEnabled && (
<ClarificationArea>
<ClarificationContainer
approvalRequestId={approvalRequest.id}
baseLocale={baseLocale}
clarificationFlowMessages={
approvalRequest.clarification_flow_messages!
}
createdByUserId={approvalRequest.created_by_user.id}
currentUserAvatarUrl={userAvatarUrl}
currentUserId={userId}
currentUserName={userName}
hasUserViewedBefore={hasUserViewedBefore}
status={approvalRequest.status}
/>
</ClarificationArea>
)}
</Container>
</>
);
}
export default memo(ApprovalRequestPage);

View File

@@ -0,0 +1,98 @@
import { render } from "../../../test/render";
import { screen, waitFor } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import ApprovalRequestListFilters from "./ApprovalRequestListFilters";
jest.mock(
"@zendeskgarden/svg-icons/src/16/search-stroke.svg",
() => "svg-mock"
);
const mockSetApprovalRequestStatus = jest.fn();
const mockSetSearchTerm = jest.fn();
describe("ApprovalRequestListFilters", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("renders the status filter with all options", async () => {
const user = userEvent.setup();
render(
<ApprovalRequestListFilters
approvalRequestStatus="any"
setApprovalRequestStatus={mockSetApprovalRequestStatus}
setSearchTerm={mockSetSearchTerm}
/>
);
expect(screen.getByLabelText("Status")).toBeInTheDocument();
const combobox = screen.getByRole("combobox", {
name: /status/i,
});
await user.click(combobox);
expect(screen.getByRole("option", { name: "Any" })).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "Decision pending" })
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "Approved" })
).toBeInTheDocument();
expect(screen.getByRole("option", { name: "Denied" })).toBeInTheDocument();
expect(
screen.getByRole("option", { name: "Withdrawn" })
).toBeInTheDocument();
});
it("calls setApprovalRequestStatus when the status filter changes", async () => {
const user = userEvent.setup();
render(
<ApprovalRequestListFilters
approvalRequestStatus="any"
setApprovalRequestStatus={mockSetApprovalRequestStatus}
setSearchTerm={mockSetSearchTerm}
/>
);
const combobox = screen.getByRole("combobox", {
name: /status/i,
});
await user.click(combobox);
await user.click(screen.getByRole("option", { name: "Approved" }));
expect(mockSetApprovalRequestStatus).toHaveBeenCalledWith("approved");
waitFor(() => {
expect(combobox).toHaveValue("Approved");
});
});
it("calls setSearchTerm when the search input changes", async () => {
const user = userEvent.setup();
render(
<ApprovalRequestListFilters
approvalRequestStatus="any"
setApprovalRequestStatus={mockSetApprovalRequestStatus}
setSearchTerm={mockSetSearchTerm}
/>
);
const searchInput = screen.getByPlaceholderText(
/search approval requests/i
);
await user.click(searchInput);
await user.type(searchInput, "test search");
await waitFor(() => {
expect(mockSetSearchTerm).toHaveBeenCalledTimes(1);
expect(mockSetSearchTerm).toHaveBeenLastCalledWith("test search");
});
});
});

View File

@@ -0,0 +1,157 @@
import {
memo,
useCallback,
type Dispatch,
type SetStateAction,
useMemo,
} from "react";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import debounce from "lodash.debounce";
import SearchIcon from "@zendeskgarden/svg-icons/src/16/search-stroke.svg";
import { Field, Combobox, Option } from "@zendeskgarden/react-dropdowns";
import { MediaInput } from "@zendeskgarden/react-forms";
import type { IComboboxProps } from "@zendeskgarden/react-dropdowns";
import type { ApprovalRequestDropdownStatus } from "../../types";
import { APPROVAL_REQUEST_STATES } from "../../constants";
const FiltersContainer = styled.div`
display: flex;
gap: ${(props) => props.theme.space.base * 17}px; /* 68px */
align-items: flex-end;
@media (max-width: ${(props) => props.theme.breakpoints.md}) {
flex-direction: column;
align-items: normal;
gap: ${(props) => props.theme.space.base * 4}px; /* 16px */
}
`;
const SearchField = styled(Field)`
flex: 3;
`;
const DropdownFilterField = styled(Field)`
flex: 1;
`;
interface ApprovalRequestListFiltersProps {
approvalRequestStatus: ApprovalRequestDropdownStatus;
setApprovalRequestStatus: Dispatch<
SetStateAction<ApprovalRequestDropdownStatus>
>;
setSearchTerm: Dispatch<SetStateAction<string>>;
}
function ApprovalRequestListFilters({
approvalRequestStatus,
setApprovalRequestStatus,
setSearchTerm,
}: ApprovalRequestListFiltersProps) {
const { t } = useTranslation();
const getStatusLabel = useCallback(
(status: ApprovalRequestDropdownStatus) => {
switch (status) {
case "any":
return t("approval-requests.list.status-dropdown.any", "Any");
case "active":
return t(
"approval-requests.status.decision-pending",
"Decision pending"
);
case "approved":
return t("approval-requests.status.approved", "Approved");
case "rejected":
return t("approval-requests.status.denied", "Denied");
case "withdrawn":
return t("approval-requests.status.withdrawn", "Withdrawn");
}
},
[t]
);
const handleChange = useCallback<NonNullable<IComboboxProps["onChange"]>>(
(changes) => {
if (!changes.selectionValue) {
return;
}
setApprovalRequestStatus(
changes.selectionValue as ApprovalRequestDropdownStatus
);
setSearchTerm(""); // Reset search term when changing status
},
[setApprovalRequestStatus, setSearchTerm]
);
const debouncedSetSearchTerm = useMemo(
() => debounce((value: string) => setSearchTerm(value), 300),
[setSearchTerm]
);
const handleSearch = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
debouncedSetSearchTerm(event.target.value);
},
[debouncedSetSearchTerm]
);
return (
<FiltersContainer>
<SearchField>
<SearchField.Label hidden>
{t(
"approval-requests.list.search-placeholder",
"Search approval requests"
)}
</SearchField.Label>
<MediaInput
start={<SearchIcon />}
placeholder={t(
"approval-requests.list.search-placeholder",
"Search approval requests"
)}
onChange={handleSearch}
/>
</SearchField>
<DropdownFilterField>
<DropdownFilterField.Label>
{t("approval-requests.list.status-dropdown.label_v2", "Status")}
</DropdownFilterField.Label>
<Combobox
isEditable={false}
onChange={handleChange}
selectionValue={approvalRequestStatus}
inputValue={getStatusLabel(approvalRequestStatus)}
>
<Option
value="any"
label={t("approval-requests.list.status-dropdown.any", "Any")}
/>
<Option
value={APPROVAL_REQUEST_STATES.ACTIVE}
label={t(
"approval-requests.status.decision-pending",
"Decision pending"
)}
/>
<Option
value={APPROVAL_REQUEST_STATES.APPROVED}
label={t("approval-requests.status.approved", "Approved")}
/>
<Option
value={APPROVAL_REQUEST_STATES.REJECTED}
label={t("approval-requests.status.denied", "Denied")}
/>
<Option
value={APPROVAL_REQUEST_STATES.WITHDRAWN}
label={t("approval-requests.status.withdrawn", "Withdrawn")}
/>
</Combobox>
</DropdownFilterField>
</FiltersContainer>
);
}
export default memo(ApprovalRequestListFilters);

View File

@@ -0,0 +1,111 @@
import { render } from "../../../test/render";
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import ApprovalRequestListTable from "./ApprovalRequestListTable";
import type { SearchApprovalRequest } from "../../types";
const mockApprovalRequests: SearchApprovalRequest[] = [
{
id: 123,
subject: "Hardware request",
requester_name: "Jane Doe",
created_by_name: "John Doe",
created_at: "2024-02-20T10:00:00Z",
status: "active",
},
{
id: 456,
subject: "Software license",
requester_name: "Jane Smith",
created_by_name: "Bob Smith",
created_at: "2024-02-19T15:00:00Z",
status: "approved",
},
];
const mockOnSortChange = jest.fn();
describe("ApprovalRequestListTable", () => {
it("renders the table headers correctly", () => {
render(
<ApprovalRequestListTable
approvalRequests={[]}
helpCenterPath="/hc/en-us"
baseLocale="en-US"
sortDirection="desc"
onSortChange={mockOnSortChange}
/>
);
// validate table headers
expect(screen.getByText("Subject")).toBeInTheDocument();
expect(screen.getByText("Requester")).toBeInTheDocument();
expect(screen.getByText("Sent by")).toBeInTheDocument();
expect(screen.getByText("Sent on")).toBeInTheDocument();
expect(screen.getByText("Approval status")).toBeInTheDocument();
});
it("renders approval requests with the correct data", () => {
render(
<ApprovalRequestListTable
approvalRequests={mockApprovalRequests}
helpCenterPath="/hc/en-us"
baseLocale="en-US"
sortDirection="desc"
onSortChange={mockOnSortChange}
/>
);
expect(screen.getByText("Hardware request")).toBeInTheDocument();
expect(screen.getByText("Hardware request")).toHaveAttribute(
"href",
"/hc/en-us/approval_requests/123"
);
expect(screen.getByText("Jane Doe")).toBeInTheDocument();
expect(screen.getByText("John Doe")).toBeInTheDocument();
expect(screen.getByText(/Feb 20, 2024/)).toBeInTheDocument();
expect(screen.getByText("Decision pending")).toBeInTheDocument();
expect(screen.getByText("Software license")).toBeInTheDocument();
expect(screen.getByText("Software license")).toHaveAttribute(
"href",
"/hc/en-us/approval_requests/456"
);
expect(screen.getByText("Jane Smith")).toBeInTheDocument();
expect(screen.getByText("Bob Smith")).toBeInTheDocument();
expect(screen.getByText(/Feb 19, 2024/)).toBeInTheDocument();
expect(screen.getByText("Approved")).toBeInTheDocument();
});
it("renders the no approval requests text when the filtered approval requests are empty", () => {
render(
<ApprovalRequestListTable
approvalRequests={[]}
helpCenterPath="/hc/en-us"
baseLocale="en-US"
sortDirection={undefined}
onSortChange={mockOnSortChange}
/>
);
expect(screen.getByText("No approval requests found.")).toBeInTheDocument();
});
it("calls the onSortChange function if the Sent On sortable header cell is clicked", async () => {
const user = userEvent.setup();
render(
<ApprovalRequestListTable
approvalRequests={[]}
helpCenterPath="/hc/en-us"
baseLocale="en-US"
sortDirection={undefined}
onSortChange={mockOnSortChange}
/>
);
const sentOnHeader = screen.getByText("Sent on");
await user.click(sentOnHeader);
expect(mockOnSortChange).toHaveBeenCalledWith("asc");
});
});

View File

@@ -0,0 +1,121 @@
import { memo } from "react";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { Table } from "@zendeskgarden/react-tables";
import { Anchor } from "@zendeskgarden/react-buttons";
import { getColor } from "@zendeskgarden/react-theming";
import ApprovalStatusTag from "../approval-request/ApprovalStatusTag";
import { formatApprovalRequestDate } from "../../utils";
import type { SearchApprovalRequest } from "../../types";
import NoApprovalRequestsText from "./NoApprovalRequestsText";
const ApprovalRequestAnchor = styled(Anchor)`
&:visited {
color: ${({ theme }) => getColor({ theme, hue: "blue", shade: 600 })};
}
`;
const sortableCellProps = {
style: {
paddingTop: "22px",
paddingBottom: "22px",
},
isTruncated: true,
};
interface ApprovalRequestListTableProps {
approvalRequests: SearchApprovalRequest[];
helpCenterPath: string;
baseLocale: string;
sortDirection: "asc" | "desc" | undefined;
onSortChange: (direction: "asc" | "desc" | undefined) => void;
}
function ApprovalRequestListTable({
approvalRequests,
helpCenterPath,
baseLocale,
sortDirection,
onSortChange,
}: ApprovalRequestListTableProps) {
const { t } = useTranslation();
const handleSortClick = () => {
if (sortDirection === "asc") {
onSortChange("desc");
} else if (sortDirection === "desc") {
onSortChange(undefined);
} else {
onSortChange("asc");
}
};
return (
<Table size="large">
<Table.Head>
<Table.HeaderRow>
<Table.HeaderCell width="40%" isTruncated>
{t("approval-requests.list.table.subject", "Subject")}
</Table.HeaderCell>
<Table.HeaderCell isTruncated>
{t("approval-requests.list.table.requester", "Requester")}
</Table.HeaderCell>
<Table.HeaderCell isTruncated>
{t("approval-requests.list.table.sent-by", "Sent by")}
</Table.HeaderCell>
<Table.SortableCell
onClick={handleSortClick}
sort={sortDirection}
cellProps={sortableCellProps}
>
{t("approval-requests.list.table.sent-on", "Sent on")}
</Table.SortableCell>
<Table.HeaderCell isTruncated>
{t(
"approval-requests.list.table.approval-status",
"Approval status"
)}
</Table.HeaderCell>
</Table.HeaderRow>
</Table.Head>
<Table.Body>
{approvalRequests.length === 0 ? (
<Table.Row>
<Table.Cell colSpan={5}>
<NoApprovalRequestsText />
</Table.Cell>
</Table.Row>
) : (
approvalRequests.map((approvalRequest) => (
<Table.Row key={approvalRequest.id}>
<Table.Cell isTruncated>
<ApprovalRequestAnchor
href={`${helpCenterPath}/approval_requests/${approvalRequest.id}`}
>
{approvalRequest.subject}
</ApprovalRequestAnchor>
</Table.Cell>
<Table.Cell isTruncated>
{approvalRequest.requester_name}
</Table.Cell>
<Table.Cell isTruncated>
{approvalRequest.created_by_name}
</Table.Cell>
<Table.Cell isTruncated>
{formatApprovalRequestDate(
approvalRequest.created_at,
baseLocale
)}
</Table.Cell>
<Table.Cell isTruncated>
<ApprovalStatusTag status={approvalRequest.status} />
</Table.Cell>
</Table.Row>
))
)}
</Table.Body>
</Table>
);
}
export default memo(ApprovalRequestListTable);

View File

@@ -0,0 +1,21 @@
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { MD } from "@zendeskgarden/react-typography";
import { getColor } from "@zendeskgarden/react-theming";
import { memo } from "react";
const StyledMD = styled(MD)`
color: ${({ theme }) => getColor({ theme, hue: "grey", shade: 600 })};
`;
function NoApprovalRequestsText() {
const { t } = useTranslation();
return (
<StyledMD>
{t("approval-requests.list.no-requests", "No approval requests found.")}
</StyledMD>
);
}
export default memo(NoApprovalRequestsText);

View File

@@ -0,0 +1,39 @@
import { render } from "../../../test/render";
import { screen } from "@testing-library/react";
import ApprovalRequestBreadcrumbs from "./ApprovalRequestBreadcrumbs";
describe("ApprovalRequestBreadcrumbs", () => {
it("renders breadcrumbs with organization name when organization exists", () => {
render(
<ApprovalRequestBreadcrumbs
helpCenterPath="/hc/en-us"
organizations={[{ id: 1, name: "Test Organization" }]}
/>
);
const orgLink = screen.getByRole("link", { name: "Test Organization" });
const approvalRequestsLink = screen.getByRole("link", {
name: "Approval requests",
});
expect(orgLink).toBeInTheDocument();
expect(orgLink).toHaveAttribute("href", "/hc/en-us");
expect(approvalRequestsLink).toHaveAttribute(
"href",
"/hc/en-us/approval_requests"
);
});
it("renders breadcrumbs without organization name when no organizations exist", () => {
render(
<ApprovalRequestBreadcrumbs
helpCenterPath="/hc/en-us"
organizations={[]}
/>
);
expect(
screen.getByRole("link", { name: "Approval requests" })
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,55 @@
import { memo } from "react";
import { Breadcrumb } from "@zendeskgarden/react-breadcrumbs";
import { useTranslation } from "react-i18next";
import { Anchor } from "@zendeskgarden/react-buttons";
import type { Organization } from "../../../ticket-fields/data-types/Organization";
import styled from "styled-components";
import { getColor } from "@zendeskgarden/react-theming";
const StyledBreadcrumb = styled(Breadcrumb)`
margin-top: ${(props) => props.theme.space.lg}; /* 32px */
`;
const BreadcrumbAnchor = styled(Anchor)`
&:visited {
color: ${({ theme }) => getColor({ theme, hue: "blue", shade: 600 })};
}
`;
interface ApprovalRequestBreadcrumbsProps {
helpCenterPath: string;
organizations: Array<Organization>;
}
function ApprovalRequestBreadcrumbs({
organizations,
helpCenterPath,
}: ApprovalRequestBreadcrumbsProps) {
const { t } = useTranslation();
const defaultOrganizationName =
organizations.length > 0 ? organizations[0]?.name : null;
if (defaultOrganizationName) {
return (
<StyledBreadcrumb>
<BreadcrumbAnchor href={helpCenterPath}>
{defaultOrganizationName}
</BreadcrumbAnchor>
<BreadcrumbAnchor href={`${helpCenterPath}/approval_requests`}>
{t("approval-requests.list.header", "Approval requests")}
</BreadcrumbAnchor>
</StyledBreadcrumb>
);
}
return (
<StyledBreadcrumb>
<BreadcrumbAnchor href={`${helpCenterPath}/approval_requests`}>
{t("approval-requests.list.header", "Approval requests")}
</BreadcrumbAnchor>
</StyledBreadcrumb>
);
}
export default memo(ApprovalRequestBreadcrumbs);

View File

@@ -0,0 +1,177 @@
import { render } from "../../../test/render";
import { screen } from "@testing-library/react";
import type { ApprovalRequest } from "../../types";
import ApprovalRequestDetails from "./ApprovalRequestDetails";
const mockApprovalRequest: ApprovalRequest = {
id: "123",
subject: "Test approval request",
message: "Please review this request",
status: "active",
created_at: "2024-02-20T10:30:00Z",
created_by_user: {
id: 123,
name: "John Sender",
photo: {
content_url: null,
},
},
assignee_user: {
id: 456,
name: "Jane Approver",
photo: {
content_url: null,
},
},
decided_at: null,
decisions: [],
withdrawn_reason: null,
ticket_details: {
id: "789",
priority: "normal",
custom_fields: [],
requester: {
id: 789,
name: "Request Creator",
photo: {
content_url: null,
},
},
},
};
describe("ApprovalRequestDetails", () => {
it("renders the basic approval request details without the decision notes and decided date", () => {
render(
<ApprovalRequestDetails
approvalRequest={mockApprovalRequest}
baseLocale="en-US"
/>
);
expect(screen.getByText("Approval request details")).toBeInTheDocument();
expect(screen.getByText("John Sender")).toBeInTheDocument();
expect(screen.getByText("Jane Approver")).toBeInTheDocument();
expect(screen.getByText("Decision pending")).toBeInTheDocument();
expect(screen.queryByText("Reason")).not.toBeInTheDocument();
expect(screen.queryByText("Decided")).not.toBeInTheDocument();
});
it("renders the decision notes and decided date when present", () => {
const approvalRequestWithNotesAndDecision: ApprovalRequest = {
...mockApprovalRequest,
status: "approved",
decided_at: "2024-02-21T15:45:00Z",
decisions: [
{
decision_notes: "This looks good to me",
decided_at: "2024-02-21T15:45:00Z",
decided_by_user: {
id: 456,
name: "Jane Approver",
photo: {
content_url: null,
},
},
status: "approved",
},
],
};
render(
<ApprovalRequestDetails
approvalRequest={approvalRequestWithNotesAndDecision}
baseLocale="en-US"
/>
);
expect(screen.getByText("Reason")).toBeInTheDocument();
expect(screen.getByText(/this looks good to me/i)).toBeInTheDocument();
expect(screen.getByText("Decided")).toBeInTheDocument();
expect(screen.getByText(/this looks good to me/i)).toBeInTheDocument();
});
it("renders a withdrawn approval request with the withdrawal reason", () => {
const withdrawnRequest: ApprovalRequest = {
...mockApprovalRequest,
status: "withdrawn",
withdrawn_reason: "No longer needed",
decided_at: "2024-02-21T15:45:00Z",
};
render(
<ApprovalRequestDetails
approvalRequest={withdrawnRequest}
baseLocale="en-US"
/>
);
expect(screen.getByText("Withdrawn on")).toBeInTheDocument();
expect(screen.getByText("No longer needed")).toBeInTheDocument();
});
it("shows the previous decision when an approval request is withdrawn with prior approval", () => {
const withdrawnWithPriorApproval: ApprovalRequest = {
...mockApprovalRequest,
status: "withdrawn",
withdrawn_reason: "Changed my mind",
decided_at: "2024-02-21T15:45:00Z",
decisions: [
{
decision_notes: "Originally stamped",
decided_at: "2024-02-20T10:30:00Z",
decided_by_user: {
id: 456,
name: "Jane Approver",
photo: {
content_url: null,
},
},
status: "approved",
},
],
};
render(
<ApprovalRequestDetails
approvalRequest={withdrawnWithPriorApproval}
baseLocale="en-US"
/>
);
expect(screen.getByText("Previous decision")).toBeInTheDocument();
expect(screen.getByText(/approved/i)).toBeInTheDocument();
expect(screen.getByText(/"Originally stamped"/)).toBeInTheDocument();
});
it("does not show the previous decision for non-withdrawn requests", () => {
const approvedRequest: ApprovalRequest = {
...mockApprovalRequest,
status: "approved",
decided_at: "2024-02-21T15:45:00Z",
decisions: [
{
decision_notes: "Looks good",
decided_at: "2024-02-21T15:45:00Z",
decided_by_user: {
id: 456,
name: "Jane Approver",
photo: {
content_url: null,
},
},
status: "approved",
},
],
};
render(
<ApprovalRequestDetails
approvalRequest={approvedRequest}
baseLocale="en-US"
/>
);
expect(screen.queryByText("Previous decision")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,197 @@
import { memo } from "react";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { MD } from "@zendeskgarden/react-typography";
import { getColor } from "@zendeskgarden/react-theming";
import { Grid } from "@zendeskgarden/react-grid";
import type { ApprovalRequest } from "../../types";
import ApprovalStatusTag from "./ApprovalStatusTag";
import { formatApprovalRequestDate } from "../../utils";
import { APPROVAL_REQUEST_STATES } from "../../constants";
import ApprovalRequestPreviousDecision from "./ApprovalRequestPreviousDecision";
const Container = styled(Grid)`
padding: ${(props) => props.theme.space.base * 6}px; /* 24px */
margin-left: 0;
background: ${({ theme }) =>
getColor({ theme, variable: "background.default" })};
border-radius: ${(props) => props.theme.borderRadii.md}; /* 4px */
max-width: 296px;
@media (max-width: ${(props) => props.theme.breakpoints.md}) {
max-width: 100%;
}
`;
const ApprovalRequestHeader = styled(MD)`
margin-bottom: ${(props) => props.theme.space.base * 4}px; /* 16px */
`;
const WrappedText = styled(MD)`
white-space: normal;
overflow-wrap: break-word;
`;
const FieldLabel = styled(MD)`
color: ${({ theme }) => getColor({ theme, hue: "grey", shade: 600 })};
`;
const DetailRow = styled(Grid.Row)`
margin-bottom: ${(props) => props.theme.space.sm}; /* 12px */
&:last-child {
margin-bottom: 0;
}
@media (max-width: ${(props) => props.theme.breakpoints.sm}) {
flex-direction: column; /* stack columns vertically */
> div {
width: 100% !important; /* full width for each Col */
max-width: 100% !important;
flex: none !important;
margin-bottom: ${(props) => props.theme.space.xxs}; /* 4px */
}
> div:last-child {
margin-bottom: 0;
}
}
`;
interface ApprovalRequestDetailsProps {
approvalRequest: ApprovalRequest;
baseLocale: string;
}
function ApprovalRequestDetails({
approvalRequest,
baseLocale,
}: ApprovalRequestDetailsProps) {
const { t } = useTranslation();
const shouldShowApprovalRequestComment =
approvalRequest.status === APPROVAL_REQUEST_STATES.WITHDRAWN
? Boolean(approvalRequest.withdrawn_reason)
: approvalRequest.decisions.length > 0;
const shouldShowPreviousDecision =
approvalRequest.status === APPROVAL_REQUEST_STATES.WITHDRAWN &&
approvalRequest.decisions.length > 0;
return (
<Container>
<ApprovalRequestHeader isBold>
{t(
"approval-requests.request.approval-request-details.header",
"Approval request details"
)}
</ApprovalRequestHeader>
<DetailRow>
<Grid.Col size={4}>
<FieldLabel>
{t(
"approval-requests.request.approval-request-details.sent-by",
"Sent by"
)}
</FieldLabel>
</Grid.Col>
<Grid.Col size={8}>
<WrappedText>{approvalRequest.created_by_user.name}</WrappedText>
</Grid.Col>
</DetailRow>
<DetailRow>
<Grid.Col size={4}>
<FieldLabel>
{t(
"approval-requests.request.approval-request-details.sent-on",
"Sent on"
)}
</FieldLabel>
</Grid.Col>
<Grid.Col size={8}>
<MD>
{formatApprovalRequestDate(approvalRequest.created_at, baseLocale)}
</MD>
</Grid.Col>
</DetailRow>
<DetailRow>
<Grid.Col size={4}>
<FieldLabel>
{t(
"approval-requests.request.approval-request-details.approver",
"Approver"
)}
</FieldLabel>
</Grid.Col>
<Grid.Col size={8}>
<WrappedText>{approvalRequest.assignee_user.name}</WrappedText>
</Grid.Col>
</DetailRow>
<DetailRow>
<Grid.Col size={4}>
<FieldLabel>
{t(
"approval-requests.request.approval-request-details.status",
"Status"
)}
</FieldLabel>
</Grid.Col>
<Grid.Col size={8}>
<MD>
<ApprovalStatusTag status={approvalRequest.status} />
</MD>
</Grid.Col>
</DetailRow>
{shouldShowApprovalRequestComment && (
<DetailRow>
<Grid.Col size={4}>
<FieldLabel>
{t(
"approval-requests.request.approval-request-details.comment_v2",
"Reason"
)}
</FieldLabel>
</Grid.Col>
<Grid.Col size={8}>
<WrappedText>
{approvalRequest.status === APPROVAL_REQUEST_STATES.WITHDRAWN
? approvalRequest.withdrawn_reason
: approvalRequest.decisions[0]?.decision_notes ?? "-"}
</WrappedText>
</Grid.Col>
</DetailRow>
)}
{approvalRequest.decided_at && (
<DetailRow>
<Grid.Col size={4}>
<FieldLabel>
{t(
approvalRequest.status === APPROVAL_REQUEST_STATES.WITHDRAWN
? "approval-requests.request.approval-request-details.withdrawn-on"
: "approval-requests.request.approval-request-details.decided",
approvalRequest.status === APPROVAL_REQUEST_STATES.WITHDRAWN
? "Withdrawn on"
: "Decided"
)}
</FieldLabel>
</Grid.Col>
<Grid.Col size={8}>
<MD>
{formatApprovalRequestDate(
approvalRequest.decided_at,
baseLocale
)}
</MD>
</Grid.Col>
</DetailRow>
)}
{shouldShowPreviousDecision && approvalRequest.decisions[0] && (
<ApprovalRequestPreviousDecision
decision={approvalRequest.decisions[0]}
baseLocale={baseLocale}
/>
)}
</Container>
);
}
export default memo(ApprovalRequestDetails);

View File

@@ -0,0 +1,49 @@
import { render } from "../../../test/render";
import { screen } from "@testing-library/react";
import ApprovalRequestPreviousDecision from "./ApprovalRequestPreviousDecision";
import type { ApprovalDecision } from "../../types";
const mockDecision: ApprovalDecision = {
decision_notes: "Looks good to me",
decided_at: "2024-02-21T15:45:00Z",
decided_by_user: {
id: 456,
name: "Jane Approver",
photo: {
content_url: null,
},
},
status: "approved",
};
describe("ApprovalRequestPreviousDecision", () => {
it("renders the previous decision header, status, and notes", () => {
render(
<ApprovalRequestPreviousDecision
decision={mockDecision}
baseLocale="en-US"
/>
);
expect(screen.getByText("Previous decision")).toBeInTheDocument();
expect(screen.getByText(/approved/i)).toBeInTheDocument();
expect(screen.getByText(/"Looks good to me"/)).toBeInTheDocument();
});
it("renders the decision without decision notes if they do not exist", () => {
const decisionWithoutNotes: ApprovalDecision = {
...mockDecision,
decision_notes: null,
};
render(
<ApprovalRequestPreviousDecision
decision={decisionWithoutNotes}
baseLocale="en-US"
/>
);
expect(screen.getByText(/approved/i)).toBeInTheDocument();
expect(screen.queryByText(/"/)).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,71 @@
import { memo } from "react";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { MD } from "@zendeskgarden/react-typography";
import { getColor } from "@zendeskgarden/react-theming";
import { APPROVAL_REQUEST_STATES } from "../../constants";
import { formatApprovalRequestDate } from "../../utils";
import type { ApprovalDecision } from "../../types";
const Container = styled.div`
border-top: ${({ theme }) =>
`1px solid ${getColor({ theme, hue: "grey", shade: 300 })}`};
display: flex;
flex-direction: column;
padding-top: ${(props) => props.theme.space.base * 4}px; /* 16px */
`;
const PreviousDecisionTitle = styled(MD)`
margin-bottom: ${(props) => props.theme.space.xxs}; /* 4px */
`;
const FieldLabel = styled(MD)`
color: ${({ theme }) => getColor({ theme, hue: "grey", shade: 600 })};
`;
function getPreviousDecisionFallbackLabel(status: string) {
switch (status) {
case APPROVAL_REQUEST_STATES.APPROVED:
return "Approved";
case APPROVAL_REQUEST_STATES.REJECTED:
return "Rejected";
default:
return status;
}
}
interface ApprovalRequestPreviousDecisionProps {
decision: ApprovalDecision;
baseLocale: string;
}
function ApprovalRequestPreviousDecision({
decision,
baseLocale,
}: ApprovalRequestPreviousDecisionProps) {
const { t } = useTranslation();
return (
<Container>
<PreviousDecisionTitle>
{t(
"approval-requests.request.approval-request-details.previous-decision",
"Previous decision"
)}
</PreviousDecisionTitle>
<FieldLabel>
{t(
`approval-requests.request.approval-request-details.${decision.status.toLowerCase()}`,
getPreviousDecisionFallbackLabel(decision.status)
)}{" "}
{formatApprovalRequestDate(decision.decided_at ?? "", baseLocale)}
</FieldLabel>
{decision.decision_notes && (
// eslint-disable-next-line @shopify/jsx-no-hardcoded-content
<FieldLabel>{`"${decision.decision_notes}"`}</FieldLabel>
)}
</Container>
);
}
export default memo(ApprovalRequestPreviousDecision);

View File

@@ -0,0 +1,29 @@
import { render } from "../../../test/render";
import { screen } from "@testing-library/react";
import ApprovalStatusTag from "./ApprovalStatusTag";
describe("ApprovalStatusTag", () => {
it("renders the active status tag correctly", () => {
render(<ApprovalStatusTag status="active" />);
expect(screen.getByText("Decision pending")).toBeInTheDocument();
});
it("renders the approved status tag correctly", () => {
render(<ApprovalStatusTag status="approved" />);
expect(screen.getByText("Approved")).toBeInTheDocument();
});
it("renders the rejected status tag correctly", () => {
render(<ApprovalStatusTag status="rejected" />);
expect(screen.getByText("Denied")).toBeInTheDocument();
});
it("renders the withdrawn status tag correctly", () => {
render(<ApprovalStatusTag status="withdrawn" />);
expect(screen.getByText("Withdrawn")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,43 @@
import { memo } from "react";
import { useTranslation } from "react-i18next";
import { Tag } from "@zendeskgarden/react-tags";
import { Ellipsis } from "@zendeskgarden/react-typography";
import type { ApprovalRequestStatus } from "../../types";
import { APPROVAL_REQUEST_STATES } from "../../constants";
const DEFAULT_STATUS_CONFIG = { hue: "grey", label: "Unknown status" };
interface ApprovalStatusTagProps {
status: ApprovalRequestStatus;
}
function ApprovalStatusTag({ status }: ApprovalStatusTagProps) {
const { t } = useTranslation();
const statusTagMap = {
[APPROVAL_REQUEST_STATES.ACTIVE]: {
hue: "blue",
label: t("approval-requests.status.decision-pending", "Decision pending"),
},
[APPROVAL_REQUEST_STATES.APPROVED]: {
hue: "green",
label: t("approval-requests.status.approved", "Approved"),
},
[APPROVAL_REQUEST_STATES.REJECTED]: {
hue: "red",
label: t("approval-requests.status.denied", "Denied"),
},
[APPROVAL_REQUEST_STATES.WITHDRAWN]: {
hue: "grey",
label: t("approval-requests.status.withdrawn", "Withdrawn"),
},
};
const config = statusTagMap[status] || DEFAULT_STATUS_CONFIG;
return (
<Tag hue={config.hue}>
<Ellipsis>{config.label}</Ellipsis>
</Tag>
);
}
export default memo(ApprovalStatusTag);

View File

@@ -0,0 +1,96 @@
import { render } from "../../../test/render";
import { screen } from "@testing-library/react";
import ApprovalTicketDetails from "./ApprovalTicketDetails";
import type { ApprovalRequestTicket } from "../../types";
const mockTicket: ApprovalRequestTicket = {
id: "123",
priority: "normal",
requester: {
id: 456,
name: "John Requester",
photo: {
content_url: null,
},
},
custom_fields: [],
};
describe("ApprovalTicketDetails", () => {
it("renders basic ticket details", () => {
render(<ApprovalTicketDetails ticket={mockTicket} />);
expect(screen.getByText("Ticket details")).toBeInTheDocument();
expect(screen.getByText("John Requester")).toBeInTheDocument();
expect(screen.getByText("123")).toBeInTheDocument();
expect(screen.getByText("normal")).toBeInTheDocument();
});
it("renders custom fields with string values", () => {
const ticketWithCustomFields: ApprovalRequestTicket = {
...mockTicket,
custom_fields: [
{ id: "field1", title_in_portal: "Department", value: "IT" },
{ id: "field2", title_in_portal: "Cost Center", value: "123-45" },
],
};
render(<ApprovalTicketDetails ticket={ticketWithCustomFields} />);
expect(screen.getByText("Department")).toBeInTheDocument();
expect(screen.getByText("IT")).toBeInTheDocument();
expect(screen.getByText("Cost Center")).toBeInTheDocument();
expect(screen.getByText("123-45")).toBeInTheDocument();
});
it("renders custom fields with boolean values", () => {
const ticketWithBooleanFields: ApprovalRequestTicket = {
...mockTicket,
custom_fields: [
{ id: "field1", title_in_portal: "Urgent", value: true },
{ id: "field2", title_in_portal: "Reviewed", value: false },
],
};
render(<ApprovalTicketDetails ticket={ticketWithBooleanFields} />);
expect(screen.getByText("Urgent")).toBeInTheDocument();
expect(screen.getByText("Yes")).toBeInTheDocument();
expect(screen.getByText("Reviewed")).toBeInTheDocument();
expect(screen.getByText("No")).toBeInTheDocument();
});
it("renders custom fields with array values", () => {
const ticketWithArrayFields: ApprovalRequestTicket = {
...mockTicket,
custom_fields: [
{
id: "field1",
title_in_portal: "Categories",
value: ["Hardware", "Software"],
},
],
};
render(<ApprovalTicketDetails ticket={ticketWithArrayFields} />);
expect(screen.getByText("Categories")).toBeInTheDocument();
expect(screen.getByText("Hardware")).toBeInTheDocument();
expect(screen.getByText("Software")).toBeInTheDocument();
});
it("renders placeholder for empty or undefined custom field values", () => {
const ticketWithEmptyFields: ApprovalRequestTicket = {
...mockTicket,
custom_fields: [
{ id: "field1", title_in_portal: "Empty Field", value: undefined },
{ id: "field2", title_in_portal: "Empty Array", value: [] },
],
};
render(<ApprovalTicketDetails ticket={ticketWithEmptyFields} />);
expect(screen.getByText("Empty Field")).toBeInTheDocument();
expect(screen.getAllByText("-")).toHaveLength(2);
});
});

View File

@@ -0,0 +1,145 @@
import { memo } from "react";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { MD } from "@zendeskgarden/react-typography";
import { getColor } from "@zendeskgarden/react-theming";
import { Grid } from "@zendeskgarden/react-grid";
import { Tag } from "@zendeskgarden/react-tags";
import type { ApprovalRequestTicket } from "../../types";
const TicketContainer = styled(Grid)`
padding: ${(props) => props.theme.space.md}; /* 20px */
border: ${(props) => props.theme.borders.sm}
${({ theme }) => getColor({ theme, hue: "grey", shade: 300 })};
border-radius: ${(props) => props.theme.borderRadii.md}; /* 4px */
`;
const TicketDetailsHeader = styled(MD)`
margin-bottom: ${(props) => props.theme.space.md}; /* 20px */
`;
const FieldLabel = styled(MD)`
color: ${({ theme }) => getColor({ theme, hue: "grey", shade: 600 })};
`;
const MultiselectTag = styled(Tag)`
margin-inline-end: ${(props) => props.theme.space.xxs}; /* 4px */
`;
const CustomFieldsGrid = styled.div`
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: ${(props) => props.theme.space.md}; /* 20px */
margin-top: ${(props) => props.theme.space.md}; /* 20px */
@media (max-width: ${(props) => props.theme.breakpoints.md}) {
grid-template-columns: repeat(2, 1fr);
}
`;
const NULL_VALUE_PLACEHOLDER = "-";
function CustomFieldValue({
value,
}: {
value: string | boolean | Array<string> | undefined;
}) {
const { t } = useTranslation();
if (Array.isArray(value) && value.length > 0) {
return (
<MD>
{value.map((val) => (
<MultiselectTag key={val} hue="grey">
{val}
</MultiselectTag>
))}
</MD>
);
}
if (typeof value === "boolean") {
return (
<MD>
{value
? t(
"approval-requests.request.ticket-details.checkbox-value.yes",
"Yes"
)
: t(
"approval-requests.request.ticket-details.checkbox-value.no",
"No"
)}
</MD>
);
}
if (!value || (Array.isArray(value) && value.length === 0)) {
return <MD>{NULL_VALUE_PLACEHOLDER}</MD>;
}
return <MD>{value}</MD>;
}
interface ApprovalTicketDetailsProps {
ticket: ApprovalRequestTicket;
}
const TicketPriorityKeys = {
Low: "approval-requests.request.ticket-details.priority_low",
Normal: "approval-requests.request.ticket-details.priority_normal",
High: "approval-requests.request.ticket-details.priority_high",
Urgent: "approval-requests.request.ticket-details.priority_urgent",
};
function ApprovalTicketDetails({ ticket }: ApprovalTicketDetailsProps) {
const { t } = useTranslation();
const displayPriority = TicketPriorityKeys[
ticket.priority as keyof typeof TicketPriorityKeys
]
? t(
TicketPriorityKeys[ticket.priority as keyof typeof TicketPriorityKeys],
ticket.priority
)
: ticket.priority;
return (
<TicketContainer>
<TicketDetailsHeader isBold>
{t("approval-requests.request.ticket-details.header", "Ticket details")}
</TicketDetailsHeader>
<CustomFieldsGrid>
<div>
<FieldLabel>
{t(
"approval-requests.request.ticket-details.requester",
"Requester"
)}
</FieldLabel>
<MD>{ticket.requester.name}</MD>
</div>
<div>
<FieldLabel>
{t("approval-requests.request.ticket-details.id", "ID")}
</FieldLabel>
<MD>{ticket.id}</MD>
</div>
<div>
<FieldLabel>
{t("approval-requests.request.ticket-details.priority", "Priority")}
</FieldLabel>
<MD>{displayPriority}</MD>
</div>
{ticket.custom_fields.map((field) => (
<div key={String(field.id)}>
<FieldLabel>{field.title_in_portal}</FieldLabel>
<CustomFieldValue value={field.value} />
</div>
))}
</CustomFieldsGrid>
</TicketContainer>
);
}
export default memo(ApprovalTicketDetails);

View File

@@ -0,0 +1,274 @@
import { render } from "../../../test/render";
import { screen, waitFor, act } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import ApproverActions from "./ApproverActions";
import { notify } from "../../../shared";
jest.mock("../../../shared/notifications", () => ({
notify: jest.fn(),
}));
const mockNotify = notify as jest.Mock;
const mockSubmitApprovalDecision = jest.fn();
jest.mock("../../submitApprovalDecision", () => ({
submitApprovalDecision: (...args: unknown[]) =>
mockSubmitApprovalDecision(...args),
}));
const mockAssigneeUser = {
id: 123,
name: "Test User",
photo: { content_url: null },
};
const mockApprovalRequestId = "123";
const mockApprovalWorkflowInstanceId = "456";
const mockSetApprovalRequest = jest.fn();
describe("ApproverActions", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("initially renders the approve and deny buttons", () => {
render(
<ApproverActions
approvalRequestId={mockApprovalRequestId}
approvalWorkflowInstanceId={mockApprovalWorkflowInstanceId}
setApprovalRequest={mockSetApprovalRequest}
assigneeUser={mockAssigneeUser}
/>
);
expect(screen.getByText("Approve request")).toBeInTheDocument();
expect(screen.getByText("Deny request")).toBeInTheDocument();
});
it("shows the comment section when clicking Approve request", async () => {
const user = userEvent.setup();
render(
<ApproverActions
approvalRequestId={mockApprovalRequestId}
approvalWorkflowInstanceId={mockApprovalWorkflowInstanceId}
setApprovalRequest={mockSetApprovalRequest}
assigneeUser={mockAssigneeUser}
/>
);
await user.click(screen.getByText("Approve request"));
expect(screen.getByText("Additional note")).toBeInTheDocument();
expect(screen.getByText("Submit approval")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
it("does not show the Avatar when assigneeUser has no photo", async () => {
const user = userEvent.setup();
render(
<ApproverActions
approvalRequestId={mockApprovalRequestId}
approvalWorkflowInstanceId={mockApprovalWorkflowInstanceId}
setApprovalRequest={mockSetApprovalRequest}
assigneeUser={mockAssigneeUser}
/>
);
await user.click(screen.getByText("Approve request"));
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
it("shows the Avatar when assigneeUser has a photo", async () => {
const assigneeUserWithPhoto = {
id: 123,
name: "Test User",
photo: {
content_url: "https://example.com/avatar.jpg",
},
};
const user = userEvent.setup();
render(
<ApproverActions
approvalRequestId={mockApprovalRequestId}
approvalWorkflowInstanceId={mockApprovalWorkflowInstanceId}
setApprovalRequest={mockSetApprovalRequest}
assigneeUser={assigneeUserWithPhoto}
/>
);
await user.click(screen.getByText("Approve request"));
const avatar = screen.getByRole("img");
expect(avatar).toBeInTheDocument();
expect(avatar).toHaveAttribute("src", "https://example.com/avatar.jpg");
expect(avatar).toHaveAttribute("alt", "Assignee avatar");
});
it("shows the comment section with required field when clicking Deny request", async () => {
const user = userEvent.setup();
render(
<ApproverActions
approvalRequestId={mockApprovalRequestId}
approvalWorkflowInstanceId={mockApprovalWorkflowInstanceId}
setApprovalRequest={mockSetApprovalRequest}
assigneeUser={mockAssigneeUser}
/>
);
await user.click(screen.getByText("Deny request"));
expect(
screen.getByText("Reason for denial* (Required)")
).toBeInTheDocument();
expect(screen.getByText("Submit denial")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();
});
it("shows the validation error when submitting an empty denial", async () => {
const user = userEvent.setup();
render(
<ApproverActions
approvalRequestId={mockApprovalRequestId}
approvalWorkflowInstanceId={mockApprovalWorkflowInstanceId}
setApprovalRequest={mockSetApprovalRequest}
assigneeUser={mockAssigneeUser}
/>
);
await user.click(screen.getByText("Deny request"));
await user.click(screen.getByText("Submit denial"));
expect(screen.getByText("Enter a reason for denial")).toBeInTheDocument();
expect(mockSubmitApprovalDecision).not.toHaveBeenCalled();
});
it("returns to initial state when clicking Cancel", async () => {
const user = userEvent.setup();
render(
<ApproverActions
approvalRequestId={mockApprovalRequestId}
approvalWorkflowInstanceId={mockApprovalWorkflowInstanceId}
setApprovalRequest={mockSetApprovalRequest}
assigneeUser={mockAssigneeUser}
/>
);
await user.click(screen.getByText("Approve request"));
await user.click(screen.getByText("Cancel"));
expect(screen.getByText("Approve request")).toBeInTheDocument();
expect(screen.getByText("Deny request")).toBeInTheDocument();
});
it("handles successful approval submission", async () => {
const user = userEvent.setup();
mockSubmitApprovalDecision.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ approval_request: { id: "123" } }),
});
render(
<ApproverActions
approvalRequestId={mockApprovalRequestId}
approvalWorkflowInstanceId={mockApprovalWorkflowInstanceId}
setApprovalRequest={mockSetApprovalRequest}
assigneeUser={mockAssigneeUser}
/>
);
await user.click(screen.getByText("Approve request"));
await user.type(screen.getByRole("textbox"), "Test comment");
await act(async () => {
await user.click(screen.getByText("Submit approval"));
});
await waitFor(() => {
expect(mockSubmitApprovalDecision).toHaveBeenCalledWith(
"456",
"123",
"approved",
"Test comment"
);
});
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: "success",
title: "Approval submitted",
message: "",
});
});
});
it("handles successful denial submission", async () => {
const user = userEvent.setup();
mockSubmitApprovalDecision.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ approval_request: { id: "123" } }),
});
render(
<ApproverActions
approvalRequestId={mockApprovalRequestId}
approvalWorkflowInstanceId={mockApprovalWorkflowInstanceId}
setApprovalRequest={mockSetApprovalRequest}
assigneeUser={mockAssigneeUser}
/>
);
await user.click(screen.getByText("Deny request"));
await user.type(screen.getByRole("textbox"), "Denial reason");
await act(async () => {
await user.click(screen.getByText("Submit denial"));
});
await waitFor(() => {
expect(mockSubmitApprovalDecision).toHaveBeenCalledWith(
"456",
"123",
"rejected",
"Denial reason"
);
});
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: "success",
title: "Denial submitted",
message: "",
});
});
});
it("handles failed submission", async () => {
const user = userEvent.setup();
mockSubmitApprovalDecision.mockResolvedValue({ ok: false });
render(
<ApproverActions
approvalRequestId={mockApprovalRequestId}
approvalWorkflowInstanceId={mockApprovalWorkflowInstanceId}
setApprovalRequest={mockSetApprovalRequest}
assigneeUser={mockAssigneeUser}
/>
);
await user.click(screen.getByText("Approve request"));
await act(async () => {
await user.click(screen.getByText("Submit approval"));
});
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith({
type: "error",
title: "Error submitting decision",
message: "Please try again later",
});
});
});
});

View File

@@ -0,0 +1,244 @@
import { useState, useCallback, memo } from "react";
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { Button } from "@zendeskgarden/react-buttons";
import { Field, Textarea } from "@zendeskgarden/react-forms";
import { Avatar } from "@zendeskgarden/react-avatars";
import { submitApprovalDecision } from "../../submitApprovalDecision";
import type { ApprovalDecision } from "../../submitApprovalDecision";
import type { ApprovalRequest } from "../../types";
import { APPROVAL_REQUEST_STATES } from "../../constants";
import { notify } from "../../../shared";
const PENDING_APPROVAL_STATUS = {
APPROVED: "APPROVED",
REJECTED: "REJECTED",
} as const;
const ButtonContainer = styled.div<{
hasAvatar?: boolean;
isSubmitButton?: boolean;
}>`
display: flex;
flex-direction: row;
gap: ${(props) => props.theme.space.md}; /* 20px */
margin-inline-start: ${(props) =>
props.hasAvatar ? "55px" : "0"}; // avatar width + margin + border
@media (max-width: ${(props) => props.theme.breakpoints.md}) {
flex-direction: ${(props) =>
props.isSubmitButton ? "row-reverse" : "column"};
gap: ${(props) => props.theme.space.base * 4}px; /* 16px */
}
`;
const CommentSection = styled.div`
display: flex;
flex-direction: column;
gap: ${(props) => props.theme.space.lg}; /* 32px */
@media (max-width: ${(props) => props.theme.breakpoints.md}) {
gap: ${(props) => props.theme.space.base * 4}px; /* 16px */
}
`;
const TextAreaContainer = styled.div`
display: flex;
gap: ${(props) => props.theme.space.base * 4}px; /* 16px */
margin-top: ${(props) => props.theme.space.base * 6}px; /* 24px */
align-items: flex-start;
`;
const TextAreaAndMessage = styled.div`
display: flex;
flex-direction: column;
flex: 1;
`;
interface ApproverActionsProps {
approvalRequestId: string;
approvalWorkflowInstanceId: string;
setApprovalRequest: (approvalRequest: ApprovalRequest) => void;
assigneeUser: ApprovalRequest["assignee_user"];
}
function ApproverActions({
approvalRequestId,
approvalWorkflowInstanceId,
setApprovalRequest,
assigneeUser,
}: ApproverActionsProps) {
const { t } = useTranslation();
const [comment, setComment] = useState("");
const [pendingStatus, setPendingStatus] = useState<
| (typeof PENDING_APPROVAL_STATUS)[keyof typeof PENDING_APPROVAL_STATUS]
| null
>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showValidation, setShowValidation] = useState(false);
const isCommentValid =
pendingStatus !== PENDING_APPROVAL_STATUS.REJECTED || comment.trim() !== "";
const shouldShowValidationError = showValidation && !isCommentValid;
const handleApproveRequestClick = useCallback(() => {
setPendingStatus(PENDING_APPROVAL_STATUS.APPROVED);
setShowValidation(false);
}, []);
const handleDenyRequestClick = useCallback(() => {
setPendingStatus(PENDING_APPROVAL_STATUS.REJECTED);
setShowValidation(false);
}, []);
const handleInputValueChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setComment(e.target.value);
},
[]
);
const handleCancelClick = useCallback(() => {
setPendingStatus(null);
setComment("");
setShowValidation(false);
}, []);
const handleSubmitDecisionClick = async () => {
setShowValidation(true);
if (!pendingStatus || !isCommentValid) return;
setIsSubmitting(true);
try {
const decision: ApprovalDecision =
pendingStatus === PENDING_APPROVAL_STATUS.APPROVED
? APPROVAL_REQUEST_STATES.APPROVED
: APPROVAL_REQUEST_STATES.REJECTED;
const response = await submitApprovalDecision(
approvalWorkflowInstanceId,
approvalRequestId,
decision,
comment
);
if (response.ok) {
const data = await response.json();
setApprovalRequest(data.approval_request);
const notificationTitle =
decision === APPROVAL_REQUEST_STATES.APPROVED
? t(
"approval-requests.request.notification.approval-submitted",
"Approval submitted"
)
: t(
"approval-requests.request.notification.denial-submitted",
"Denial submitted"
);
notify({
type: "success",
title: notificationTitle,
message: "",
});
} else {
throw new Error(`Failed to submit ${decision} decision`);
}
} catch (error) {
notify({
type: "error",
title: "Error submitting decision",
message: "Please try again later",
});
} finally {
setIsSubmitting(false);
}
};
if (pendingStatus) {
const fieldLabel =
pendingStatus === PENDING_APPROVAL_STATUS.APPROVED
? t(
"approval-requests.request.approver-actions.additional-note-label",
"Additional note"
)
: t(
"approval-requests.request.approver-actions.denial-reason-label",
"Reason for denial* (Required)"
);
const shouldShowAvatar = Boolean(assigneeUser?.photo?.content_url);
return (
<CommentSection>
<Field>
<Field.Label>{fieldLabel}</Field.Label>
<TextAreaContainer>
{shouldShowAvatar && (
<Avatar>
<img
alt="Assignee avatar"
src={assigneeUser.photo.content_url ?? undefined}
/>
</Avatar>
)}
<TextAreaAndMessage>
<Textarea
minRows={5}
value={comment}
onChange={handleInputValueChange}
disabled={isSubmitting}
validation={shouldShowValidationError ? "error" : undefined}
/>
{shouldShowValidationError && (
<Field.Message validation="error">
{t(
"approval-requests.request.approver-actions.denial-reason-validation",
"Enter a reason for denial"
)}
</Field.Message>
)}
</TextAreaAndMessage>
</TextAreaContainer>
</Field>
<ButtonContainer hasAvatar={shouldShowAvatar} isSubmitButton>
<Button
isPrimary
onClick={handleSubmitDecisionClick}
disabled={isSubmitting}
>
{pendingStatus === PENDING_APPROVAL_STATUS.APPROVED
? t(
"approval-requests.request.approver-actions.submit-approval",
"Submit approval"
)
: t(
"approval-requests.request.approver-actions.submit-denial",
"Submit denial"
)}
</Button>
<Button onClick={handleCancelClick} disabled={isSubmitting}>
{t("approval-requests.request.approver-actions.cancel", "Cancel")}
</Button>
</ButtonContainer>
</CommentSection>
);
}
return (
<ButtonContainer>
<Button isPrimary onClick={handleApproveRequestClick}>
{t(
"approval-requests.request.approver-actions.approve-request",
"Approve request"
)}
</Button>
<Button onClick={handleDenyRequestClick}>
{t(
"approval-requests.request.approver-actions.deny-request",
"Deny request"
)}
</Button>
</ButtonContainer>
);
}
export default memo(ApproverActions);

View File

@@ -0,0 +1,57 @@
import type React from "react";
import styled from "styled-components";
import { Avatar } from "@zendeskgarden/react-avatars";
import { getColor } from "@zendeskgarden/react-theming";
import HeadsetBadge from "@zendeskgarden/svg-icons/src/16/headset-fill.svg";
import { DEFAULT_AVATAR_URL } from "./constants";
const AvatarWrapper = styled.div`
position: relative;
display: inline-block;
`;
const HeadsetBadgeWrapper = styled.div`
position: absolute;
bottom: -3px;
right: -3px;
border-radius: 50%;
width: 13px;
height: 13px;
display: flex;
align-items: center;
justify-content: center;
background-color: ${({ theme }) =>
getColor({ theme, hue: "grey", shade: 100 })};
border: ${({ theme }) => theme.borders.sm};
`;
const HeadsetIcon = styled(HeadsetBadge)`
width: 9px;
height: 9px;
color: ${({ theme }) => getColor({ theme, hue: "grey", shade: 900 })};
`;
interface AvatarWithBadgeProps {
name: string;
photoUrl?: string;
size: "small" | "medium" | "large";
}
const AvatarWithBadge: React.FC<AvatarWithBadgeProps> = ({
name,
photoUrl,
size,
}) => {
return (
<AvatarWrapper>
<Avatar size={size}>
<img alt={name} src={photoUrl ? photoUrl : DEFAULT_AVATAR_URL} />
</Avatar>
<HeadsetBadgeWrapper>
<HeadsetIcon />
</HeadsetBadgeWrapper>
</AvatarWrapper>
);
};
export default AvatarWithBadge;

View File

@@ -0,0 +1,106 @@
import type React from "react";
import { useRef, memo } from "react";
import { Grid, Col, Row } from "@zendeskgarden/react-grid";
import { Avatar } from "@zendeskgarden/react-avatars";
import styled from "styled-components";
import { type ApprovalClarificationFlowMessage } from "../../../types";
import { useIntersectionObserver } from "./hooks/useIntersectionObserver";
import { RelativeTime } from "./RelativeTime";
import AvatarWithHeadsetBadge from "./AvatarWithBadge";
import { DEFAULT_AVATAR_URL } from "./constants";
import Circle from "@zendeskgarden/svg-icons/src/12/circle-sm-fill.svg";
import { getColor } from "@zendeskgarden/react-theming";
import { MD } from "@zendeskgarden/react-typography";
const MessageContainer = styled.div`
margin-top: ${({ theme }) => theme.space.sm};
`;
const Body = styled.div`
margin-top: ${({ theme }) => theme.space.xs};
`;
const AvatarCol = styled(Col)`
max-width: 55px;
`;
const CircleWrapper = styled.span`
padding: 0px 6px;
`;
const NameAndTimestampCol = styled(Col)`
display: flex;
flex-direction: row;
align-items: start;
`;
const StyledCircle = styled(Circle)`
width: ${({ theme }) => theme.space.xs};
height: ${({ theme }) => theme.space.xs};
color: ${({ theme }) => getColor({ theme, hue: "grey", shade: 600 })};
`;
export interface ClarificationCommentProps {
baseLocale: string;
children?: React.ReactNode;
comment: ApprovalClarificationFlowMessage;
commentKey: string;
createdByUserId: number;
markCommentAsVisible: (commentKey: string) => void;
}
function ClarificationCommentComponent({
baseLocale,
children,
comment,
commentKey,
createdByUserId,
markCommentAsVisible,
}: ClarificationCommentProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const { author, created_at } = comment;
const { avatar, name, id: authorId } = author;
useIntersectionObserver(containerRef, () => markCommentAsVisible(commentKey));
const isRequester = createdByUserId === authorId;
return (
<MessageContainer ref={containerRef}>
<Grid gutters={false}>
<Row>
<AvatarCol>
{isRequester ? (
<AvatarWithHeadsetBadge
photoUrl={avatar}
size="small"
name={name}
/>
) : (
<Avatar size="small">
<img alt={name} src={avatar ? avatar : DEFAULT_AVATAR_URL} />
</Avatar>
)}
</AvatarCol>
<Col>
<Row alignItems="start" justifyContent="start">
<NameAndTimestampCol>
<MD isBold>{name}</MD>
<CircleWrapper>
<StyledCircle />
</CircleWrapper>
{created_at && (
<RelativeTime eventTime={created_at} locale={baseLocale} />
)}
</NameAndTimestampCol>
</Row>
<Body>{children}</Body>
</Col>
</Row>
</Grid>
</MessageContainer>
);
}
const ClarificationComment = memo(ClarificationCommentComponent);
export default ClarificationComment;

View File

@@ -0,0 +1,123 @@
import { Grid, Col, Row } from "@zendeskgarden/react-grid";
import { Field, Textarea, Label, Message } from "@zendeskgarden/react-forms";
import { useGetClarificationCopy } from "./hooks/useGetClarificationCopy";
import { Avatar } from "@zendeskgarden/react-avatars";
import { useCommentForm } from "./hooks/useCommentForm";
import { useSubmitComment } from "./hooks/useSubmitComment";
import { Button } from "@zendeskgarden/react-buttons";
import styled from "styled-components";
import { DEFAULT_AVATAR_URL } from "./constants";
interface ClarificationCommentFormProps {
baseLocale: string;
currentUserAvatarUrl: string;
currentUserName: string;
markAllCommentsAsRead: () => void;
}
const FormContainer = styled(Grid)`
margin-top: ${({ theme }) => theme.space.xs};
padding-top: ${({ theme }) => theme.space.md};
`;
const AvatarCol = styled(Col)`
max-width: 55px;
`;
const ButtonsRow = styled(Row)`
margin-top: 10px;
margin-left: 55px;
`;
const CancelButton = styled(Button)`
margin: 10px;
`;
function ClarificationCommentForm({
baseLocale,
currentUserAvatarUrl,
currentUserName,
markAllCommentsAsRead,
}: ClarificationCommentFormProps) {
const {
comment_form_aria_label,
submit_button,
cancel_button,
validation_empty_input,
} = useGetClarificationCopy();
const { handleSubmitComment, isLoading = false } = useSubmitComment();
const {
buttonsContainerRef,
comment,
commentValidation,
charLimitMessage,
handleBlur,
handleCancel,
handleFocus,
handleKeyDown,
handleSubmit,
handleChange,
isInputFocused,
textareaRef,
} = useCommentForm({
onSubmit: handleSubmitComment,
baseLocale,
markAllCommentsAsRead,
});
return (
<FormContainer gutters={false}>
<Row>
<AvatarCol>
<Avatar size="small">
<img
alt={currentUserName}
src={
currentUserAvatarUrl ? currentUserAvatarUrl : DEFAULT_AVATAR_URL
}
/>
</Avatar>
</AvatarCol>
<Col>
<Field>
<Label hidden>{comment_form_aria_label}</Label>
<Textarea
ref={textareaRef}
validation={commentValidation}
minRows={isInputFocused || comment.trim().length > 0 ? 4 : 1}
maxRows={4}
value={comment}
onChange={handleChange}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={handleFocus}
/>
<Message validation={commentValidation}>
{commentValidation === "error"
? validation_empty_input
: commentValidation === "warning"
? charLimitMessage
: null}
</Message>
</Field>
</Col>
</Row>
{(isInputFocused || comment.trim().length > 0) && (
<ButtonsRow ref={buttonsContainerRef}>
<Col textAlign="start">
<Button disabled={isLoading} onClick={handleSubmit}>
{submit_button}
</Button>
<CancelButton disabled={isLoading} onClick={handleCancel} isBasic>
{cancel_button}
</CancelButton>
</Col>
</ButtonsRow>
)}
</FormContainer>
);
}
export default ClarificationCommentForm;

View File

@@ -0,0 +1,162 @@
import React, { useEffect } from "react";
import { useGetClarificationCopy } from "./hooks/useGetClarificationCopy";
import { getColor, getColorV8 } from "@zendeskgarden/react-theming";
import { MD } from "@zendeskgarden/react-typography";
import styled from "styled-components";
import ClarificationCommentForm from "./ClarificationCommentForm";
import { MAX_COMMENTS } from "./constants";
import type {
ApprovalClarificationFlowMessage,
ApprovalRequest,
} from "../../../types";
import { buildCommentEntityKey } from "./utils";
import { useGetUnreadComments } from "./hooks/useGetUnreadComments";
import NewCommentIndicator from "./NewCommentIndicator";
import ClarificationComment from "./ClarificationComment";
import CommentLimitAlert from "./CommentLimitAlert";
interface ClarificationContainerProps {
approvalRequestId: string;
baseLocale: string;
clarificationFlowMessages: ApprovalClarificationFlowMessage[];
createdByUserId: number;
currentUserAvatarUrl: string;
currentUserId: number;
currentUserName: string;
hasUserViewedBefore: boolean;
status: ApprovalRequest["status"];
}
const Container = styled.div<{ showCommentHeader: boolean }>`
display: flex;
flex-direction: column;
border-top: ${({ showCommentHeader, theme }) =>
showCommentHeader
? `1px solid ${getColor({ theme, hue: "grey", shade: 200 })}`
: "none"};
padding-top: 16px;
`;
const CommentListArea = styled.div`
flex: 1 1 auto;
`;
const ClarificationContent = styled(MD)`
padding: ${({ theme }) => theme.space.xxs} 0;
overflow-wrap: break-word;
white-space: normal;
`;
const TitleAndDescriptionContainer = styled.div`
padding-bottom: 16px;
`;
const StyledDescription = styled(MD)`
padding-top: ${({ theme }) => theme.space.xxs};
color: ${(props) => getColorV8("grey", 600, props.theme)};
`;
export default function ClarificationContainer({
approvalRequestId,
baseLocale,
clarificationFlowMessages,
createdByUserId,
currentUserAvatarUrl,
currentUserId,
currentUserName,
hasUserViewedBefore,
status,
}: ClarificationContainerProps) {
const copy = useGetClarificationCopy();
const hasComments =
clarificationFlowMessages && clarificationFlowMessages.length > 0;
const isTerminalStatus =
!!status &&
(status === "withdrawn" || status === "approved" || status === "rejected");
const canComment =
!isTerminalStatus && clarificationFlowMessages!.length < MAX_COMMENTS;
const showCommentHeader = !isTerminalStatus || hasComments;
const {
unreadComments,
firstUnreadCommentKey,
markCommentAsVisible,
markAllCommentsAsRead,
} = useGetUnreadComments({
comments: clarificationFlowMessages,
currentUserId,
approvalRequestId,
});
useEffect(() => {
const handleBeforeUnload = () => {
markAllCommentsAsRead();
};
window.addEventListener("beforeunload", handleBeforeUnload);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [markAllCommentsAsRead]);
return (
<Container showCommentHeader={showCommentHeader}>
{showCommentHeader && (
<TitleAndDescriptionContainer>
<MD isBold>{copy.title}</MD>
{canComment && (
<StyledDescription>{copy.description}</StyledDescription>
)}
</TitleAndDescriptionContainer>
)}
<CommentListArea data-testid="comment-list-area">
{hasComments &&
clarificationFlowMessages.map((comment) => {
const commentKey = buildCommentEntityKey(
approvalRequestId,
comment
);
const isFirstUnread = commentKey === firstUnreadCommentKey;
const unreadCount = unreadComments.length;
return (
<React.Fragment key={`${comment.id}`}>
{!isTerminalStatus &&
unreadCount > 0 &&
isFirstUnread &&
hasUserViewedBefore && (
<NewCommentIndicator unreadCount={unreadCount} />
)}
<ClarificationContent key={comment.id}>
<ClarificationComment
baseLocale={baseLocale}
comment={comment}
commentKey={commentKey}
createdByUserId={createdByUserId}
markCommentAsVisible={markCommentAsVisible}
>
{comment.message}
</ClarificationComment>
</ClarificationContent>
</React.Fragment>
);
})}
</CommentListArea>
<CommentLimitAlert
approvalRequestId={approvalRequestId}
commentCount={clarificationFlowMessages!.length}
currentUserId={currentUserId}
isTerminalStatus={isTerminalStatus}
/>
{canComment && (
<ClarificationCommentForm
baseLocale={baseLocale}
currentUserAvatarUrl={currentUserAvatarUrl}
currentUserName={currentUserName}
markAllCommentsAsRead={markAllCommentsAsRead}
/>
)}
</Container>
);
}

View File

@@ -0,0 +1,94 @@
import type React from "react";
import { useState, useCallback } from "react";
import { Alert, Title, Close } from "@zendeskgarden/react-notifications";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { MAX_COMMENTS, NEAR_COMMENTS_LIMIT_THRESHOLD } from "./constants";
const StyledAlert = styled(Alert)`
margin-top: ${({ theme }) => theme.space.md};
padding: ${({ theme }) => theme.space.md} ${({ theme }) => theme.space.xl};
`;
interface CommentLimitAlertProps {
approvalRequestId: string;
commentCount: number;
currentUserId: number;
isTerminalStatus: boolean;
}
const CommentLimitAlert: React.FC<CommentLimitAlertProps> = ({
approvalRequestId,
commentCount,
currentUserId,
isTerminalStatus,
}) => {
const { t } = useTranslation();
const alertDismissalKey = `nearLimitAlertDismissed_${currentUserId}_${approvalRequestId}`;
const [nearLimitAlertVisible, setNearLimitAlertVisible] = useState(() => {
return localStorage.getItem(alertDismissalKey) !== "true";
});
const isAtMaxComments = commentCount >= MAX_COMMENTS;
const isNearLimit =
commentCount >= MAX_COMMENTS - NEAR_COMMENTS_LIMIT_THRESHOLD &&
commentCount < MAX_COMMENTS &&
nearLimitAlertVisible;
const commentsRemaining = MAX_COMMENTS - commentCount;
const handleCloseAlert = useCallback(() => {
localStorage.setItem(alertDismissalKey, "true");
setNearLimitAlertVisible(false);
}, [alertDismissalKey]);
if (!isTerminalStatus) {
if (isAtMaxComments) {
return (
<StyledAlert type="info">
<Title>
{t(
"txt.approval_requests.clarification.max_comment_alert_title",
"Comment limit reached"
)}
</Title>
{t(
"txt.approval_requests.clarification.max_comment_alert_message",
"You can't add more comments, approvers can still approve or deny."
)}
</StyledAlert>
);
} else if (isNearLimit) {
return (
<StyledAlert type="info">
<Title>
{t(
"txt.approval_requests.clarification.near_comment_limit_alert_title",
"Comment limit nearly reached"
)}
</Title>
{
(t(
"txt.approval_requests.panel.single_approval_request.clarification.near_comment_limit_alert_message",
{
current_count: commentCount,
remaining_count: commentsRemaining,
}
),
`This request has ${commentCount} of 40 comments available. You have ${commentsRemaining} remaining.`)
}
<Close
onClick={handleCloseAlert}
aria-label={t(
"txt.approval_requests.panel.single_approval_request.clarification.close_alert_button_aria_label",
"Close alert"
)}
/>
</StyledAlert>
);
}
}
return null;
};
export default CommentLimitAlert;

View File

@@ -0,0 +1,52 @@
import styled from "styled-components";
import { useTranslation } from "react-i18next";
import { getColor } from "@zendeskgarden/react-theming";
const StyledNewCommentIndicator = styled.div`
align-items: center;
color: ${({ theme }) => getColor({ theme, hue: "red", shade: 600 })};
display: flex;
font-size: ${({ theme }) => theme.fontSizes.md};
text-align: center;
padding-top: ${({ theme }) => theme.space.sm};
padding-bottom: ${({ theme }) => theme.space.xxs};
&:before,
&:after {
content: "";
flex: 1;
border-bottom: 1px solid
${(props) => getColor({ theme: props.theme, hue: "red", shade: 600 })};
}
&:before {
margin-right: 16px;
}
&:after {
margin-left: 16px;
}
`;
interface NewCommentIndicatorProps {
unreadCount: number;
}
function NewCommentIndicator({ unreadCount }: NewCommentIndicatorProps) {
const { t } = useTranslation();
return (
<StyledNewCommentIndicator>
{unreadCount === 1
? t(
"txt.approval_requests.clarification.new_comment_indicator",
"New comment"
)
: t(
"txt.approval_requests.clarification.new_comments_indicator",
"New comments"
)}
</StyledNewCommentIndicator>
);
}
export default NewCommentIndicator;

View File

@@ -0,0 +1,154 @@
import type React from "react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import {
isSameDay as dfIsSameDay,
subDays,
isSameYear,
differenceInMinutes,
differenceInSeconds,
} from "date-fns";
import styled from "styled-components";
import { getColor } from "@zendeskgarden/react-theming";
import { SM } from "@zendeskgarden/react-typography";
const StyledTimestamp = styled(SM)`
color: ${({ theme }) => getColor({ theme, hue: "grey", shade: 600 })};
align-self: center;
justify-content: center;
padding-top: 1px;
`;
const isSameDay = (date1: Date, date2: Date) => dfIsSameDay(date1, date2);
const isYesterday = (date: Date, now: Date) => {
const yesterday = subDays(now, 1);
return dfIsSameDay(date, yesterday);
};
function detectHour12(locale: string): boolean {
const dtf = new Intl.DateTimeFormat(locale, { hour: "numeric" });
const options = dtf.resolvedOptions();
return options.hour12 ?? true;
}
const formatDate = (date: Date, locale: string) =>
date.toLocaleDateString(locale, { month: "short", day: "2-digit" });
const formatDateWithYear = (date: Date, locale: string) =>
date.toLocaleDateString(locale, {
month: "short",
day: "2-digit",
year: "numeric",
});
const formatTime = (date: Date, locale: string, hour12: boolean) =>
date.toLocaleTimeString(locale, {
hour: "numeric",
minute: "2-digit",
hour12,
});
interface RelativeTimeProps {
eventTime: string;
locale: string;
}
export const RelativeTime: React.FC<RelativeTimeProps> = ({
eventTime,
locale,
}) => {
const { t } = useTranslation();
const hour12 = useMemo(() => detectHour12(locale), [locale]);
const now = new Date();
const eventDate = new Date(eventTime);
if (isNaN(eventDate.getTime())) return null;
const elapsedSeconds = differenceInSeconds(now, eventDate);
const elapsedMinutes = differenceInMinutes(now, eventDate);
if (elapsedSeconds < 0 || elapsedSeconds < 60) {
return (
<StyledTimestamp>
{
(t("approval_request.clarification.timestamp_lessThanAMinuteAgo"),
`< 1 minute ago`)
}
</StyledTimestamp>
);
}
if (elapsedMinutes < 60) {
const pluralRules = new Intl.PluralRules(locale);
const plural = pluralRules.select(elapsedMinutes);
return (
<StyledTimestamp>
{
(t("approval_request.clarification.timestamp_minutesAgo", {
count: elapsedMinutes,
plural,
}),
`${elapsedMinutes} minute${plural === "one" ? "" : "s"} ago`)
}
</StyledTimestamp>
);
}
const timeStr = formatTime(eventDate, locale, hour12);
if (isSameDay(eventDate, now)) {
return (
<StyledTimestamp>
{
(t("approval_request.clarification.timestamp_todayAt", {
time: timeStr,
}),
`Today at ${timeStr}`)
}
</StyledTimestamp>
);
}
if (isYesterday(eventDate, now)) {
return (
<StyledTimestamp>
{
(t("approval_request.clarification.timestamp_yesterdayAt", {
time: timeStr,
}),
`Yesterday at ${timeStr}`)
}
</StyledTimestamp>
);
}
if (isSameYear(eventDate, now)) {
const dateStr = formatDate(eventDate, locale);
return (
<StyledTimestamp>
{
(t("approval_request.clarification.timestamp_dateAt", {
date: dateStr,
time: timeStr,
}),
`${dateStr} at ${timeStr}`)
}
</StyledTimestamp>
);
}
const dateStr = formatDateWithYear(eventDate, locale);
return (
<StyledTimestamp>
{
(t("approval_request.clarification.timestamp_dateAt", {
date: dateStr,
time: timeStr,
}),
`${dateStr} at ${timeStr}`)
}
</StyledTimestamp>
);
};
export default RelativeTime;

View File

@@ -0,0 +1,6 @@
export const MAX_COMMENTS = 40;
export const NEAR_COMMENTS_LIMIT_THRESHOLD = 5;
export const MAX_CHAR_COUNT = 500;
export const WARNING_THRESHOLD = 10;
export const DEFAULT_AVATAR_URL =
"https://secure.gravatar.com/avatar/6d713fed56e4dd3e48f6b824b8789d7f?default=https%3A%2F%2Fassets.zendesk.com%2Fhc%2Fassets%2Fdefault_avatar.png&r=g";

View File

@@ -0,0 +1,393 @@
import type React from "react";
import { useCommentForm } from "../useCommentForm";
import { MAX_CHAR_COUNT, WARNING_THRESHOLD } from "../../constants";
import { act, renderHook } from "@testing-library/react-hooks";
const mockEvent = (val: string) => {
return {
target: { value: val },
currentTarget: {
value: val,
},
preventDefault: jest.fn(),
} as unknown as React.ChangeEvent<HTMLTextAreaElement>;
};
const mockKeyboardEvent = (
key: string,
options = {}
): React.KeyboardEvent<HTMLTextAreaElement> => {
return {
key,
shiftKey: false,
ctrlKey: false,
metaKey: false,
preventDefault: jest.fn(),
...options,
} as unknown as React.KeyboardEvent<HTMLTextAreaElement>;
};
const mockMarkAllCommentsAsRead = jest.fn();
describe("useCommentForm", () => {
const baseLocale = "en-US";
const successOnSubmit = jest.fn(() => Promise.resolve());
const markAllCommentsAsRead = mockMarkAllCommentsAsRead;
beforeEach(() => {
jest.clearAllMocks();
});
it("should return initial values", () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
const { comment, commentValidation, charLimitMessage, isInputFocused } =
result.current;
expect(comment).toBe("");
expect(commentValidation).toBeUndefined();
expect(charLimitMessage).toBe("");
expect(isInputFocused).toBe(false);
});
describe("validation", () => {
it("should not submit when input is empty", () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
result.current.handleChange(mockEvent(""));
result.current.handleSubmit();
});
expect(result.current.comment).toBe("");
expect(result.current.commentValidation).toBe("error");
expect(successOnSubmit).not.toHaveBeenCalled();
});
it("should submit when input is not empty", async () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
result.current.handleChange(mockEvent("t"));
});
expect(result.current.comment).toBe("t");
await act(async () => {
await result.current.handleSubmit();
});
expect(result.current.comment).toBe(""); // after submit comment is cleared
expect(result.current.commentValidation).toBe(undefined);
expect(successOnSubmit).toHaveBeenCalled();
});
it("should handleBlur", () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
result.current.handleFocus();
});
expect(result.current.isInputFocused).toBe(true);
const mockFocusEvent = {
target: {},
currentTarget: {},
preventDefault: jest.fn(),
} as unknown as React.FocusEvent<HTMLTextAreaElement>;
act(() => {
result.current.handleBlur(mockFocusEvent);
});
expect(result.current.isInputFocused).toBe(false);
});
it("should submit when input is not empty and user clicks enter", async () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
result.current.handleChange(mockEvent("t"));
});
const enterEvent = mockKeyboardEvent("Enter", { shiftKey: false });
await act(async () => {
await result.current.handleKeyDown(enterEvent);
});
expect(result.current.comment).toBe("");
expect(result.current.commentValidation).toBe(undefined);
expect(successOnSubmit).toHaveBeenCalled();
});
it("should show charLimitMessage warning when 10 characters remaining", () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
const inputWith10Remaining = "a".repeat(
MAX_CHAR_COUNT - WARNING_THRESHOLD
);
result.current.handleChange(mockEvent(inputWith10Remaining));
});
expect(result.current.commentValidation).toBe("warning");
expect(result.current.charLimitMessage).toContain(
"10 characters remaining"
);
});
it("should update charLimitMessage and validation warning with 1 character remaining", () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
const inputWithOneRemaining = "a".repeat(499);
result.current.handleChange(mockEvent(inputWithOneRemaining));
});
expect(result.current.comment.length).toBe(499);
expect(result.current.commentValidation).toBe("warning");
expect(result.current.charLimitMessage).toContain(
"1 character remaining"
);
});
it("should truncate input exceeding max length", () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
const longInput = "a".repeat(MAX_CHAR_COUNT + 10);
result.current.handleChange(mockEvent(longInput));
});
expect(result.current.comment.length).toBe(MAX_CHAR_COUNT);
});
it("should clear error validation when user types valid input after error", () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
result.current.handleSubmit();
});
act(() => {
result.current.handleChange(mockEvent("a"));
});
expect(result.current.commentValidation).toBeUndefined();
});
it("calls markAllCommentsAsRead after successful submit", async () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
result.current.handleChange(mockEvent("valid comment"));
});
await act(async () => {
await result.current.handleSubmit();
});
expect(mockMarkAllCommentsAsRead).toHaveBeenCalledTimes(1);
});
describe("handleKeyDown", () => {
it("does not submit on Enter with Shift (allows newline)", () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
const enterShiftEvent = mockKeyboardEvent("Enter", { shiftKey: true });
act(() => {
result.current.handleKeyDown(enterShiftEvent);
});
expect(enterShiftEvent.preventDefault).not.toHaveBeenCalled();
expect(successOnSubmit).not.toHaveBeenCalled();
});
it("submits on Enter", async () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
result.current.handleChange(mockEvent("t"));
});
const enterEvent = mockKeyboardEvent("Enter");
await act(async () => {
result.current.handleKeyDown(enterEvent);
});
expect(successOnSubmit).toHaveBeenCalled();
});
it("cancels on Escape key", () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
const escapeEvent = mockKeyboardEvent("Escape");
act(() => {
result.current.handleKeyDown(escapeEvent);
});
expect(escapeEvent.preventDefault).toHaveBeenCalled();
const { comment, commentValidation, charLimitMessage, isInputFocused } =
result.current;
expect(comment).toBe("");
expect(commentValidation).toBeUndefined();
expect(charLimitMessage).toBe("");
expect(isInputFocused).toBe(false);
});
it("prevents input when max length reached and non-allowed key pressed", () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
result.current.handleChange({
target: { value: "a".repeat(MAX_CHAR_COUNT) },
} as React.ChangeEvent<HTMLTextAreaElement>);
});
const keyEvent = mockKeyboardEvent("b");
act(() => {
result.current.handleKeyDown(keyEvent);
});
expect(keyEvent.preventDefault).toHaveBeenCalled();
});
it("allows navigation keys and shortcuts at max length", () => {
const { result } = renderHook(() =>
useCommentForm({
onSubmit: successOnSubmit,
baseLocale,
markAllCommentsAsRead,
})
);
act(() => {
result.current.handleChange({
target: { value: "a".repeat(MAX_CHAR_COUNT) },
} as React.ChangeEvent<HTMLTextAreaElement>);
});
const allowedKeys = [
"Backspace",
"Delete",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Tab",
"Home",
"End",
];
const shortcuts = [
{ key: "c", ctrlKey: true },
{ key: "a", metaKey: true },
{ key: "x", ctrlKey: true },
{ key: "z", ctrlKey: true },
];
allowedKeys.forEach((key) => {
const event = mockKeyboardEvent(key);
act(() => {
result.current.handleKeyDown(event);
});
expect(event.preventDefault).not.toHaveBeenCalled();
});
shortcuts.forEach(({ key, ctrlKey, metaKey }) => {
const event = mockKeyboardEvent(key, { ctrlKey, metaKey });
act(() => {
result.current.handleKeyDown(event);
});
expect(event.preventDefault).not.toHaveBeenCalled();
});
});
});
});
});

View File

@@ -0,0 +1,158 @@
import { renderHook } from "@testing-library/react-hooks";
import { act } from "@testing-library/react";
import { useGetUnreadComments } from "../useGetUnreadComments";
import type { ApprovalClarificationFlowMessage } from "../../../../../types";
import { buildCommentEntityKey } from "../../utils";
describe("useGetUnreadComments with real buildCommentEntityKey", () => {
const approvalRequestId = "123";
const currentUserId = 456;
const comment1: ApprovalClarificationFlowMessage = {
id: "comment1-id",
author: {
id: currentUserId,
email: "currentuser@example.com",
avatar: "",
name: "Current User",
},
message: "My own comment",
created_at: "2024-06-01T10:00:00Z",
};
const comment2: ApprovalClarificationFlowMessage = {
id: "comment2-id",
author: {
id: 567,
email: "jane@example.com",
avatar: "",
name: "Jane Doe",
},
message: "First comment",
created_at: "2024-06-02T11:00:00Z",
};
const comment3: ApprovalClarificationFlowMessage = {
id: "comment3-id",
author: {
id: 678,
email: "john@example.com",
avatar: "",
name: "John Doe",
},
message: "Second comment",
created_at: "2024-06-03T12:00:00Z",
};
const generateCommentKey = (comment: ApprovalClarificationFlowMessage) =>
buildCommentEntityKey(approvalRequestId, comment);
beforeEach(() => {
window.localStorage.clear();
});
it("filters unread comments correctly and returns firstUnreadCommentKey", () => {
const comments = [comment1, comment2, comment3];
const { result } = renderHook(() =>
useGetUnreadComments({
comments,
currentUserId,
approvalRequestId,
})
);
// comment1 is currentUser's and filtered out, only comment2 and comment3 considered unread
expect(result.current.unreadComments.length).toBe(2);
expect(result.current.unreadComments).toContainEqual(comment2);
expect(result.current.unreadComments).toContainEqual(comment3);
expect(result.current.firstUnreadCommentKey).toBe(
generateCommentKey(comment2)
);
});
it("markCommentAsVisible sets the comment visible in storage and state", () => {
const comments = [comment2];
const storageKey = `readComments:${currentUserId}:${approvalRequestId}`;
const commentKey = generateCommentKey(comment2);
const { result } = renderHook(() =>
useGetUnreadComments({
comments,
currentUserId,
approvalRequestId,
})
);
act(() => {
result.current.markCommentAsVisible(commentKey);
});
const stored = JSON.parse(window.localStorage.getItem(storageKey) || "{}");
expect(stored[commentKey]).toEqual({ visible: true, read: false });
});
it("markAllCommentsAsRead marks all visible comments as read", () => {
const comments = [comment2, comment3];
const storageKey = `readComments:${currentUserId}:${approvalRequestId}`;
const key1 = generateCommentKey(comment2);
const key2 = generateCommentKey(comment3);
window.localStorage.setItem(
storageKey,
JSON.stringify({
[key1]: { visible: true, read: false },
[key2]: { visible: true, read: false },
})
);
const { result } = renderHook(() =>
useGetUnreadComments({
comments,
currentUserId,
approvalRequestId,
})
);
act(() => {
result.current.markAllCommentsAsRead();
});
const stored = JSON.parse(window.localStorage.getItem(storageKey) || "{}");
expect(stored[key1]).toEqual({ visible: true, read: true });
expect(stored[key2]).toEqual({ visible: true, read: true });
});
it("markAllCommentsAsRead does not mark comments as read if visible is false", () => {
const comments = [comment2, comment3];
const storageKey = `readComments:${currentUserId}:${approvalRequestId}`;
const key2 = generateCommentKey(comment2);
const key3 = generateCommentKey(comment3);
window.localStorage.setItem(
storageKey,
JSON.stringify({
[key2]: { visible: false, read: false },
[key3]: { visible: true, read: false },
})
);
const { result } = renderHook(() =>
useGetUnreadComments({
comments,
currentUserId,
approvalRequestId,
})
);
act(() => {
result.current.markAllCommentsAsRead();
});
const stored = JSON.parse(window.localStorage.getItem(storageKey) || "{}");
expect(stored[key2]).toEqual({ visible: false, read: false });
expect(stored[key3]).toEqual({ visible: true, read: true });
});
});

View File

@@ -0,0 +1,54 @@
import { renderHook } from "@testing-library/react-hooks";
import { act } from "@testing-library/react";
import { useIntersectionObserver } from "../useIntersectionObserver";
describe("useIntersectionObserver", () => {
let observeMock: jest.Mock;
let unobserveMock: jest.Mock;
beforeEach(() => {
observeMock = jest.fn();
unobserveMock = jest.fn();
class IntersectionObserverMock {
callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
}
observe = observeMock;
unobserve = unobserveMock;
disconnect = jest.fn();
takeRecords = jest.fn();
}
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
configurable: true,
value: IntersectionObserverMock,
});
});
it("observes element and calls callback on intersect", () => {
const mockCallback = jest.fn();
const ref = { current: document.createElement("div") };
const { unmount } = renderHook(() =>
useIntersectionObserver(ref as React.RefObject<Element>, mockCallback)
);
expect(observeMock).toHaveBeenCalledWith(ref.current);
act(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const observerInstance = observeMock.mock.instances[0] as any;
observerInstance.callback([
{ target: ref.current, isIntersecting: true },
]);
});
expect(mockCallback).toHaveBeenCalledTimes(1);
unmount();
expect(unobserveMock).toHaveBeenCalledWith(ref.current);
});
});

View File

@@ -0,0 +1,12 @@
import { renderHook } from "@testing-library/react-hooks";
import { useSubmitComment } from "../useSubmitComment";
describe("useSubmitComment", () => {
it("should return initial values", () => {
const { result } = renderHook(() => useSubmitComment());
const { handleSubmitComment, isLoading } = result.current;
expect(handleSubmitComment).toBeDefined();
expect(isLoading).toBe(false);
});
});

View File

@@ -0,0 +1,182 @@
import type React from "react";
import { useCallback, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { MAX_CHAR_COUNT, WARNING_THRESHOLD } from "../constants";
export const useCommentForm = ({
onSubmit,
baseLocale,
markAllCommentsAsRead,
}: {
onSubmit: (comment: string) => Promise<unknown>;
baseLocale: string;
markAllCommentsAsRead: () => void;
}) => {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const buttonsContainerRef = useRef<HTMLDivElement | null>(null);
const [comment, setComment] = useState<string>("");
const [commentValidation, setCommentValidation] = useState<
undefined | "error" | "warning"
>();
const [isInputFocused, setIsInputFocused] = useState(false);
const [charLimitMessage, setCharLimitMessage] = useState("");
const { t } = useTranslation();
const validateComment = (value: string) => {
const isValid = value.trim().length > 0;
return isValid;
};
const handleCancel = () => {
setComment("");
setCommentValidation(undefined);
setCharLimitMessage("");
setIsInputFocused(false);
// Blur textarea to remove focus; state update alone doesnt blur DOM element.
textareaRef.current?.blur();
};
const handleBlur = (e?: React.FocusEvent<HTMLTextAreaElement>) => {
// Ignore blur if focus is moving to the buttons to keep validation UI visible,
// especially when the user submits an empty comment.
const relatedTarget = e?.relatedTarget;
if (
relatedTarget &&
(buttonsContainerRef.current?.contains(relatedTarget) ?? false)
) {
return;
}
setIsInputFocused(false);
};
const handleFocus = () => {
setIsInputFocused(true);
};
const handleSubmit = useCallback(async () => {
const isValid = validateComment(comment);
if (isValid) {
try {
await onSubmit(comment);
markAllCommentsAsRead();
// clear form
handleCancel();
return true;
} catch (error) {
console.error(error);
return false;
}
} else {
setCommentValidation("error");
textareaRef.current?.focus();
return false;
}
}, [comment, markAllCommentsAsRead, onSubmit]);
const handleKeyDown = useCallback(
async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const allowedKeys = [
"Backspace",
"Delete",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Tab",
"Enter",
"Escape",
"Home",
"End",
];
const isCopyShortcut =
(e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c";
const isSelectAllShortcut =
(e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a";
const isCutShortcut =
(e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "x";
const isUndoShortcut =
(e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z";
// Block input beyond max char count and allowed certain keys and shortcuts
if (
comment.length >= MAX_CHAR_COUNT &&
!allowedKeys.includes(e.key) &&
!isCopyShortcut &&
!isSelectAllShortcut &&
!isCutShortcut &&
!isUndoShortcut
) {
e.preventDefault();
return;
}
if (e.key === "Enter" && e.shiftKey === false) {
e.preventDefault();
await handleSubmit();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
},
[comment.length, handleSubmit]
);
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
let input = e.target.value;
// Only update state if input length is within max limit
if (input.length > MAX_CHAR_COUNT) {
input = input.substring(0, MAX_CHAR_COUNT);
}
setComment(input);
setIsInputFocused(true);
const charsLeft = MAX_CHAR_COUNT - input.length;
if (charsLeft >= 0 && charsLeft <= WARNING_THRESHOLD) {
const pluralRules = new Intl.PluralRules(baseLocale);
const plural = pluralRules.select(charsLeft);
setCommentValidation("warning");
setCharLimitMessage(
t(`txt.approval_requests.validation.characters_remaining.${plural}`, {
numCharacters: charsLeft,
defaultValue: `${charsLeft} character${
plural === "one" ? "" : "s"
} remaining`,
})
);
} else if (commentValidation === "warning") {
// Clear warning if no longer near limit and warning was previously shown
setCommentValidation(undefined);
setCharLimitMessage("");
}
// Clear error if user starts typing valid input after error was shown
if (commentValidation === "error" && input.trim().length > 0) {
setCommentValidation(undefined);
}
},
[baseLocale, commentValidation, t]
);
return {
textareaRef,
buttonsContainerRef,
charLimitMessage,
comment,
commentValidation,
isInputFocused,
setComment,
handleBlur,
handleCancel,
handleChange,
handleFocus,
handleKeyDown,
handleSubmit,
};
};

View File

@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
export const useGetClarificationCopy = () => {
const { t } = useTranslation();
return {
title: t("txt.approval_requests.clarification.title", "Comments"),
description: t(
"txt.approval_requests.clarification.description",
"Add notes or ask for additional information about this request"
),
comment_form_aria_label: t(
"txt.approval_requests.clarification.comment_form_aria_label",
"Enter a comment to ask for additional information about this approval request"
),
submit_button: t(
"txt.approval_requests.clarification.submit_button",
"Send"
),
cancel_button: t(
"txt.approval_requests.clarification.cancel_button",
"Cancel"
),
validation_empty_input: t(
"txt.approval_requests.clarification.validation_empty_comment_error",
"Enter a comment"
),
};
};

View File

@@ -0,0 +1,121 @@
import { useEffect, useState, useMemo, useCallback } from "react";
import { buildCommentEntityKey } from "../utils";
import type { ApprovalClarificationFlowMessage } from "../../../../types";
interface UseGetUnreadCommentsParams {
approvalRequestId: string;
comments: ApprovalClarificationFlowMessage[];
currentUserId: number;
storageKeyPrefix?: string;
}
interface CommentReadState {
read: boolean;
visible?: boolean;
}
interface UseGetUnreadCommentsResult {
firstUnreadCommentKey: string | null;
markCommentAsVisible: (commentKey: string) => void;
markAllCommentsAsRead: () => void;
unreadComments: ApprovalClarificationFlowMessage[];
}
export function useGetUnreadComments({
comments,
currentUserId,
approvalRequestId,
}: UseGetUnreadCommentsParams): UseGetUnreadCommentsResult {
const storage = window.localStorage;
const localStorageKey = `readComments:${currentUserId}:${approvalRequestId}`;
const getLocalReadStates = useCallback((): Record<
string,
CommentReadState
> => {
try {
const stored = storage.getItem(localStorageKey);
return stored ? JSON.parse(stored) : {};
} catch {
return {};
}
}, [localStorageKey, storage]);
const setLocalReadStates = useCallback(
(states: Record<string, CommentReadState>) => {
try {
storage.setItem(localStorageKey, JSON.stringify(states));
} catch {
// Ignore storage errors
}
},
[localStorageKey, storage]
);
const [localReadStates, setLocalReadStatesState] = useState<
Record<string, CommentReadState>
>(() => getLocalReadStates());
// Re-sync local read states when approvalRequestId changes
useEffect(() => {
setLocalReadStatesState(getLocalReadStates());
}, [approvalRequestId, getLocalReadStates]);
const markCommentAsVisible = (commentKey: string) => {
const currentStates = { ...localReadStates };
if (!currentStates[commentKey]?.visible) {
currentStates[commentKey] = {
...currentStates[commentKey],
visible: true,
read: currentStates[commentKey]?.read ?? false,
};
setLocalReadStates(currentStates);
setLocalReadStatesState(currentStates);
}
};
const markAllCommentsAsRead = useCallback(() => {
setLocalReadStatesState((prev) => {
const newStates = { ...prev };
Object.keys(newStates).forEach((key) => {
if (newStates[key]?.visible) {
newStates[key] = {
...newStates[key],
read: true,
};
}
});
setLocalReadStates(newStates);
return newStates;
});
}, [setLocalReadStates]);
// Compute unread comments filtering out current user's comments
const { unreadComments, firstUnreadCommentKey } = useMemo(() => {
const filtered = comments.filter(
(comment) => String(comment.author.id) !== String(currentUserId)
);
const unread = filtered.filter((comment) => {
const key = buildCommentEntityKey(approvalRequestId, comment);
const state = localReadStates[key];
return !state?.read;
});
const firstUnreadKey = unread[0]
? buildCommentEntityKey(approvalRequestId, unread[0])
: null;
return { unreadComments: unread, firstUnreadCommentKey: firstUnreadKey };
}, [comments, localReadStates, currentUserId, approvalRequestId]);
return {
firstUnreadCommentKey,
markCommentAsVisible,
markAllCommentsAsRead,
unreadComments,
};
}

View File

@@ -0,0 +1,55 @@
import { useEffect, useCallback } from "react";
const observedElements = new Map<Element, () => void>();
let observer: IntersectionObserver | null = null;
const getObserver = () => {
if (observer) return observer;
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const elementCallback = observedElements.get(entry.target);
if (elementCallback) {
elementCallback();
observer?.unobserve(entry.target);
observedElements.delete(entry.target);
}
}
});
},
{ threshold: 1.0 }
);
return observer;
};
export const useIntersectionObserver = (
ref: React.RefObject<Element>,
markCommentAsVisible: () => void
) => {
const observe = useCallback(() => {
const element = ref.current;
if (!element) return;
const ob = getObserver();
observedElements.set(element, markCommentAsVisible);
ob.observe(element);
}, [ref, markCommentAsVisible]);
const unobserve = useCallback(() => {
const element = ref.current;
if (!element || !observer) return;
observer.unobserve(element);
observedElements.delete(element);
}, [ref]);
useEffect(() => {
observe();
return () => unobserve();
}, [observe, unobserve]);
};

View File

@@ -0,0 +1,20 @@
import { useState } from "react";
export const useSubmitComment = () => {
const [isLoading, setIsLoading] = useState(false);
const handleSubmitComment = async (comment: string) => {
setIsLoading(true);
try {
console.log("Submitting comment:", comment);
await new Promise((resolve) => setTimeout(resolve, 500));
return { success: true, message: "Comment logged successfully" };
} finally {
setIsLoading(false);
}
};
return { handleSubmitComment, isLoading };
};

View File

@@ -0,0 +1,123 @@
import { screen } from "@testing-library/react";
import ClarificationComment from "../ClarificationComment";
import { type ApprovalClarificationFlowMessage } from "../../../../types";
import { renderWithTheme } from "../../../../testHelpers";
jest.mock("@zendeskgarden/svg-icons/src/16/headset-fill.svg", () => "svg-mock");
jest.mock(
"@zendeskgarden/svg-icons/src/12/circle-sm-fill.svg",
() => "svg-mock"
);
describe("ClarificationComment", () => {
const mockComment: ApprovalClarificationFlowMessage = {
id: "comment-id-1",
author: {
id: 123,
email: `user@example.com`,
avatar: "https://example.com/avatar.png",
name: "John Doe",
},
message: "This is a test comment.",
created_at: "2024-01-01T12:00:00Z",
} as ApprovalClarificationFlowMessage;
const mockCommentKey = "some-unique-comment-key";
const mockMarkCommentAsVisible = jest.fn();
const intersectionObserverMock = {
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
takeRecords: jest.fn(),
};
class IntersectionObserverMock {
callback: IntersectionObserverCallback;
constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
}
observe = jest.fn((element: Element) => {
this.callback(
[
{
isIntersecting: true,
target: element,
} as IntersectionObserverEntry,
],
this as unknown as IntersectionObserver
);
intersectionObserverMock.observe(element);
});
unobserve = intersectionObserverMock.unobserve;
disconnect = intersectionObserverMock.disconnect;
takeRecords = intersectionObserverMock.takeRecords;
}
const props = {
baseLocale: "en-US",
comment: mockComment,
commentKey: mockCommentKey,
markCommentAsVisible: mockMarkCommentAsVisible,
children: mockComment.message,
createdByUserId: 999,
};
beforeAll(() => {
Object.defineProperty(window, "IntersectionObserver", {
writable: true,
configurable: true,
value: IntersectionObserverMock,
});
});
it("calls markCommentAsVisible when comment becomes visible", () => {
renderWithTheme(<ClarificationComment {...props} />);
expect(mockMarkCommentAsVisible).toHaveBeenCalledTimes(1);
expect(mockMarkCommentAsVisible).toHaveBeenCalledWith(mockCommentKey);
});
it("renders the author name", () => {
renderWithTheme(<ClarificationComment {...props} />);
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
it("renders the comment body", () => {
renderWithTheme(<ClarificationComment {...props} />);
expect(screen.getByText("This is a test comment.")).toBeInTheDocument();
});
it("renders the author avatar", () => {
renderWithTheme(<ClarificationComment {...props} />);
const avatar = screen.getByRole("img", { name: /John doe/i });
expect(avatar).toBeInTheDocument();
});
it("handles empty children gracefully", () => {
renderWithTheme(
<ClarificationComment
baseLocale="en-US"
comment={mockComment}
commentKey={mockCommentKey}
markCommentAsVisible={mockMarkCommentAsVisible}
createdByUserId={999}
/>
);
expect(
screen.queryByText("This is a test comment.")
).not.toBeInTheDocument();
});
it("displays message", () => {
renderWithTheme(<ClarificationComment {...props} />);
expect(screen.queryByText("This is a test comment.")).toBeInTheDocument();
});
});

Some files were not shown because too many files have changed in this diff Show More