diff --git a/frontend/package-lock.json b/frontend/package-lock.json index abe5ae7eb..458db7a8c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "my-nethesis-ui", - "version": "0.7.9", + "version": "0.7.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "my-nethesis-ui", - "version": "0.7.9", + "version": "0.7.10", "dependencies": { "@fontsource/poppins": "^5.2.6", "@fortawesome/fontawesome-svg-core": "^6.7.2", @@ -16,8 +16,8 @@ "@logto/vue": "^3.0.8", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/vue-components": "^3.11.0", - "@pinia/colada": "^1.3.1", + "@nethesis/vue-components": "^3.12.1", + "@pinia/colada": "^1.4.2", "@pinia/colada-plugin-auto-refetch": "^0.2.6", "@tailwindcss/vite": "^4.1.10", "@vuepic/vue-datepicker": "^12.1.0", @@ -34,7 +34,7 @@ "vue-router": "^4.5.0" }, "devDependencies": { - "@pinia/colada-devtools": "^1.1.0", + "@pinia/colada-devtools": "^1.1.2", "@tsconfig/node22": "^22.0.1", "@types/lodash": "^4.17.18", "@types/node": "^22.14.0", @@ -1665,9 +1665,9 @@ } }, "node_modules/@nethesis/vue-components": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/@nethesis/vue-components/-/vue-components-3.11.0.tgz", - "integrity": "sha512-hthf4Zc0mTDH4CJNZ0eINPzt1AYXxFzEAuLAZDXH2LH43hFHa80w2Pv2FedTBOj1oSBpLZrMezfZH0esO9epow==", + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/@nethesis/vue-components/-/vue-components-3.12.1.tgz", + "integrity": "sha512-tkAOFUdkiKnKpk1y5u8NZ4LoE8btep1ZGhw+ZWB02Pn2yj3ehgPfF/Y2fsXXmAJhnrJqWdymF6mE39wMrbxpWQ==", "dependencies": { "@fontsource/poppins": "^5.2.6", "@fortawesome/fontawesome-svg-core": "^6.5.1", @@ -1680,7 +1680,6 @@ "date-fns": "^2.30.0", "date-fns-tz": "^2.0.0", "lodash-es": "^4.17.21", - "uuid": "^11.0.0", "vue": "^3.5.12", "vue-tippy": "^6.3.1" } @@ -1825,15 +1824,18 @@ "license": "MIT" }, "node_modules/@pinia/colada": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@pinia/colada/-/colada-1.3.1.tgz", - "integrity": "sha512-DLHtyCud5sACbNLdVIHsoPrhcHMT/1LvObFHxxbmJKU2fqGPluc8Y8CtmCTQ71BgnzcNMuAl97G5XrAgOZqxvg==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@pinia/colada/-/colada-1.4.2.tgz", + "integrity": "sha512-HDCFSBd+Uxzuw/BEdl7Kwsm4uccWwkX5uSxpZhc4/MhfFASU+t+DmjByyzNoAG9EK3SGJ7uf6pNR7bJg+dX80w==", "license": "MIT", + "dependencies": { + "nostics": "^1.1.4" + }, "funding": { "url": "https://github.com/sponsors/posva" }, "peerDependencies": { - "pinia": "^2.2.6 || ^3.0.0", + "pinia": "^2.2.6 || ^3.0.0 || ^4.0.2", "vue": "^3.5.17" } }, @@ -2428,9 +2430,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2447,9 +2446,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2466,9 +2462,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2485,9 +2478,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -6367,6 +6357,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/nostics": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz", + "integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==", + "license": "MIT" + }, "node_modules/npm-normalize-package-bin": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-4.0.0.tgz", @@ -7801,19 +7797,6 @@ "dev": true, "license": "MIT" }, - "node_modules/uuid": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz", - "integrity": "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, "node_modules/valibot": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 49e26edd9..6a39054ac 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,8 +26,8 @@ "@logto/vue": "^3.0.8", "@nethesis/nethesis-light-svg-icons": "github:nethesis/Font-Awesome#ns-light", "@nethesis/nethesis-solid-svg-icons": "github:nethesis/Font-Awesome#ns-solid", - "@nethesis/vue-components": "^3.11.0", - "@pinia/colada": "^1.3.1", + "@nethesis/vue-components": "^3.12.1", + "@pinia/colada": "^1.4.2", "@pinia/colada-plugin-auto-refetch": "^0.2.6", "@tailwindcss/vite": "^4.1.10", "@vuepic/vue-datepicker": "^12.1.0", @@ -54,7 +54,7 @@ "ws": "^8.21.0" }, "devDependencies": { - "@pinia/colada-devtools": "^1.1.0", + "@pinia/colada-devtools": "^1.1.2", "@tsconfig/node22": "^22.0.1", "@types/lodash": "^4.17.18", "@types/node": "^22.14.0", diff --git a/frontend/src/components/account/apiKeys/ApiKeysPanel.vue b/frontend/src/components/account/apiKeys/ApiKeysPanel.vue index 5f1255ab4..ead059c9d 100644 --- a/frontend/src/components/account/apiKeys/ApiKeysPanel.vue +++ b/frontend/src/components/account/apiKeys/ApiKeysPanel.vue @@ -19,6 +19,8 @@ import { NeTextInput, NeSortDropdown, type SortEvent, + formatDateTimeNoSeconds, + formatRelativeTime, } from '@nethesis/vue-components' import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' import { @@ -37,7 +39,6 @@ import { useLoginStore } from '@/stores/login' import { useNotificationsStore } from '@/stores/notifications' import { useApiKeys } from '@/queries/apiKeys' import { API_KEYS_KEY, API_KEYS_TABLE_ID, deleteApiKey, type ApiKey } from '@/lib/apiKeys' -import { formatDateTimeNoSeconds, formatRelativeTime } from '@/lib/dateTime' import { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS, @@ -339,7 +340,7 @@ function confirmDelete() {
- {{ formatRelativeTime(key.last_used_at, locale) }} + {{ formatRelativeTime(new Date(key.last_used_at), locale) }} {{ formatDateTimeNoSeconds(new Date(key.last_used_at), locale) }} @@ -348,7 +349,7 @@ function confirmDelete() {
- {{ formatRelativeTime(key.expires_at, locale) }} + {{ formatRelativeTime(new Date(key.expires_at), locale) }} {{ formatDateTimeNoSeconds(new Date(key.expires_at), locale) }} diff --git a/frontend/src/components/account/impersonation/ImpersonationPanel.vue b/frontend/src/components/account/impersonation/ImpersonationPanel.vue index 706efa360..5d40cfd73 100644 --- a/frontend/src/components/account/impersonation/ImpersonationPanel.vue +++ b/frontend/src/components/account/impersonation/ImpersonationPanel.vue @@ -6,7 +6,13 @@ @@ -193,6 +200,18 @@ const goToApplicationDetails = (application: Application) => { :description="state.error.message" class="mb-6" /> + + { - + +import { NeLink, NeSkeleton } from '@nethesis/vue-components' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' +import { faAngleRight } from '@fortawesome/free-solid-svg-icons' +import { RouterLink, type RouteLocationRaw } from 'vue-router' +import { useI18n } from 'vue-i18n' + +const { + section, + to = undefined, + current = undefined, + loading = false, +} = defineProps<{ + // title of the list page this detail page belongs to + section: string + // omit when the user cannot read the list page: the section renders as plain text + to?: RouteLocationRaw + // name of the entity shown by the detail page + current?: string + loading?: boolean +}>() + +const { t } = useI18n() + + + diff --git a/frontend/src/components/customers/CustomersTable.vue b/frontend/src/components/customers/CustomersTable.vue index cf23e2f6d..7bca88adf 100644 --- a/frontend/src/components/customers/CustomersTable.vue +++ b/frontend/src/components/customers/CustomersTable.vue @@ -54,6 +54,7 @@ import { useCustomers } from '@/queries/organizations/customers' import { canManageCustomers, canDestroyCustomers } from '@/lib/permissions' import router from '@/router' import UpdatingSpinner from '@/components/common/UpdatingSpinner.vue' +import OrganizationDropdownFilter from '@/components/organizations/OrganizationDropdownFilter.vue' const { isShownCreateCustomerDrawer = false } = defineProps<{ isShownCreateCustomerDrawer: boolean @@ -70,6 +71,8 @@ const { textFilter, statusFilter, createdByFilter, + organizationFilter, + includeHierarchy, sortBy, sortDescending, areDefaultFiltersApplied, @@ -269,7 +272,13 @@ const onSort = (payload: SortEvent) => { } const goToCustomerDetails = (customer: Customer) => { - router.push({ name: 'customer_detail', params: { companyId: customer.logto_id } }) + router + .push({ name: 'customer_detail', params: { companyId: customer.logto_id } }) + .catch((error) => { + // router.push() swallows navigation failures by default; log them so an + // intermittent "URL changes but the page doesn't" report leaves a trace + console.error('[goToCustomerDetails]', error) + }) } @@ -283,6 +292,18 @@ const goToCustomerDetails = (customer: Customer) => { :description="state.error.message" class="mb-6" /> + +
@@ -312,6 +333,12 @@ const goToCustomerDetails = (customer: Customer) => { :custom-action-label="t('ne_dropdown_filter.reset_selection')" @custom-action="resetStatusFilter" /> + + { - + { +
+ + + + +
diff --git a/frontend/src/components/distributors/DistributorsTable.vue b/frontend/src/components/distributors/DistributorsTable.vue index b83815c6f..d0a272516 100644 --- a/frontend/src/components/distributors/DistributorsTable.vue +++ b/frontend/src/components/distributors/DistributorsTable.vue @@ -271,7 +271,13 @@ const onSort = (payload: SortEvent) => { } const goToDistributorDetails = (distributor: Distributor) => { - router.push({ name: 'distributor_detail', params: { companyId: distributor.logto_id } }) + router + .push({ name: 'distributor_detail', params: { companyId: distributor.logto_id } }) + .catch((error) => { + // router.push() swallows navigation failures by default; log them so an + // intermittent "URL changes but the page doesn't" report leaves a trace + console.error('[goToDistributorDetails]', error) + }) } @@ -403,7 +409,7 @@ const goToDistributorDetails = (distributor: Distributor) => { - + { {{ item.custom_data?.vat || '-' }} -
+ + + +
{
-
+ + + +
{ +
() + const { t } = useI18n() const route = useRoute() @@ -45,7 +49,11 @@ const moreApplications = computed(() => { const goToApplications = () => { const companyId = route.params.companyId as string clearApplicationsFilters() - organizationFilterForApps.value = companyId ? [{ id: companyId, label: companyId }] : [] + // the filter renders the label carried by the selection: pass the organization + // name, as it may not be among the options the dropdown has loaded + organizationFilterForApps.value = companyId + ? [{ id: companyId, label: organizationName || companyId }] + : [] router.push({ name: 'applications' }) } @@ -57,6 +65,7 @@ const goToApplications = () => { :icon="faGridOne" :loading="applicationsSummary.status === 'pending'" :centeredCounter="!applicationsCount" + @counter-click="goToApplications" >
({ + useOrganizationFilter: () => ({ + organizations: ref(servedPage), + loading: ref(false), + onSearch: vi.fn(), + currentSearch: ref(''), + }), +})) + +const i18n = createI18n({ legacy: false, locale: 'en', missingWarn: false, fallbackWarn: false }) + +// The label is written to the input after mount, so flush before reading it. +async function mountAndReadDisplayedValue(props: { + modelValue: string + selectedOrganization?: { logto_id?: string; name: string; type: string } +}) { + const wrapper = mount(OrganizationCombobox, { + props: { label: 'Company', ...props }, + global: { plugins: [i18n] }, + }) + await flushPromises() + + return (wrapper.find('input').element as HTMLInputElement).value +} + +describe('OrganizationCombobox', () => { + it('displays a company that falls outside the served page of options', async () => { + expect( + await mountAndReadDisplayedValue({ + modelValue: 'org-zzz', + selectedOrganization: { logto_id: 'org-zzz', name: 'Zeta Ltd', type: 'customer' }, + }), + ).toBe('Zeta Ltd') + }) + + it('displays a company that is present in the served page', async () => { + expect(await mountAndReadDisplayedValue({ modelValue: 'org-bbb' })).toBe('Beta Ltd') + }) + + // Guards the regression this component exists to prevent: without the assigned + // company the field renders empty, which is what users saw in the edit drawers. + it('renders empty when a company outside the served page is not supplied', async () => { + expect(await mountAndReadDisplayedValue({ modelValue: 'org-zzz' })).toBe('') + }) + + it('ignores a supplied company whose id does not match the model value', async () => { + expect( + await mountAndReadDisplayedValue({ + modelValue: 'org-aaa', + selectedOrganization: { logto_id: 'org-zzz', name: 'Zeta Ltd', type: 'customer' }, + }), + ).toBe('Alpha Ltd') + }) + + // Owner-org entities come back from the API with an empty name; showing an + // unlabelled selection would be worse than leaving the field blank. + it('ignores a supplied company with a blank name', async () => { + expect( + await mountAndReadDisplayedValue({ + modelValue: 'org-owner', + selectedOrganization: { logto_id: 'org-owner', name: '', type: 'owner' }, + }), + ).toBe('') + }) +}) diff --git a/frontend/src/components/organizations/OrganizationCombobox.vue b/frontend/src/components/organizations/OrganizationCombobox.vue index 975129241..1abdb0c04 100644 --- a/frontend/src/components/organizations/OrganizationCombobox.vue +++ b/frontend/src/components/organizations/OrganizationCombobox.vue @@ -24,6 +24,11 @@ const props = withDefaults( allowedTypes?: string[] // Exclude these organization ids from the options (e.g. the caller's own org). excludeOrganizationIds?: string[] + // The organization already assigned to the entity being edited. The options + // are a single page of a server-side search, so a company that falls outside + // it has no option to resolve its label from — pass it here and the field can + // still show its name. + selectedOrganization?: { logto_id?: string; name: string; type: string } }>(), { isShown: true, @@ -56,6 +61,20 @@ const organizationOptions = computed(() => { })) }) +// Skipped when the name is empty (owner-org entities, whose name the API leaves +// blank) so the field stays blank instead of showing an unlabelled selection. +const selectedOption = computed(() => { + const org = props.selectedOrganization + if (!props.modelValue || org?.logto_id !== props.modelValue || !org.name) { + return undefined + } + return { + id: props.modelValue, + label: org.name, + description: t(`organizations.${org.type}`), + } +}) + const isLoading = loading const isInitiallyLoading = computed( @@ -80,6 +99,7 @@ defineExpose({ ref="comboboxRef" :model-value="props.modelValue" :options="organizationOptions" + :selected-option="selectedOption" :label="props.label" :placeholder="computedPlaceholder" :invalid-message="props.invalidMessage" diff --git a/frontend/src/components/organizations/OrganizationDropdownFilter.vue b/frontend/src/components/organizations/OrganizationDropdownFilter.vue index 435260289..631620787 100644 --- a/frontend/src/components/organizations/OrganizationDropdownFilter.vue +++ b/frontend/src/components/organizations/OrganizationDropdownFilter.vue @@ -14,10 +14,16 @@ const { modelValue, label, showNoCompanyOption = false, + organizationType, } = defineProps<{ modelValue: NeDropdownFilterV2Option[] label?: string showNoCompanyOption?: boolean + /** + * Restricts the options to a single company type (distributor, reseller, + * customer). Omit to offer every company the user can see. + */ + organizationType?: string }>() const emit = defineEmits<{ @@ -26,7 +32,10 @@ const emit = defineEmits<{ const { t } = useI18n() const computedLabel = label ?? t('organizations.organization') -const { options, loading, onSearch, currentSearch } = useOrganizationFilter() +const { options, loading, onSearch, currentSearch } = useOrganizationFilter( + undefined, + () => organizationType, +) const noCompanyLabel = computed(() => t('organizations.no_company')) diff --git a/frontend/src/components/organizations/OrganizationSystemsCard.vue b/frontend/src/components/organizations/OrganizationSystemsCard.vue index d87900bcf..8fc9c1c58 100644 --- a/frontend/src/components/organizations/OrganizationSystemsCard.vue +++ b/frontend/src/components/organizations/OrganizationSystemsCard.vue @@ -26,6 +26,7 @@ const props = defineProps<{ systemsStatus: 'pending' | 'success' | 'error' systemsData: SystemData | undefined statsStatus: 'pending' | 'success' | 'error' + organizationName?: string }>() const { t } = useI18n() @@ -48,7 +49,11 @@ const moreSystems = computed(() => { const goToSystems = () => { const companyId = route.params.companyId as string - organizationFilterForSystems.value = companyId ? [{ id: companyId, label: companyId }] : [] + // the filter renders the label carried by the selection: pass the organization + // name, as it may not be among the options the dropdown has loaded + organizationFilterForSystems.value = companyId + ? [{ id: companyId, label: props.organizationName || companyId }] + : [] router.push({ name: 'systems' }) } @@ -60,6 +65,7 @@ const goToSystems = () => { :icon="faServer" :loading="statsStatus === 'pending' || systemsStatus === 'pending'" :centeredCounter="!systemsCount" + @counter-click="goToSystems" >
{ } const goToResellerDetails = (reseller: Reseller) => { - router.push({ name: 'reseller_detail', params: { companyId: reseller.logto_id } }) + router + .push({ name: 'reseller_detail', params: { companyId: reseller.logto_id } }) + .catch((error) => { + // router.push() swallows navigation failures by default; log them so an + // intermittent "URL changes but the page doesn't" report leaves a trace + console.error('[goToResellerDetails]', error) + }) } @@ -333,6 +341,12 @@ const goToResellerDetails = (reseller: Reseller) => { :options-filter-placeholder="t('ne_dropdown_filter.options_filter_placeholder')" @custom-action="resetStatusFilter" /> + + { - + { {{ item.custom_data?.vat || '-' }} -
+ + + +
{ +
@@ -57,6 +63,7 @@ const goToApplications = () => { :icon="faGridOne" :loading="applicationsSummary.status === 'pending'" :centeredCounter="!applicationsCount" + @counter-click="goToApplications" >