Add archived department filtering feature for superusers, including E2E tests, UI updates, and OpenAPI enhancements. Extend department table with archived toggle switch and update translations.

This commit is contained in:
Jeppe Bundgaard
2026-05-07 12:54:31 +02:00
parent dc2840e023
commit fb162eef6c
13 changed files with 365 additions and 97 deletions
+12 -1
View File
@@ -3375,7 +3375,7 @@ paths:
tags:
- Departments
summary: List departments
description: Retrieve a list of all visible departments
description: Retrieve visible, active departments by default. Superuser department access may filter archived departments with `filters=archived:1`.
operationId: listDepartments
parameters:
- name: id
@@ -3386,6 +3386,11 @@ paths:
- $ref: '#/components/parameters/PageParam'
- $ref: '#/components/parameters/PerPageParam'
- $ref: '#/components/parameters/SearchParam'
- name: filters
in: query
schema:
type: string
description: Comma-separated field filters. `archived:1` is only honored for users with superuser department access.
responses:
'200':
description: Departments retrieved successfully
@@ -15652,6 +15657,8 @@ components:
type: integer
visible:
type: boolean
archived:
type: boolean
dimension:
type: integer
branding:
@@ -15724,6 +15731,8 @@ components:
type: integer
visible:
type: boolean
archived:
type: boolean
longitude:
type: number
format: float
@@ -15744,6 +15753,8 @@ components:
type: string
visible:
type: boolean
archived:
type: boolean
longitude:
type: number
format: float
@@ -1,18 +1,19 @@
<script setup>
import { computed, provide, ref } from "vue";
import { useI18n } from "vue-i18n";
import DepartmentsTable from "@/components/displays/superuser/tables/departmentsTable.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import PaginationDisplayGeneralSearchReload from "@/components/displays/pagination/PaginationDisplayGeneralSearchReload.vue";
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import { PaginatedListKey, usePaginatedList } from "@/components/pagination/paginatedList.vue";
let props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
import { useRouter } from "vue-router";
import {
usePaginatedList,
PaginatedListKey
} from "@/components/pagination/paginatedList.vue";
import { provide, computed } from "vue";
const props = defineProps(["setCustomerFilter", "hideSearch", "autoLoad"]);
const { t } = useI18n();
const paginatedList = usePaginatedList();
provide(PaginatedListKey, paginatedList);
const {
isLoaded,
isLoading,
list,
loadList,
@@ -22,30 +23,27 @@ const {
setEndpoint,
setMetaItemsPerPage,
setPage,
metaSearch,
search,
setFilter,
setOrder,
hideSearchField,
setHideSearchField,
} = paginatedList;
import PaginationNavigation from "@/components/displays/pagination/PaginationNavigation.vue";
import LoadButtonWhileAwait from "@/components/request/LoadButtonWhileAwait.vue";
import PaginationDisplay from "@/components/displays/pagination/PaginationDisplay.vue";
import OrdersTable from "@/components/displays/department/pos/orders/ordersTable.vue";
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const router = useRouter();
setEndpoint("/departments", false);
setOrder('order_priority', 'asc');
setOrder("order_priority", "asc");
const archivedFilter = ref("0");
const sortedList = computed(() => {
return [...list.value].sort((a, b) => (a.order_priority || 0) - (b.order_priority || 0));
});
// Hide the search field, if the hideSearch prop is set
const handleArchivedFilterChange = () => {
setFilter("archived", archivedFilter.value);
};
setFilter("archived", archivedFilter.value, false);
if (props.hideSearch) {
setHideSearchField(true);
}
@@ -53,17 +51,57 @@ if (props.hideSearch) {
if (props.autoLoad) {
loadList();
}
</script>
<template>
<input @input="search($event.target.value)" class="input" type="text" :placeholder="$t('global.search_department')" v-if="!hideSearchField"/>
<PaginationDisplay :metaItemsPerPage="metaItemsPerPage" :loadFunction="loadList" :isLoading="isLoading" :setMetaItemsPerPage="setMetaItemsPerPage"/>
<PaginationDisplayGeneralSearchReload v-if="!hideSearchField" />
<PaginationDisplay
:metaItemsPerPage="metaItemsPerPage"
:loadFunction="loadList"
:isLoading="isLoading"
:setMetaItemsPerPage="setMetaItemsPerPage"
>
<template #paginationColumns>
<div class="column is-narrow my-3 department-status-filter">
<label class="label is-small" for="department-archive-filter">{{
t("objects.departments.filters.status")
}}</label>
<div class="control">
<div class="select">
<select
id="department-archive-filter"
v-model="archivedFilter"
data-testid="superuser-departments-archive-filter"
@change="handleArchivedFilterChange"
>
<option value="0">{{ t("objects.departments.filters.active") }}</option>
<option value="1">{{ t("objects.departments.filters.archived") }}</option>
</select>
</div>
</div>
</div>
</template>
</PaginationDisplay>
<div v-if="hideSearchField" class="mb-3">
<button class="button is-dark" :class="{ 'is-loading': isLoading }" :disabled="isLoading" @click="loadList">
<span class="icon is-small">
<i class="fas fa-sync-alt" aria-hidden="true"></i>
</span>
<span>{{ t("pagination.reload") }}</span>
</button>
</div>
<DepartmentsTable :objects="sortedList" />
<PaginationNavigation :currentPage="metaCurrentPage" :totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)" :loadFunction="loadList" :setPage="setPage" :isLoading="isLoading" />
<LoadButtonWhileAwait class="is-dark" :isLoading="isLoading" :loadFunction="loadList" icon="fas fa-sync-alt">{{ t('pagination.reload') }}</LoadButtonWhileAwait>
<PaginationNavigation
:currentPage="metaCurrentPage"
:totalPages="Math.ceil(metaTotalItems / metaItemsPerPage)"
:loadFunction="loadList"
:setPage="setPage"
:isLoading="isLoading"
/>
</template>
<style scoped>
</style>
.department-status-filter {
min-width: 14rem;
}
</style>
@@ -1,11 +1,7 @@
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const props = defineProps(['objects']);
import { ref } from 'vue';
import { departments, getDepartments, isLoading, getDepartmentName} from "@/components/pagination/departmentTabs.vue";
const props = defineProps(["objects"]);
import { ref } from "vue";
import { departments, getDepartments, isLoading, getDepartmentName } from "@/components/pagination/departmentTabs.vue";
import { usePaginatedListInstance } from "@/components/pagination/paginatedList.vue";
const { loadList, loadSwitch, metaCurrentPage, metaItemsPerPage, setList } = usePaginatedListInstance();
import EditableTableColumn from "@/components/displays/buttons/EditableTableColumn.vue";
@@ -20,8 +16,8 @@ const dragOverIndex = ref(null);
const onDragStart = (event, index) => {
draggingIndex.value = index;
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', index);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", index);
};
const onDragOver = (event, index) => {
@@ -78,7 +74,7 @@ const parseCustomerName = (user) => {
} else {
return "-";
}
}
};
import { showEditDepartmentForm } from "@/components/forms/superUser/editDepartmentForm.vue";
import { showEditUserForm } from "@/components/forms/superUser/editUserForm.vue";
@@ -86,102 +82,169 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue";
const redirect = (path) => {
window.location = path;
}
};
const archiveSwitchLoadingIds = ref(new Set());
const isDepartmentArchived = (department) => {
return (
department?.archived === true ||
department?.archived === 1 ||
department?.archived === "1" ||
department?.archived === "true"
);
};
const isArchiveSwitchLoading = (departmentId) => {
return archiveSwitchLoadingIds.value.has(departmentId);
};
const setArchiveSwitchLoading = (departmentId, loading) => {
const loadingIds = new Set(archiveSwitchLoadingIds.value);
if (loading) {
loadingIds.add(departmentId);
} else {
loadingIds.delete(departmentId);
}
archiveSwitchLoadingIds.value = loadingIds;
};
const toggleArchived = async (department) => {
const nextArchived = !isDepartmentArchived(department);
setArchiveSwitchLoading(department.id, true);
try {
await SessionUser.objects.departments.set.archived(department.id, nextArchived);
department.archived = nextArchived;
loadList();
} finally {
setArchiveSwitchLoading(department.id, false);
}
};
</script>
<template>
<table class="table is-fullwidth">
<thead>
<table class="table is-fullwidth" data-testid="superuser-departments-table">
<thead>
<tr>
<th v-if="SessionUser.canAccessAdmin()" style="width: 40px;"></th>
<th v-if="SessionUser.canAccessAdmin()" style="width: 40px"></th>
<th>{{ SessionUser.objects.departments.columns.order_priority.label }}</th>
<th>{{ SessionUser.objects.departments.columns.id.label }}</th>
<th>{{ SessionUser.objects.departments.columns.name.label }}</th>
<th>{{ SessionUser.objects.departments.columns.description.label }}</th>
<th>{{ SessionUser.objects.departments.columns.economic_department_id.label }}</th>
<th>{{ SessionUser.objects.departments.columns.archived.label }}</th>
<th>{{ SessionUser.objects.departments.columns.latitude.label }}</th>
<th>{{ SessionUser.objects.departments.columns.longitude.label }}</th>
<th>{{ $t('tables.actions') }}</th>
<th>{{ $t("tables.actions") }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(department, index) in objects" :key="department.id"
:class="{'is-dragging': draggingIndex === index, 'drag-over': dragOverIndex === index}"
@dragover="onDragOver($event, index)"
@dragleave="dragOverIndex = null"
@drop="onDrop($event, index)"
@dragend="onDragEnd">
<td v-if="SessionUser.canAccessAdmin()"
class="has-text-centered"
draggable="true"
@dragstart="onDragStart($event, index)"
style="cursor: move;">
</thead>
<tbody>
<tr
v-for="(department, index) in objects"
:key="department.id"
:data-testid="`superuser-departments-row-${department.id}`"
:class="{ 'is-dragging': draggingIndex === index, 'drag-over': dragOverIndex === index }"
@dragover="onDragOver($event, index)"
@dragleave="dragOverIndex = null"
@drop="onDrop($event, index)"
@dragend="onDragEnd"
>
<td
v-if="SessionUser.canAccessAdmin()"
class="has-text-centered"
draggable="true"
@dragstart="onDragStart($event, index)"
style="cursor: move"
>
<span class="icon is-small">
<i class="fas fa-grip-lines"></i>
</span>
</td>
<EditableTableColumn
:object="department"
:loadList="loadList"
column="order_priority"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
:object="department"
:loadList="loadList"
column="order_priority"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
/>
<td>{{ department.id }}</td>
<EditableTableColumn
:object="department"
:loadList="loadList"
column="name"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
:object="department"
:loadList="loadList"
column="name"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="department"
:loadList="loadList"
column="description"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
:object="department"
:loadList="loadList"
column="description"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="department"
:loadList="loadList"
column="economic_department_id"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
:parse-function="SessionUser.objects.economic_departments.functions.getEconomicDepartmentName"
:object="department"
:loadList="loadList"
column="economic_department_id"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
:parse-function="SessionUser.objects.economic_departments.functions.getEconomicDepartmentName"
/>
<td class="archive-switch-cell">
<input
:id="`department-archived-${department.id}`"
class="switch is-rounded is-small"
type="checkbox"
:checked="isDepartmentArchived(department)"
:disabled="isArchiveSwitchLoading(department.id)"
:data-testid="`department-archive-toggle-${department.id}`"
:aria-label="`${SessionUser.objects.departments.columns.archived.label}: ${department.name}`"
@change="toggleArchived(department)"
/>
<label :for="`department-archived-${department.id}`"></label>
</td>
<EditableTableColumn
:object="department"
:loadList="loadList"
column="latitude"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="department"
:loadList="loadList"
column="latitude"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
/>
<EditableTableColumn
:object="department"
:loadList="loadList"
column="longitude"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
:object="department"
:loadList="loadList"
column="longitude"
:edit-function="SessionUser.objects.departments.showEditObjectFieldForm"
/>
<td>
<div class="buttons">
<button class="button is-small" @click="showEditDepartmentForm(department.id, department.name, department.description, department.economic_department_id)">
<button
class="button is-small"
@click="
showEditDepartmentForm(
department.id,
department.name,
department.description,
department.economic_department_id
)
"
>
<span class="icon">
<i class="fas fa-edit"></i>
</span>
</button>
<button class="button is-small" @click="redirect('/admin/' + department.id)">
<!-- External link icon -->
<span class="icon">
<i class="fas fa-external-link-alt"></i>
</span>
<span class="icon">
<i class="fas fa-external-link-alt"></i>
</span>
</button>
<button class="button is-small is-dark" @click="redirect('/superuser/departments/' + department.id)">
<span class="icon">
<i class="fas fa-cog"></i>
</span>
<span class="icon">
<i class="fas fa-cog"></i>
</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</tbody>
</table>
</template>
<style scoped>
@@ -191,4 +254,8 @@ const redirect = (path) => {
.drag-over td {
border-top: 3px solid #3273dc !important;
}
</style>
.archive-switch-cell {
min-width: 4rem;
white-space: nowrap;
}
</style>
@@ -101,6 +101,14 @@ export const getDepartmentName = async (id) => {
required: false
}
},
archived: {
label: t('objects.departments.columns.archived'),
type: "boolean",
sortable: true,
creation: {
required: false
}
},
longitude: {
label: t('objects.departments.columns.longitude'),
type: "number",
@@ -177,6 +185,14 @@ export const getDepartmentName = async (id) => {
parseInt(dimension)
)
},
archived: async (id, archived) => {
return ObjectsGlobal.set.column(
Departments.meta.endpoint,
id,
"archived",
ObjectsGlobal.parse.boolean(archived)
)
},
longitude: async (id, longitude) => {
return ObjectsGlobal.set.column(
Departments.meta.endpoint,
@@ -237,4 +253,4 @@ export const getDepartmentName = async (id) => {
);
}
};
</script>
</script>
@@ -307,7 +307,7 @@ export const ObjectsGlobal = {
},
parse: {
boolean: (value) => {
return value === 'true' || value === true;
return value === 'true' || value === '1' || value === true || value === 1;
},
number: (value) => {
return parseInt(value);
+6
View File
@@ -2512,6 +2512,7 @@
},
"departments": {
"columns": {
"archived": "Arkiveret",
"dimension": "Dimensjon",
"economic_department": "E-conomic afdeling",
"latitude": "Breddegrad",
@@ -2519,6 +2520,11 @@
"slack_webhook": "Slack webhook"
},
"description": "Oversigt over afdelinger",
"filters": {
"active": "Aktive afdelinger",
"archived": "Arkiverede afdelinger",
"status": "Status"
},
"multiple": "afdelinger",
"single": "afdeling",
"title": "Afdelinger"
+6
View File
@@ -2512,6 +2512,7 @@
},
"departments": {
"columns": {
"archived": "Archiviert",
"dimension": "Abmessung",
"economic_department": "E-conomic-Abteilung",
"latitude": "Breitengrad",
@@ -2519,6 +2520,11 @@
"slack_webhook": "Slack Webhook"
},
"description": "?bersicht ?ber Abteilungen",
"filters": {
"active": "Aktive Abteilungen",
"archived": "Archivierte Abteilungen",
"status": "Status"
},
"multiple": "Abteilungen",
"single": "Abteilung",
"title": "Abteilungen"
+6
View File
@@ -2512,6 +2512,7 @@
},
"departments": {
"columns": {
"archived": "Archived",
"dimension": "Dimension",
"economic_department": "E-conomic Department",
"latitude": "Latitude",
@@ -2519,6 +2520,11 @@
"slack_webhook": "Slack Webhook"
},
"description": "Overview of departments",
"filters": {
"active": "Active departments",
"archived": "Archived departments",
"status": "Status"
},
"multiple": "departments",
"single": "department",
"title": "Departments"
+6
View File
@@ -2512,6 +2512,7 @@
},
"departments": {
"columns": {
"archived": "Arkivert",
"dimension": "Dimensjon",
"economic_department": "E-økonomisk avdeling",
"latitude": "Breddegrad",
@@ -2519,6 +2520,11 @@
"slack_webhook": "Slakk Webhook"
},
"description": "Oversikt over avdelinger",
"filters": {
"active": "Aktive avdelinger",
"archived": "Arkiverte avdelinger",
"status": "Status"
},
"multiple": "avdelinger",
"single": "avdeling",
"title": "Avdelinger"
+6
View File
@@ -2512,6 +2512,7 @@
},
"departments": {
"columns": {
"archived": "Arkiverad",
"dimension": "Dimension",
"economic_department": "E-conomic-avdelning",
"latitude": "Latitude",
@@ -2519,6 +2520,11 @@
"slack_webhook": "Slack Webhook"
},
"description": "översikt över avdelningar",
"filters": {
"active": "Aktiva avdelningar",
"archived": "Arkiverade avdelningar",
"status": "Status"
},
"multiple": "avdelningar",
"single": "avdelning",
"title": "Avdelningar"
+10 -1
View File
@@ -22,8 +22,17 @@ export const hasValidDepartmentName = (department) => {
return isDepartmentLabelValid(department?.name);
};
export const isDepartmentArchived = (department) => {
return (
department?.archived === true
|| department?.archived === 1
|| department?.archived === "1"
|| department?.archived === "true"
);
};
export const isDepartmentVisible = (department) => {
return !(
return !isDepartmentArchived(department) && !(
department?.visible === false
|| department?.visible === 0
|| department?.visible === "0"
@@ -9,6 +9,7 @@ const adminPermissions = [
"department_access_2",
"department_access_3",
"department_access_4",
"department_access_5",
];
const defaultDepartments = [
@@ -16,6 +17,7 @@ const defaultDepartments = [
{ id: 2, name: "Hidden South", visible: false },
{ id: 3, name: "Legacy East" },
{ id: 4, name: "Numeric Hidden", visible: 0 },
{ id: 5, name: "Archived West", visible: true, archived: true },
];
const json = (body: unknown, status = 200) => ({
@@ -180,6 +182,7 @@ test.describe("Admin department visibility", () => {
await expect(departmentControls.getByRole("button", { name: "Legacy East" })).toBeVisible();
await expect(departmentControls.getByRole("button", { name: "Hidden South" })).toHaveCount(0);
await expect(departmentControls.getByRole("button", { name: "Numeric Hidden" })).toHaveCount(0);
await expect(departmentControls.getByRole("button", { name: "Archived West" })).toHaveCount(0);
if (isDesktopProject(test.info())) {
const desktopDepartmentSelect = page.getByTestId("desktop-header-department-select");
@@ -187,6 +190,7 @@ test.describe("Admin department visibility", () => {
await expect(desktopDepartmentSelect.locator("option", { hasText: "Legacy East" })).toHaveCount(1);
await expect(desktopDepartmentSelect.locator("option", { hasText: "Hidden South" })).toHaveCount(0);
await expect(desktopDepartmentSelect.locator("option", { hasText: "Numeric Hidden" })).toHaveCount(0);
await expect(desktopDepartmentSelect.locator("option", { hasText: "Archived West" })).toHaveCount(0);
}
});
@@ -0,0 +1,93 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
import { isDesktopProject } from "./support/projects";
const json = (body: unknown, status = 200) => ({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
const now = "2026-05-07 10:00:00";
const buildDepartment = (department: Record<string, unknown>) => ({
description: "E2E department",
economic_department_id: 0,
created_at: now,
updated_at: now,
dimension: 0,
branding: 0,
latitude: 0,
longitude: 0,
order_priority: 1,
archived: false,
...department,
});
const departmentEnvelope = (departments: Array<Record<string, unknown>>) => ({
data: departments,
meta: {
pagination: {
page: 1,
per_page: 100,
total: departments.length,
},
},
});
test.describe("Superuser department archive filter", () => {
test("shows active departments by default and archived departments only after selecting archived", async ({
page,
}, testInfo) => {
test.skip(!isDesktopProject(testInfo), "Desktop only");
const activeDepartments = [buildDepartment({ id: 1, name: "Active North", order_priority: 1, archived: false })];
const archivedDepartments = [buildDepartment({ id: 2, name: "Archived East", order_priority: 1, archived: true })];
const departmentFiltersSeen: string[] = [];
await page.setViewportSize({ width: 1280, height: 720 });
await seedAuthenticatedState(page, "superuser-departments-archive-token");
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user", "list_departments", "superuser_fetch_department", "edit_department"],
sessionData: {
group_id: 1,
},
});
await page.route(
/https?:\/\/(?:api\.truckwash\.io(?::\d+)?\/departments|localhost(?::\d+)?\/api\/departments|127\.0\.0\.1(?::\d+)?\/api\/departments)(?:\?.*)?$/i,
async (route) => {
const request = route.request();
const url = new URL(request.url());
if (request.method() === "PUT") {
await route.fulfill(json({ data: { message: "Department updated successfully" } }));
return;
}
const filters = url.searchParams.get("filters") || "";
departmentFiltersSeen.push(filters);
const departments = filters.includes("archived:1") ? archivedDepartments : activeDepartments;
await route.fulfill(json(departmentEnvelope(departments)));
}
);
await page.goto("/superuser/departments");
await expect(page.getByTestId("pagination-reload-actions")).toBeVisible();
await expect(page.getByTestId("superuser-departments-table")).toBeVisible();
await expect(page.getByTestId("superuser-departments-archive-filter")).toHaveValue("0");
await expect(page.getByTestId("superuser-departments-row-1")).toContainText("Active North");
await expect(page.getByTestId("superuser-departments-row-2")).toHaveCount(0);
await page.getByTestId("superuser-departments-archive-filter").selectOption("1");
await expect(page.getByTestId("superuser-departments-row-2")).toContainText("Archived East");
await expect(page.getByTestId("superuser-departments-row-1")).toHaveCount(0);
expect(departmentFiltersSeen.some((filters) => filters.includes("archived:0"))).toBeTruthy();
expect(departmentFiltersSeen.some((filters) => filters.includes("archived:1"))).toBeTruthy();
});
});