Skip to main content

Overview

Frappe Helpdesk uses Vue 3 with TypeScript, Composition API, and Tailwind CSS for the frontend. All components are located in desk/src/components/.

Component Structure

Components use Single File Component (SFC) format with the <script setup lang="ts"> syntax.

Basic Component Template

<template>
  <div class="bg-surface-white rounded-lg border border-outline-gray-2 p-4">
    <h2 class="text-lg font-semibold text-ink-gray-9 mb-2">
      {{ title }}
    </h2>
    <p class="text-p-sm text-ink-gray-7">
      {{ description }}
    </p>
  </div>
</template>

<script setup lang="ts">
import { ref, computed } from "vue";

interface Props {
  title: string;
  description?: string;
}

const props = defineProps<Props>();
const emit = defineEmits<{
  update: [value: string];
}>();

const internalState = ref("");

function handleUpdate() {
  emit("update", internalState.value);
}
</script>

Real-World Component Examples

Example 1: Autocomplete Component (from Autocomplete.vue)

<template>
  <Combobox v-slot="{ open: isComboboxOpen }" v-model="selectedValue" nullable>
    <Popover v-model:show="showOptions">
      <template #target="{ open: openPopover, togglePopover }">
        <div class="w-full -ml-0.5">
          <button
            class="flex w-full items-center justify-between focus:outline-none"
            :class="inputClasses"
            @click="!disabled && togglePopover()"
          >
            <div class="flex items-center truncate">
              <span v-if="selectedValue" class="overflow-hidden text-ellipsis whitespace-nowrap text-base leading-5">
                {{ displayValue(selectedValue) }}
              </span>
              <span v-else class="text-base leading-5 text-gray-500">
                {{ placeholder || "" }}
              </span>
            </div>
            <FeatherIcon name="chevron-down" class="h-4 w-4 text-gray-600" />
          </button>
        </div>
      </template>
      <template #body="{ isOpen }">
        <div v-show="isOpen">
          <div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
            <div class="relative px-1.5 pt-0.5">
              <ComboboxInput
                ref="search"
                class="form-input w-full"
                type="text"
                :value="query"
                autocomplete="off"
                placeholder="Search"
                @change="(e) => { query = e.target.value; }"
              />
            </div>
            <ComboboxOptions class="my-1 max-h-[12rem] overflow-y-auto px-1.5" static>
              <ComboboxOption
                v-for="option in filteredOptions"
                :key="option.value"
                v-slot="{ active, selected }"
                :value="option"
              >
                <li :class="['flex items-center rounded px-2.5 py-1.5 text-base', { 'bg-gray-100': active }]">
                  {{ option.label }}
                </li>
              </ComboboxOption>
            </ComboboxOptions>
          </div>
        </div>
      </template>
    </Popover>
  </Combobox>
</template>

<script setup>
import { Combobox, ComboboxInput, ComboboxOptions, ComboboxOption } from "@headlessui/vue";
import { Popover, FeatherIcon } from "frappe-ui";
import { ref, computed } from "vue";

const props = defineProps({
  modelValue: { type: String, default: "" },
  options: { type: Array, default: () => [] },
  placeholder: { type: String, default: "" },
  disabled: { type: Boolean, default: false },
});

const emit = defineEmits(["update:modelValue"]);

const query = ref("");
const showOptions = ref(false);

const selectedValue = computed({
  get() { return props.modelValue; },
  set(val) {
    query.value = "";
    if (val) showOptions.value = false;
    emit("update:modelValue", val);
  },
});

const filteredOptions = computed(() => {
  if (!query.value) return props.options;
  return props.options.filter((option) =>
    option.label.toLowerCase().includes(query.value.toLowerCase())
  );
});
</script>

Example 2: Comment Box Component (from CommentBox.vue)

<template>
  <div class="flex-col text-base flex-1" ref="commentBoxRef">
    <div class="mb-1 ml-0.5 flex items-center justify-between">
      <div class="text-gray-600 flex items-center gap-2">
        <Avatar
          size="md"
          :label="commenter"
          :image="getUser(commentedBy).user_image"
        />
        <p>
          <span class="font-medium text-gray-800">{{ commenter }}</span>
          <span> added a comment</span>
        </p>
      </div>
      <div class="flex items-center gap-1">
        <Tooltip :text="dateFormat(creation, dateTooltipFormat)">
          <span class="pl-0.5 text-sm text-gray-600">
            {{ timeAgo(creation) }}
          </span>
        </Tooltip>
      </div>
    </div>
    <div class="rounded bg-gray-50 transition-colors px-4 py-3">
      <TextEditor
        ref="editorRef"
        :editor-class="['prose-f shrink text-p-sm transition-all duration-300', getFontFamily(_content)]"
        :content="_content"
        :editable="editable"
        @change="(event: string) => { _content = event }"
      >
        <template #bottom v-if="editable">
          <div class="flex flex-row-reverse gap-2">
            <Button label="Save" @click="handleSaveComment" variant="solid" />
            <Button label="Discard" @click="handleDiscard" />
          </div>
        </template>
      </TextEditor>
    </div>
  </div>
</template>

<script setup lang="ts">
import { Avatar, TextEditor, Tooltip, Button, createResource, toast } from "frappe-ui";
import { PropType, computed, ref } from "vue";
import { useUserStore } from "@/stores/user";
import { CommentActivity } from "@/types";
import { dateFormat, timeAgo, getFontFamily } from "@/utils";

const props = defineProps({
  activity: {
    type: Object as PropType<CommentActivity>,
    required: true,
  },
});

const { getUser } = useUserStore();
const { name, creation, content, commenter, commentedBy } = props.activity;

const emit = defineEmits(["update"]);
const editable = ref(false);
const _content = ref(content);
const commentBoxRef = ref(null);
const editorRef = ref(null);

function handleSaveComment() {
  updateComment.submit(
    {
      doctype: "HD Ticket Comment",
      name: name,
      fieldname: "content",
      value: _content.value,
    },
    {
      onSuccess: () => {
        editable.value = false;
        emit("update");
        toast.success("Comment updated");
      },
    }
  );
}

const updateComment = createResource({
  url: "frappe.client.set_value",
});
</script>

Data Fetching with frappe-ui

Using createResource

<script setup lang="ts">
import { createResource } from "frappe-ui";

const apiCall = createResource({
  url: "helpdesk.api.ticket.assign_ticket_to_agent",
  onSuccess(data) {
    console.log("Success:", data);
  },
  onError(error) {
    console.error("Error:", error);
  },
});

// Make the call
function assignTicket(ticketId: string, agentId: string) {
  apiCall.submit({ ticket_id: ticketId, agent_id: agentId });
}
</script>

Using createListResource

<script setup lang="ts">
import { createListResource } from "frappe-ui";

const tickets = createListResource({
  doctype: "HD Ticket",
  filters: { status: "Open" },
  fields: ["name", "subject", "modified"],
  limit: 20,
  auto: true, // Auto-fetch on mount
});

// Access data
const ticketList = tickets.data; // Array of tickets
const isLoading = tickets.loading; // Boolean

// Operations
tickets.reload(); // Refresh
tickets.update({ name: "TICKET-001", subject: "Updated" }); // Update
tickets.insert({ subject: "New Ticket" }); // Insert
tickets.delete("TICKET-001"); // Delete
</script>

Using createDocumentResource

<script setup lang="ts">
import { createDocumentResource } from "frappe-ui";

const ticket = createDocumentResource({
  doctype: "HD Ticket",
  name: props.ticketId,
  auto: true,
});

// Access data
const ticketData = ticket.doc;
const isLoading = ticket.loading;

// Operations
ticket.get(); // Fetch/refresh
ticket.setValue("status", "Resolved"); // Update field
ticket.save(); // Save changes
ticket.delete(); // Delete
</script>

State Management with Pinia

Creating a Store (from stores/agent.ts)

import { computed } from "vue";
import { defineStore } from "pinia";
import { createListResource } from "frappe-ui";

export const useAgentStore = defineStore("agent", () => {
  const agents = createListResource({
    doctype: "HD Agent",
    fields: ["name", "agent_name", "user", "user.user_image"],
    filters: { is_active: 1 },
    pageLength: 99999,
  });

  const dropdown = computed(() =>
    agents.data?.map((o) => ({
      label: o.agent_name,
      value: o.name,
    }))
  );

  function searchAgents(query: string) {
    return agents.data.filter((a) =>
      a.user?.toLowerCase().includes(query.toLowerCase())
    );
  }

  return {
    dropdown,
    agents,
    searchAgents,
  };
});

Using the Store

<script setup lang="ts">
import { useAgentStore } from "@/stores/agent";

const agentStore = useAgentStore();

// Access state
const agents = agentStore.agents.data;
const agentDropdown = agentStore.dropdown;

// Call actions
const results = agentStore.searchAgents("john");
</script>

Icons

Use Lucide icons for all new components:
<template>
  <button class="flex items-center gap-2">
    <LucidePlus class="size-4" />
    Add Item
  </button>
  
  <LucideTicket class="size-5 text-ink-gray-6" />
  <LucideX class="size-4" />
</template>

<script setup lang="ts">
import LucidePlus from "~icons/lucide/plus";
import LucideTicket from "~icons/lucide/ticket";
import LucideX from "~icons/lucide/x";
</script>

Styling with Tailwind

Use semantic color classes:
<template>
  <!-- Backgrounds -->
  <div class="bg-surface-white border border-outline-gray-2">
    <div class="bg-surface-gray-1 p-4">
      <!-- Text colors -->
      <h1 class="text-ink-gray-9 text-2xl font-bold">Title</h1>
      <p class="text-ink-gray-7 text-p-base">Description text</p>
      <span class="text-ink-gray-5">Muted text</span>
    </div>
  </div>
  
  <!-- Buttons -->
  <button class="bg-surface-gray-2 hover:bg-surface-gray-3 text-ink-gray-8 px-4 py-2 rounded">
    Click Me
  </button>
</template>

Semantic Color System

  • Backgrounds: bg-surface-white, bg-surface-gray-1 through bg-surface-gray-9, bg-surface-black
  • Text: text-ink-white, text-ink-gray-1 through text-ink-gray-9, text-ink-black
  • Borders: border-outline-white, border-outline-gray-1 through border-outline-gray-5, border-outline-black
  • Font sizes: text-xs, text-sm, text-base, text-lg, text-xl, text-2xl, text-3xl
  • Multiline text: text-p-xs, text-p-sm, text-p-base, text-p-lg, text-p-xl, text-p-2xl

Form Components

<template>
  <form @submit.prevent="handleSubmit">
    <div class="space-y-4">
      <div>
        <label class="block text-sm font-medium text-ink-gray-8 mb-1">
          Subject
        </label>
        <input
          v-model="form.subject"
          type="text"
          class="form-input w-full"
          placeholder="Enter subject"
          required
        />
      </div>
      
      <div>
        <label class="block text-sm font-medium text-ink-gray-8 mb-1">
          Priority
        </label>
        <Autocomplete
          v-model="form.priority"
          :options="priorityOptions"
          placeholder="Select priority"
        />
      </div>
      
      <div class="flex gap-2">
        <Button type="submit" variant="solid">Submit</Button>
        <Button @click="handleCancel">Cancel</Button>
      </div>
    </div>
  </form>
</template>

<script setup lang="ts">
import { reactive } from "vue";
import { Button } from "frappe-ui";
import { Autocomplete } from "@/components";

const form = reactive({
  subject: "",
  priority: "",
});

const priorityOptions = [
  { label: "Low", value: "Low" },
  { label: "Medium", value: "Medium" },
  { label: "High", value: "High" },
];

function handleSubmit() {
  console.log("Form submitted:", form);
}

function handleCancel() {
  form.subject = "";
  form.priority = "";
}
</script>

Best Practices

  1. Use <script setup lang="ts">: Preferred over Options API
  2. Type Everything: Use TypeScript interfaces for props and emits
  3. Composition API: Use ref, computed, watch from Vue
  4. Semantic Classes: Always use Tailwind semantic color classes
  5. Lucide Icons: Use Lucide icons for all new components
  6. frappe-ui Resources: Use createResource, createListResource, createDocumentResource
  7. Pinia for State: Use Pinia stores for complex shared state
  8. VueUse: Prefer @vueuse/core composables over custom implementations
  9. Accessibility: Use semantic HTML and ARIA attributes
  10. Mobile First: Design responsive components with Tailwind breakpoints

Component Communication

Props and Emits

<script setup lang="ts">
interface Props {
  ticketId: string;
  editable?: boolean;
}

const props = withDefaults(defineProps<Props>(), {
  editable: true,
});

const emit = defineEmits<{
  save: [ticketId: string];
  cancel: [];
}>();

function handleSave() {
  emit("save", props.ticketId);
}
</script>

Provide/Inject

<!-- Parent Component -->
<script setup lang="ts">
import { provide } from "vue";

const ticketId = "TICKET-001";
provide("ticketId", ticketId);
</script>

<!-- Child Component -->
<script setup lang="ts">
import { inject } from "vue";

const ticketId = inject<string>("ticketId");
</script>

Testing Components

Run the development server:
cd desk
yarn dev
The frontend will be available at http://localhost:8080.

Build docs developers (and LLMs) love