Skip to content

Commit 7c58071

Browse files
committed
Maintenance: Desktop View - Improve grouping feature for overviews if custom object manager attributes are used.
1 parent 2368f93 commit 7c58071

9 files changed

Lines changed: 135 additions & 42 deletions

File tree

app/frontend/apps/desktop/components/CommonFlyoutObjectForm/CommonFlyoutObjectForm.vue

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,8 @@ import type {
1414
import { useForm } from '#shared/components/Form/useForm.ts'
1515
import { useObjectAttributeFormData } from '#shared/entities/object-attributes/composables/useObjectAttributeFormData.ts'
1616
import { useObjectAttributes } from '#shared/entities/object-attributes/composables/useObjectAttributes.ts'
17-
import type {
18-
EnumFormUpdaterId,
19-
EnumObjectManagerObjects,
20-
ObjectAttributeValue,
21-
} from '#shared/graphql/types.ts'
17+
import { flattenObjectAttributeValues } from '#shared/entities/object-attributes/utils.ts'
18+
import type { EnumFormUpdaterId, EnumObjectManagerObjects } from '#shared/graphql/types.ts'
2219
import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
2320
import type { OperationMutationFunction } from '#shared/types/server/apollo/handler.ts'
2421
import type { ObjectLike } from '#shared/types/utils.ts'
@@ -69,14 +66,7 @@ const { form } = useForm()
6966
// Formkit relies on it to manage form state internally and not reuse the same form in memory
7067
const formNodeId = computed(() => `${props.name}-${getUuid()}`)
7168
72-
const objectAttributes: Record<string, string> =
73-
props.object?.objectAttributeValues?.reduce(
74-
(acc: Record<string, string>, cur: ObjectAttributeValue) => {
75-
acc[cur.attribute.name] = cur.value
76-
return acc
77-
},
78-
{},
79-
) || {}
69+
const objectAttributes = flattenObjectAttributeValues(props.object?.objectAttributeValues)
8070
8171
const initialFlatObject = {
8272
...props.object,

app/frontend/apps/desktop/components/CommonTable/CommonAdvancedTable.vue

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import ObjectAttributeContent from '#shared/components/ObjectAttributes/ObjectAt
1010
import { useOnEmitter } from '#shared/composables/useOnEmitter.ts'
1111
import { useObjectAttributes } from '#shared/entities/object-attributes/composables/useObjectAttributes.ts'
1212
import type { ObjectAttribute } from '#shared/entities/object-attributes/types/store.ts'
13+
import { flattenObjectAttributeValues } from '#shared/entities/object-attributes/utils.ts'
1314
import type { TicketById } from '#shared/entities/ticket/types.ts'
1415
import { EnumObjectManagerObjects, EnumOrderDirection } from '#shared/graphql/types.ts'
1516
import { i18n } from '#shared/i18n.ts'
@@ -296,6 +297,27 @@ const groupByAttributeItemName = computed(() => {
296297
return groupByAttribute.value.dataOption?.belongs_to || groupByAttribute.value.name
297298
})
298299
300+
const extractGroupByValue = (
301+
item: TableAdvancedItem,
302+
name: string,
303+
isRelation: boolean,
304+
): string | number => {
305+
// Relation: Use related object's identifier in case we're dealing with a relation,
306+
// otherwise use item's own value (e.g. state of a ticket).
307+
const value = (isRelation && item[name] ? (item[name] as ObjectLike).id : item[name]) as
308+
| string
309+
| number
310+
311+
if (value) return value
312+
313+
// Custom object manager attribute.
314+
const objectAttributeValues = flattenObjectAttributeValues(
315+
(item as ObjectLike).objectAttributeValues,
316+
)
317+
318+
return objectAttributeValues[name] as string | number
319+
}
320+
299321
const groupByRowCounts = computed(() => {
300322
if (!groupByAttribute.value || !groupByAttributeItemName.value) return
301323
@@ -306,9 +328,7 @@ const groupByRowCounts = computed(() => {
306328
let lastValue: string | number
307329
308330
return localItems.value.reduce((groupByRowIds: string[][], item) => {
309-
const value = (isRelation && item[name] ? (item[name] as ObjectLike).id : item[name]) as
310-
| string
311-
| number
331+
const value = extractGroupByValue(item, name, isRelation)
312332
313333
if ((lastValue && value !== lastValue) || (groupByRowIds.length > 0 && !lastValue && value)) {
314334
groupByValueIndex += 1

app/frontend/apps/desktop/components/CommonTable/__tests__/CommonAdvancedTable.spec.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,4 +656,83 @@ describe('CommonAdvancedTable', () => {
656656

657657
expect(wrapper.getByRole('row', { name: 'Nicole Braun2+' })).toBeInTheDocument()
658658
})
659+
660+
it('supports grouping with object manager attributes', async () => {
661+
const ticketObjectAttributesPayload = ticketObjectAttributes()
662+
mockObjectManagerFrontendAttributesQuery({
663+
objectManagerFrontendAttributes: {
664+
...ticketObjectAttributesPayload,
665+
attributes: [
666+
...ticketObjectAttributesPayload.attributes,
667+
{
668+
name: 'dummy',
669+
display: 'dummy',
670+
dataType: 'input',
671+
dataOption: {
672+
default: '',
673+
type: 'url',
674+
maxlength: 120,
675+
null: true,
676+
options: {},
677+
relation: '',
678+
},
679+
isInternal: false,
680+
screens: {
681+
create_middle: {
682+
shown: true,
683+
required: false,
684+
item_class: 'column',
685+
},
686+
edit: {
687+
shown: true,
688+
required: false,
689+
},
690+
},
691+
__typename: 'ObjectManagerFrontendAttribute',
692+
},
693+
],
694+
},
695+
})
696+
697+
const items = [
698+
createDummyTicket(),
699+
createDummyTicket({
700+
ticketId: '2',
701+
title: faker.word.sample(),
702+
objectAttributeValues: [
703+
{
704+
attribute: {
705+
name: 'dummy',
706+
display: 'dummy',
707+
},
708+
value: 'grouping1',
709+
renderedLink: null,
710+
},
711+
],
712+
}),
713+
]
714+
715+
const wrapper = await renderTable({
716+
headers: [
717+
'priorityIcon',
718+
'stateIcon',
719+
'title',
720+
'customer',
721+
'organization',
722+
'group',
723+
'owner',
724+
'state',
725+
'created_at',
726+
'dummy',
727+
],
728+
items,
729+
hasCheckboxColumn: true,
730+
totalItems: 30,
731+
maxItems: 30,
732+
groupBy: 'dummy',
733+
caption: 'Table caption',
734+
})
735+
736+
expect(wrapper.getByRole('row', { name: 'grouping11+' })).toBeInTheDocument()
737+
})
659738
})

app/frontend/apps/mobile/components/CommonDialogObjectForm/CommonDialogObjectForm.vue

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,8 @@ import { useForm } from '#shared/components/Form/useForm.ts'
1212
import { useConfirmation } from '#shared/composables/useConfirmation.ts'
1313
import { useObjectAttributeFormData } from '#shared/entities/object-attributes/composables/useObjectAttributeFormData.ts'
1414
import { useObjectAttributes } from '#shared/entities/object-attributes/composables/useObjectAttributes.ts'
15-
import type {
16-
EnumFormUpdaterId,
17-
EnumObjectManagerObjects,
18-
ObjectAttributeValue,
19-
} from '#shared/graphql/types.ts'
15+
import { flattenObjectAttributeValues } from '#shared/entities/object-attributes/utils.ts'
16+
import type { EnumFormUpdaterId, EnumObjectManagerObjects } from '#shared/graphql/types.ts'
2017
import { MutationHandler } from '#shared/server/apollo/handler/index.ts'
2118
import type { OperationMutationFunction } from '#shared/types/server/apollo/handler.ts'
2219
import type { ObjectLike } from '#shared/types/utils.ts'
@@ -49,18 +46,11 @@ const updateMutation = new MutationHandler(props.mutation({}), {
4946
})
5047
const { form, isDirty, canSubmit } = useForm()
5148
52-
const objectAtrributes: Record<string, string> =
53-
props.object?.objectAttributeValues?.reduce(
54-
(acc: Record<string, string>, cur: ObjectAttributeValue) => {
55-
acc[cur.attribute.name] = cur.value
56-
return acc
57-
},
58-
{},
59-
) || {}
49+
const objectAttributes = flattenObjectAttributeValues(props.object?.objectAttributeValues)
6050
6151
const initialFlatObject = {
6252
...props.object,
63-
...objectAtrributes,
53+
...objectAttributes,
6454
}
6555
6656
const { attributesLookup: objectAttributesLookup } = useObjectAttributes(props.type)

app/frontend/shared/components/Form/Form.vue

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { NotificationTypes } from '#shared/components/CommonNotifications/types.
2424
import { useNotifications } from '#shared/components/CommonNotifications/useNotifications.ts'
2525
import { useObjectAttributeFormFields } from '#shared/entities/object-attributes/composables/useObjectAttributeFormFields.ts'
2626
import { useObjectAttributeLoadFormFields } from '#shared/entities/object-attributes/composables/useObjectAttributeLoadFormFields.ts'
27+
import { flattenObjectAttributeValues } from '#shared/entities/object-attributes/utils.ts'
2728
import UserError from '#shared/errors/UserError.ts'
2829
import type {
2930
EnumObjectManagerObjects,
@@ -457,14 +458,7 @@ const setInitialEntityObjectAttributeMap = (initialEntityObject = props.initialE
457458
458459
// Reduce object attribute values to flat structure
459460
initialEntityObjectAttributeMap =
460-
objectAttributeValues.reduce((acc: Record<string, FormFieldValue>, cur) => {
461-
const { attribute } = cur
462-
463-
if (!attribute || !attribute.name) return acc
464-
465-
acc[attribute.name] = cur.value
466-
return acc
467-
}, {}) || {}
461+
flattenObjectAttributeValues<FormFieldValue>(objectAttributeValues)
468462
}
469463
470464
// Initialize the initial entity object attribute map during the setup in a static way.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
2+
3+
import type { ObjectAttributeValue } from '#shared/graphql/types.ts'
4+
5+
import type { PartialDeep } from 'type-fest'
6+
7+
export const flattenObjectAttributeValues = <T = ObjectAttributeValue['value']>(
8+
objectAttributeValues?: Array<PartialDeep<ObjectAttributeValue>> | null,
9+
): Record<string, T> => {
10+
if (!objectAttributeValues?.length) return {}
11+
12+
return objectAttributeValues.reduce((acc: Record<string, T>, cur) => {
13+
const attributeName = cur.attribute?.name
14+
if (!attributeName) return acc
15+
16+
acc[attributeName] = cur.value as T
17+
return acc
18+
}, {})
19+
}

app/frontend/shared/entities/ticket-article/__tests__/mocks/ticket.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export const createDummyTicket = <R = TicketQuery['ticket']>(options?: {
125125
firstResponseEscalationAt?: TicketQuery['ticket']['firstResponseEscalationAt']
126126
updateEscalationAt?: TicketQuery['ticket']['updateEscalationAt']
127127
closeEscalationAt?: TicketQuery['ticket']['closeEscalationAt']
128+
objectAttributeValues?: TicketQuery['ticket']['objectAttributeValues']
128129
}): R => {
129130
return nullableMock({
130131
__typename: 'Ticket',
@@ -147,7 +148,7 @@ export const createDummyTicket = <R = TicketQuery['ticket']>(options?: {
147148
state: options?.state === undefined ? defaultState : options?.state,
148149
group: options?.group === undefined ? defaultGroup : options?.group,
149150
priority: options?.defaultPriority === undefined ? defaultPriority : options?.defaultPriority,
150-
objectAttributeValues: [],
151+
objectAttributeValues: options?.objectAttributeValues || [],
151152
policy: options?.defaultPolicy === undefined ? defaultPolicy : options?.defaultPolicy,
152153
tags: options?.tags || [],
153154
timeUnit: options?.timeUnit || null,

app/models/concerns/can_selector/advanced_sorting/default_sort.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def self.applicable?(_input, _locale, _object)
99

1010
def calculate_sorting
1111
if object.columns_hash[adjusted_column.to_s].type == :string
12-
"#{raw_selectors_quoted_column(adjusted_column)} #{collate} #{direction}"
12+
"COALESCE(#{raw_selectors_quoted_column(adjusted_column)}, '') #{collate} #{direction}"
1313
else
1414
"#{raw_selectors_quoted_column(adjusted_column)} #{direction}"
1515
end

spec/models/concerns/can_selector/advanced_sorting/default_sort_spec.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,9 @@
1616
context 'when given a string column' do
1717
let(:column) { 'number' }
1818

19-
it 'includes collation on Postgres' do
19+
it 'includes collation on Postgres and treats NULL as empty string' do
2020
expect(instance.calculate_sorting)
21-
.to eq('"tickets"."number" COLLATE "de-DE-x-icu" ASC')
21+
.to eq('COALESCE("tickets"."number", \'\') COLLATE "de-DE-x-icu" ASC')
2222
end
2323
end
2424

0 commit comments

Comments
 (0)