Merge branch 'develop' into fix/bump-frappe-ui

This commit is contained in:
RitvikSardana
2026-06-27 19:45:51 +05:30
committed by GitHub
12 changed files with 136 additions and 136 deletions
+3 -5
View File
@@ -1,10 +1,10 @@
<template>
<Select
:model-value="currentStatus"
:model-value="agentStatusStore.myStatus"
:options="dropdownOptions"
:placeholder="__('Set status')"
variant="subtle"
@update:model-value="setStatus"
@update:model-value="agentStatusStore.setMyStatus"
>
<template #item-prefix="{ option }">
<div
@@ -16,17 +16,15 @@
</template>
<script setup lang="ts">
import { useAvailability } from "@/composables/useAvailability";
import { useAgentStatusStore } from "@/stores/agentStatus";
import { __ } from "@/translation";
import { Select } from "frappe-ui";
import { computed } from "vue";
const { currentStatus, options, setStatus } = useAvailability();
const agentStatusStore = useAgentStatusStore();
const dropdownOptions = computed(() =>
options.value.map((statusOption: string) => ({
agentStatusStore.statusOptions.map((statusOption: string) => ({
label: __(statusOption),
value: statusOption,
}))
@@ -15,11 +15,15 @@
<div class="flex items-center size-4">
<span
class="size-2.5 mx-auto shrink-0 rounded-full"
:class="agentStatusStore.statusColor(currentStatus)"
:class="agentStatusStore.statusColor(agentStatusStore.myStatus)"
/>
</div>
<span class="truncate">
{{ currentStatus ? __(currentStatus) : __("Set status") }}
{{
agentStatusStore.myStatus
? __(agentStatusStore.myStatus)
: __("Set status")
}}
</span>
</div>
</button>
@@ -29,7 +33,7 @@
class="flex flex-col mx-3 p-1.5 rounded-lg border border-outline-gray-1 bg-surface-base shadow-xl min-w-[140px]"
>
<button
v-for="option in options"
v-for="option in agentStatusStore.statusOptions"
:key="option"
class="flex items-center gap-2 rounded p-1.5 text-sm text-ink-gray-8 hover:bg-surface-gray-2"
@click="selectStatus(option, close)"
@@ -47,15 +51,13 @@
<script setup lang="ts">
import { Popover } from "frappe-ui";
import { useAvailability } from "@/composables/useAvailability";
import { useAgentStatusStore } from "@/stores/agentStatus";
import { __ } from "@/translation";
const { currentStatus, options, setStatus } = useAvailability();
const agentStatusStore = useAgentStatusStore();
function selectStatus(status: string, close: () => void) {
setStatus(status);
agentStatusStore.setMyStatus(status);
close();
}
</script>
@@ -22,9 +22,11 @@
:label="fullName"
/>
<div
v-if="currentStatus"
v-if="agentStatusStore.myStatus"
class="absolute bottom-0.5 right-0.5 size-3.5 rounded-full outline outline-white outline-4"
:class="agentStatusStore.statusColor(currentStatus)"
:class="
agentStatusStore.statusColor(agentStatusStore.myStatus)
"
/>
<Tooltip
:hoverDelay="0"
@@ -179,11 +181,9 @@ const emit = defineEmits(["updateStep"]);
import AvailabilityMenu from "@/components/AvailabilityMenu.vue";
import SettingsLayoutBase from "@/components/layouts/SettingsLayoutBase.vue";
import { useAvailability } from "@/composables/useAvailability";
import { useAgentStatusStore } from "@/stores/agentStatus";
import ChangePasswordModal from "./components/ChangePasswordModal.vue";
const { currentStatus } = useAvailability();
const agentStatusStore = useAgentStatusStore();
const showChangePasswordModal = ref(false);
+17 -37
View File
@@ -162,7 +162,6 @@ import {
LocalAssignee,
TicketSymbol,
} from "@/types";
import type { HDAgent } from "@/types/doctypes";
import { useDebounceFn } from "@vueuse/core";
import {
Button,
@@ -199,7 +198,7 @@ const activities = inject(ActivitiesSymbol)!;
const { getUser } = useUserStore();
const currentUser = computed(() => getUser("")); // empty string returns current user
const agentStatusStore = useAgentStatusStore();
const currentAgentName = (window as any).agent as string | null;
const currentAgentName = window.agent;
const searchText = ref("");
const highlightedIndex = ref(0);
@@ -259,30 +258,11 @@ const agentResource = createListResource({
"availability",
"availability_changed_on",
],
filters: { is_active: true, name: ["!=", "[email protected]"] },
filters: { is_active: true },
pageLength: 20,
auto: true,
});
// Fetch current agent separately to guarantee they appear in the list
const currentAgentResource = currentAgentName
? createResource({
url: "frappe.client.get",
params: {
doctype: "HD Agent",
name: currentAgentName,
fields: [
"name",
"agent_name",
"user_image",
"availability",
"availability_changed_on",
],
},
auto: true,
})
: null;
const debouncedSearch = useDebounceFn((text: string) => {
const filters: Record<string, any> = { is_active: true };
if (text) {
@@ -315,16 +295,16 @@ const agentOptions = computed<AgentOption[]>(() => {
const agents: AgentOption[] = [];
const seen = new Set<string>();
// Include current agent only when not searching
if (!searchText.value && currentAgentResource?.data) {
const a = currentAgentResource.data;
// Include current agent only when not searching. Built from the session user
// and the store's live status (seeded from auth.get_user) — no extra fetch.
if (!searchText.value && currentAgentName) {
agents.push({
value: a.name,
label: a.agent_name || getUser(a.name).full_name,
image: a.user_image || getUser(a.name).user_image,
...liveAvailability(a),
value: currentAgentName,
label: currentUser.value.full_name || currentAgentName,
image: currentUser.value.user_image || "",
...liveAvailability({ name: currentAgentName }),
});
seen.add(a.name);
seen.add(currentAgentName);
}
if (agentResource.data) {
@@ -555,22 +535,22 @@ const removeAssigneesResource = createResource({
// Toast a warning for each newly-added agent who isn't currently active.
// Returns true if any warning was shown so the caller can defer the success toast.
function warnUnavailableAgents(addedNames: string[]): boolean {
const agents = (agentResource.data as HDAgent[]) ?? [];
let hasUnavailable = false;
for (const agent of agents) {
if (!addedNames.includes(agent.name)) continue;
// Iterate the rendered options (not just the fetched page) so agents picked
// via search — who aren't in the default list — are still checked.
for (const agent of sortedAgentOptions.value) {
if (!addedNames.includes(agent.value)) continue;
// No point warning agents about their own status when assigning themselves.
if (agent.name === currentAgentName) continue;
if (agent.value === currentAgentName) continue;
const category = agentStatusStore.getStatus(
agent.availability || ""
)?.category;
if (category !== "Away" && category !== "Unavailable") continue;
const name = agent.agent_name || agent.name;
toast.warning(
category === "Unavailable"
? __("{0} is currently unavailable.", name)
: __("{0} is currently away.", name)
? __("{0} is currently unavailable.", agent.label)
: __("{0} is currently away.", agent.label)
);
hasUnavailable = true;
}
-32
View File
@@ -1,32 +0,0 @@
import { createResource, toast } from "frappe-ui";
import { computed } from "vue";
import { __ } from "@/translation";
import { useAuthStore } from "@/stores/auth";
const availability = createResource({
url: "helpdesk.api.agent.get_my_availability",
});
const setAvailability = createResource({
url: "helpdesk.api.agent.set_my_availability",
onSuccess: () => { availability.reload();
toast.success(__("Status updated successfully."));
},
});
export function useAvailability() {
const authStore = useAuthStore();
if (authStore.isAgent && !availability.fetched) {
availability.fetch();
}
const currentStatus = computed(() => availability.data?.availability || "");
const options = computed(() => availability.data?.options || []);
function setStatus(status: string) {
if (status === currentStatus.value) return;
setAvailability.submit({ availability: status });
}
return { currentStatus, options, setStatus };
}
+1 -2
View File
@@ -27,8 +27,7 @@
html,
body {
color: var(--ink-gray-9);
background-color: var(--surface-base);
overscroll-behavior: none;
background-color: var(--surface-white);
}
+74 -11
View File
@@ -1,8 +1,10 @@
import { useAuthStore } from "@/stores/auth";
import { globalStore } from "@/stores/globalStore";
import { __ } from "@/translation";
import { HDAgentStatus } from "@/types/doctypes";
import { createListResource } from "frappe-ui";
import { createListResource, createResource, toast } from "frappe-ui";
import { defineStore } from "pinia";
import { reactive } from "vue";
import { computed, reactive, watch } from "vue";
interface LiveAvailability {
availability: string;
@@ -35,6 +37,9 @@ const dotColorMap: Record<string, string> = {
const defaultColor = "bg-surface-gray-8";
export const useAgentStatusStore = defineStore("agentStatus", () => {
// Set only for sessions that have an HD Agent record; null otherwise.
const myAgentName = window.agent;
const statuses = createListResource({
doctype: "HD Agent Status",
cache: ["HD Agent Status", "list"],
@@ -44,19 +49,69 @@ export const useAgentStatusStore = defineStore("agentStatus", () => {
auto: true,
});
// Live availability pushed over the socket when an agent changes their
// status (helpdesk.api.agent.set_my_availability). Keyed by HD Agent name,
// it overrides the value fetched when a list first loaded.
// Live availability keyed by HD Agent name. Seeded by fetches and kept current
// over the socket — set_my_availability broadcasts to every client, so this is
// the single source of truth for "who is what right now", including ourselves.
const liveStatuses = reactive<Record<string, LiveAvailability>>({});
function applyLive(agent: string, availability?: string, changedOn?: string) {
if (!agent || !availability) return;
liveStatuses[agent] = { availability, changedOn: changedOn ?? "" };
}
const { $socket } = globalStore();
$socket.on("agent_availability_updated", (data: AvailabilityEvent) => {
liveStatuses[data.agent] = {
availability: data.availability,
changedOn: data.availability_changed_on,
};
$socket.on("agent_availability_updated", (data: AvailabilityEvent) =>
applyLive(data.agent, data.availability, data.availability_changed_on)
);
// Seed our own status from the session payload (auth.get_user already resolves
// the agent), then let the socket and the optimistic write keep it current — no
// dedicated fetch and no reload.
const auth = useAuthStore();
watch(
() => auth.availability,
(availability) =>
applyLive(myAgentName ?? "", availability, auth.availabilityChangedOn),
{ immediate: true }
);
const setMyAvailability = createResource({
url: "helpdesk.api.agent.set_my_availability",
onSuccess: () => toast.success(__("Status updated successfully.")),
onError: () => toast.error(__("Could not update status.")),
});
const myStatus = computed(
() => (myAgentName && liveStatuses[myAgentName]?.availability) || ""
);
// Selectable statuses, derived from the same list the presence dots use so the
// menu and the dots never disagree and we avoid a second server round-trip.
const statusOptions = computed<string[]>(() =>
(statuses.data ?? [])
.filter((s: HDAgentStatus) => s.enable)
.map((s: HDAgentStatus) => s.agent_status)
);
function setMyStatus(status: string) {
if (!myAgentName || !status || status === myStatus.value) return;
// Optimistic: update immediately, then roll back if the server rejects it.
// changedOn is left empty — it is never rendered for our own dot and the
// socket echo overwrites it with the authoritative server timestamp.
const previous = liveStatuses[myAgentName];
applyLive(myAgentName, status);
setMyAvailability.submit(
{ availability: status },
{
// Roll back the optimistic write on failure.
onError: () => {
if (previous) liveStatuses[myAgentName] = previous;
else delete liveStatuses[myAgentName];
},
}
);
}
function getStatus(name: string): HDAgentStatus | undefined {
return statuses.data?.find((s: HDAgentStatus) => s.agent_status === name);
}
@@ -66,5 +121,13 @@ export const useAgentStatusStore = defineStore("agentStatus", () => {
return (color && dotColorMap[color]) || defaultColor;
}
return { statuses, liveStatuses, getStatus, statusColor };
return {
statuses,
liveStatuses,
myStatus,
statusOptions,
setMyStatus,
getStatus,
statusColor,
};
});
+8
View File
@@ -44,6 +44,12 @@ export const useAuthStore = defineStore("auth", () => {
);
const userName: ComputedRef<string> = computed(() => user__.value.user_name);
const username: ComputedRef<string> = computed(() => user__.value.username);
const availability: ComputedRef<string> = computed(
() => user__.value.availability || ""
);
const availabilityChangedOn: ComputedRef<string> = computed(
() => user__.value.availability_changed_on || ""
);
const timezone: ComputedRef<string> = computed(() => user__.value.time_zone);
const userTeams: ComputedRef<string[]> = computed(
() => user__.value.user_teams
@@ -94,6 +100,8 @@ export const useAuthStore = defineStore("auth", () => {
userImage,
userName,
username,
availability,
availabilityChangedOn,
timezone,
userTeams,
language,
-32
View File
@@ -30,38 +30,6 @@ def sent_invites(emails: list[str], send_welcome_mail_to_user: bool = True):
return
@frappe.whitelist()
@agent_only
def get_availability_options() -> list[str]:
return frappe.get_all(
"HD Agent Status",
filters={"enable": 1},
order_by="`tabHD Agent Status`.status_order asc",
pluck="agent_status",
)
@frappe.whitelist()
@agent_only
def get_my_availability() -> dict:
name = get_agent_name()
values = (
frappe.db.get_value(
"HD Agent",
name,
["availability", "availability_changed_on"],
as_dict=True,
)
if name
else {}
) or {}
return {
"availability": values.get("availability"),
"availability_changed_on": values.get("availability_changed_on"),
"options": get_availability_options(),
}
@frappe.whitelist()
@agent_only
def set_my_availability(availability: str) -> dict:
+14 -1
View File
@@ -28,8 +28,19 @@ def get_user():
is_admin = "System Manager" in roles or "Administrator" in roles
is_agent = _is_agent()
has_agent_record = bool(get_agent_name())
agent_name = get_agent_name()
has_agent_record = bool(agent_name)
has_desk_access = is_agent or is_admin
availability = (
frappe.db.get_value(
"HD Agent",
agent_name,
["availability", "availability_changed_on"],
as_dict=True,
)
if agent_name
else None
) or {}
user_image = user.user_image
user_first_name = user.first_name
user_name = user.full_name
@@ -56,6 +67,8 @@ def get_user():
"time_zone": user.time_zone,
"user_teams": user_team_names,
"language": language,
"availability": availability.get("availability"),
"availability_changed_on": availability.get("availability_changed_on"),
}
@@ -4,7 +4,8 @@
import frappe
from frappe.tests.utils import FrappeTestCase
from helpdesk.api.agent import get_my_availability, set_my_availability
from helpdesk.api.agent import set_my_availability
from helpdesk.api.auth import get_user
from helpdesk.helpdesk.doctype.hd_agent.hd_agent import update_agent_role
from helpdesk.test_utils import make_agent, make_team, make_ticket
@@ -57,16 +58,16 @@ class TestHDAgent(FrappeTestCase):
with self.assertRaises(frappe.ValidationError):
set_my_availability("Not A Status")
# get_my_availability returns the agent's status, when it changed, and options
def test_get_my_availability_returns_status_and_options(self):
# the session payload (auth.get_user) carries the agent's current status, so
# the frontend seeds availability without a dedicated round-trip
def test_get_user_includes_availability(self):
frappe.set_user(self.test_user)
set_my_availability("Away")
result = get_my_availability()
result = get_user()
self.assertEqual(result["availability"], "Away")
self.assertIsNotNone(result["availability_changed_on"])
self.assertIn("Away", result["options"])
def _make_assignment_rule(self, team_name: str, members: list[str], rule: str):
team = make_team(team_name, members)
@@ -46,7 +46,7 @@
},
{
"depends_on": "eval:doc.different_view",
"description": "Name shown on the customer portal<br> (e.g., Replied \u2192 Awaiting Response).",
"description": "Name shown on the customer portal<br> (e.g., Replied → Awaiting Response).",
"fieldname": "label_customer",
"fieldtype": "Data",
"label": "Label (customer view)",