first load
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { screen, waitFor, act, fireEvent } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import ClarificationCommentForm from "../ClarificationCommentForm";
|
||||
import { useSubmitComment } from "../hooks/useSubmitComment";
|
||||
import { useGetClarificationCopy } from "../hooks/useGetClarificationCopy";
|
||||
import { MAX_CHAR_COUNT, WARNING_THRESHOLD } from "../constants";
|
||||
import { renderHook } from "@testing-library/react-hooks";
|
||||
import { renderWithTheme } from "../../../../testHelpers";
|
||||
|
||||
jest.mock("../hooks/useSubmitComment");
|
||||
|
||||
describe("ClarificationCommentForm", () => {
|
||||
const mockMarkAllCommentsAsRead = jest.fn();
|
||||
|
||||
const props = {
|
||||
baseLocale: "en-US",
|
||||
currentUserAvatarUrl: "https://example.com/avatar.png",
|
||||
currentUserName: "Jane Doe",
|
||||
markAllCommentsAsRead: mockMarkAllCommentsAsRead,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(useSubmitComment as jest.Mock).mockReturnValue({
|
||||
status: "",
|
||||
handleSubmitComment: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the form with a textarea and hidden buttons", () => {
|
||||
const { result } = renderHook(() => useGetClarificationCopy());
|
||||
renderWithTheme(<ClarificationCommentForm {...props} />);
|
||||
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
// Buttons are hidden initially
|
||||
expect(
|
||||
screen.queryByText(result.current.submit_button)
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(result.current.cancel_button)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays the user avatar", () => {
|
||||
renderWithTheme(<ClarificationCommentForm {...props} />);
|
||||
|
||||
const avatar = screen.getByRole("img", { name: /jane doe/i });
|
||||
expect(avatar).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("disables buttons when the form is submitting", async () => {
|
||||
const { result } = renderHook(() => useGetClarificationCopy());
|
||||
(useSubmitComment as jest.Mock).mockReturnValue({
|
||||
isLoading: true,
|
||||
handleSubmitComment: jest.fn(),
|
||||
});
|
||||
|
||||
renderWithTheme(<ClarificationCommentForm {...props} />);
|
||||
|
||||
// Focus textarea to trigger buttons rendering
|
||||
const textarea = screen.getByRole("textbox");
|
||||
userEvent.click(textarea);
|
||||
await waitFor(async () => {
|
||||
const submitBtn = await screen.findByRole("button", {
|
||||
name: result.current.submit_button,
|
||||
});
|
||||
const cancelBtn = await screen.findByRole("button", {
|
||||
name: result.current.cancel_button,
|
||||
});
|
||||
|
||||
expect(submitBtn).toBeDisabled();
|
||||
expect(cancelBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
it("calls handleSubmitComment when the submit button is clicked", async () => {
|
||||
const { result } = renderHook(() => useGetClarificationCopy());
|
||||
const handleSubmitCommentMock = jest.fn(() =>
|
||||
Promise.resolve({ status: "success", data: "" })
|
||||
);
|
||||
(useSubmitComment as jest.Mock).mockReturnValue({
|
||||
status: "",
|
||||
handleSubmitComment: handleSubmitCommentMock,
|
||||
});
|
||||
|
||||
renderWithTheme(<ClarificationCommentForm {...props} />);
|
||||
|
||||
await userEvent.type(screen.getByRole("textbox"), "Test comment");
|
||||
const submitBtn = await screen.findByRole("button", {
|
||||
name: result.current.submit_button,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(submitBtn);
|
||||
});
|
||||
|
||||
expect(handleSubmitCommentMock).toHaveBeenCalledWith("Test comment");
|
||||
});
|
||||
|
||||
it("calls handleCancel when the cancel button is clicked", async () => {
|
||||
const { result } = renderHook(() => useGetClarificationCopy());
|
||||
renderWithTheme(<ClarificationCommentForm {...props} />);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
|
||||
await userEvent.type(textarea, "Test comment");
|
||||
expect(screen.getByText("Test comment")).toBeInTheDocument();
|
||||
|
||||
const cancelButton = screen.getByText(result.current.cancel_button);
|
||||
await act(async () => {
|
||||
await userEvent.click(cancelButton);
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Test comment")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows validation error when input is empty and submit is clicked", async () => {
|
||||
const { result } = renderHook(() => useGetClarificationCopy());
|
||||
renderWithTheme(<ClarificationCommentForm {...props} />);
|
||||
|
||||
// Focus textarea to trigger buttons rendering
|
||||
const textarea = screen.getByRole("textbox");
|
||||
|
||||
userEvent.click(textarea);
|
||||
const sendButton = await screen.findByRole("button", {
|
||||
name: result.current.submit_button,
|
||||
});
|
||||
await userEvent.click(sendButton);
|
||||
expect(
|
||||
screen.getByText(result.current.validation_empty_input)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows warning message when near max character limit", async () => {
|
||||
renderWithTheme(<ClarificationCommentForm {...props} />);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
const nearMaxLengthText = "a".repeat(MAX_CHAR_COUNT - WARNING_THRESHOLD);
|
||||
|
||||
// using fireEvent here because userEvent.type has issues with large text inputs
|
||||
fireEvent.change(textarea, { target: { value: nearMaxLengthText } });
|
||||
|
||||
const warningMessage = screen.getByRole("alert");
|
||||
expect(warningMessage).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(`${WARNING_THRESHOLD} characters remaining`)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("buttons show only when input is focused or has content", async () => {
|
||||
const { result } = renderHook(() => useGetClarificationCopy());
|
||||
|
||||
renderWithTheme(<ClarificationCommentForm {...props} />);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
|
||||
// Initially buttons hidden
|
||||
expect(
|
||||
screen.queryByRole("button", { name: result.current.submit_button })
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
userEvent.type(textarea, "Some text");
|
||||
|
||||
expect(
|
||||
await screen.findByRole("button", { name: result.current.submit_button })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("prevents typing beyond max length", () => {
|
||||
renderWithTheme(<ClarificationCommentForm {...props} />);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
|
||||
const longText = "a".repeat(MAX_CHAR_COUNT + 10);
|
||||
// using fireEvent here because userEvent.type has issues with large text inputs
|
||||
fireEvent.change(textarea, { target: { value: longText } });
|
||||
expect(textarea).toHaveValue(longText.slice(0, MAX_CHAR_COUNT));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import { screen, fireEvent, within } from "@testing-library/react";
|
||||
import ClarificationContainer from "../ClarificationContainer";
|
||||
import { useGetClarificationCopy } from "../hooks/useGetClarificationCopy";
|
||||
import { useGetUnreadComments } from "../hooks/useGetUnreadComments";
|
||||
import { MAX_COMMENTS } from "../constants";
|
||||
import NewCommentIndicator from "../NewCommentIndicator";
|
||||
import { buildCommentEntityKey } from "../utils";
|
||||
import { renderWithTheme } from "../../../../testHelpers";
|
||||
import { APPROVAL_REQUEST_STATES } from "../../../../constants";
|
||||
import { renderHook } from "@testing-library/react-hooks";
|
||||
import type { ApprovalClarificationFlowMessage } from "../../../../types";
|
||||
|
||||
jest.mock(
|
||||
"@zendeskgarden/svg-icons/src/12/circle-sm-fill.svg",
|
||||
() => "svg-mock"
|
||||
);
|
||||
jest.mock("../hooks/useGetUnreadComments");
|
||||
jest.mock("../NewCommentIndicator");
|
||||
|
||||
jest.mock("@zendeskgarden/svg-icons/src/16/headset-fill.svg", () => "svg-mock");
|
||||
|
||||
const createClarificationFlowMessage = (
|
||||
index: number
|
||||
): ApprovalClarificationFlowMessage => ({
|
||||
id: `id-${index}`,
|
||||
author: {
|
||||
email: `user${index}@example.com`,
|
||||
id: 456,
|
||||
avatar: `https://i.pravatar.cc/150?img=${index}`,
|
||||
name: `User ${index}`,
|
||||
},
|
||||
message: `Comment message ${index}`,
|
||||
created_at: "2024-01-01T12:00:00Z",
|
||||
});
|
||||
|
||||
describe("ClarificationContainer", () => {
|
||||
const mockMessages = Array.from({ length: 2 }, (_, i) =>
|
||||
createClarificationFlowMessage(i + 1)
|
||||
);
|
||||
|
||||
const approvalRequestId = "req-123";
|
||||
const baseLocale = "en-US";
|
||||
|
||||
const defaultProps = {
|
||||
baseLocale,
|
||||
approvalRequestId,
|
||||
currentUserId: 456,
|
||||
createdByUserId: 123,
|
||||
currentUserName: "Jane Doe",
|
||||
currentUserAvatarUrl: "https://example.com/avatar.png",
|
||||
status: APPROVAL_REQUEST_STATES.ACTIVE,
|
||||
clarificationFlowMessages: mockMessages,
|
||||
hasUserViewedBefore: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
(useGetUnreadComments as jest.Mock).mockReturnValue({
|
||||
unreadComments: [],
|
||||
firstUnreadCommentKey: null,
|
||||
markCommentAsVisible: jest.fn(),
|
||||
markAllCommentsAsRead: jest.fn(),
|
||||
});
|
||||
|
||||
(NewCommentIndicator as jest.Mock).mockImplementation(() => (
|
||||
<div data-testid="new-comment-indicator" />
|
||||
));
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
class IntersectionObserverMock {
|
||||
callback: IntersectionObserverCallback;
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, "IntersectionObserver", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: IntersectionObserverMock,
|
||||
});
|
||||
|
||||
Object.defineProperty(global, "IntersectionObserver", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: IntersectionObserverMock,
|
||||
});
|
||||
});
|
||||
|
||||
it("renders title and description copy", () => {
|
||||
const { result } = renderHook(() => useGetClarificationCopy());
|
||||
|
||||
renderWithTheme(<ClarificationContainer {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(result.current.title)).toBeInTheDocument();
|
||||
expect(screen.getByText(result.current.description)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not display description if commenting is not allowed (terminal status)", () => {
|
||||
const { result } = renderHook(() => useGetClarificationCopy());
|
||||
renderWithTheme(
|
||||
<ClarificationContainer {...defaultProps} status="approved" />
|
||||
);
|
||||
|
||||
expect(screen.getByText(result.current.title)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(result.current.description)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders ClarificationComment components for each message", () => {
|
||||
renderWithTheme(
|
||||
<ClarificationContainer
|
||||
{...defaultProps}
|
||||
clarificationFlowMessages={mockMessages}
|
||||
/>
|
||||
);
|
||||
|
||||
const commentListArea = screen.getByTestId("comment-list-area");
|
||||
|
||||
mockMessages.forEach(({ message }) => {
|
||||
expect(within(commentListArea).getByText(message)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders NewCommentIndicator above the first unread comment if unread exists and not terminal", () => {
|
||||
const firstUnreadCommentKey = buildCommentEntityKey(
|
||||
approvalRequestId,
|
||||
mockMessages[0]!
|
||||
);
|
||||
(useGetUnreadComments as jest.Mock).mockReturnValue({
|
||||
unreadComments: [mockMessages[0]],
|
||||
firstUnreadCommentKey,
|
||||
markCommentAsVisible: jest.fn(),
|
||||
markAllCommentsAsRead: jest.fn(),
|
||||
});
|
||||
|
||||
renderWithTheme(
|
||||
<ClarificationContainer
|
||||
{...defaultProps}
|
||||
clarificationFlowMessages={mockMessages}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("new-comment-indicator")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render NewCommentIndicator if status is terminal", () => {
|
||||
const firstUnreadCommentKey = buildCommentEntityKey(
|
||||
approvalRequestId,
|
||||
mockMessages[0]!
|
||||
);
|
||||
(useGetUnreadComments as jest.Mock).mockReturnValue({
|
||||
unreadComments: [mockMessages[0]],
|
||||
firstUnreadCommentKey,
|
||||
markCommentAsVisible: jest.fn(),
|
||||
markAllCommentsAsRead: jest.fn(),
|
||||
});
|
||||
|
||||
renderWithTheme(
|
||||
<ClarificationContainer
|
||||
{...defaultProps}
|
||||
status="approved"
|
||||
clarificationFlowMessages={mockMessages}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId("new-comment-indicator")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders ClarificationCommentForm if canComment is true", () => {
|
||||
const messages = Array.from({ length: MAX_COMMENTS - 1 }, (_, i) =>
|
||||
createClarificationFlowMessage(i + 1)
|
||||
);
|
||||
|
||||
renderWithTheme(
|
||||
<ClarificationContainer
|
||||
{...defaultProps}
|
||||
clarificationFlowMessages={messages}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("textbox")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls markAllCommentsAsRead on window beforeunload event", () => {
|
||||
const markAllCommentsAsReadMock = jest.fn();
|
||||
(useGetUnreadComments as jest.Mock).mockReturnValue({
|
||||
unreadComments: [],
|
||||
firstUnreadCommentKey: null,
|
||||
markCommentAsVisible: jest.fn(),
|
||||
markAllCommentsAsRead: markAllCommentsAsReadMock,
|
||||
});
|
||||
|
||||
renderWithTheme(<ClarificationContainer {...defaultProps} />);
|
||||
|
||||
fireEvent(window, new Event("beforeunload"));
|
||||
expect(markAllCommentsAsReadMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cleans up beforeunload listener on unmount", () => {
|
||||
const addEventListenerSpy = jest.spyOn(window, "addEventListener");
|
||||
const removeEventListenerSpy = jest.spyOn(window, "removeEventListener");
|
||||
|
||||
const { unmount } = renderWithTheme(
|
||||
<ClarificationContainer {...defaultProps} />
|
||||
);
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
"beforeunload",
|
||||
expect.any(Function)
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith(
|
||||
"beforeunload",
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import CommentLimitAlert from "../CommentLimitAlert";
|
||||
import { MAX_COMMENTS } from "../constants";
|
||||
import { renderWithTheme } from "../../../../testHelpers";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("CommentLimitAlert component", () => {
|
||||
const defaultProps = {
|
||||
approvalRequestId: "123",
|
||||
currentUserId: 456,
|
||||
};
|
||||
|
||||
it("renders nothing if isTerminalStatus is true", () => {
|
||||
renderWithTheme(
|
||||
<CommentLimitAlert
|
||||
{...defaultProps}
|
||||
commentCount={MAX_COMMENTS}
|
||||
isTerminalStatus
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders max comments alert when commentCount >= MAX_COMMENTS", () => {
|
||||
renderWithTheme(
|
||||
<CommentLimitAlert
|
||||
{...defaultProps}
|
||||
commentCount={MAX_COMMENTS}
|
||||
isTerminalStatus={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Comment limit reached")).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"You can't add more comments, approvers can still approve or deny."
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders near limit alert when commentCount is near max and alert is visible", () => {
|
||||
renderWithTheme(
|
||||
<CommentLimitAlert
|
||||
{...defaultProps}
|
||||
commentCount={MAX_COMMENTS - 1}
|
||||
isTerminalStatus={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText("Comment limit nearly reached")
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
"This request has 39 of 40 comments available. You have 1 remaining."
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render near limit alert if previously dismissed", () => {
|
||||
const alertDismissalKey = `nearLimitAlertDismissed_${defaultProps.currentUserId}_${defaultProps.approvalRequestId}`;
|
||||
localStorage.setItem(alertDismissalKey, "true");
|
||||
|
||||
renderWithTheme(
|
||||
<CommentLimitAlert
|
||||
{...defaultProps}
|
||||
commentCount={MAX_COMMENTS - 1}
|
||||
isTerminalStatus={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Comment limit nearly reached")).toBeNull();
|
||||
});
|
||||
|
||||
it("dismiss near limit alert on close button click", () => {
|
||||
renderWithTheme(
|
||||
<CommentLimitAlert
|
||||
{...defaultProps}
|
||||
commentCount={MAX_COMMENTS - 1}
|
||||
isTerminalStatus={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole("button", {
|
||||
name: "Close alert",
|
||||
});
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(screen.queryByText("Comment limit nearly reached")).toBeNull();
|
||||
|
||||
const alertDismissalKey = `nearLimitAlertDismissed_${defaultProps.currentUserId}_${defaultProps.approvalRequestId}`;
|
||||
expect(localStorage.getItem(alertDismissalKey)).toBe("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import NewCommentIndicator from "../NewCommentIndicator";
|
||||
import { renderWithTheme } from "../../../../testHelpers";
|
||||
|
||||
describe("NewCommentIndicator", () => {
|
||||
it("renders singular new comment text when unreadCount is 1", () => {
|
||||
renderWithTheme(<NewCommentIndicator unreadCount={1} />);
|
||||
expect(screen.getByText("New comment")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders plural new comments text when unreadCount is greater than 1", () => {
|
||||
renderWithTheme(<NewCommentIndicator unreadCount={5} />);
|
||||
expect(screen.getByText("New comments")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ApprovalClarificationFlowMessage } from "../../../types";
|
||||
|
||||
export const buildCommentEntityKey = (
|
||||
approvalRequestId: string,
|
||||
comment: ApprovalClarificationFlowMessage
|
||||
) => {
|
||||
return `zenGuide:approvalRequest:${approvalRequestId}:comment:${comment.id}`;
|
||||
};
|
||||
6
src/modules/approval-requests/constants.ts
Normal file
6
src/modules/approval-requests/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const APPROVAL_REQUEST_STATES = {
|
||||
ACTIVE: "active",
|
||||
APPROVED: "approved",
|
||||
REJECTED: "rejected",
|
||||
WITHDRAWN: "withdrawn",
|
||||
} as const;
|
||||
48
src/modules/approval-requests/hooks/useApprovalRequest.tsx
Normal file
48
src/modules/approval-requests/hooks/useApprovalRequest.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ApprovalRequest } from "../types";
|
||||
|
||||
export function useApprovalRequest(
|
||||
approvalWorkflowInstanceId: string,
|
||||
approvalRequestId: string
|
||||
): {
|
||||
approvalRequest: ApprovalRequest | undefined;
|
||||
errorFetchingApprovalRequest: unknown;
|
||||
isLoading: boolean;
|
||||
setApprovalRequest: (approvalRequest: ApprovalRequest) => void;
|
||||
} {
|
||||
const [approvalRequest, setApprovalRequest] = useState<
|
||||
ApprovalRequest | undefined
|
||||
>();
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchApprovalRequest = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v2/approval_workflow_instances/${approvalWorkflowInstanceId}/approval_requests/${approvalRequestId}`
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setApprovalRequest(data.approval_request);
|
||||
} else {
|
||||
throw new Error("Error fetching approval request");
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchApprovalRequest();
|
||||
}, [approvalRequestId, approvalWorkflowInstanceId]);
|
||||
|
||||
return {
|
||||
approvalRequest,
|
||||
errorFetchingApprovalRequest: error,
|
||||
isLoading,
|
||||
setApprovalRequest,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import type {
|
||||
ApprovalRequestDropdownStatus,
|
||||
SearchApprovalRequest,
|
||||
} from "../types";
|
||||
|
||||
export function useSearchApprovalRequests(): {
|
||||
approvalRequests: SearchApprovalRequest[];
|
||||
errorFetchingApprovalRequests: unknown;
|
||||
approvalRequestStatus: ApprovalRequestDropdownStatus;
|
||||
setApprovalRequestStatus: Dispatch<
|
||||
SetStateAction<ApprovalRequestDropdownStatus>
|
||||
>;
|
||||
isLoading: boolean;
|
||||
} {
|
||||
const [approvalRequests, setApprovalRequests] = useState<
|
||||
SearchApprovalRequest[]
|
||||
>([]);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [approvalRequestStatus, setApprovalRequestStatus] =
|
||||
useState<ApprovalRequestDropdownStatus>("any");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchApprovalRequests = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const currentUserRequest = await fetch("/api/v2/users/me.json");
|
||||
if (!currentUserRequest.ok) {
|
||||
throw new Error("Error fetching current user data");
|
||||
}
|
||||
const currentUser = await currentUserRequest.json();
|
||||
|
||||
// TODO: can be any ULID, the API was implemented this way for future proofing, we will likely need to update the route in the UI to match
|
||||
const approvalWorkflowInstanceId = "01JJQFNX5ADZ6PRQCFWRDNKZRD";
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v2/approval_workflow_instances/${approvalWorkflowInstanceId}/approval_requests/search${
|
||||
approvalRequestStatus === "any"
|
||||
? ""
|
||||
: `?status=${approvalRequestStatus}`
|
||||
}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": currentUser.user.authenticity_token,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setApprovalRequests(data.approval_requests);
|
||||
} else {
|
||||
throw new Error("Error fetching approval requests");
|
||||
}
|
||||
} catch (error) {
|
||||
setError(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchApprovalRequests();
|
||||
}, [approvalRequestStatus]);
|
||||
|
||||
return {
|
||||
approvalRequests,
|
||||
errorFetchingApprovalRequests: error,
|
||||
approvalRequestStatus,
|
||||
setApprovalRequestStatus,
|
||||
isLoading: isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
interface UseUserViewedApprovalStatusParams {
|
||||
approvalRequestId: string | undefined;
|
||||
currentUserId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that tracks whether a user has viewed a specific approval request before, using localStorage.
|
||||
*/
|
||||
export function useUserViewedApprovalStatus({
|
||||
approvalRequestId,
|
||||
currentUserId,
|
||||
}: UseUserViewedApprovalStatusParams) {
|
||||
const isValid = approvalRequestId !== undefined;
|
||||
|
||||
const storage = window.localStorage;
|
||||
const storageKey = `userViewedApproval:${currentUserId}:${approvalRequestId}`;
|
||||
|
||||
const getViewedStatus = useCallback((): boolean => {
|
||||
if (!isValid) return false;
|
||||
try {
|
||||
return storage.getItem(storageKey) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [isValid, storage, storageKey]);
|
||||
|
||||
const [hasUserViewedBefore, setHasUserViewedBefore] = useState<boolean>(
|
||||
() => {
|
||||
return getViewedStatus();
|
||||
}
|
||||
);
|
||||
|
||||
const markUserViewed = useCallback(() => {
|
||||
if (!isValid) return;
|
||||
try {
|
||||
storage.setItem(storageKey, "true");
|
||||
setHasUserViewedBefore(true);
|
||||
} catch {
|
||||
// ignore errors
|
||||
}
|
||||
}, [isValid, storage, storageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isValid) return;
|
||||
setHasUserViewedBefore(getViewedStatus());
|
||||
}, [approvalRequestId, currentUserId, getViewedStatus, isValid]);
|
||||
|
||||
return { hasUserViewedBefore, markUserViewed };
|
||||
}
|
||||
2
src/modules/approval-requests/index.tsx
Normal file
2
src/modules/approval-requests/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { renderApprovalRequestList } from "./renderApprovalRequestList";
|
||||
export { renderApprovalRequest } from "./renderApprovalRequest";
|
||||
35
src/modules/approval-requests/renderApprovalRequest.tsx
Normal file
35
src/modules/approval-requests/renderApprovalRequest.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { render } from "react-dom";
|
||||
|
||||
import ApprovalRequestPage from "./ApprovalRequestPage";
|
||||
import type { ApprovalRequestPageProps } from "./ApprovalRequestPage";
|
||||
import {
|
||||
createTheme,
|
||||
ThemeProviders,
|
||||
initI18next,
|
||||
loadTranslations,
|
||||
} from "../shared";
|
||||
import type { Settings } from "../shared";
|
||||
import { ErrorBoundary } from "../shared/error-boundary/ErrorBoundary";
|
||||
|
||||
export async function renderApprovalRequest(
|
||||
container: HTMLElement,
|
||||
settings: Settings,
|
||||
props: ApprovalRequestPageProps,
|
||||
helpCenterPath: string
|
||||
) {
|
||||
const { baseLocale } = props;
|
||||
initI18next(baseLocale);
|
||||
await loadTranslations(baseLocale, [
|
||||
() => import(`./translations/locales/${baseLocale}.json`),
|
||||
() => import(`../shared/translations/locales/${baseLocale}.json`),
|
||||
]);
|
||||
|
||||
render(
|
||||
<ThemeProviders theme={createTheme(settings)}>
|
||||
<ErrorBoundary helpCenterPath={helpCenterPath}>
|
||||
<ApprovalRequestPage {...props} helpCenterPath={helpCenterPath} />
|
||||
</ErrorBoundary>
|
||||
</ThemeProviders>,
|
||||
container
|
||||
);
|
||||
}
|
||||
34
src/modules/approval-requests/renderApprovalRequestList.tsx
Normal file
34
src/modules/approval-requests/renderApprovalRequestList.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { render } from "react-dom";
|
||||
import ApprovalRequestListPage from "./ApprovalRequestListPage";
|
||||
import type { ApprovalRequestListPageProps } from "./ApprovalRequestListPage";
|
||||
import {
|
||||
createTheme,
|
||||
ThemeProviders,
|
||||
initI18next,
|
||||
loadTranslations,
|
||||
} from "../shared";
|
||||
import type { Settings } from "../shared";
|
||||
import { ErrorBoundary } from "../shared/error-boundary/ErrorBoundary";
|
||||
|
||||
export async function renderApprovalRequestList(
|
||||
container: HTMLElement,
|
||||
settings: Settings,
|
||||
props: ApprovalRequestListPageProps,
|
||||
helpCenterPath: string
|
||||
) {
|
||||
const { baseLocale } = props;
|
||||
initI18next(baseLocale);
|
||||
await loadTranslations(baseLocale, [
|
||||
() => import(`./translations/locales/${baseLocale}.json`),
|
||||
() => import(`../shared/translations/locales/${baseLocale}.json`),
|
||||
]);
|
||||
|
||||
render(
|
||||
<ThemeProviders theme={createTheme(settings)}>
|
||||
<ErrorBoundary helpCenterPath={helpCenterPath}>
|
||||
<ApprovalRequestListPage {...props} helpCenterPath={helpCenterPath} />
|
||||
</ErrorBoundary>
|
||||
</ThemeProviders>,
|
||||
container
|
||||
);
|
||||
}
|
||||
35
src/modules/approval-requests/submitApprovalDecision.tsx
Normal file
35
src/modules/approval-requests/submitApprovalDecision.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
export type ApprovalDecision = "approved" | "rejected";
|
||||
|
||||
export async function submitApprovalDecision(
|
||||
approvalWorkflowInstanceId: string,
|
||||
approvalRequestId: string,
|
||||
decision: ApprovalDecision,
|
||||
decisionNote: string
|
||||
) {
|
||||
try {
|
||||
const currentUserRequest = await fetch("/api/v2/users/me.json");
|
||||
if (!currentUserRequest.ok) {
|
||||
throw new Error("Error fetching current user data");
|
||||
}
|
||||
const currentUser = await currentUserRequest.json();
|
||||
|
||||
const response = await fetch(
|
||||
`/api/v2/approval_workflow_instances/${approvalWorkflowInstanceId}/approval_requests/${approvalRequestId}/decision`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": currentUser.user.authenticity_token,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
status: decision,
|
||||
notes: decisionNote,
|
||||
}),
|
||||
}
|
||||
);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Error submitting approval decision:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
7
src/modules/approval-requests/testHelpers.tsx
Normal file
7
src/modules/approval-requests/testHelpers.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { ThemeProvider, DEFAULT_THEME } from "@zendeskgarden/react-theming";
|
||||
import { render } from "@testing-library/react";
|
||||
|
||||
export const renderWithTheme = (ui: ReactElement) => {
|
||||
return render(<ThemeProvider theme={DEFAULT_THEME}>{ui}</ThemeProvider>);
|
||||
};
|
||||
319
src/modules/approval-requests/translations/en-us.yml
Normal file
319
src/modules/approval-requests/translations/en-us.yml
Normal file
@@ -0,0 +1,319 @@
|
||||
#
|
||||
# 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.
|
||||
#
|
||||
|
||||
title: "Copenhagen Theme Approval requests"
|
||||
packages:
|
||||
- "approval-requests"
|
||||
parts:
|
||||
- translation:
|
||||
key: "approval-requests.request.ticket-details.header"
|
||||
title: "Header for the ticket details section displayed on the individual approval request page"
|
||||
screenshot: "https://drive.google.com/file/d/1-mngvtPAewxxavZdD5s9XZrIUgtGW4Vj/view?usp=drive_link"
|
||||
value: "Ticket details"
|
||||
- translation:
|
||||
key: "approval-requests.request.ticket-details.requester"
|
||||
title: "Label for the person who submitted the ticket associated with the approval request"
|
||||
screenshot: "https://drive.google.com/file/d/14qPWuPcsMFDC6J_HhOBbNlI9hBeLMKA-/view?usp=sharing"
|
||||
value: "Requester"
|
||||
- translation:
|
||||
key: "approval-requests.request.ticket-details.id"
|
||||
title: "Label for the unique identifier of the ticket associated with the approval request"
|
||||
screenshot: "https://drive.google.com/file/d/1IJdg-vXQAUtcgS-JWWwgVOyRg5IO8Cey/view?usp=drive_link"
|
||||
value: "ID"
|
||||
- translation:
|
||||
key: "approval-requests.request.ticket-details.priority"
|
||||
title: "Label for the priority of the ticket associated with the approval request"
|
||||
screenshot: "https://drive.google.com/file/d/1rB8uGfpnaDXGATBlsNyRdbntZKvS4BeW/view?usp=sharing"
|
||||
value: "Priority"
|
||||
- translation:
|
||||
key: "approval-requests.request.ticket-details.priority_low"
|
||||
title: "Low priority level for a ticket associated with an approval request"
|
||||
screenshot: "https://drive.google.com/file/d/1-RB1gY755d5Ffr_1rirtajAk1wxnmC_L/view?usp=sharing"
|
||||
value: "Low"
|
||||
- translation:
|
||||
key: "approval-requests.request.ticket-details.priority_normal"
|
||||
title: "Normal priority level for a ticket associated with an approval request"
|
||||
screenshot: "https://drive.google.com/file/d/1WYuPkB_Ju-CcfjX4E6R3cY8plXMhfzu5/view?usp=sharing"
|
||||
value: "Normal"
|
||||
- translation:
|
||||
key: "approval-requests.request.ticket-details.priority_high"
|
||||
title: "High priority level for a ticket associated with an approval request"
|
||||
screenshot: "https://drive.google.com/file/d/17M5eebl-XR_Z8mrF5VNwkRSOCH8qbPqQ/view?usp=sharing"
|
||||
value: "High"
|
||||
- translation:
|
||||
key: "approval-requests.request.ticket-details.priority_urgent"
|
||||
title: "Urgent priority level for a ticket associated with an approval request"
|
||||
screenshot: "https://drive.google.com/file/d/1PsUJkvvePPAL7GuEXLiV6AeRSleu9lib/view?usp=sharing"
|
||||
value: "Urgent"
|
||||
- translation:
|
||||
key: "approval-requests.request.ticket-details.checkbox-value.yes"
|
||||
title: "Value representing true for a checkbox custom field"
|
||||
screenshot: "https://drive.google.com/file/d/1Vk8x7k2DzF7t9DT5Q-KfmEbCZRmFOBeE/view?usp=drive_link"
|
||||
value: "Yes"
|
||||
- translation:
|
||||
key: "approval-requests.request.ticket-details.checkbox-value.no"
|
||||
title: "Value representing false for a checkbox custom field"
|
||||
screenshot: "https://drive.google.com/file/d/1AiuLQXnW6qELTJtIFQX0JaBCA7zViMXA/view?usp=drive_link"
|
||||
value: "No"
|
||||
- translation:
|
||||
key: "approval-requests.request.approval-request-details.header"
|
||||
title: "Header for the approval request section displayed on the individual approval request page"
|
||||
screenshot: "https://drive.google.com/file/d/1fteldN_Z4yTgd7UPzQi0xYv8TTPJxX9c/view?usp=drive_link"
|
||||
value: "Approval request details"
|
||||
- translation:
|
||||
key: "approval-requests.request.approval-request-details.sent-by"
|
||||
title: "Label for the person who submitted the approval request - Subject: 'approval request'"
|
||||
screenshot: "https://drive.google.com/file/d/1-RFlHvEdegvChs6TuVTXxx6ubeSab-hc/view?usp=drive_link"
|
||||
value: "Sent by"
|
||||
- translation:
|
||||
key: "approval-requests.request.approval-request-details.sent-on"
|
||||
title: "Label for the date that the approval request was submitted - Subject: 'approval request'"
|
||||
screenshot: "https://drive.google.com/file/d/1Kmy-XgNfCE5dgh-oipsc1P_sZ3gmvjD6/view?usp=drive_link"
|
||||
value: "Sent on"
|
||||
- translation:
|
||||
key: "approval-requests.request.approval-request-details.approver"
|
||||
title: "Label for the individual assigned to review and approve the request"
|
||||
screenshot: "https://drive.google.com/file/d/1VnGlaQE9sSsKqwY3iKZn-AiJK8RnfUL8/view?usp=drive_link"
|
||||
value: "Approver"
|
||||
- translation:
|
||||
key: "approval-requests.request.approval-request-details.status"
|
||||
title: "Label for the current status of the approval request"
|
||||
screenshot: "https://drive.google.com/file/d/11G6qZpoQoMzyEUxncBo4RovT93thSuXx/view?usp=drive_link"
|
||||
value: "Status"
|
||||
- translation:
|
||||
key: "approval-requests.request.approval-request-details.comment"
|
||||
title: "Noun - Label for the decision comment on an approval request"
|
||||
screenshot: "https://drive.google.com/file/d/1j9IzjnAa1AVXzWq6r4x4QcaQN-Y64lM5/view?usp=drive_link"
|
||||
value: "Comment"
|
||||
- translation:
|
||||
key: "approval-requests.request.approval-request-details.decided"
|
||||
title: "Label for the decision date on an approval request - Subject: 'approval request'"
|
||||
screenshot: "https://drive.google.com/file/d/1xpM_3Rt3hCD8aE-U7mEbL0Sh5dsC0ScN/view?usp=drive_link"
|
||||
value: "Decided"
|
||||
- translation:
|
||||
key: "approval-requests.request.approval-request-details.withdrawn-on"
|
||||
title: "Label for the withdrawn date on an approval request - Subject: 'approval request'"
|
||||
screenshot: "https://drive.google.com/file/d/1hkgy-71WFkLQnZ0PmEHFaWhfnuguiUmv/view?usp=drive_link"
|
||||
value: "Withdrawn on"
|
||||
- translation:
|
||||
key: "approval-requests.request.approval-request-details.previous-decision"
|
||||
title: "Label in approval request details component"
|
||||
screenshot: "https://drive.google.com/file/d/1R55CIlKhowOKZ9J22ChB81qiMz2NifYO/view?usp=drive_link"
|
||||
value: "Previous decision"
|
||||
- translation:
|
||||
key: "approval-requests.request.approver-actions.approve-request"
|
||||
title: "Label for the approve request button to begin approving an approval request"
|
||||
screenshot: "https://drive.google.com/file/d/1uWDMNa7cfF2EdD5HpDxcTVHTa6M07v81/view?usp=drive_link"
|
||||
value: "Approve request"
|
||||
- translation:
|
||||
key: "approval-requests.request.approver-actions.deny-request"
|
||||
title: "Label for the deny request button to begin denying an approval request"
|
||||
screenshot: "https://drive.google.com/file/d/1sEO2wxGJU6Jz1ZClBvkYDTXCXT9yqsGZ/view?usp=drive_link"
|
||||
value: "Deny request"
|
||||
- translation:
|
||||
key: "approval-requests.request.approver-actions.denial-reason-label"
|
||||
title: "Label for the input box where the reason for denying an approval request is provided"
|
||||
screenshot: "https://drive.google.com/file/d/1WZx7DbwkudNbNYMIEwF56npryQGIpsG7/view?usp=drive_link"
|
||||
value: "Reason for denial* (Required)"
|
||||
- translation:
|
||||
key: "approval-requests.request.approver-actions.denial-reason-validation"
|
||||
title: "Validation message shown when attempting to deny an approval request without a reason for denial"
|
||||
screenshot: "https://drive.google.com/file/d/1-wo8BGxMuV6bHD7mjlX8Lm3OTc_9RyFz/view?usp=drive_link"
|
||||
value: "Enter a reason for denial"
|
||||
- translation:
|
||||
key: "approval-requests.request.approver-actions.additional-note-label"
|
||||
title: "Label for the input box where the additional note when approving an approval request is provided"
|
||||
screenshot: "https://drive.google.com/file/d/1gacesWd8MkyIY6UXvQSatv2ok67ZhbKk/view?usp=drive_link"
|
||||
value: "Additional note"
|
||||
- translation:
|
||||
key: "approval-requests.request.approver-actions.submit-approval"
|
||||
title: "Label for the submit approval button when approving an approval request"
|
||||
screenshot: "https://drive.google.com/file/d/12C2F8zoCD_nMU-jSeu4kaMWwTeJNE3HJ/view?usp=drive_link"
|
||||
value: "Submit approval"
|
||||
- translation:
|
||||
key: "approval-requests.request.approver-actions.submit-denial"
|
||||
title: "Label for the submit denial button when denying an approval request"
|
||||
screenshot: "https://drive.google.com/file/d/153Idb3CgJ_eFfWxLE4esAdB0DceOH8gj/view?usp=drive_link"
|
||||
value: "Submit denial"
|
||||
- translation:
|
||||
key: "approval-requests.request.approver-actions.cancel"
|
||||
title: "Label for the cancel button when approving or denying an approval request"
|
||||
screenshot: "https://drive.google.com/file/d/1TjjF7CrDr-VYbZ-rpGs0pLCersevq9uL/view?usp=drive_link"
|
||||
value: "Cancel"
|
||||
- translation:
|
||||
key: "approval-requests.request.notification.denial-submitted"
|
||||
title: "Notification message shown when the denial of an approval request was submitted"
|
||||
screenshot: "https://drive.google.com/file/d/1_Af2Xi2l6kcKI9QmFFQxH3SvI_Eaarg5/view?usp=drive_link"
|
||||
value: "Denial submitted"
|
||||
- translation:
|
||||
key: "approval-requests.request.notification.approval-submitted"
|
||||
title: "Notification message shown when the approval of an approval request was submitted"
|
||||
screenshot: "https://drive.google.com/file/d/16BOEFXrbM29me3KdGgROerXXO5ns-7oh/view?usp=drive_link"
|
||||
value: "Approval submitted"
|
||||
- translation:
|
||||
key: "approval-requests.list.header"
|
||||
title: "Header for the approval requests list page"
|
||||
screenshot: "https://drive.google.com/file/d/1MUj23YI3kUT-Udmh1LA0rt85-q078SMj/view?usp=drive_link"
|
||||
value: "Approval requests"
|
||||
- translation:
|
||||
key: "approval-requests.list.search-placeholder"
|
||||
title: "Placeholder for the search dropdown on the approval requests list page"
|
||||
screenshot: "https://drive.google.com/file/d/10Mdnw0YkIq3w4CjdDOSD9efAAxDapzYr/view?usp=drive_link"
|
||||
value: "Search approval requests"
|
||||
- translation:
|
||||
key: "approval-requests.list.no-requests"
|
||||
title: "Text to convey that no approval requests exist on the approval requests list page."
|
||||
screenshot: "https://drive.google.com/file/d/1rrZ1MZKAsY7mNo89McYaZNwEB5pDjKtk/view?usp=sharing"
|
||||
value: "No approval requests found."
|
||||
- translation:
|
||||
key: "approval-requests.list.status-dropdown.label"
|
||||
title: "Label for the status dropdown on the approval requests list page"
|
||||
screenshot: "https://drive.google.com/file/d/18_4fS0yROAMC1yVdDYLfYvjKWeUdWCDH/view?usp=drive_link"
|
||||
value: "Status:"
|
||||
obsolete: "2025-05-24"
|
||||
- translation:
|
||||
key: "approval-requests.list.status-dropdown.label_v2"
|
||||
title: "Label for the status dropdown on the approval requests list page"
|
||||
screenshot: "https://drive.google.com/file/d/1T7BGzFgCWC74RIoUBBZ8tZzAvcksIw1r/view?usp=drive_link"
|
||||
value: "Status"
|
||||
- translation:
|
||||
key: "approval-requests.list.status-dropdown.any"
|
||||
title: "Label for any dropdown option within the status dropdown on the approval requests list page - Subject: 'status'"
|
||||
screenshot: "https://drive.google.com/file/d/1dIURRxQu-EkQD9f1U0gKj_4mIBqBSRz3/view?usp=drive_link"
|
||||
value: "Any"
|
||||
- translation:
|
||||
key: "approval-requests.list.table.subject"
|
||||
title: "Header cell label for the subject column of the approval requests list table"
|
||||
screenshot: "https://drive.google.com/file/d/1AfMOHvrRQoTODXWor5gtn7wn8AFI_5n-/view?usp=drive_link"
|
||||
value: "Subject"
|
||||
- translation:
|
||||
key: "approval-requests.list.table.requester"
|
||||
title: "Header cell label for the requester column of the approval requests list table"
|
||||
screenshot: "https://drive.google.com/file/d/1tUTbmtSZCYxxDVbpQtZ6C50QDYlJU88I/view?usp=drive_link"
|
||||
value: "Requester"
|
||||
- translation:
|
||||
key: "approval-requests.list.table.sent-by"
|
||||
title: "Header cell label for the sent by column of the approval requests list table - Subject: 'approval request'"
|
||||
screenshot: "https://drive.google.com/file/d/1k49KpSKk1OT4NYfeZqdb7WZLEwJZndpq/view?usp=drive_link"
|
||||
value: "Sent by"
|
||||
- translation:
|
||||
key: "approval-requests.list.table.sent-on"
|
||||
title: "Header cell label for the sent on column of the approval requests list table - Subject: 'approval request'"
|
||||
screenshot: "https://drive.google.com/file/d/1i2fKqUDlWbNnYRZdxp041dAPhMsFznXO/view?usp=drive_link"
|
||||
value: "Sent on"
|
||||
- translation:
|
||||
key: "approval-requests.list.table.approval-status"
|
||||
title: "Header cell label for the approval status column of the approval requests list table"
|
||||
screenshot: "https://drive.google.com/file/d/1ltG2DXbLbR1-6CaK_su1pbKQeN7kVMMV/view?usp=drive_link"
|
||||
value: "Approval status"
|
||||
- translation:
|
||||
key: "approval-requests.status.decision-pending"
|
||||
title: "Status label for the decision pending status of an approval request - Subject: 'status'"
|
||||
screenshot: "https://drive.google.com/file/d/1wMA0ZAm8f_Mw0wgj7UKl7wkBU8XGH_ox/view?usp=drive_link"
|
||||
value: "Decision pending"
|
||||
- translation:
|
||||
key: "approval-requests.status.info-needed"
|
||||
title: "Status label for the info needed status of an approval request - Subject: 'status'"
|
||||
screenshot: "https://drive.google.com/file/d/11Isf_F0qGOFh6Dv5RnR44ta5yJxX9n_4/view?usp=drive_link"
|
||||
value: "Info needed"
|
||||
- translation:
|
||||
key: "approval-requests.status.approved"
|
||||
title: "Status label for the approved status of an approval request - Subject: 'status'"
|
||||
screenshot: "https://drive.google.com/file/d/1Xc8uehxpLHQR2Pjxfyo-wbvooPwe8-WX/view?usp=drive_link"
|
||||
value: "Approved"
|
||||
- translation:
|
||||
key: "approval-requests.status.denied"
|
||||
title: "Status label for the denied status of an approval request - Subject: 'status'"
|
||||
screenshot: "https://drive.google.com/file/d/13qjrnfFVk61r5QMxd4CL_B8RY4cYzO1r/view?usp=drive_link"
|
||||
value: "Denied"
|
||||
- translation:
|
||||
key: "approval-requests.status.withdrawn"
|
||||
title: "Status label for the withdrawn status of an approval request - Subject: 'status'"
|
||||
screenshot: "https://drive.google.com/file/d/1iOTsHEB5xae4LGZbjr4ibtdSDf0h9BOE/view?usp=drive_link"
|
||||
value: "Withdrawn"
|
||||
- translation:
|
||||
key: "txt.approval_requests.clarification.title"
|
||||
title: "Title of the comments section"
|
||||
value: "Comments"
|
||||
screenshot: "https://drive.google.com/file/d/1_71h6mJIjYHEDXPYLZ6vjZt_y3Nx9ZDD/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.clarification.description"
|
||||
title: "Description of the comments section"
|
||||
value: "Add notes or ask for additional information about this request"
|
||||
screenshot: "https://drive.google.com/file/d/1_71h6mJIjYHEDXPYLZ6vjZt_y3Nx9ZDD/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.clarification.comment_form_aria_label"
|
||||
title: "[A11Y] Hidden accessibility label for the comment form in the single approval request sidebar panel"
|
||||
value: "Enter a comment to ask for additional information about this approval request"
|
||||
screenshot: "https://drive.google.com/file/d/194B7-DmEvZJOB5X6QyNXNKGEyLsbjOdw/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.clarification.submit_button"
|
||||
title: "Submit comment button label"
|
||||
value: "Send"
|
||||
screenshot: "https://drive.google.com/file/d/1_71h6mJIjYHEDXPYLZ6vjZt_y3Nx9ZDD/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.clarification.cancel_button"
|
||||
title: "Cancel comment button label"
|
||||
value: "Cancel"
|
||||
screenshot: "https://drive.google.com/file/d/1_71h6mJIjYHEDXPYLZ6vjZt_y3Nx9ZDD/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.validation.characters_remaining.zero"
|
||||
title: "Warning validation message informing the user that there are zero remaining characters before they reach the limit."
|
||||
value: "{{numCharacters}} characters remaining"
|
||||
screenshot: "https://drive.google.com/file/d/1MSVyUqRUqCu9vAYfRzaHgZw82vvF6qNv/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.validation.characters_remaining.one"
|
||||
title: "Warning validation message informing the user that there is one remaining character before they reach the limit."
|
||||
value: "{{numCharacters}} character remaining"
|
||||
screenshot: "https://drive.google.com/file/d/17nQCx_XH2IMJtXVomjPYTLRI1i-WYI1W/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.validation.characters_remaining.two"
|
||||
title: "Warning validation message informing the user that there are two remaining characters before they reach the limit."
|
||||
value: "{{numCharacters}} characters remaining"
|
||||
screenshot: "https://drive.google.com/file/d/1atQ7bIrRvXXUg8Wl3il26l1UC0CiDA6u/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.validation.characters_remaining.few"
|
||||
title: "Warning validation message informing the user of the number of characters left before the limit, when the few plural form applies"
|
||||
value: "{{numCharacters}} characters remaining"
|
||||
screenshot: "https://drive.google.com/file/d/1atQ7bIrRvXXUg8Wl3il26l1UC0CiDA6u/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.validation.characters_remaining.many"
|
||||
title: "Warning validation message informing the user of the number of characters left before the limit, when the many plural form applies"
|
||||
value: "{{numCharacters}} characters remaining"
|
||||
screenshot: "https://drive.google.com/file/d/1atQ7bIrRvXXUg8Wl3il26l1UC0CiDA6u/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.validation.characters_remaining.other"
|
||||
title: "Warning validation message informing the user of the number of characters left before the limit, when the default plural form applies"
|
||||
value: "{{numCharacters}} characters remaining"
|
||||
screenshot: "https://drive.google.com/file/d/1atQ7bIrRvXXUg8Wl3il26l1UC0CiDA6u/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.validation.empty_comment_error"
|
||||
title: "Error validation message informing the user that the comment field is empty"
|
||||
value: "Enter a comment"
|
||||
screenshot: "https://drive.google.com/file/d/1EjO40n6Vg3H5gUpNqp3TKUXSdQ0Hyg6S/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.clarification.new_comment_indicator"
|
||||
title: "This is the text that appears when a new comment is added to the approval request clarification section"
|
||||
value: "New comment"
|
||||
screenshot: "https://drive.google.com/file/d/1HngajEqfo_Y6Q5dU8CzCSfgmS1nLGeEQ/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.clarification.new_comments_indicator"
|
||||
title: "This is the text that appears when multiple new comments are added to the approval request clarification section"
|
||||
value: "New comments"
|
||||
screenshot: "https://drive.google.com/file/d/1HusOTEN6w5Pc820fsdGhKtov0YGA-OZ9/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.clarification.max_comment_alert_title"
|
||||
title: "This is the title for an alert notification that is shown when 40 comments has been added to the approval request clarification section"
|
||||
value: "Comment limit reached"
|
||||
screenshot: "https://drive.google.com/file/d/1zJ4YRvuaKSbgUhvXW7urZUmVYo-x0f4M/view?usp=sharing"
|
||||
- translation:
|
||||
key: "txt.approval_requests.clarification.max_comment_alert_message"
|
||||
title: "This is the message for an alert notification that is shown when 40 comments has been added to the approval request clarification section"
|
||||
value: "You can't add more comments, approvers can still approve or deny."
|
||||
screenshot: "https://drive.google.com/file/d/1zJ4YRvuaKSbgUhvXW7urZUmVYo-x0f4M/view?usp=sharing"
|
||||
46
src/modules/approval-requests/translations/locales/af.json
Normal file
46
src/modules/approval-requests/translations/locales/af.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "[ผู้龍ḀḀṗṗṛṓṓṽααḽ ṛḛḛʠṵṵḛḛṡṭṡ龍ผู้]",
|
||||
"approval-requests.list.no-requests": "[ผู้龍Ṅṓṓ ααṗṗṛṓṓṽααḽ ṛḛḛʠṵṵḛḛṡṭṡ ϝṓṓṵṵṇḍ.龍ผู้]",
|
||||
"approval-requests.list.search-placeholder": "[ผู้龍Ṣḛḛααṛͼḥ ααṗṗṛṓṓṽααḽ ṛḛḛʠṵṵḛḛṡṭṡ龍ผู้]",
|
||||
"approval-requests.list.status-dropdown.any": "[ผู้龍ḀḀṇẏẏ龍ผู้]",
|
||||
"approval-requests.list.status-dropdown.label_v2": "[ผู้龍Ṣṭααṭṵṵṡ龍ผู้]",
|
||||
"approval-requests.list.table.approval-status": "[ผู้龍ḀḀṗṗṛṓṓṽααḽ ṡṭααṭṵṵṡ龍ผู้]",
|
||||
"approval-requests.list.table.requester": "[ผู้龍Ṛḛḛʠṵṵḛḛṡṭḛḛṛ龍ผู้]",
|
||||
"approval-requests.list.table.sent-by": "[ผู้龍Ṣḛḛṇṭ ḅẏẏ龍ผู้]",
|
||||
"approval-requests.list.table.sent-on": "[ผู้龍Ṣḛḛṇṭ ṓṓṇ龍ผู้]",
|
||||
"approval-requests.list.table.subject": "[ผู้龍Ṣṵṵḅĵḛḛͼṭ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.approver": "[ผู้龍ḀḀṗṗṛṓṓṽḛḛṛ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.comment": "[ผู้龍Ḉṓṓṃṃḛḛṇṭ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.decided": "[ผู้龍Ḍḛḛͼḭḭḍḛḛḍ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.header": "[ผู้龍ḀḀṗṗṛṓṓṽααḽ ṛḛḛʠṵṵḛḛṡṭ ḍḛḛṭααḭḭḽṡ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "[ผู้龍Ṕṛḛḛṽḭḭṓṓṵṵṡ ḍḛḛͼḭḭṡḭḭṓṓṇ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.sent-by": "[ผู้龍Ṣḛḛṇṭ ḅẏẏ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.sent-on": "[ผู้龍Ṣḛḛṇṭ ṓṓṇ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.status": "[ผู้龍Ṣṭααṭṵṵṡ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "[ผู้龍Ŵḭḭṭḥḍṛααẁṇ ṓṓṇ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "[ผู้龍ḀḀḍḍḭḭṭḭḭṓṓṇααḽ ṇṓṓṭḛḛ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.approve-request": "[ผู้龍ḀḀṗṗṛṓṓṽḛḛ ṛḛḛʠṵṵḛḛṡṭ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.cancel": "[ผู้龍Ḉααṇͼḛḛḽ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "[ผู้龍Ṛḛḛααṡṓṓṇ ϝṓṓṛ ḍḛḛṇḭḭααḽ* (Ṛḛḛʠṵṵḭḭṛḛḛḍ)龍ผู้]",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "[ผู้龍ḚḚṇṭḛḛṛ αα ṛḛḛααṡṓṓṇ ϝṓṓṛ ḍḛḛṇḭḭααḽ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.deny-request": "[ผู้龍Ḍḛḛṇẏẏ ṛḛḛʠṵṵḛḛṡṭ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.submit-approval": "[ผู้龍Ṣṵṵḅṃḭḭṭ ααṗṗṛṓṓṽααḽ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.submit-denial": "[ผู้龍Ṣṵṵḅṃḭḭṭ ḍḛḛṇḭḭααḽ龍ผู้]",
|
||||
"approval-requests.request.notification.approval-submitted": "[ผู้龍ḀḀṗṗṛṓṓṽααḽ ṡṵṵḅṃḭḭṭṭḛḛḍ龍ผู้]",
|
||||
"approval-requests.request.notification.denial-submitted": "[ผู้龍Ḍḛḛṇḭḭααḽ ṡṵṵḅṃḭḭṭṭḛḛḍ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "[ผู้龍Ṅṓṓ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "[ผู้龍ŶŶḛḛṡ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.header": "[ผู้龍Ṫḭḭͼḳḛḛṭ ḍḛḛṭααḭḭḽṡ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.id": "[ผู้龍ḬḬḌ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.priority": "[ผู้龍Ṕṛḭḭṓṓṛḭḭṭẏẏ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.priority_high": "[ผู้龍Ḥḭḭḡḥ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.priority_low": "[ผู้龍Ḻṓṓẁ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.priority_normal": "[ผู้龍Ṅṓṓṛṃααḽ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "[ผู้龍ṲṲṛḡḛḛṇṭ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.requester": "[ผู้龍Ṛḛḛʠṵṵḛḛṡṭḛḛṛ龍ผู้]",
|
||||
"approval-requests.status.approved": "[ผู้龍ḀḀṗṗṛṓṓṽḛḛḍ龍ผู้]",
|
||||
"approval-requests.status.decision-pending": "[ผู้龍Ḍḛḛͼḭḭṡḭḭṓṓṇ ṗḛḛṇḍḭḭṇḡ龍ผู้]",
|
||||
"approval-requests.status.denied": "[ผู้龍Ḍḛḛṇḭḭḛḛḍ龍ผู้]",
|
||||
"approval-requests.status.info-needed": "[ผู้龍ḬḬṇϝṓṓ ṇḛḛḛḛḍḛḛḍ龍ผู้]",
|
||||
"approval-requests.status.withdrawn": "[ผู้龍Ŵḭḭṭḥḍṛααẁṇ龍ผู้]"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/ar.json
Normal file
46
src/modules/approval-requests/translations/locales/ar.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "طلبات الموافقة",
|
||||
"approval-requests.list.no-requests": "لم يتم العثور على طلبات الموافقة.",
|
||||
"approval-requests.list.search-placeholder": "طلبات الموافقة على البحث",
|
||||
"approval-requests.list.status-dropdown.any": "أي عنصر",
|
||||
"approval-requests.list.status-dropdown.label_v2": "الحالة",
|
||||
"approval-requests.list.table.approval-status": "حالة الموافقة",
|
||||
"approval-requests.list.table.requester": "الطالب",
|
||||
"approval-requests.list.table.sent-by": "تم الإرسال من قبل",
|
||||
"approval-requests.list.table.sent-on": "تاريخ الإرسال",
|
||||
"approval-requests.list.table.subject": "الموضوع",
|
||||
"approval-requests.request.approval-request-details.approver": "جهة الموافقة",
|
||||
"approval-requests.request.approval-request-details.comment": "التعليق",
|
||||
"approval-requests.request.approval-request-details.decided": "تاريخ القرار",
|
||||
"approval-requests.request.approval-request-details.header": "تفاصيل طلب الموافقة",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "القرار السابق",
|
||||
"approval-requests.request.approval-request-details.sent-by": "تم الإرسال من قبل",
|
||||
"approval-requests.request.approval-request-details.sent-on": "تاريخ الإرسال",
|
||||
"approval-requests.request.approval-request-details.status": "الحالة",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "تاريخ السحب",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "ملاحظة إضافية",
|
||||
"approval-requests.request.approver-actions.approve-request": "الموافقة على الطلب",
|
||||
"approval-requests.request.approver-actions.cancel": "إلغاء",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "سبب الرفض* (مطلوب)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "أدخل سببًا للرفض",
|
||||
"approval-requests.request.approver-actions.deny-request": "رفض الطلب",
|
||||
"approval-requests.request.approver-actions.submit-approval": "إرسال الموافقة",
|
||||
"approval-requests.request.approver-actions.submit-denial": "إرسال الرفض",
|
||||
"approval-requests.request.notification.approval-submitted": "تم إرسال الموافقة",
|
||||
"approval-requests.request.notification.denial-submitted": "تم إرسال الرفض",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "لا",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "نعم",
|
||||
"approval-requests.request.ticket-details.header": "تفاصيل التذكرة",
|
||||
"approval-requests.request.ticket-details.id": "المُعرِّف",
|
||||
"approval-requests.request.ticket-details.priority": "الأولوية",
|
||||
"approval-requests.request.ticket-details.priority_high": "عالية",
|
||||
"approval-requests.request.ticket-details.priority_low": "منخفضة",
|
||||
"approval-requests.request.ticket-details.priority_normal": "عادية",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "مستعجلة",
|
||||
"approval-requests.request.ticket-details.requester": "الطالب",
|
||||
"approval-requests.status.approved": "مقبول",
|
||||
"approval-requests.status.decision-pending": "بانتظار صدور قرار",
|
||||
"approval-requests.status.denied": "مرفوض",
|
||||
"approval-requests.status.info-needed": "هناك حاجة إلى معلومات",
|
||||
"approval-requests.status.withdrawn": "تم السحب"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/az.json
Normal file
46
src/modules/approval-requests/translations/locales/az.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/be.json
Normal file
46
src/modules/approval-requests/translations/locales/be.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Запросы на утверждение",
|
||||
"approval-requests.list.no-requests": "Нет запросов на утверждение.",
|
||||
"approval-requests.list.search-placeholder": "Поиск запросов на утверждение",
|
||||
"approval-requests.list.status-dropdown.any": "Любой",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Статус",
|
||||
"approval-requests.list.table.approval-status": "Статус утверждения",
|
||||
"approval-requests.list.table.requester": "Инициатор",
|
||||
"approval-requests.list.table.sent-by": "Отправитель",
|
||||
"approval-requests.list.table.sent-on": "Дата отправки",
|
||||
"approval-requests.list.table.subject": "Тема",
|
||||
"approval-requests.request.approval-request-details.approver": "Утверждающий пользователь",
|
||||
"approval-requests.request.approval-request-details.comment": "Комментарий",
|
||||
"approval-requests.request.approval-request-details.decided": "Принято",
|
||||
"approval-requests.request.approval-request-details.header": "Сведения о запросе на утверждение",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Предыдущее решение",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Отправитель",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Дата отправки",
|
||||
"approval-requests.request.approval-request-details.status": "Статус",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Дата аннулирования",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Дополнительное примечание",
|
||||
"approval-requests.request.approver-actions.approve-request": "Утвердить запрос",
|
||||
"approval-requests.request.approver-actions.cancel": "Отмена",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Причина отказа* (обязательное поле)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Введите причину отказа",
|
||||
"approval-requests.request.approver-actions.deny-request": "Отклонить запрос",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Отправить утверждение",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Отправить отказ",
|
||||
"approval-requests.request.notification.approval-submitted": "Утверждение отправлено",
|
||||
"approval-requests.request.notification.denial-submitted": "Отказ отправлен",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "Нет",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Да",
|
||||
"approval-requests.request.ticket-details.header": "Сведения о тикете",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Приоритет",
|
||||
"approval-requests.request.ticket-details.priority_high": "Высокий",
|
||||
"approval-requests.request.ticket-details.priority_low": "Низкий",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Нормальный",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Экстренный",
|
||||
"approval-requests.request.ticket-details.requester": "Инициатор",
|
||||
"approval-requests.status.approved": "Утверждено",
|
||||
"approval-requests.status.decision-pending": "В ожидании решения",
|
||||
"approval-requests.status.denied": "Отклонено",
|
||||
"approval-requests.status.info-needed": "Требуется информация",
|
||||
"approval-requests.status.withdrawn": "Аннулировано"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/bg.json
Normal file
46
src/modules/approval-requests/translations/locales/bg.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Заявки за одобрение",
|
||||
"approval-requests.list.no-requests": "Не са намерени заявки за одобрение.",
|
||||
"approval-requests.list.search-placeholder": "Търсене на заявки за одобрение",
|
||||
"approval-requests.list.status-dropdown.any": "Всеки",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Статус",
|
||||
"approval-requests.list.table.approval-status": "Статус на одобрение",
|
||||
"approval-requests.list.table.requester": "Заявител",
|
||||
"approval-requests.list.table.sent-by": "Изпратена от",
|
||||
"approval-requests.list.table.sent-on": "Изпратена на",
|
||||
"approval-requests.list.table.subject": "Тема",
|
||||
"approval-requests.request.approval-request-details.approver": "Одобряващ",
|
||||
"approval-requests.request.approval-request-details.comment": "Коментар",
|
||||
"approval-requests.request.approval-request-details.decided": "Решено",
|
||||
"approval-requests.request.approval-request-details.header": "Подробности за заявката за одобрение",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Предишно решение",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Изпратена от",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Изпратена на",
|
||||
"approval-requests.request.approval-request-details.status": "Статус",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Оттеглена на",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Допълнителна забележка",
|
||||
"approval-requests.request.approver-actions.approve-request": "Одобряване на заявката",
|
||||
"approval-requests.request.approver-actions.cancel": "Отказ",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Причина за отказ* (задължително)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Въведете причина за отказ",
|
||||
"approval-requests.request.approver-actions.deny-request": "Отхвърляне на заявката",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Подаване на одобрение",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Подаване на отхвърляне",
|
||||
"approval-requests.request.notification.approval-submitted": "Одобрението е подадено",
|
||||
"approval-requests.request.notification.denial-submitted": "Отхвърлянето е подадено",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "Не",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Да",
|
||||
"approval-requests.request.ticket-details.header": "Подробности за казуса",
|
||||
"approval-requests.request.ticket-details.id": "Идентификатор",
|
||||
"approval-requests.request.ticket-details.priority": "Приоритет",
|
||||
"approval-requests.request.ticket-details.priority_high": "Висок",
|
||||
"approval-requests.request.ticket-details.priority_low": "Нисък",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Нормален",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Спешен",
|
||||
"approval-requests.request.ticket-details.requester": "Заявител",
|
||||
"approval-requests.status.approved": "Одобрен",
|
||||
"approval-requests.status.decision-pending": "Чакащ решение",
|
||||
"approval-requests.status.denied": "Отказан",
|
||||
"approval-requests.status.info-needed": "Нужна е информация",
|
||||
"approval-requests.status.withdrawn": "Оттеглен"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/bn.json
Normal file
46
src/modules/approval-requests/translations/locales/bn.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/bs.json
Normal file
46
src/modules/approval-requests/translations/locales/bs.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/ca.json
Normal file
46
src/modules/approval-requests/translations/locales/ca.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Solicitudes de aprobación",
|
||||
"approval-requests.list.no-requests": "No se encontraron solicitudes de aprobación.",
|
||||
"approval-requests.list.search-placeholder": "Buscar solicitudes de aprobación",
|
||||
"approval-requests.list.status-dropdown.any": "Cualquiera",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Estado",
|
||||
"approval-requests.list.table.approval-status": "Estado de aprobación",
|
||||
"approval-requests.list.table.requester": "Solicitante",
|
||||
"approval-requests.list.table.sent-by": "Enviado por",
|
||||
"approval-requests.list.table.sent-on": "Enviado el",
|
||||
"approval-requests.list.table.subject": "Asunto",
|
||||
"approval-requests.request.approval-request-details.approver": "Aprobador",
|
||||
"approval-requests.request.approval-request-details.comment": "Comentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Decidido",
|
||||
"approval-requests.request.approval-request-details.header": "Detalles de la solicitud de aprobación",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Decisión anterior",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Enviado por",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Enviado el",
|
||||
"approval-requests.request.approval-request-details.status": "Estado",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Retirado el",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Nota adicional",
|
||||
"approval-requests.request.approver-actions.approve-request": "Aprobar solicitud",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancelar",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Motivo de la denegación* (obligatorio)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Ingrese un motivo para la denegación",
|
||||
"approval-requests.request.approver-actions.deny-request": "Denegar solicitud",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Enviar aprobación",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Enviar denegación",
|
||||
"approval-requests.request.notification.approval-submitted": "Aprobación enviada",
|
||||
"approval-requests.request.notification.denial-submitted": "Denegación enviada",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Sí",
|
||||
"approval-requests.request.ticket-details.header": "Detalles del ticket",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Prioridad",
|
||||
"approval-requests.request.ticket-details.priority_high": "Alta",
|
||||
"approval-requests.request.ticket-details.priority_low": "Baja",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgente",
|
||||
"approval-requests.request.ticket-details.requester": "Solicitante",
|
||||
"approval-requests.status.approved": "Aprobado",
|
||||
"approval-requests.status.decision-pending": "Decisión pendiente",
|
||||
"approval-requests.status.denied": "Denegado",
|
||||
"approval-requests.status.info-needed": "Se necesita información",
|
||||
"approval-requests.status.withdrawn": "Retirado"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/cs.json
Normal file
46
src/modules/approval-requests/translations/locales/cs.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Požadavky na schválení",
|
||||
"approval-requests.list.no-requests": "Nebyly nalezeny žádné požadavky na schválení.",
|
||||
"approval-requests.list.search-placeholder": "Hledejte požadavky na schválení",
|
||||
"approval-requests.list.status-dropdown.any": "Jakékoli",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Stav",
|
||||
"approval-requests.list.table.approval-status": "Stav schválení",
|
||||
"approval-requests.list.table.requester": "Klient",
|
||||
"approval-requests.list.table.sent-by": "Odeslal/a",
|
||||
"approval-requests.list.table.sent-on": "Odesláno dne",
|
||||
"approval-requests.list.table.subject": "Předmět",
|
||||
"approval-requests.request.approval-request-details.approver": "Schvalovatel",
|
||||
"approval-requests.request.approval-request-details.comment": "Komentář",
|
||||
"approval-requests.request.approval-request-details.decided": "Rozhodnutí",
|
||||
"approval-requests.request.approval-request-details.header": "Podrobnosti požadavku na schválení",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Předchozí rozhodnutí",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Odeslal/a",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Odesláno dne",
|
||||
"approval-requests.request.approval-request-details.status": "Stav",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Staženo dne",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Dodatečná poznámka",
|
||||
"approval-requests.request.approver-actions.approve-request": "Schválit požadavek",
|
||||
"approval-requests.request.approver-actions.cancel": "Zrušit",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Důvod zamítnutí* (povinné pole)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Zadejte důvod zamítnutí.",
|
||||
"approval-requests.request.approver-actions.deny-request": "Zamítnout požadavek",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Odeslat schválení",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Odeslat zamítnutí",
|
||||
"approval-requests.request.notification.approval-submitted": "Schválení odesláno",
|
||||
"approval-requests.request.notification.denial-submitted": "Zamítnutí odesláno",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "Ne",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Ano",
|
||||
"approval-requests.request.ticket-details.header": "Detaily tiketu",
|
||||
"approval-requests.request.ticket-details.id": "Identifikátor",
|
||||
"approval-requests.request.ticket-details.priority": "Priorita",
|
||||
"approval-requests.request.ticket-details.priority_high": "Vysoká",
|
||||
"approval-requests.request.ticket-details.priority_low": "Nízká",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normální",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Naléhavá",
|
||||
"approval-requests.request.ticket-details.requester": "Klient",
|
||||
"approval-requests.status.approved": "Schváleno",
|
||||
"approval-requests.status.decision-pending": "Čeká se na rozhodnutí",
|
||||
"approval-requests.status.denied": "Zamítnuto",
|
||||
"approval-requests.status.info-needed": "Požadované informace",
|
||||
"approval-requests.status.withdrawn": "Staženo"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/cy.json
Normal file
46
src/modules/approval-requests/translations/locales/cy.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/da.json
Normal file
46
src/modules/approval-requests/translations/locales/da.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Anmodninger om godkendelse",
|
||||
"approval-requests.list.no-requests": "Der blev ikke fundet nogen anmodninger om godkendelse.",
|
||||
"approval-requests.list.search-placeholder": "Søg i anmodninger om godkendelse",
|
||||
"approval-requests.list.status-dropdown.any": "Alle",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Godkendelsesstatus",
|
||||
"approval-requests.list.table.requester": "Anmoder",
|
||||
"approval-requests.list.table.sent-by": "Sendt af",
|
||||
"approval-requests.list.table.sent-on": "Sendt d.",
|
||||
"approval-requests.list.table.subject": "Emne",
|
||||
"approval-requests.request.approval-request-details.approver": "Godkender",
|
||||
"approval-requests.request.approval-request-details.comment": "Kommentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Afgjort",
|
||||
"approval-requests.request.approval-request-details.header": "Oplysninger om anmodning om godkendelse",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Tidligere beslutning",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sendt af",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sendt d.",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Trukket tilbage d.",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Yderligere bemærkning",
|
||||
"approval-requests.request.approver-actions.approve-request": "Godkend anmodning",
|
||||
"approval-requests.request.approver-actions.cancel": "Annuller",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Årsag til afvisning* (obligatorisk)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Indtast en årsag til afvisningen",
|
||||
"approval-requests.request.approver-actions.deny-request": "Afvis anmodning",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Indsend godkendelse",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Indsend afvisning",
|
||||
"approval-requests.request.notification.approval-submitted": "Godkendelse indsendt",
|
||||
"approval-requests.request.notification.denial-submitted": "Afvisning indsendt",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "Nej",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Ja",
|
||||
"approval-requests.request.ticket-details.header": "Oplysninger om ticket",
|
||||
"approval-requests.request.ticket-details.id": "Id",
|
||||
"approval-requests.request.ticket-details.priority": "Prioritet",
|
||||
"approval-requests.request.ticket-details.priority_high": "Høj",
|
||||
"approval-requests.request.ticket-details.priority_low": "Lav",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Haster",
|
||||
"approval-requests.request.ticket-details.requester": "Anmoder",
|
||||
"approval-requests.status.approved": "Godkendt",
|
||||
"approval-requests.status.decision-pending": "Afgørelse venter",
|
||||
"approval-requests.status.denied": "Afvist",
|
||||
"approval-requests.status.info-needed": "Oplysninger nødvendige",
|
||||
"approval-requests.status.withdrawn": "Tilbagetrukket"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Genehmigungsanfragen",
|
||||
"approval-requests.list.no-requests": "Keine Genehmigungsanfragen gefunden.",
|
||||
"approval-requests.list.search-placeholder": "Genehmigungsanfragen suchen",
|
||||
"approval-requests.list.status-dropdown.any": "Beliebig",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Genehmigungsstatus",
|
||||
"approval-requests.list.table.requester": "Anfragender",
|
||||
"approval-requests.list.table.sent-by": "Gesendet von",
|
||||
"approval-requests.list.table.sent-on": "Gesendet am",
|
||||
"approval-requests.list.table.subject": "Betreff",
|
||||
"approval-requests.request.approval-request-details.approver": "Genehmiger",
|
||||
"approval-requests.request.approval-request-details.comment": "Kommentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Entschieden",
|
||||
"approval-requests.request.approval-request-details.header": "Details zur Genehmigungsanfrage",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Vorherige Entscheidung",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Gesendet von",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Gesendet am",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Zurückgezogen am",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Zusätzlicher Hinweis",
|
||||
"approval-requests.request.approver-actions.approve-request": "Anfrage genehmigen",
|
||||
"approval-requests.request.approver-actions.cancel": "Abbrechen",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Grund für Ablehnung* (erforderlich)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Grund für die Ablehnung eingeben",
|
||||
"approval-requests.request.approver-actions.deny-request": "Anfrage ablehnen",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Genehmigung senden",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Ablehnung senden",
|
||||
"approval-requests.request.notification.approval-submitted": "Genehmigung gesendet",
|
||||
"approval-requests.request.notification.denial-submitted": "Ablehnung gesendet",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "Nein",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Ja",
|
||||
"approval-requests.request.ticket-details.header": "Ticketdetails",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priorität",
|
||||
"approval-requests.request.ticket-details.priority_high": "Hoch",
|
||||
"approval-requests.request.ticket-details.priority_low": "Niedrig",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Dringend",
|
||||
"approval-requests.request.ticket-details.requester": "Anfragender",
|
||||
"approval-requests.status.approved": "Genehmigt",
|
||||
"approval-requests.status.decision-pending": "Entscheidung ausstehend",
|
||||
"approval-requests.status.denied": "Abgelehnt",
|
||||
"approval-requests.status.info-needed": "Weitere Informationen erforderlich",
|
||||
"approval-requests.status.withdrawn": "Zurückgezogen"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Genehmigungsanfragen",
|
||||
"approval-requests.list.no-requests": "Keine Genehmigungsanfragen gefunden.",
|
||||
"approval-requests.list.search-placeholder": "Genehmigungsanfragen suchen",
|
||||
"approval-requests.list.status-dropdown.any": "Beliebig",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Genehmigungsstatus",
|
||||
"approval-requests.list.table.requester": "Anfragender",
|
||||
"approval-requests.list.table.sent-by": "Gesendet von",
|
||||
"approval-requests.list.table.sent-on": "Gesendet am",
|
||||
"approval-requests.list.table.subject": "Betreff",
|
||||
"approval-requests.request.approval-request-details.approver": "Genehmiger",
|
||||
"approval-requests.request.approval-request-details.comment": "Kommentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Entschieden",
|
||||
"approval-requests.request.approval-request-details.header": "Details zur Genehmigungsanfrage",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Vorherige Entscheidung",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Gesendet von",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Gesendet am",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Zurückgezogen am",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Zusätzlicher Hinweis",
|
||||
"approval-requests.request.approver-actions.approve-request": "Anfrage genehmigen",
|
||||
"approval-requests.request.approver-actions.cancel": "Abbrechen",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Grund für Ablehnung* (erforderlich)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Grund für die Ablehnung eingeben",
|
||||
"approval-requests.request.approver-actions.deny-request": "Anfrage ablehnen",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Genehmigung senden",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Ablehnung senden",
|
||||
"approval-requests.request.notification.approval-submitted": "Genehmigung gesendet",
|
||||
"approval-requests.request.notification.denial-submitted": "Ablehnung gesendet",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "Nein",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Ja",
|
||||
"approval-requests.request.ticket-details.header": "Ticketdetails",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priorität",
|
||||
"approval-requests.request.ticket-details.priority_high": "Hoch",
|
||||
"approval-requests.request.ticket-details.priority_low": "Niedrig",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Dringend",
|
||||
"approval-requests.request.ticket-details.requester": "Anfragender",
|
||||
"approval-requests.status.approved": "Genehmigt",
|
||||
"approval-requests.status.decision-pending": "Entscheidung ausstehend",
|
||||
"approval-requests.status.denied": "Abgelehnt",
|
||||
"approval-requests.status.info-needed": "Weitere Informationen erforderlich",
|
||||
"approval-requests.status.withdrawn": "Zurückgezogen"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/de.json
Normal file
46
src/modules/approval-requests/translations/locales/de.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Genehmigungsanfragen",
|
||||
"approval-requests.list.no-requests": "Keine Genehmigungsanfragen gefunden.",
|
||||
"approval-requests.list.search-placeholder": "Genehmigungsanfragen suchen",
|
||||
"approval-requests.list.status-dropdown.any": "Beliebig",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Genehmigungsstatus",
|
||||
"approval-requests.list.table.requester": "Anfragender",
|
||||
"approval-requests.list.table.sent-by": "Gesendet von",
|
||||
"approval-requests.list.table.sent-on": "Gesendet am",
|
||||
"approval-requests.list.table.subject": "Betreff",
|
||||
"approval-requests.request.approval-request-details.approver": "Genehmiger",
|
||||
"approval-requests.request.approval-request-details.comment": "Kommentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Entschieden",
|
||||
"approval-requests.request.approval-request-details.header": "Details zur Genehmigungsanfrage",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Vorherige Entscheidung",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Gesendet von",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Gesendet am",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Zurückgezogen am",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Zusätzlicher Hinweis",
|
||||
"approval-requests.request.approver-actions.approve-request": "Anfrage genehmigen",
|
||||
"approval-requests.request.approver-actions.cancel": "Abbrechen",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Grund für Ablehnung* (erforderlich)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Grund für die Ablehnung eingeben",
|
||||
"approval-requests.request.approver-actions.deny-request": "Anfrage ablehnen",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Genehmigung senden",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Ablehnung senden",
|
||||
"approval-requests.request.notification.approval-submitted": "Genehmigung gesendet",
|
||||
"approval-requests.request.notification.denial-submitted": "Ablehnung gesendet",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "Nein",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Ja",
|
||||
"approval-requests.request.ticket-details.header": "Ticketdetails",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priorität",
|
||||
"approval-requests.request.ticket-details.priority_high": "Hoch",
|
||||
"approval-requests.request.ticket-details.priority_low": "Niedrig",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Dringend",
|
||||
"approval-requests.request.ticket-details.requester": "Anfragender",
|
||||
"approval-requests.status.approved": "Genehmigt",
|
||||
"approval-requests.status.decision-pending": "Entscheidung ausstehend",
|
||||
"approval-requests.status.denied": "Abgelehnt",
|
||||
"approval-requests.status.info-needed": "Weitere Informationen erforderlich",
|
||||
"approval-requests.status.withdrawn": "Zurückgezogen"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/el.json
Normal file
46
src/modules/approval-requests/translations/locales/el.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Αιτήματα έγκρισης",
|
||||
"approval-requests.list.no-requests": "Δεν βρέθηκαν αιτήματα έγκρισης.",
|
||||
"approval-requests.list.search-placeholder": "Αναζητήστε αιτήματα έγκρισης",
|
||||
"approval-requests.list.status-dropdown.any": "Οποιαδήποτε",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Κατάσταση",
|
||||
"approval-requests.list.table.approval-status": "Κατάσταση έγκρισης",
|
||||
"approval-requests.list.table.requester": "Αιτών",
|
||||
"approval-requests.list.table.sent-by": "Στάλθηκε από",
|
||||
"approval-requests.list.table.sent-on": "Στάλθηκε στις",
|
||||
"approval-requests.list.table.subject": "Θέμα",
|
||||
"approval-requests.request.approval-request-details.approver": "Υπεύθυνος έγκρισης",
|
||||
"approval-requests.request.approval-request-details.comment": "Σχόλιο",
|
||||
"approval-requests.request.approval-request-details.decided": "Αποφασίστηκε",
|
||||
"approval-requests.request.approval-request-details.header": "Λεπτομέρειες αιτήματος έγκρισης",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Προηγούμενη απόφαση",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Στάλθηκε από",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Στάλθηκε στις",
|
||||
"approval-requests.request.approval-request-details.status": "Κατάσταση",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Ανακλήθηκε στις",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Πρόσθετη σημείωση",
|
||||
"approval-requests.request.approver-actions.approve-request": "Έγκριση αιτήματος",
|
||||
"approval-requests.request.approver-actions.cancel": "Ακύρωση",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Λόγος απόρριψης* (απαιτείται)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Εισαγάγετε έναν λόγο απόρριψης",
|
||||
"approval-requests.request.approver-actions.deny-request": "Απόρριψη αιτήματος",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Υποβολή έγκρισης",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Υποβολή απόρριψης",
|
||||
"approval-requests.request.notification.approval-submitted": "Η έγκριση υποβλήθηκε",
|
||||
"approval-requests.request.notification.denial-submitted": "Η απόρριψη υποβλήθηκε",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "Όχι",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Ναι",
|
||||
"approval-requests.request.ticket-details.header": "Λεπτομέρειες δελτίου",
|
||||
"approval-requests.request.ticket-details.id": "Αναγνωριστικό",
|
||||
"approval-requests.request.ticket-details.priority": "Προτεραιότητα",
|
||||
"approval-requests.request.ticket-details.priority_high": "Υψηλή",
|
||||
"approval-requests.request.ticket-details.priority_low": "Χαμηλή",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Κανονική",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Επείγουσα",
|
||||
"approval-requests.request.ticket-details.requester": "Αιτών",
|
||||
"approval-requests.status.approved": "Εγκρίθηκε",
|
||||
"approval-requests.status.decision-pending": "Απόφαση σε εκκρεμότητα",
|
||||
"approval-requests.status.denied": "Απορρίφθηκε",
|
||||
"approval-requests.status.info-needed": "Χρειάζονται πληροφορίες",
|
||||
"approval-requests.status.withdrawn": "Ανακλήθηκε"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "approval-requests.list.header",
|
||||
"approval-requests.list.no-requests": "approval-requests.list.no-requests",
|
||||
"approval-requests.list.search-placeholder": "approval-requests.list.search-placeholder",
|
||||
"approval-requests.list.status-dropdown.any": "approval-requests.list.status-dropdown.any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "approval-requests.list.status-dropdown.label_v2",
|
||||
"approval-requests.list.table.approval-status": "approval-requests.list.table.approval-status",
|
||||
"approval-requests.list.table.requester": "approval-requests.list.table.requester",
|
||||
"approval-requests.list.table.sent-by": "approval-requests.list.table.sent-by",
|
||||
"approval-requests.list.table.sent-on": "approval-requests.list.table.sent-on",
|
||||
"approval-requests.list.table.subject": "approval-requests.list.table.subject",
|
||||
"approval-requests.request.approval-request-details.approver": "approval-requests.request.approval-request-details.approver",
|
||||
"approval-requests.request.approval-request-details.comment": "approval-requests.request.approval-request-details.comment",
|
||||
"approval-requests.request.approval-request-details.decided": "approval-requests.request.approval-request-details.decided",
|
||||
"approval-requests.request.approval-request-details.header": "approval-requests.request.approval-request-details.header",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "approval-requests.request.approval-request-details.previous-decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "approval-requests.request.approval-request-details.sent-by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "approval-requests.request.approval-request-details.sent-on",
|
||||
"approval-requests.request.approval-request-details.status": "approval-requests.request.approval-request-details.status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "approval-requests.request.approval-request-details.withdrawn-on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "approval-requests.request.approver-actions.additional-note-label",
|
||||
"approval-requests.request.approver-actions.approve-request": "approval-requests.request.approver-actions.approve-request",
|
||||
"approval-requests.request.approver-actions.cancel": "approval-requests.request.approver-actions.cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "approval-requests.request.approver-actions.denial-reason-label",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "approval-requests.request.approver-actions.denial-reason-validation",
|
||||
"approval-requests.request.approver-actions.deny-request": "approval-requests.request.approver-actions.deny-request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "approval-requests.request.approver-actions.submit-approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "approval-requests.request.approver-actions.submit-denial",
|
||||
"approval-requests.request.notification.approval-submitted": "approval-requests.request.notification.approval-submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "approval-requests.request.notification.denial-submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "approval-requests.request.ticket-details.checkbox-value.no",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "approval-requests.request.ticket-details.checkbox-value.yes",
|
||||
"approval-requests.request.ticket-details.header": "approval-requests.request.ticket-details.header",
|
||||
"approval-requests.request.ticket-details.id": "approval-requests.request.ticket-details.id",
|
||||
"approval-requests.request.ticket-details.priority": "approval-requests.request.ticket-details.priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "approval-requests.request.ticket-details.priority_high",
|
||||
"approval-requests.request.ticket-details.priority_low": "approval-requests.request.ticket-details.priority_low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "approval-requests.request.ticket-details.priority_normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "approval-requests.request.ticket-details.priority_urgent",
|
||||
"approval-requests.request.ticket-details.requester": "approval-requests.request.ticket-details.requester",
|
||||
"approval-requests.status.approved": "approval-requests.status.approved",
|
||||
"approval-requests.status.decision-pending": "approval-requests.status.decision-pending",
|
||||
"approval-requests.status.denied": "approval-requests.status.denied",
|
||||
"approval-requests.status.info-needed": "approval-requests.status.info-needed",
|
||||
"approval-requests.status.withdrawn": "approval-requests.status.withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "[ผู้龍ḀḀṗṗṛṓṓṽααḽ ṛḛḛʠṵṵḛḛṡṭṡ龍ผู้]",
|
||||
"approval-requests.list.no-requests": "[ผู้龍Ṅṓṓ ααṗṗṛṓṓṽααḽ ṛḛḛʠṵṵḛḛṡṭṡ ϝṓṓṵṵṇḍ.龍ผู้]",
|
||||
"approval-requests.list.search-placeholder": "[ผู้龍Ṣḛḛααṛͼḥ ααṗṗṛṓṓṽααḽ ṛḛḛʠṵṵḛḛṡṭṡ龍ผู้]",
|
||||
"approval-requests.list.status-dropdown.any": "[ผู้龍ḀḀṇẏẏ龍ผู้]",
|
||||
"approval-requests.list.status-dropdown.label_v2": "[ผู้龍Ṣṭααṭṵṵṡ龍ผู้]",
|
||||
"approval-requests.list.table.approval-status": "[ผู้龍ḀḀṗṗṛṓṓṽααḽ ṡṭααṭṵṵṡ龍ผู้]",
|
||||
"approval-requests.list.table.requester": "[ผู้龍Ṛḛḛʠṵṵḛḛṡṭḛḛṛ龍ผู้]",
|
||||
"approval-requests.list.table.sent-by": "[ผู้龍Ṣḛḛṇṭ ḅẏẏ龍ผู้]",
|
||||
"approval-requests.list.table.sent-on": "[ผู้龍Ṣḛḛṇṭ ṓṓṇ龍ผู้]",
|
||||
"approval-requests.list.table.subject": "[ผู้龍Ṣṵṵḅĵḛḛͼṭ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.approver": "[ผู้龍ḀḀṗṗṛṓṓṽḛḛṛ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.comment": "[ผู้龍Ḉṓṓṃṃḛḛṇṭ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.decided": "[ผู้龍Ḍḛḛͼḭḭḍḛḛḍ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.header": "[ผู้龍ḀḀṗṗṛṓṓṽααḽ ṛḛḛʠṵṵḛḛṡṭ ḍḛḛṭααḭḭḽṡ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "[ผู้龍Ṕṛḛḛṽḭḭṓṓṵṵṡ ḍḛḛͼḭḭṡḭḭṓṓṇ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.sent-by": "[ผู้龍Ṣḛḛṇṭ ḅẏẏ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.sent-on": "[ผู้龍Ṣḛḛṇṭ ṓṓṇ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.status": "[ผู้龍Ṣṭααṭṵṵṡ龍ผู้]",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "[ผู้龍Ŵḭḭṭḥḍṛααẁṇ ṓṓṇ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "[ผู้龍ḀḀḍḍḭḭṭḭḭṓṓṇααḽ ṇṓṓṭḛḛ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.approve-request": "[ผู้龍ḀḀṗṗṛṓṓṽḛḛ ṛḛḛʠṵṵḛḛṡṭ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.cancel": "[ผู้龍Ḉααṇͼḛḛḽ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "[ผู้龍Ṛḛḛααṡṓṓṇ ϝṓṓṛ ḍḛḛṇḭḭααḽ* (Ṛḛḛʠṵṵḭḭṛḛḛḍ)龍ผู้]",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "[ผู้龍ḚḚṇṭḛḛṛ αα ṛḛḛααṡṓṓṇ ϝṓṓṛ ḍḛḛṇḭḭααḽ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.deny-request": "[ผู้龍Ḍḛḛṇẏẏ ṛḛḛʠṵṵḛḛṡṭ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.submit-approval": "[ผู้龍Ṣṵṵḅṃḭḭṭ ααṗṗṛṓṓṽααḽ龍ผู้]",
|
||||
"approval-requests.request.approver-actions.submit-denial": "[ผู้龍Ṣṵṵḅṃḭḭṭ ḍḛḛṇḭḭααḽ龍ผู้]",
|
||||
"approval-requests.request.notification.approval-submitted": "[ผู้龍ḀḀṗṗṛṓṓṽααḽ ṡṵṵḅṃḭḭṭṭḛḛḍ龍ผู้]",
|
||||
"approval-requests.request.notification.denial-submitted": "[ผู้龍Ḍḛḛṇḭḭααḽ ṡṵṵḅṃḭḭṭṭḛḛḍ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "[ผู้龍Ṅṓṓ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "[ผู้龍ŶŶḛḛṡ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.header": "[ผู้龍Ṫḭḭͼḳḛḛṭ ḍḛḛṭααḭḭḽṡ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.id": "[ผู้龍ḬḬḌ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.priority": "[ผู้龍Ṕṛḭḭṓṓṛḭḭṭẏẏ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.priority_high": "[ผู้龍Ḥḭḭḡḥ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.priority_low": "[ผู้龍Ḻṓṓẁ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.priority_normal": "[ผู้龍Ṅṓṓṛṃααḽ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "[ผู้龍ṲṲṛḡḛḛṇṭ龍ผู้]",
|
||||
"approval-requests.request.ticket-details.requester": "[ผู้龍Ṛḛḛʠṵṵḛḛṡṭḛḛṛ龍ผู้]",
|
||||
"approval-requests.status.approved": "[ผู้龍ḀḀṗṗṛṓṓṽḛḛḍ龍ผู้]",
|
||||
"approval-requests.status.decision-pending": "[ผู้龍Ḍḛḛͼḭḭṡḭḭṓṓṇ ṗḛḛṇḍḭḭṇḡ龍ผู้]",
|
||||
"approval-requests.status.denied": "[ผู้龍Ḍḛḛṇḭḭḛḛḍ龍ผู้]",
|
||||
"approval-requests.status.info-needed": "[ผู้龍ḬḬṇϝṓṓ ṇḛḛḛḛḍḛḛḍ龍ผู้]",
|
||||
"approval-requests.status.withdrawn": "[ผู้龍Ŵḭḭṭḥḍṛααẁṇ龍ผู้]"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Solicitudes de aprobación",
|
||||
"approval-requests.list.no-requests": "No se encontraron solicitudes de aprobación.",
|
||||
"approval-requests.list.search-placeholder": "Buscar solicitudes de aprobación",
|
||||
"approval-requests.list.status-dropdown.any": "Cualquiera",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Estado",
|
||||
"approval-requests.list.table.approval-status": "Estado de aprobación",
|
||||
"approval-requests.list.table.requester": "Solicitante",
|
||||
"approval-requests.list.table.sent-by": "Enviado por",
|
||||
"approval-requests.list.table.sent-on": "Enviado el",
|
||||
"approval-requests.list.table.subject": "Asunto",
|
||||
"approval-requests.request.approval-request-details.approver": "Aprobador",
|
||||
"approval-requests.request.approval-request-details.comment": "Comentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Decidido",
|
||||
"approval-requests.request.approval-request-details.header": "Detalles de la solicitud de aprobación",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Decisión anterior",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Enviado por",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Enviado el",
|
||||
"approval-requests.request.approval-request-details.status": "Estado",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Retirado el",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Nota adicional",
|
||||
"approval-requests.request.approver-actions.approve-request": "Aprobar solicitud",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancelar",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Motivo de la denegación* (obligatorio)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Ingrese un motivo para la denegación",
|
||||
"approval-requests.request.approver-actions.deny-request": "Denegar solicitud",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Enviar aprobación",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Enviar denegación",
|
||||
"approval-requests.request.notification.approval-submitted": "Aprobación enviada",
|
||||
"approval-requests.request.notification.denial-submitted": "Denegación enviada",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Sí",
|
||||
"approval-requests.request.ticket-details.header": "Detalles del ticket",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Prioridad",
|
||||
"approval-requests.request.ticket-details.priority_high": "Alta",
|
||||
"approval-requests.request.ticket-details.priority_low": "Baja",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgente",
|
||||
"approval-requests.request.ticket-details.requester": "Solicitante",
|
||||
"approval-requests.status.approved": "Aprobado",
|
||||
"approval-requests.status.decision-pending": "Decisión pendiente",
|
||||
"approval-requests.status.denied": "Denegado",
|
||||
"approval-requests.status.info-needed": "Se necesita información",
|
||||
"approval-requests.status.withdrawn": "Retirado"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Solicitudes de aprobación",
|
||||
"approval-requests.list.no-requests": "No se encontraron solicitudes de aprobación.",
|
||||
"approval-requests.list.search-placeholder": "Buscar solicitudes de aprobación",
|
||||
"approval-requests.list.status-dropdown.any": "Cualquiera",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Estado",
|
||||
"approval-requests.list.table.approval-status": "Estado de aprobación",
|
||||
"approval-requests.list.table.requester": "Solicitante",
|
||||
"approval-requests.list.table.sent-by": "Enviado por",
|
||||
"approval-requests.list.table.sent-on": "Enviado el",
|
||||
"approval-requests.list.table.subject": "Asunto",
|
||||
"approval-requests.request.approval-request-details.approver": "Aprobador",
|
||||
"approval-requests.request.approval-request-details.comment": "Comentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Decidido",
|
||||
"approval-requests.request.approval-request-details.header": "Detalles de la solicitud de aprobación",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Decisión anterior",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Enviado por",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Enviado el",
|
||||
"approval-requests.request.approval-request-details.status": "Estado",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Retirado el",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Nota adicional",
|
||||
"approval-requests.request.approver-actions.approve-request": "Aprobar solicitud",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancelar",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Motivo de la denegación* (obligatorio)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Ingrese un motivo para la denegación",
|
||||
"approval-requests.request.approver-actions.deny-request": "Denegar solicitud",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Enviar aprobación",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Enviar denegación",
|
||||
"approval-requests.request.notification.approval-submitted": "Aprobación enviada",
|
||||
"approval-requests.request.notification.denial-submitted": "Denegación enviada",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Sí",
|
||||
"approval-requests.request.ticket-details.header": "Detalles del ticket",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Prioridad",
|
||||
"approval-requests.request.ticket-details.priority_high": "Alta",
|
||||
"approval-requests.request.ticket-details.priority_low": "Baja",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgente",
|
||||
"approval-requests.request.ticket-details.requester": "Solicitante",
|
||||
"approval-requests.status.approved": "Aprobado",
|
||||
"approval-requests.status.decision-pending": "Decisión pendiente",
|
||||
"approval-requests.status.denied": "Denegado",
|
||||
"approval-requests.status.info-needed": "Se necesita información",
|
||||
"approval-requests.status.withdrawn": "Retirado"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Solicitudes de aprobación",
|
||||
"approval-requests.list.no-requests": "No se encontraron solicitudes de aprobación.",
|
||||
"approval-requests.list.search-placeholder": "Buscar solicitudes de aprobación",
|
||||
"approval-requests.list.status-dropdown.any": "Cualquiera",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Estado",
|
||||
"approval-requests.list.table.approval-status": "Estado de aprobación",
|
||||
"approval-requests.list.table.requester": "Solicitante",
|
||||
"approval-requests.list.table.sent-by": "Enviado por",
|
||||
"approval-requests.list.table.sent-on": "Enviado el",
|
||||
"approval-requests.list.table.subject": "Asunto",
|
||||
"approval-requests.request.approval-request-details.approver": "Aprobador",
|
||||
"approval-requests.request.approval-request-details.comment": "Comentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Decidido",
|
||||
"approval-requests.request.approval-request-details.header": "Detalles de la solicitud de aprobación",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Decisión anterior",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Enviado por",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Enviado el",
|
||||
"approval-requests.request.approval-request-details.status": "Estado",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Retirado el",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Nota adicional",
|
||||
"approval-requests.request.approver-actions.approve-request": "Aprobar solicitud",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancelar",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Motivo de la denegación* (obligatorio)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Ingrese un motivo para la denegación",
|
||||
"approval-requests.request.approver-actions.deny-request": "Denegar solicitud",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Enviar aprobación",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Enviar denegación",
|
||||
"approval-requests.request.notification.approval-submitted": "Aprobación enviada",
|
||||
"approval-requests.request.notification.denial-submitted": "Denegación enviada",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Sí",
|
||||
"approval-requests.request.ticket-details.header": "Detalles del ticket",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Prioridad",
|
||||
"approval-requests.request.ticket-details.priority_high": "Alta",
|
||||
"approval-requests.request.ticket-details.priority_low": "Baja",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgente",
|
||||
"approval-requests.request.ticket-details.requester": "Solicitante",
|
||||
"approval-requests.status.approved": "Aprobado",
|
||||
"approval-requests.status.decision-pending": "Decisión pendiente",
|
||||
"approval-requests.status.denied": "Denegado",
|
||||
"approval-requests.status.info-needed": "Se necesita información",
|
||||
"approval-requests.status.withdrawn": "Retirado"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Solicitudes de aprobación",
|
||||
"approval-requests.list.no-requests": "No se encontraron solicitudes de aprobación.",
|
||||
"approval-requests.list.search-placeholder": "Buscar solicitudes de aprobación",
|
||||
"approval-requests.list.status-dropdown.any": "Cualquiera",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Estado",
|
||||
"approval-requests.list.table.approval-status": "Estado de aprobación",
|
||||
"approval-requests.list.table.requester": "Solicitante",
|
||||
"approval-requests.list.table.sent-by": "Enviado por",
|
||||
"approval-requests.list.table.sent-on": "Enviado el",
|
||||
"approval-requests.list.table.subject": "Asunto",
|
||||
"approval-requests.request.approval-request-details.approver": "Aprobador",
|
||||
"approval-requests.request.approval-request-details.comment": "Comentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Decidido",
|
||||
"approval-requests.request.approval-request-details.header": "Detalles de la solicitud de aprobación",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Decisión anterior",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Enviado por",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Enviado el",
|
||||
"approval-requests.request.approval-request-details.status": "Estado",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Retirado el",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Nota adicional",
|
||||
"approval-requests.request.approver-actions.approve-request": "Aprobar solicitud",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancelar",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Motivo de la denegación* (obligatorio)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Ingrese un motivo para la denegación",
|
||||
"approval-requests.request.approver-actions.deny-request": "Denegar solicitud",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Enviar aprobación",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Enviar denegación",
|
||||
"approval-requests.request.notification.approval-submitted": "Aprobación enviada",
|
||||
"approval-requests.request.notification.denial-submitted": "Denegación enviada",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Sí",
|
||||
"approval-requests.request.ticket-details.header": "Detalles del ticket",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Prioridad",
|
||||
"approval-requests.request.ticket-details.priority_high": "Alta",
|
||||
"approval-requests.request.ticket-details.priority_low": "Baja",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgente",
|
||||
"approval-requests.request.ticket-details.requester": "Solicitante",
|
||||
"approval-requests.status.approved": "Aprobado",
|
||||
"approval-requests.status.decision-pending": "Decisión pendiente",
|
||||
"approval-requests.status.denied": "Denegado",
|
||||
"approval-requests.status.info-needed": "Se necesita información",
|
||||
"approval-requests.status.withdrawn": "Retirado"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Solicitudes de aprobación",
|
||||
"approval-requests.list.no-requests": "No se encontraron solicitudes de aprobación.",
|
||||
"approval-requests.list.search-placeholder": "Buscar solicitudes de aprobación",
|
||||
"approval-requests.list.status-dropdown.any": "Cualquiera",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Estado",
|
||||
"approval-requests.list.table.approval-status": "Estado de aprobación",
|
||||
"approval-requests.list.table.requester": "Solicitante",
|
||||
"approval-requests.list.table.sent-by": "Enviado por",
|
||||
"approval-requests.list.table.sent-on": "Enviado el",
|
||||
"approval-requests.list.table.subject": "Asunto",
|
||||
"approval-requests.request.approval-request-details.approver": "Aprobador",
|
||||
"approval-requests.request.approval-request-details.comment": "Comentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Decidido",
|
||||
"approval-requests.request.approval-request-details.header": "Detalles de la solicitud de aprobación",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Decisión anterior",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Enviado por",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Enviado el",
|
||||
"approval-requests.request.approval-request-details.status": "Estado",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Retirado el",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Nota adicional",
|
||||
"approval-requests.request.approver-actions.approve-request": "Aprobar solicitud",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancelar",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Motivo de la denegación* (obligatorio)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Ingrese un motivo para la denegación",
|
||||
"approval-requests.request.approver-actions.deny-request": "Denegar solicitud",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Enviar aprobación",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Enviar denegación",
|
||||
"approval-requests.request.notification.approval-submitted": "Aprobación enviada",
|
||||
"approval-requests.request.notification.denial-submitted": "Denegación enviada",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Sí",
|
||||
"approval-requests.request.ticket-details.header": "Detalles del ticket",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Prioridad",
|
||||
"approval-requests.request.ticket-details.priority_high": "Alta",
|
||||
"approval-requests.request.ticket-details.priority_low": "Baja",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgente",
|
||||
"approval-requests.request.ticket-details.requester": "Solicitante",
|
||||
"approval-requests.status.approved": "Aprobado",
|
||||
"approval-requests.status.decision-pending": "Decisión pendiente",
|
||||
"approval-requests.status.denied": "Denegado",
|
||||
"approval-requests.status.info-needed": "Se necesita información",
|
||||
"approval-requests.status.withdrawn": "Retirado"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Solicitudes de aprobación",
|
||||
"approval-requests.list.no-requests": "No se encontraron solicitudes de aprobación.",
|
||||
"approval-requests.list.search-placeholder": "Buscar solicitudes de aprobación",
|
||||
"approval-requests.list.status-dropdown.any": "Cualquiera",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Estado",
|
||||
"approval-requests.list.table.approval-status": "Estado de aprobación",
|
||||
"approval-requests.list.table.requester": "Solicitante",
|
||||
"approval-requests.list.table.sent-by": "Enviado por",
|
||||
"approval-requests.list.table.sent-on": "Enviado el",
|
||||
"approval-requests.list.table.subject": "Asunto",
|
||||
"approval-requests.request.approval-request-details.approver": "Aprobador",
|
||||
"approval-requests.request.approval-request-details.comment": "Comentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Decidido",
|
||||
"approval-requests.request.approval-request-details.header": "Detalles de la solicitud de aprobación",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Decisión anterior",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Enviado por",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Enviado el",
|
||||
"approval-requests.request.approval-request-details.status": "Estado",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Retirado el",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Nota adicional",
|
||||
"approval-requests.request.approver-actions.approve-request": "Aprobar solicitud",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancelar",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Motivo de la denegación* (obligatorio)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Ingrese un motivo para la denegación",
|
||||
"approval-requests.request.approver-actions.deny-request": "Denegar solicitud",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Enviar aprobación",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Enviar denegación",
|
||||
"approval-requests.request.notification.approval-submitted": "Aprobación enviada",
|
||||
"approval-requests.request.notification.denial-submitted": "Denegación enviada",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Sí",
|
||||
"approval-requests.request.ticket-details.header": "Detalles del ticket",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Prioridad",
|
||||
"approval-requests.request.ticket-details.priority_high": "Alta",
|
||||
"approval-requests.request.ticket-details.priority_low": "Baja",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgente",
|
||||
"approval-requests.request.ticket-details.requester": "Solicitante",
|
||||
"approval-requests.status.approved": "Aprobado",
|
||||
"approval-requests.status.decision-pending": "Decisión pendiente",
|
||||
"approval-requests.status.denied": "Denegado",
|
||||
"approval-requests.status.info-needed": "Se necesita información",
|
||||
"approval-requests.status.withdrawn": "Retirado"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/es.json
Normal file
46
src/modules/approval-requests/translations/locales/es.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Solicitudes de aprobación",
|
||||
"approval-requests.list.no-requests": "No se encontraron solicitudes de aprobación.",
|
||||
"approval-requests.list.search-placeholder": "Buscar solicitudes de aprobación",
|
||||
"approval-requests.list.status-dropdown.any": "Cualquiera",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Estado",
|
||||
"approval-requests.list.table.approval-status": "Estado de aprobación",
|
||||
"approval-requests.list.table.requester": "Solicitante",
|
||||
"approval-requests.list.table.sent-by": "Enviado por",
|
||||
"approval-requests.list.table.sent-on": "Enviado el",
|
||||
"approval-requests.list.table.subject": "Asunto",
|
||||
"approval-requests.request.approval-request-details.approver": "Aprobador",
|
||||
"approval-requests.request.approval-request-details.comment": "Comentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Decidido",
|
||||
"approval-requests.request.approval-request-details.header": "Detalles de la solicitud de aprobación",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Decisión anterior",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Enviado por",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Enviado el",
|
||||
"approval-requests.request.approval-request-details.status": "Estado",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Retirado el",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Nota adicional",
|
||||
"approval-requests.request.approver-actions.approve-request": "Aprobar solicitud",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancelar",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Motivo de la denegación* (obligatorio)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Ingrese un motivo para la denegación",
|
||||
"approval-requests.request.approver-actions.deny-request": "Denegar solicitud",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Enviar aprobación",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Enviar denegación",
|
||||
"approval-requests.request.notification.approval-submitted": "Aprobación enviada",
|
||||
"approval-requests.request.notification.denial-submitted": "Denegación enviada",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Sí",
|
||||
"approval-requests.request.ticket-details.header": "Detalles del ticket",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Prioridad",
|
||||
"approval-requests.request.ticket-details.priority_high": "Alta",
|
||||
"approval-requests.request.ticket-details.priority_low": "Baja",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgente",
|
||||
"approval-requests.request.ticket-details.requester": "Solicitante",
|
||||
"approval-requests.status.approved": "Aprobado",
|
||||
"approval-requests.status.decision-pending": "Decisión pendiente",
|
||||
"approval-requests.status.denied": "Denegado",
|
||||
"approval-requests.status.info-needed": "Se necesita información",
|
||||
"approval-requests.status.withdrawn": "Retirado"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/et.json
Normal file
46
src/modules/approval-requests/translations/locales/et.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Approval requests",
|
||||
"approval-requests.list.no-requests": "No approval requests found.",
|
||||
"approval-requests.list.search-placeholder": "Search approval requests",
|
||||
"approval-requests.list.status-dropdown.any": "Any",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Status",
|
||||
"approval-requests.list.table.approval-status": "Approval status",
|
||||
"approval-requests.list.table.requester": "Requester",
|
||||
"approval-requests.list.table.sent-by": "Sent by",
|
||||
"approval-requests.list.table.sent-on": "Sent on",
|
||||
"approval-requests.list.table.subject": "Subject",
|
||||
"approval-requests.request.approval-request-details.approver": "Approver",
|
||||
"approval-requests.request.approval-request-details.comment": "Comment",
|
||||
"approval-requests.request.approval-request-details.decided": "Decided",
|
||||
"approval-requests.request.approval-request-details.header": "Approval request details",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Previous decision",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Sent by",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Sent on",
|
||||
"approval-requests.request.approval-request-details.status": "Status",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Withdrawn on",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Additional note",
|
||||
"approval-requests.request.approver-actions.approve-request": "Approve request",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancel",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Reason for denial* (Required)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Enter a reason for denial",
|
||||
"approval-requests.request.approver-actions.deny-request": "Deny request",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Submit approval",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Submit denial",
|
||||
"approval-requests.request.notification.approval-submitted": "Approval submitted",
|
||||
"approval-requests.request.notification.denial-submitted": "Denial submitted",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Yes",
|
||||
"approval-requests.request.ticket-details.header": "Ticket details",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Priority",
|
||||
"approval-requests.request.ticket-details.priority_high": "High",
|
||||
"approval-requests.request.ticket-details.priority_low": "Low",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgent",
|
||||
"approval-requests.request.ticket-details.requester": "Requester",
|
||||
"approval-requests.status.approved": "Approved",
|
||||
"approval-requests.status.decision-pending": "Decision pending",
|
||||
"approval-requests.status.denied": "Denied",
|
||||
"approval-requests.status.info-needed": "Info needed",
|
||||
"approval-requests.status.withdrawn": "Withdrawn"
|
||||
}
|
||||
46
src/modules/approval-requests/translations/locales/eu.json
Normal file
46
src/modules/approval-requests/translations/locales/eu.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"approval-requests.list.header": "Solicitudes de aprobación",
|
||||
"approval-requests.list.no-requests": "No se encontraron solicitudes de aprobación.",
|
||||
"approval-requests.list.search-placeholder": "Buscar solicitudes de aprobación",
|
||||
"approval-requests.list.status-dropdown.any": "Cualquiera",
|
||||
"approval-requests.list.status-dropdown.label_v2": "Estado",
|
||||
"approval-requests.list.table.approval-status": "Estado de aprobación",
|
||||
"approval-requests.list.table.requester": "Solicitante",
|
||||
"approval-requests.list.table.sent-by": "Enviado por",
|
||||
"approval-requests.list.table.sent-on": "Enviado el",
|
||||
"approval-requests.list.table.subject": "Asunto",
|
||||
"approval-requests.request.approval-request-details.approver": "Aprobador",
|
||||
"approval-requests.request.approval-request-details.comment": "Comentar",
|
||||
"approval-requests.request.approval-request-details.decided": "Decidido",
|
||||
"approval-requests.request.approval-request-details.header": "Detalles de la solicitud de aprobación",
|
||||
"approval-requests.request.approval-request-details.previous-decision": "Decisión anterior",
|
||||
"approval-requests.request.approval-request-details.sent-by": "Enviado por",
|
||||
"approval-requests.request.approval-request-details.sent-on": "Enviado el",
|
||||
"approval-requests.request.approval-request-details.status": "Estado",
|
||||
"approval-requests.request.approval-request-details.withdrawn-on": "Retirado el",
|
||||
"approval-requests.request.approver-actions.additional-note-label": "Nota adicional",
|
||||
"approval-requests.request.approver-actions.approve-request": "Aprobar solicitud",
|
||||
"approval-requests.request.approver-actions.cancel": "Cancelar",
|
||||
"approval-requests.request.approver-actions.denial-reason-label": "Motivo de la denegación* (obligatorio)",
|
||||
"approval-requests.request.approver-actions.denial-reason-validation": "Ingrese un motivo para la denegación",
|
||||
"approval-requests.request.approver-actions.deny-request": "Denegar solicitud",
|
||||
"approval-requests.request.approver-actions.submit-approval": "Enviar aprobación",
|
||||
"approval-requests.request.approver-actions.submit-denial": "Enviar denegación",
|
||||
"approval-requests.request.notification.approval-submitted": "Aprobación enviada",
|
||||
"approval-requests.request.notification.denial-submitted": "Denegación enviada",
|
||||
"approval-requests.request.ticket-details.checkbox-value.no": "No",
|
||||
"approval-requests.request.ticket-details.checkbox-value.yes": "Sí",
|
||||
"approval-requests.request.ticket-details.header": "Detalles del ticket",
|
||||
"approval-requests.request.ticket-details.id": "ID",
|
||||
"approval-requests.request.ticket-details.priority": "Prioridad",
|
||||
"approval-requests.request.ticket-details.priority_high": "Alta",
|
||||
"approval-requests.request.ticket-details.priority_low": "Baja",
|
||||
"approval-requests.request.ticket-details.priority_normal": "Normal",
|
||||
"approval-requests.request.ticket-details.priority_urgent": "Urgente",
|
||||
"approval-requests.request.ticket-details.requester": "Solicitante",
|
||||
"approval-requests.status.approved": "Aprobado",
|
||||
"approval-requests.status.decision-pending": "Decisión pendiente",
|
||||
"approval-requests.status.denied": "Denegado",
|
||||
"approval-requests.status.info-needed": "Se necesita información",
|
||||
"approval-requests.status.withdrawn": "Retirado"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user