first load
This commit is contained in:
23
.a11yrc.json.example
Normal file
23
.a11yrc.json.example
Normal 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
63
.eslintrc.js
Normal 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
3
.gitattributes
vendored
Normal 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
240
.github/workflows/release_build.yml
vendored
Normal 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
128
.github/workflows/sync_repo.yml
vendored
Normal 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
78
.github/workflows/update_readme.yml
vendored
Normal 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
16
.gitignore
vendored
Normal 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
4
.husky/commit-msg
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx --no -- commitlint --edit ${1}
|
||||
21
.releaserc
Normal file
21
.releaserc
Normal 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
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
5
.yarnrc.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
enableTelemetry: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.10.3.cjs
|
||||
1617
CHANGELOG.md
Normal file
1617
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
53
README.md
53
README.md
@@ -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 -->
|
||||
[](https://github.com/ivancarlosti/copenlight/stargazers)
|
||||
[](https://github.com/sponsors/ivancarlosti)
|
||||
[](https://github.com/sponsors/ivancarlosti)
|
||||
[](https://github.com/ivancarlosti/copenlight/pulse)
|
||||
[](https://github.com/ivancarlosti/copenlight/issues)
|
||||
[](LICENSE)
|
||||
[](https://github.com/ivancarlosti/copenlight/commits)
|
||||
[](https://github.com/ivancarlosti/copenlight/security)
|
||||
[](https://github.com/ivancarlosti/copenlight?tab=coc-ov-file)
|
||||
[][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
0
assets/.gitkeep
generated
Normal file
269
assets/approval-requests-bundle.js
generated
Normal file
269
assets/approval-requests-bundle.js
generated
Normal file
File diff suppressed because one or more lines are too long
1
assets/approval-requests-translations-bundle.js
generated
Normal file
1
assets/approval-requests-translations-bundle.js
generated
Normal file
File diff suppressed because one or more lines are too long
2
assets/es-module-shims.js
generated
Normal file
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
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
111
assets/new-request-form-bundle.js
generated
Normal file
File diff suppressed because one or more lines are too long
1
assets/new-request-form-translations-bundle.js
generated
Normal file
1
assets/new-request-form-translations-bundle.js
generated
Normal file
File diff suppressed because one or more lines are too long
198
assets/service-catalog-bundle.js
generated
Normal file
198
assets/service-catalog-bundle.js
generated
Normal file
File diff suppressed because one or more lines are too long
1
assets/service-catalog-translations-bundle.js
generated
Normal file
1
assets/service-catalog-translations-bundle.js
generated
Normal file
File diff suppressed because one or more lines are too long
65
assets/shared-bundle.js
generated
Normal file
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
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
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
256
bin/extract-strings.mjs
Normal 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
36
bin/lighthouse/account.js
Normal 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
78
bin/lighthouse/config.js
Normal 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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
7
bin/lighthouse/constants.js
Normal file
7
bin/lighthouse/constants.js
Normal 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
119
bin/lighthouse/index.js
Normal 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
16
bin/lighthouse/login.js
Normal 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;
|
||||
84
bin/lighthouse/processor.js
Normal file
84
bin/lighthouse/processor.js
Normal 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
167
bin/lighthouse/urls.js
Normal 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
25
bin/theme-upload.js
Normal 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}`);
|
||||
}
|
||||
10
bin/update-manifest-version.sh
Normal file
10
bin/update-manifest-version.sh
Normal 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
|
||||
81
bin/update-modules-translations.mjs
Normal file
81
bin/update-modules-translations.mjs
Normal 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);
|
||||
}
|
||||
77
bin/update-translations.js
Normal file
77
bin/update-translations.js
Normal 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
65
custom/script.js.template
Normal 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
268
custom/style.css.template
Normal 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
86
generate-import-map.mjs
Normal 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
12
jest.config.mjs
Normal 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
34
jest.setup.js
Normal 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
408
manifest.json
Normal 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
122
package.json
Normal 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
105
rollup.config.mjs
Normal 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
742
script.js
generated
Normal 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 ### */
|
||||
BIN
settings/community_background_image.jpg
Normal file
BIN
settings/community_background_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 KiB |
BIN
settings/community_image.jpg
Normal file
BIN
settings/community_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
settings/favicon.png
Normal file
BIN
settings/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
BIN
settings/homepage_background_image.jpg
Normal file
BIN
settings/homepage_background_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
BIN
settings/logo.png
Normal file
BIN
settings/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
288
src/Dropdown.js
Normal file
288
src/Dropdown.js
Normal 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
471
src/Dropdown.spec.js
Normal 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
7
src/Keys.js
Normal 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
15
src/dropdowns.js
Normal 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
15
src/focus.js
Normal 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
168
src/forms.js
Normal 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
7
src/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import "../styles/index.scss";
|
||||
|
||||
import "./navigation";
|
||||
import "./dropdowns";
|
||||
import "./share";
|
||||
import "./search";
|
||||
import "./forms";
|
||||
163
src/modules/approval-requests/ApprovalRequestListPage.test.tsx
Normal file
163
src/modules/approval-requests/ApprovalRequestListPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
106
src/modules/approval-requests/ApprovalRequestListPage.tsx
Normal file
106
src/modules/approval-requests/ApprovalRequestListPage.tsx
Normal 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);
|
||||
179
src/modules/approval-requests/ApprovalRequestPage.test.tsx
Normal file
179
src/modules/approval-requests/ApprovalRequestPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
193
src/modules/approval-requests/ApprovalRequestPage.tsx
Normal file
193
src/modules/approval-requests/ApprovalRequestPage.tsx
Normal 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);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 doesn’t 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,
|
||||
};
|
||||
};
|
||||
@@ -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"
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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
Reference in New Issue
Block a user