Add Workfeed module with configuration management, diagnostics, and API integration:

- Implement `Workfeed` module with endpoints for employees, shifts, departments, and diagnostics.
- Add configuration UI for enabling Workfeed, API settings (`api_url`, `api_key`), and integration diagnostics.
- Add unit tests for module schema validation, API endpoints, and diagnostics contract.
- Introduce smoke test for Workfeed configuration and modal diagnostics.
- Extend i18n locales to support Workfeed integration.
This commit is contained in:
Jeppe Bundgaard
2026-03-24 11:19:09 +01:00
parent 0949d861f9
commit 943bc8ec97
18 changed files with 1107 additions and 4 deletions
@@ -155,6 +155,13 @@ const menu_items = ref([
children: [],
hidden: false
},
{
label: SessionUser.superUser.modules.workfeed.meta.title,
value: SessionUser.superUser.modules.workfeed.meta.config_endpoint,
icon: SessionUser.superUser.modules.workfeed.meta.icon,
children: [],
hidden: false
},
],
onSelect: async (value) => {
SessionUser.functions.redirectTo.superUser(value);
@@ -44,6 +44,7 @@ const items = computed<NavigationItemProps[]>(() => [
{ label: firstToUpperCase(SessionUser.superUser.modules.stripe.meta.labels.multiple), to: '/superuser/configuration/stripe'},
{ label: firstToUpperCase(SessionUser.superUser.modules.fxratesapi.meta.labels.multiple), to: '/superuser/configuration/fxratesapi'},
{ label: firstToUpperCase(SessionUser.superUser.modules.weatherapi.meta.labels.multiple), to: '/superuser/configuration/weatherapi'},
{ label: firstToUpperCase(SessionUser.superUser.modules.workfeed.meta.labels.multiple), to: '/superuser/configuration/workfeed'},
{ label: firstToUpperCase(SessionUser.superUser.modules.gatewayapi.meta.labels.multiple), to: '/superuser/configuration/gatewayapi'},
{ label: firstToUpperCase(SessionUser.superUser.modules.entra.meta.labels.multiple), to: '/superuser/configuration/entra'},
{ label: firstToUpperCase(SessionUser.superUser.modules.limble.meta.labels.multiple), to: '/superuser/configuration/limble'},
@@ -0,0 +1,57 @@
<script>
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
/**
* The Workfeed -> Config object
*/
export const Config = {
get: async (variable) => {
return authenticatedRequest(
"/workfeed/config?variable=" + variable,
"GET"
);
},
get_all: async () => {
return authenticatedRequest(
"/workfeed/config",
"GET"
);
},
set: async (variable, value) => {
return authenticatedRequest(
"/workfeed/config",
"POST",
{
variable: variable,
value: value
}
);
},
keys: {
api_url: {
get: async () => {
return Config.get("api_url");
},
set: async (value) => {
return Config.set("api_url", value);
},
},
api_key: {
get: async () => {
return Config.get("api_key");
},
set: async (value) => {
return Config.set("api_key", value);
},
},
},
enabled: {
get: async () => {
return Config.get("enabled");
},
set: async (enabled) => {
return Config.set("enabled", enabled);
},
},
};
</script>
@@ -0,0 +1,99 @@
<script>
import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue";
import { Config } from "@/components/session/token/superUser/modules/workfeed/Config.vue";
const sanitizeQuery = (query = {}) => {
return Object.fromEntries(
Object.entries(query).filter(([, value]) => value !== undefined && value !== null && value !== "")
);
};
/**
* The Workfeed object
*/
export const Workfeed = {
meta: {
title: "Workfeed",
icon: "fas fa-users-cog",
description: "The Workfeed integration",
endpoint: "/modules/workfeed",
config_endpoint: "/configuration/workfeed",
labels: {
single: "workfeed",
multiple: "workfeed",
}
},
config: Config,
functions: {
listEmployees: async ({
search,
departmentId,
includeInactive,
limit,
cursor,
} = {}) => {
return authenticatedRequest(
"/modules/workfeed/employees",
"GET",
sanitizeQuery({
search,
departmentId,
includeInactive,
limit,
cursor,
})
);
},
getEmployee: async (id) => {
return authenticatedRequest(
"/modules/workfeed/employees/" + encodeURIComponent(id),
"GET"
);
},
listShifts: async ({
from,
to,
departmentId,
employeeId,
status,
limit,
cursor,
} = {}) => {
return authenticatedRequest(
"/modules/workfeed/shifts",
"GET",
sanitizeQuery({
from,
to,
departmentId,
employeeId,
status,
limit,
cursor,
})
);
},
getShift: async (id) => {
return authenticatedRequest(
"/modules/workfeed/shifts/" + encodeURIComponent(id),
"GET"
);
},
listDepartments: async ({
search,
limit,
cursor,
} = {}) => {
return authenticatedRequest(
"/modules/workfeed/departments",
"GET",
sanitizeQuery({
search,
limit,
cursor,
})
);
},
}
};
</script>
@@ -11,6 +11,7 @@ import { MotorAPI } from "@/components/session/token/superUser/modules/motorapi/
import { Stripe } from "@/components/session/token/superUser/modules/stripe/Stripe.vue";
import { FXRatesAPI } from "@/components/session/token/superUser/modules/fxratesapi/FXRatesAPI.vue";
import { WeatherAPI } from "@/components/session/token/superUser/modules/weatherapi/WeatherAPI.vue";
import { Workfeed } from "@/components/session/token/superUser/modules/workfeed/Workfeed.vue";
import { GatewayAPI } from "@/components/session/token/superUser/modules/gatewayapi/GatewayAPI.vue";
import { XLVask } from "@/components/session/token/superUser/modules/xlvask/XLVask.vue";
import { Entra } from "@/components/session/token/superUser/modules/entra/Entra.vue";
@@ -41,6 +42,7 @@ export const SuperUserObject = {
get stripe() { return Stripe; },
get fxratesapi() { return FXRatesAPI; },
get weatherapi() { return WeatherAPI; },
get workfeed() { return Workfeed; },
get gatewayapi() { return GatewayAPI; },
get xlvask() { return XLVask; },
get entra() { return Entra; },
+40 -1
View File
@@ -1149,6 +1149,46 @@
"title": "XLVask konfiguration",
"username": "Brugernavn",
"username_desc": "Brugernavnet bruges til at autentificere API forbindelsen."
},
"workfeed": {
"title": "Workfeed configuration",
"subtitle": "Configuration of the Workfeed integration",
"general_settings": "General settings",
"general_settings_desc": "General settings for the Workfeed integration.",
"enable": "Enable Workfeed",
"enable_desc": "Enable or disable the Workfeed integration.",
"api_settings": "API connection settings",
"api_settings_desc": "The API connection settings used for Workfeed requests.",
"api_url": "API URL",
"api_url_desc": "The base URL for the Workfeed API.",
"api_key": "API key",
"api_key_desc": "The API key used to authenticate requests to Workfeed.",
"close": "Close",
"lookup": "Lookup",
"validation_required": "This field is required.",
"request_failed": "Request failed: {message}",
"diagnostics": {
"title": "Diagnostics",
"description": "Run test lookups against the Workfeed integration endpoints.",
"list_departments": "List departments",
"list_employees": "List employees",
"list_shifts": "List shifts",
"get_employee": "Get employee by ID",
"get_shift": "Get shift by ID",
"prompt_search_optional": "Optional search text",
"prompt_status_optional": "Optional shift status",
"prompt_employee_id": "Employee ID",
"prompt_shift_id": "Shift ID",
"placeholder_search": "e.g. north facility",
"placeholder_status": "e.g. published",
"placeholder_employee_id": "e.g. emp_01J5P2F2A0CQKBBX4P8M3Y7R5P",
"placeholder_shift_id": "e.g. shf_01J5P3X9D35R9C6BDZ1S0R4V6Q",
"result_departments": "Department lookup result",
"result_employees": "Employee lookup result",
"result_shifts": "Shift lookup result",
"result_employee": "Employee result",
"result_shift": "Shift result"
}
}
},
"customer_creation": {
@@ -4279,4 +4319,3 @@
}
}
}
+40 -1
View File
@@ -1149,6 +1149,46 @@
"title": "XLVask-Konfiguration",
"username": "Benutzername",
"username_desc": "Der Benutzername wird zur Authentifizierung der API-Verbindung verwendet."
},
"workfeed": {
"title": "Workfeed configuration",
"subtitle": "Configuration of the Workfeed integration",
"general_settings": "General settings",
"general_settings_desc": "General settings for the Workfeed integration.",
"enable": "Enable Workfeed",
"enable_desc": "Enable or disable the Workfeed integration.",
"api_settings": "API connection settings",
"api_settings_desc": "The API connection settings used for Workfeed requests.",
"api_url": "API URL",
"api_url_desc": "The base URL for the Workfeed API.",
"api_key": "API key",
"api_key_desc": "The API key used to authenticate requests to Workfeed.",
"close": "Close",
"lookup": "Lookup",
"validation_required": "This field is required.",
"request_failed": "Request failed: {message}",
"diagnostics": {
"title": "Diagnostics",
"description": "Run test lookups against the Workfeed integration endpoints.",
"list_departments": "List departments",
"list_employees": "List employees",
"list_shifts": "List shifts",
"get_employee": "Get employee by ID",
"get_shift": "Get shift by ID",
"prompt_search_optional": "Optional search text",
"prompt_status_optional": "Optional shift status",
"prompt_employee_id": "Employee ID",
"prompt_shift_id": "Shift ID",
"placeholder_search": "e.g. north facility",
"placeholder_status": "e.g. published",
"placeholder_employee_id": "e.g. emp_01J5P2F2A0CQKBBX4P8M3Y7R5P",
"placeholder_shift_id": "e.g. shf_01J5P3X9D35R9C6BDZ1S0R4V6Q",
"result_departments": "Department lookup result",
"result_employees": "Employee lookup result",
"result_shifts": "Shift lookup result",
"result_employee": "Employee result",
"result_shift": "Shift result"
}
}
},
"customer_creation": {
@@ -4277,4 +4317,3 @@
}
}
}
+40
View File
@@ -1149,6 +1149,46 @@
"title": "XLVask configuration",
"username": "Username",
"username_desc": "The username is used to authenticate the API connection."
},
"workfeed": {
"title": "Workfeed configuration",
"subtitle": "Configuration of the Workfeed integration",
"general_settings": "General settings",
"general_settings_desc": "General settings for the Workfeed integration.",
"enable": "Enable Workfeed",
"enable_desc": "Enable or disable the Workfeed integration.",
"api_settings": "API connection settings",
"api_settings_desc": "The API connection settings used for Workfeed requests.",
"api_url": "API URL",
"api_url_desc": "The base URL for the Workfeed API.",
"api_key": "API key",
"api_key_desc": "The API key used to authenticate requests to Workfeed.",
"close": "Close",
"lookup": "Lookup",
"validation_required": "This field is required.",
"request_failed": "Request failed: {message}",
"diagnostics": {
"title": "Diagnostics",
"description": "Run test lookups against the Workfeed integration endpoints.",
"list_departments": "List departments",
"list_employees": "List employees",
"list_shifts": "List shifts",
"get_employee": "Get employee by ID",
"get_shift": "Get shift by ID",
"prompt_search_optional": "Optional search text",
"prompt_status_optional": "Optional shift status",
"prompt_employee_id": "Employee ID",
"prompt_shift_id": "Shift ID",
"placeholder_search": "e.g. north facility",
"placeholder_status": "e.g. published",
"placeholder_employee_id": "e.g. emp_01J5P2F2A0CQKBBX4P8M3Y7R5P",
"placeholder_shift_id": "e.g. shf_01J5P3X9D35R9C6BDZ1S0R4V6Q",
"result_departments": "Department lookup result",
"result_employees": "Employee lookup result",
"result_shifts": "Shift lookup result",
"result_employee": "Employee result",
"result_shift": "Shift result"
}
}
},
"customer_creation": {
+40 -1
View File
@@ -1144,6 +1144,46 @@
"title": "XLVask-konfigurasjon",
"username": "Brukernavn",
"username_desc": "Brukernavnet brukes til å autentisere API-tilkoblingen."
},
"workfeed": {
"title": "Workfeed configuration",
"subtitle": "Configuration of the Workfeed integration",
"general_settings": "General settings",
"general_settings_desc": "General settings for the Workfeed integration.",
"enable": "Enable Workfeed",
"enable_desc": "Enable or disable the Workfeed integration.",
"api_settings": "API connection settings",
"api_settings_desc": "The API connection settings used for Workfeed requests.",
"api_url": "API URL",
"api_url_desc": "The base URL for the Workfeed API.",
"api_key": "API key",
"api_key_desc": "The API key used to authenticate requests to Workfeed.",
"close": "Close",
"lookup": "Lookup",
"validation_required": "This field is required.",
"request_failed": "Request failed: {message}",
"diagnostics": {
"title": "Diagnostics",
"description": "Run test lookups against the Workfeed integration endpoints.",
"list_departments": "List departments",
"list_employees": "List employees",
"list_shifts": "List shifts",
"get_employee": "Get employee by ID",
"get_shift": "Get shift by ID",
"prompt_search_optional": "Optional search text",
"prompt_status_optional": "Optional shift status",
"prompt_employee_id": "Employee ID",
"prompt_shift_id": "Shift ID",
"placeholder_search": "e.g. north facility",
"placeholder_status": "e.g. published",
"placeholder_employee_id": "e.g. emp_01J5P2F2A0CQKBBX4P8M3Y7R5P",
"placeholder_shift_id": "e.g. shf_01J5P3X9D35R9C6BDZ1S0R4V6Q",
"result_departments": "Department lookup result",
"result_employees": "Employee lookup result",
"result_shifts": "Shift lookup result",
"result_employee": "Employee result",
"result_shift": "Shift result"
}
}
},
"customer_creation": {
@@ -4227,4 +4267,3 @@
}
}
}
+40 -1
View File
@@ -1144,6 +1144,46 @@
"title": "XLVask konfiguration",
"username": "Användarnamn",
"username_desc": "Användarnamnet används för att autentisera API-anslutningen."
},
"workfeed": {
"title": "Workfeed configuration",
"subtitle": "Configuration of the Workfeed integration",
"general_settings": "General settings",
"general_settings_desc": "General settings for the Workfeed integration.",
"enable": "Enable Workfeed",
"enable_desc": "Enable or disable the Workfeed integration.",
"api_settings": "API connection settings",
"api_settings_desc": "The API connection settings used for Workfeed requests.",
"api_url": "API URL",
"api_url_desc": "The base URL for the Workfeed API.",
"api_key": "API key",
"api_key_desc": "The API key used to authenticate requests to Workfeed.",
"close": "Close",
"lookup": "Lookup",
"validation_required": "This field is required.",
"request_failed": "Request failed: {message}",
"diagnostics": {
"title": "Diagnostics",
"description": "Run test lookups against the Workfeed integration endpoints.",
"list_departments": "List departments",
"list_employees": "List employees",
"list_shifts": "List shifts",
"get_employee": "Get employee by ID",
"get_shift": "Get shift by ID",
"prompt_search_optional": "Optional search text",
"prompt_status_optional": "Optional shift status",
"prompt_employee_id": "Employee ID",
"prompt_shift_id": "Shift ID",
"placeholder_search": "e.g. north facility",
"placeholder_status": "e.g. published",
"placeholder_employee_id": "e.g. emp_01J5P2F2A0CQKBBX4P8M3Y7R5P",
"placeholder_shift_id": "e.g. shf_01J5P3X9D35R9C6BDZ1S0R4V6Q",
"result_departments": "Department lookup result",
"result_employees": "Employee lookup result",
"result_shifts": "Shift lookup result",
"result_employee": "Employee result",
"result_shift": "Shift result"
}
}
},
"customer_creation": {
@@ -4227,4 +4267,3 @@
}
}
}
+7
View File
@@ -95,6 +95,7 @@ import GuestBookExteriorWash from "@/views/guest/book/GuestBookExteriorWash.vue"
import DepartmentProfile from "@/views/dashboards/superUserDashboard/department/DepartmentProfile.vue";
import ConfigurationFXRatesAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationFXRatesAPI.vue";
import ConfigurationWeatherAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationWeatherAPI.vue";
import ConfigurationWorkfeed from "@/views/dashboards/superUserDashboard/configuration/ConfigurationWorkfeed.vue";
import ConfigurationGatewayAPI from "@/views/dashboards/superUserDashboard/configuration/ConfigurationGatewayAPI.vue";
import UserWashSubscriptions from "@/views/dashboards/superUserDashboard/user/UserWashSubscriptions.vue";
import UserProfile from "@/views/dashboards/userDashboard/profile/UserProfile.vue";
@@ -816,6 +817,12 @@ export const router = createRouter({
component: ConfigurationWeatherAPI,
meta: { middleware: superUserMiddleware }
},
{
name: 'configurationWorkfeed',
path: '/superuser/configuration/workfeed',
component: ConfigurationWorkfeed,
meta: { middleware: superUserMiddleware }
},
{
name: 'configurationGatewayAPI',
path: '/superuser/configuration/gatewayapi',
@@ -0,0 +1,276 @@
<script setup>
import { onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
import Swal from "sweetalert2";
import PageTitle from "@/components/global/PageTitle.vue";
import RestrictedPageWrapper from "@/components/page/wrappers/RestrictedPageWrapper.vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
import ConfigurationSubPageWrapper
from "@/views/dashboards/superUserDashboard/configuration/ConfigurationSubPageWrapper.vue";
import ConfigurationCategory from "@/components/displays/superuser/configuration/ConfigurationCategory.vue";
import ConfigurationSwitch from "@/components/displays/superuser/configuration/ConfigurationSwitch.vue";
import ConfigurationSecretKey from "@/components/displays/superuser/configuration/ConfigurationSecretKey.vue";
import ConfigurationInput from "@/components/displays/superuser/configuration/ConfigurationInput.vue";
const { t } = useI18n();
const module_config = ref([]);
const getModuleConfig = async () => {
await SessionUser.superUser.modules.workfeed.config.get_all().then((response) => {
const tmp_module_config = response?.data?.data ?? [];
const entries = Array.isArray(tmp_module_config) ? tmp_module_config : Object.values(tmp_module_config);
module_config.value = entries.map((entry) => ({
variable: entry.variable,
value: entry.value,
}));
}).catch((error) => {
console.log(error);
});
};
const getModuleConfigValue = (variable) => {
const config = module_config.value.find((entry) => entry.variable === variable);
return config ? config.value : "";
};
const showResult = (title, value) => {
Swal.fire({
title: title,
html: "<pre>" + JSON.stringify(value, null, 2) + "</pre>",
width: "60rem",
confirmButtonText: t("configuration.workfeed.close"),
});
};
const toErrorMessage = (error) => {
if (error?.response?.data?.message) {
return error.response.data.message;
}
if (error?.response?.data?.data?.message) {
return error.response.data.data.message;
}
if (error?.message) {
return error.message;
}
return String(error);
};
const showInputModal = async ({
title,
inputLabel,
inputPlaceholder,
inputValue = "",
requireInput = false,
run,
resultTitle,
}) => {
return Swal.fire({
title: title,
input: "text",
inputLabel: inputLabel,
inputPlaceholder: inputPlaceholder,
inputValue: inputValue,
showCancelButton: true,
confirmButtonText: t("configuration.workfeed.lookup"),
showLoaderOnConfirm: true,
preConfirm: async (input) => {
const value = typeof input === "string" ? input.trim() : "";
if (requireInput && value === "") {
Swal.showValidationMessage(t("configuration.workfeed.validation_required"));
return false;
}
return run(value).then((response) => response.data.data).catch((error) => {
Swal.showValidationMessage(
t("configuration.workfeed.request_failed", { message: toErrorMessage(error) })
);
});
},
allowOutsideClick: () => !Swal.isLoading()
}).then((result) => {
if (!result.isConfirmed || !result.value) {
return;
}
showResult(resultTitle, result.value);
});
};
const onClickListDepartments = async () => {
await showInputModal({
title: t("configuration.workfeed.diagnostics.list_departments"),
inputLabel: t("configuration.workfeed.diagnostics.prompt_search_optional"),
inputPlaceholder: t("configuration.workfeed.diagnostics.placeholder_search"),
run: (search) => SessionUser.superUser.modules.workfeed.functions.listDepartments({
search: search || undefined,
limit: 20,
}),
resultTitle: t("configuration.workfeed.diagnostics.result_departments"),
});
};
const onClickListEmployees = async () => {
await showInputModal({
title: t("configuration.workfeed.diagnostics.list_employees"),
inputLabel: t("configuration.workfeed.diagnostics.prompt_search_optional"),
inputPlaceholder: t("configuration.workfeed.diagnostics.placeholder_search"),
run: (search) => SessionUser.superUser.modules.workfeed.functions.listEmployees({
search: search || undefined,
limit: 20,
}),
resultTitle: t("configuration.workfeed.diagnostics.result_employees"),
});
};
const onClickListShifts = async () => {
await showInputModal({
title: t("configuration.workfeed.diagnostics.list_shifts"),
inputLabel: t("configuration.workfeed.diagnostics.prompt_status_optional"),
inputPlaceholder: t("configuration.workfeed.diagnostics.placeholder_status"),
run: (status) => SessionUser.superUser.modules.workfeed.functions.listShifts({
status: status || undefined,
limit: 20,
}),
resultTitle: t("configuration.workfeed.diagnostics.result_shifts"),
});
};
const onClickGetEmployee = async () => {
await showInputModal({
title: t("configuration.workfeed.diagnostics.get_employee"),
inputLabel: t("configuration.workfeed.diagnostics.prompt_employee_id"),
inputPlaceholder: t("configuration.workfeed.diagnostics.placeholder_employee_id"),
requireInput: true,
run: (id) => SessionUser.superUser.modules.workfeed.functions.getEmployee(id),
resultTitle: t("configuration.workfeed.diagnostics.result_employee"),
});
};
const onClickGetShift = async () => {
await showInputModal({
title: t("configuration.workfeed.diagnostics.get_shift"),
inputLabel: t("configuration.workfeed.diagnostics.prompt_shift_id"),
inputPlaceholder: t("configuration.workfeed.diagnostics.placeholder_shift_id"),
requireInput: true,
run: (id) => SessionUser.superUser.modules.workfeed.functions.getShift(id),
resultTitle: t("configuration.workfeed.diagnostics.result_shift"),
});
};
onMounted(async () => {
await getModuleConfig();
});
</script>
<template>
<RestrictedPageWrapper :hasPermission="SessionUser.canAccessSuperUser()">
<ConfigurationSubPageWrapper>
<template #title>
<PageTitle
:title="$t('configuration.workfeed.title')"
:subtitle="$t('configuration.workfeed.subtitle')"
/>
</template>
<template v-if="module_config.length > 0" #content>
<ConfigurationCategory
class="mt-2"
module="Workfeed"
:title="$t('configuration.workfeed.general_settings')"
:description="$t('configuration.workfeed.general_settings_desc')"
icon="fas fa-users-cog"
>
<ConfigurationSwitch
class="mt-2"
module="Workfeed"
:title="$t('configuration.workfeed.enable')"
:description="$t('configuration.workfeed.enable_desc')"
icon="fas fa-users-cog"
:value="getModuleConfigValue('enabled') === true"
:on-switch="SessionUser.superUser.modules.workfeed.config.enabled.set"
/>
</ConfigurationCategory>
<ConfigurationCategory
class="mt-2"
module="Workfeed"
:title="$t('configuration.workfeed.api_settings')"
:description="$t('configuration.workfeed.api_settings_desc')"
icon="fas fa-key"
>
<ConfigurationInput
class="mt-2"
module="Workfeed"
:title="$t('configuration.workfeed.api_url')"
:description="$t('configuration.workfeed.api_url_desc')"
icon="fas fa-link"
:value="getModuleConfigValue('api_url')"
:on-save="SessionUser.superUser.modules.workfeed.config.keys.api_url.set"
/>
<ConfigurationSecretKey
class="mt-2"
module="Workfeed"
:title="$t('configuration.workfeed.api_key')"
:description="$t('configuration.workfeed.api_key_desc')"
icon="fas fa-key"
:isSet="getModuleConfigValue('api_key') !== ''"
:on-save="SessionUser.superUser.modules.workfeed.config.keys.api_key.set"
/>
</ConfigurationCategory>
<ConfigurationCategory
class="mt-2"
module="Workfeed"
:title="$t('configuration.workfeed.diagnostics.title')"
:description="$t('configuration.workfeed.diagnostics.description')"
icon="fas fa-stethoscope"
>
<div class="buttons">
<button
class="button is-dark"
data-testid="workfeed-diagnostics-list-departments"
@click="onClickListDepartments"
>
<span class="icon"><i class="fas fa-building"></i></span>
<span>{{ $t("configuration.workfeed.diagnostics.list_departments") }}</span>
</button>
<button
class="button is-dark"
data-testid="workfeed-diagnostics-list-employees"
@click="onClickListEmployees"
>
<span class="icon"><i class="fas fa-users"></i></span>
<span>{{ $t("configuration.workfeed.diagnostics.list_employees") }}</span>
</button>
<button
class="button is-dark"
data-testid="workfeed-diagnostics-list-shifts"
@click="onClickListShifts"
>
<span class="icon"><i class="fas fa-calendar-alt"></i></span>
<span>{{ $t("configuration.workfeed.diagnostics.list_shifts") }}</span>
</button>
<button
class="button is-dark"
data-testid="workfeed-diagnostics-get-employee"
@click="onClickGetEmployee"
>
<span class="icon"><i class="fas fa-user"></i></span>
<span>{{ $t("configuration.workfeed.diagnostics.get_employee") }}</span>
</button>
<button
class="button is-dark"
data-testid="workfeed-diagnostics-get-shift"
@click="onClickGetShift"
>
<span class="icon"><i class="fas fa-clock"></i></span>
<span>{{ $t("configuration.workfeed.diagnostics.get_shift") }}</span>
</button>
</div>
</ConfigurationCategory>
</template>
</ConfigurationSubPageWrapper>
</RestrictedPageWrapper>
</template>
<style scoped>
</style>
@@ -16,6 +16,7 @@ const tabs = [
{ name: 'Stripe', path: '/superuser/configuration/stripe' },
{ name: 'FXRatesAPI', path: '/superuser/configuration/fxratesapi' },
{ name: 'WeatherAPI', path: '/superuser/configuration/weatherapi' },
{ name: 'Workfeed', path: '/superuser/configuration/workfeed' },
{ name: 'GatewayAPI', path: '/superuser/configuration/gatewayapi' },
{ name: 'XLVask', path: '/superuser/configuration/xlvask' },
{ name: 'Entra', path: '/superuser/configuration/entra'},
+144
View File
@@ -353,6 +353,150 @@ export async function mockApi(page, options = {}) {
return;
}
if (pathname.endsWith("/workfeed/config")) {
const workfeedConfig = [
{ module: "workfeed", variable: "enabled", type: "bool", value: true },
{ module: "workfeed", variable: "api_url", type: "string", value: "https://api.workfeed.test" },
{ module: "workfeed", variable: "api_key", type: "string", value: "test-api-key" },
];
if (method === "GET") {
const variable = parsedUrl.searchParams.get("variable");
if (variable) {
const match = workfeedConfig.find((entry) => entry.variable === variable);
await route.fulfill(json({ data: match ? match.value : null }));
return;
}
await route.fulfill(json({ data: workfeedConfig }));
return;
}
if (method === "POST") {
await route.fulfill(json({ data: { updated: true } }));
return;
}
}
if (pathname.endsWith("/modules/workfeed/departments") && method === "GET") {
const search = (parsedUrl.searchParams.get("search") || "").toLowerCase();
const departments = [
{
id: "dep_01J5P2B0BC9Q2X7H8Y7JQ1M2N3",
name: "North Facility",
timezone: "Europe/Copenhagen",
active: true,
},
{
id: "dep_01J5P2B0BC9Q2X7H8Y7JQ1M2N4",
name: "South Facility",
timezone: "Europe/Copenhagen",
active: true,
},
];
const filtered = search
? departments.filter((entry) => entry.name.toLowerCase().includes(search))
: departments;
await route.fulfill(json({
data: {
items: filtered,
pagination: { cursor: null, nextCursor: null, limit: 20, total: filtered.length },
},
}));
return;
}
if (pathname.endsWith("/modules/workfeed/employees") && method === "GET") {
const search = (parsedUrl.searchParams.get("search") || "").toLowerCase();
const employees = [
{
id: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5P",
fullName: "Anne Nielsen",
email: "anne.nielsen@example.com",
departmentId: "dep_01J5P2B0BC9Q2X7H8Y7JQ1M2N3",
active: true,
},
{
id: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5Q",
fullName: "Mads Jensen",
email: "mads.jensen@example.com",
departmentId: "dep_01J5P2B0BC9Q2X7H8Y7JQ1M2N4",
active: true,
},
];
const filtered = search
? employees.filter((entry) => entry.fullName.toLowerCase().includes(search))
: employees;
await route.fulfill(json({
data: {
items: filtered,
pagination: { cursor: null, nextCursor: null, limit: 20, total: filtered.length },
},
}));
return;
}
if (/\/modules\/workfeed\/employees\/[^/]+$/i.test(pathname) && method === "GET") {
const id = pathname.split("/").pop();
await route.fulfill(json({
data: {
id,
fullName: "Anne Nielsen",
email: "anne.nielsen@example.com",
departmentId: "dep_01J5P2B0BC9Q2X7H8Y7JQ1M2N3",
active: true,
},
}));
return;
}
if (pathname.endsWith("/modules/workfeed/shifts") && method === "GET") {
const status = (parsedUrl.searchParams.get("status") || "").toLowerCase();
const shifts = [
{
id: "shf_01J5P3X9D35R9C6BDZ1S0R4V6Q",
title: "Morning Shift",
status: "published",
employeeId: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5P",
startAt: "2026-03-24T06:00:00Z",
endAt: "2026-03-24T14:00:00Z",
},
{
id: "shf_01J5P3X9D35R9C6BDZ1S0R4V6R",
title: "Evening Shift",
status: "draft",
employeeId: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5Q",
startAt: "2026-03-24T14:00:00Z",
endAt: "2026-03-24T22:00:00Z",
},
];
const filtered = status ? shifts.filter((entry) => entry.status.toLowerCase() === status) : shifts;
await route.fulfill(json({
data: {
items: filtered,
pagination: { cursor: null, nextCursor: null, limit: 20, total: filtered.length },
},
}));
return;
}
if (/\/modules\/workfeed\/shifts\/[^/]+$/i.test(pathname) && method === "GET") {
const id = pathname.split("/").pop();
await route.fulfill(json({
data: {
id,
title: "Morning Shift",
status: "published",
employeeId: "emp_01J5P2F2A0CQKBBX4P8M3Y7R5P",
startAt: "2026-03-24T06:00:00Z",
endAt: "2026-03-24T14:00:00Z",
},
}));
return;
}
if (selfServe) {
if (pathname.endsWith("/guest/departments") && method === "GET") {
await route.fulfill(json({ data: selfServe.departments }));
+82
View File
@@ -0,0 +1,82 @@
import { expect, test } from "@playwright/test";
import { mockApi, seedAuthenticatedState } from "./support/network.js";
async function primeSuperuserSession(page) {
const token = "superuser-workfeed-token";
await seedAuthenticatedState(page, token);
await page.goto("/login");
await page.evaluate(async (sessionToken) => {
const sessionModule = await import("/src/components/session/token/SessionUser.vue");
window.localStorage.setItem("token", sessionToken);
sessionModule.SessionUser.token.value = sessionToken;
sessionModule.SessionUser.authenticated.value = true;
sessionModule.SessionUser.permissions.value = ["superuser", "user"];
sessionModule.SessionUser.initiated.value = true;
}, token);
}
async function runDiagnostic(page, testId, inputValue = "") {
await page.locator(`[data-testid="${testId}"]:visible`).first().click();
await expect(page.locator(".swal2-popup")).toBeVisible();
const input = page.locator(".swal2-input");
if (await input.isVisible()) {
await input.fill(inputValue);
}
await page.locator(".swal2-confirm").click();
await expect(page.locator(".swal2-popup pre")).toBeVisible();
}
async function closeResultModal(page) {
await page.locator(".swal2-confirm").click();
await expect(page.locator(".swal2-popup")).toBeHidden();
}
async function expandCategory(page, title) {
await page.locator(".card-header.is-clickable:visible").filter({ hasText: title }).first().click();
}
test.describe("Workfeed configuration smoke", () => {
test.beforeEach(async ({ page }) => {
await mockApi(page, {
authenticated: true,
permissions: ["superuser", "user"],
});
await primeSuperuserSession(page);
});
test("@smoke renders workfeed config page and runs diagnostics", async ({ page }) => {
await page.goto("/superuser/configuration/workfeed");
await expect(page).toHaveURL(/\/superuser\/configuration\/workfeed$/);
await expect(page.locator("body")).toContainText("Workfeed configuration");
await expandCategory(page, "General settings");
await expect(page.locator("body")).toContainText("Enable Workfeed");
await expandCategory(page, "Diagnostics");
await expect(page.locator('[data-testid="workfeed-diagnostics-list-departments"]:visible').first()).toBeVisible();
await expect(page.locator('[data-testid="workfeed-diagnostics-list-employees"]:visible').first()).toBeVisible();
await expect(page.locator('[data-testid="workfeed-diagnostics-list-shifts"]:visible').first()).toBeVisible();
await expect(page.locator('[data-testid="workfeed-diagnostics-get-employee"]:visible').first()).toBeVisible();
await expect(page.locator('[data-testid="workfeed-diagnostics-get-shift"]:visible').first()).toBeVisible();
await runDiagnostic(page, "workfeed-diagnostics-list-departments");
await expect(page.locator(".swal2-popup pre")).toContainText("North Facility");
await closeResultModal(page);
await runDiagnostic(page, "workfeed-diagnostics-list-employees", "anne");
await expect(page.locator(".swal2-popup pre")).toContainText("Anne Nielsen");
await closeResultModal(page);
await runDiagnostic(page, "workfeed-diagnostics-list-shifts", "published");
await expect(page.locator(".swal2-popup pre")).toContainText("Morning Shift");
await closeResultModal(page);
await runDiagnostic(page, "workfeed-diagnostics-get-employee", "emp_01J5P2F2A0CQKBBX4P8M3Y7R5P");
await expect(page.locator(".swal2-popup pre")).toContainText("Anne Nielsen");
await closeResultModal(page);
await runDiagnostic(page, "workfeed-diagnostics-get-shift", "shf_01J5P3X9D35R9C6BDZ1S0R4V6Q");
await expect(page.locator(".swal2-popup pre")).toContainText("Morning Shift");
await closeResultModal(page);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const locales = {
da: JSON.parse(readFileSync(join(root, "src/i18n/locales/da.json"), "utf8")),
en: JSON.parse(readFileSync(join(root, "src/i18n/locales/en.json"), "utf8")),
sv: JSON.parse(readFileSync(join(root, "src/i18n/locales/sv.json"), "utf8")),
de: JSON.parse(readFileSync(join(root, "src/i18n/locales/de.json"), "utf8")),
no: JSON.parse(readFileSync(join(root, "src/i18n/locales/no.json"), "utf8")),
};
const flattenKeys = (value, prefix = "") => {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
return [prefix];
}
const keys = [];
for (const [key, nestedValue] of Object.entries(value)) {
const nestedPrefix = prefix ? `${prefix}.${key}` : key;
keys.push(...flattenKeys(nestedValue, nestedPrefix));
}
return keys;
};
describe("workfeed i18n coverage", () => {
it("defines configuration.workfeed namespace in every active locale", () => {
for (const [locale, data] of Object.entries(locales)) {
expect(data.configuration?.workfeed, `missing configuration.workfeed in ${locale}`).toBeTruthy();
}
});
it("keeps the same workfeed key set across locales", () => {
const baselineKeys = flattenKeys(locales.en.configuration.workfeed).sort();
for (const [locale, data] of Object.entries(locales)) {
const localeKeys = flattenKeys(data.configuration.workfeed).sort();
expect(localeKeys, `mismatched key set in ${locale}`).toEqual(baselineKeys);
}
});
it("contains required keys used by configuration view", () => {
const requiredKeys = [
"title",
"subtitle",
"general_settings",
"enable",
"api_settings",
"api_url",
"api_key",
"close",
"lookup",
"validation_required",
"request_failed",
"diagnostics.title",
"diagnostics.description",
"diagnostics.list_departments",
"diagnostics.list_employees",
"diagnostics.list_shifts",
"diagnostics.get_employee",
"diagnostics.get_shift",
"diagnostics.prompt_search_optional",
"diagnostics.prompt_status_optional",
"diagnostics.prompt_employee_id",
"diagnostics.prompt_shift_id",
"diagnostics.result_departments",
"diagnostics.result_employees",
"diagnostics.result_shifts",
"diagnostics.result_employee",
"diagnostics.result_shift",
];
const availableKeys = new Set(flattenKeys(locales.en.configuration.workfeed));
requiredKeys.forEach((key) => {
expect(availableKeys.has(key)).toBe(true);
});
});
});
+94
View File
@@ -0,0 +1,94 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const workfeedModuleSource = readFileSync(
join(root, "src/components/session/token/superUser/modules/workfeed/Workfeed.vue"),
"utf8"
);
const workfeedConfigSource = readFileSync(
join(root, "src/components/session/token/superUser/modules/workfeed/Config.vue"),
"utf8"
);
const workfeedPageSource = readFileSync(
join(root, "src/views/dashboards/superUserDashboard/configuration/ConfigurationWorkfeed.vue"),
"utf8"
);
describe("workfeed module contract", () => {
it("defines module metadata and config endpoint", () => {
expect(workfeedModuleSource).toContain('title: "Workfeed"');
expect(workfeedModuleSource).toContain('endpoint: "/modules/workfeed"');
expect(workfeedModuleSource).toContain('config_endpoint: "/configuration/workfeed"');
});
it("defines list and get endpoint wrappers for employees, shifts, and departments", () => {
expect(workfeedModuleSource).toContain('"/modules/workfeed/employees"');
expect(workfeedModuleSource).toContain('"/modules/workfeed/employees/" + encodeURIComponent(id)');
expect(workfeedModuleSource).toContain('"/modules/workfeed/shifts"');
expect(workfeedModuleSource).toContain('"/modules/workfeed/shifts/" + encodeURIComponent(id)');
expect(workfeedModuleSource).toContain('"/modules/workfeed/departments"');
});
it("supports all openapi filter parameters for list endpoints", () => {
expect(workfeedModuleSource).toContain("search");
expect(workfeedModuleSource).toContain("departmentId");
expect(workfeedModuleSource).toContain("includeInactive");
expect(workfeedModuleSource).toContain("employeeId");
expect(workfeedModuleSource).toContain("status");
expect(workfeedModuleSource).toContain("from");
expect(workfeedModuleSource).toContain("to");
expect(workfeedModuleSource).toContain("limit");
expect(workfeedModuleSource).toContain("cursor");
expect(workfeedModuleSource).toContain("sanitizeQuery");
});
});
describe("workfeed config contract", () => {
it("uses workfeed config endpoints", () => {
expect(workfeedConfigSource).toContain('"/workfeed/config?variable=" + variable');
expect(workfeedConfigSource).toContain('"/workfeed/config"');
});
it("supports openapi config variables", () => {
expect(workfeedConfigSource).toContain('Config.get("enabled")');
expect(workfeedConfigSource).toContain('Config.get("api_url")');
expect(workfeedConfigSource).toContain('Config.get("api_key")');
expect(workfeedConfigSource).toContain('Config.set("enabled", enabled)');
expect(workfeedConfigSource).toContain('Config.set("api_url", value)');
expect(workfeedConfigSource).toContain('Config.set("api_key", value)');
});
});
describe("workfeed config page contract", () => {
it("uses i18n keys and module bindings for controls", () => {
expect(workfeedPageSource).toContain("$t('configuration.workfeed.title')");
expect(workfeedPageSource).toContain("$t('configuration.workfeed.subtitle')");
expect(workfeedPageSource).toContain("$t('configuration.workfeed.enable')");
expect(workfeedPageSource).toContain("$t('configuration.workfeed.api_url')");
expect(workfeedPageSource).toContain("$t('configuration.workfeed.api_key')");
expect(workfeedPageSource).toContain(
":on-switch=\"SessionUser.superUser.modules.workfeed.config.enabled.set\""
);
expect(workfeedPageSource).toContain(
":on-save=\"SessionUser.superUser.modules.workfeed.config.keys.api_url.set\""
);
expect(workfeedPageSource).toContain(
":on-save=\"SessionUser.superUser.modules.workfeed.config.keys.api_key.set\""
);
});
it("exposes modal diagnostic actions for each workfeed endpoint family", () => {
expect(workfeedPageSource).toContain("onClickListDepartments");
expect(workfeedPageSource).toContain("onClickListEmployees");
expect(workfeedPageSource).toContain("onClickListShifts");
expect(workfeedPageSource).toContain("onClickGetEmployee");
expect(workfeedPageSource).toContain("onClickGetShift");
expect(workfeedPageSource).toContain("workfeed-diagnostics-list-departments");
expect(workfeedPageSource).toContain("workfeed-diagnostics-list-employees");
expect(workfeedPageSource).toContain("workfeed-diagnostics-list-shifts");
expect(workfeedPageSource).toContain("workfeed-diagnostics-get-employee");
expect(workfeedPageSource).toContain("workfeed-diagnostics-get-shift");
});
});
+57
View File
@@ -0,0 +1,57 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const superUserObjectSource = readFileSync(
join(root, "src/components/session/token/superUserObject.vue"),
"utf8"
);
const routerSource = readFileSync(join(root, "src/router.js"), "utf8");
const navItemsSource = readFileSync(
join(root, "src/components/models/navigation/items/NavigationMenuItemsSuperUser.vue"),
"utf8"
);
const leftMenuSource = readFileSync(
join(root, "src/components/menus/superuser/SuperUserLeftMenu.vue"),
"utf8"
);
const configTabsSource = readFileSync(
join(
root,
"src/views/dashboards/superUserDashboard/configuration/SuperUserDashboardConfigurationNavigation.vue"
),
"utf8"
);
describe("workfeed superuser wiring", () => {
it("is registered in SessionUser superuser modules", () => {
expect(superUserObjectSource).toContain(
'import { Workfeed } from "@/components/session/token/superUser/modules/workfeed/Workfeed.vue";'
);
expect(superUserObjectSource).toContain("get workfeed() { return Workfeed; },");
});
it("has a dedicated superuser configuration route", () => {
expect(routerSource).toContain(
'import ConfigurationWorkfeed from "@/views/dashboards/superUserDashboard/configuration/ConfigurationWorkfeed.vue";'
);
expect(routerSource).toContain("name: 'configurationWorkfeed'");
expect(routerSource).toContain("path: '/superuser/configuration/workfeed'");
expect(routerSource).toContain("component: ConfigurationWorkfeed");
});
it("appears in superuser configuration navigation menus", () => {
expect(navItemsSource).toContain(
"SessionUser.superUser.modules.workfeed.meta.labels.multiple"
);
expect(navItemsSource).toContain("to: '/superuser/configuration/workfeed'");
expect(leftMenuSource).toContain("SessionUser.superUser.modules.workfeed.meta.title");
expect(leftMenuSource).toContain(
"SessionUser.superUser.modules.workfeed.meta.config_endpoint"
);
expect(configTabsSource).toContain("{ name: 'Workfeed', path: '/superuser/configuration/workfeed' }");
});
});