Skip to content

Commit cc828c3

Browse files
Curate available filters instead of auto-generation (Kitware#3671)
Filters are currently automatically-generated based on the available GraphQL fields for a given filter input type. That works wonderfully when filtering by primary records, but breaks down when trying to add support for relationship filtering. For example, it would be difficult to add label relationship filters because users expect labels to simply be text strings, when in reality they are shared entities with id and text columns. This PR completely rewrites the filter UI field generation logic to instead use a predetermined list of available filter fields. The available fields are identical to the fields previously available. I plan to immediately follow this PR with a PR which adds label relationship filters.
1 parent c3c6583 commit cc828c3

7 files changed

Lines changed: 666 additions & 337 deletions

File tree

resources/js/vue/components/shared/DateTimeSelector.vue

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -96,20 +96,25 @@ export default {
9696
name: 'DateTimeSelector',
9797
props: {
9898
modelValue: {
99-
type: Object,
100-
default: () => DateTime.now().toUTC(),
99+
type: String,
100+
default: '',
101101
},
102102
},
103103
emits: ['update:modelValue'],
104104
data() {
105+
let dt = DateTime.fromISO(this.modelValue, { setZone: true });
106+
if (!dt.isValid) {
107+
dt = DateTime.now().toUTC();
108+
}
105109
return {
106-
year: this.modelValue.year,
107-
month: this.modelValue.month,
108-
day: this.modelValue.day,
109-
hour: this.modelValue.hour,
110-
minute: this.modelValue.minute,
111-
second: this.modelValue.second,
112-
timezone: this.getOffsetString(this.modelValue.offset / 60),
110+
year: dt.year,
111+
month: dt.month,
112+
day: dt.day,
113+
hour: dt.hour,
114+
minute: dt.minute,
115+
second: dt.second,
116+
timezone: this.getOffsetString(dt.offset / 60),
117+
lastEmittedValue: this.modelValue,
113118
};
114119
},
115120
computed: {
@@ -172,15 +177,30 @@ export default {
172177
second: 'updateDateTime',
173178
timezone: 'updateDateTime',
174179
modelValue(newValue) {
175-
this.year = newValue.year;
176-
this.month = newValue.month;
177-
this.day = newValue.day;
178-
this.hour = newValue.hour;
179-
this.minute = newValue.minute;
180-
this.second = newValue.second;
181-
this.timezone = this.getOffsetString(newValue.offset / 60);
180+
if (newValue === this.lastEmittedValue) {
181+
return;
182+
}
183+
let dt = DateTime.fromISO(newValue, { setZone: true });
184+
const isValid = dt.isValid;
185+
if (!isValid) {
186+
dt = DateTime.now().toUTC();
187+
}
188+
this.year = dt.year;
189+
this.month = dt.month;
190+
this.day = dt.day;
191+
this.hour = dt.hour;
192+
this.minute = dt.minute;
193+
this.second = dt.second;
194+
this.timezone = this.getOffsetString(dt.offset / 60);
195+
196+
if (isValid) {
197+
this.lastEmittedValue = dt.toISO({ suppressMilliseconds: true });
198+
}
182199
},
183200
},
201+
mounted() {
202+
this.updateDateTime();
203+
},
184204
methods: {
185205
getOffsetString(offsetHours) {
186206
return `UTC${offsetHours === 0 ? '' : (offsetHours > 0 ? '+' : '') + offsetHours}`;
@@ -202,7 +222,11 @@ export default {
202222
{ zone: this.timezone },
203223
);
204224
if (newDateTime.isValid) {
205-
this.$emit('update:modelValue', newDateTime);
225+
const isoString = newDateTime.toISO({ suppressMilliseconds: true });
226+
if (isoString !== this.lastEmittedValue) {
227+
this.lastEmittedValue = isoString;
228+
this.$emit('update:modelValue', isoString);
229+
}
206230
}
207231
},
208232
},

resources/js/vue/components/shared/FilterGroup.vue

Lines changed: 119 additions & 109 deletions
Original file line numberDiff line numberDiff line change
@@ -1,91 +1,125 @@
11
<template>
2-
<loading-indicator :is-loading="!result">
3-
<div class="tw-flex tw-w-full">
4-
<div class="tw-divider tw-divider-horizontal" />
5-
<div class="tw-flex tw-flex-col tw-w-full tw-gap-1">
6-
<div>
7-
<template v-if="primaryRecordName === ''">
8-
and
9-
</template>
10-
<template v-else>
11-
Show all {{ primaryRecordName }} where
12-
</template>
13-
<select
14-
class="tw-select tw-select-xs tw-select-bordered tw-shrink"
15-
@change="event => changeCombineType(event.target.value)"
2+
<div class="tw-flex tw-w-full">
3+
<div class="tw-divider tw-divider-horizontal" />
4+
<div class="tw-flex tw-flex-col tw-w-full tw-gap-1">
5+
<div>
6+
<template v-if="primaryRecordName === ''">
7+
and
8+
</template>
9+
<template v-else>
10+
Show all {{ primaryRecordName }} where
11+
</template>
12+
<select
13+
class="tw-select tw-select-xs tw-select-bordered tw-shrink"
14+
@change="event => changeCombineType(event.target.value)"
15+
>
16+
<option
17+
value="all"
18+
:selected="currentCombineType === 'all'"
1619
>
17-
<option
18-
value="all"
19-
:selected="currentCombineType === 'all'"
20-
>
21-
all
22-
</option>
23-
<option
24-
value="any"
25-
:selected="currentCombineType === 'any'"
26-
>
27-
any
28-
</option>
29-
</select> of the following are true
30-
</div>
31-
<div v-for="(entry, index) in filters[currentCombineType]">
32-
<filter-group
33-
v-if="entry !== 'deleted' && (entry.hasOwnProperty('any') || entry.hasOwnProperty('all'))"
34-
:initial-filters="entry"
35-
:type="type"
36-
@delete="changeRow('deleted', index)"
37-
@change-filters="newEntry => changeRow(newEntry, index)"
38-
/>
39-
<filter-row
40-
v-else-if="entry !== 'deleted'"
41-
:operators="operatorTypes.map(o => o.name)"
42-
:type="operatorTypes[0].type.name"
43-
:initial-field="filterToFilterRow(entry).field"
44-
:initial-operator="filterToFilterRow(entry).operator"
45-
:initial-value="filterToFilterRow(entry).value"
46-
@delete="changeRow('deleted', index)"
47-
@change-filters="newEntry => changeRow(newEntry, index)"
48-
/>
49-
</div>
50-
<div class="tw-flex tw-flex-row tw-w-full tw-gap-1">
51-
<button
52-
class="tw-btn tw-btn-xs"
53-
@click="addFilter"
20+
all
21+
</option>
22+
<option
23+
value="any"
24+
:selected="currentCombineType === 'any'"
5425
>
55-
<font-awesome-icon :icon="FA.faPlus" /> Add Filter
56-
</button>
57-
<button
58-
class="tw-btn tw-btn-xs"
59-
@click="addGroup"
60-
>
61-
<font-awesome-icon :icon="FA.faBarsStaggered" /> Add Group
62-
</button>
63-
<button
64-
class="tw-btn tw-btn-xs"
65-
@click="$emit('delete')"
66-
>
67-
<font-awesome-icon :icon="FA.faTrash" /> Delete Group
68-
</button>
69-
</div>
26+
any
27+
</option>
28+
</select> of the following are true
29+
</div>
30+
<div v-for="(entry, index) in filters[currentCombineType]">
31+
<filter-group
32+
v-if="entry !== 'deleted' && (entry.hasOwnProperty('any') || entry.hasOwnProperty('all'))"
33+
:initial-filters="entry"
34+
:type="type"
35+
@delete="changeRow('deleted', index)"
36+
@change-filters="newEntry => changeRow(newEntry, index)"
37+
/>
38+
<filter-row
39+
v-else-if="entry !== 'deleted'"
40+
:fields="availableFields"
41+
:initial-field="filterRowFromGraphQLFilter(entry).field"
42+
:initial-operator="filterRowFromGraphQLFilter(entry).operator"
43+
:initial-value="filterRowFromGraphQLFilter(entry).value"
44+
@delete="changeRow('deleted', index)"
45+
@change-filters="newEntry => changeRow(newEntry, index)"
46+
/>
47+
</div>
48+
<div class="tw-flex tw-flex-row tw-w-full tw-gap-1">
49+
<button
50+
class="tw-btn tw-btn-xs"
51+
@click="addFilter"
52+
>
53+
<font-awesome-icon :icon="FA.faPlus" /> Add Filter
54+
</button>
55+
<button
56+
class="tw-btn tw-btn-xs"
57+
@click="addGroup"
58+
>
59+
<font-awesome-icon :icon="FA.faBarsStaggered" /> Add Group
60+
</button>
61+
<button
62+
class="tw-btn tw-btn-xs"
63+
@click="$emit('delete')"
64+
>
65+
<font-awesome-icon :icon="FA.faTrash" /> Delete Group
66+
</button>
7067
</div>
7168
</div>
72-
</loading-indicator>
69+
</div>
7370
</template>
7471

7572
<script>
76-
import LoadingIndicator from './LoadingIndicator.vue';
77-
import {useQuery} from '@vue/apollo-composable';
78-
import gql from 'graphql-tag';
7973
import FilterRow from './FilterRow.vue';
8074
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome';
8175
import {
8276
faPlus,
8377
faBarsStaggered,
8478
faTrash,
8579
} from '@fortawesome/free-solid-svg-icons';
80+
import {BasicFilterField, FilterType, getEnumValues} from './Filters/FilterUtils';
81+
82+
const AVAILABLE_FILTERS = Object.freeze({
83+
BuildTestsFiltersMultiFilterInput: (apolloClient) => [
84+
new BasicFilterField('Name', FilterType.TEXT, null, 'name'),
85+
new BasicFilterField('Details', FilterType.TEXT, null, 'details'),
86+
new BasicFilterField('Running Time', FilterType.NUMBER, null, 'runningTime'),
87+
new BasicFilterField('Start Time', FilterType.DATETIME, null, 'startTime'),
88+
new BasicFilterField('Status', FilterType.ENUM, () => getEnumValues(apolloClient, 'TestStatus'), 'status'),
89+
new BasicFilterField('Time Status', FilterType.ENUM, () => getEnumValues(apolloClient, 'TestTimeStatusCategory'), 'timeStatusCategory'),
90+
],
91+
BuildCoverageFiltersMultiFilterInput: () => [
92+
new BasicFilterField('Lines of Code Tested', FilterType.NUMBER, null, 'linesOfCodeTested'),
93+
new BasicFilterField('Lines of Code Untested', FilterType.NUMBER, null, 'linesOfCodeUntested'),
94+
new BasicFilterField('Line Percentage', FilterType.NUMBER, null, 'linePercentage'),
95+
new BasicFilterField('Branches Tested', FilterType.NUMBER, null, 'branchesTested'),
96+
new BasicFilterField('Branches Untested', FilterType.NUMBER, null, 'branchesUntested'),
97+
new BasicFilterField('Branch Percentage', FilterType.NUMBER, null, 'branchPercentage'),
98+
new BasicFilterField('Functions Tested', FilterType.NUMBER, null, 'functionsTested'),
99+
new BasicFilterField('Functions Untested', FilterType.NUMBER, null, 'functionsUntested'),
100+
new BasicFilterField('Function Percentage', FilterType.NUMBER, null, 'functionPercentage'),
101+
new BasicFilterField('File Path', FilterType.TEXT, null, 'filePath'),
102+
new BasicFilterField('File', FilterType.TEXT, null, 'file'),
103+
],
104+
BuildCommandsFiltersMultiFilterInput: (apolloClient) => [
105+
new BasicFilterField('Type', FilterType.ENUM, () => getEnumValues(apolloClient, 'BuildCommandType'), 'type'),
106+
new BasicFilterField('Start Time', FilterType.DATETIME, null, 'startTime'),
107+
new BasicFilterField('Duration', FilterType.NUMBER, null, 'duration'),
108+
new BasicFilterField('Command', FilterType.TEXT, null, 'command'),
109+
new BasicFilterField('Working Directory', FilterType.TEXT, null, 'workingDirectory'),
110+
new BasicFilterField('Result', FilterType.TEXT, null, 'result'),
111+
new BasicFilterField('Source', FilterType.TEXT, null, 'source'),
112+
new BasicFilterField('Language', FilterType.TEXT, null, 'language'),
113+
new BasicFilterField('Config', FilterType.TEXT, null, 'config'),
114+
],
115+
BuildTargetsFiltersMultiFilterInput: (apolloClient) => [
116+
new BasicFilterField('Name', FilterType.TEXT, null, 'name'),
117+
new BasicFilterField('Type', FilterType.ENUM, () => getEnumValues(apolloClient, 'TargetType'), 'type'),
118+
],
119+
});
86120
87121
export default {
88-
components: { FontAwesomeIcon, FilterRow, LoadingIndicator },
122+
components: { FontAwesomeIcon, FilterRow },
89123
90124
props: {
91125
type: {
@@ -116,33 +150,10 @@ export default {
116150
'delete',
117151
],
118152
119-
setup(props) {
120-
const { result, error } = useQuery(gql`
121-
query {
122-
typeInformation: __type(name: "${props.type}") {
123-
inputFields {
124-
name
125-
type {
126-
name
127-
kind
128-
ofType {
129-
name
130-
}
131-
}
132-
}
133-
}
134-
}
135-
`);
136-
137-
return {
138-
result,
139-
error,
140-
};
141-
},
142-
143153
data() {
144154
return {
145155
filters: JSON.parse(JSON.stringify(this.initialFilters)),
156+
availableFields: AVAILABLE_FILTERS[this.type](this.$apollo.provider.defaultClient),
146157
};
147158
},
148159
@@ -156,12 +167,7 @@ export default {
156167
},
157168
158169
currentCombineType() {
159-
// eslint-disable-next-line no-prototype-builtins
160-
return this.filters.hasOwnProperty('any') ? 'any' : 'all';
161-
},
162-
163-
operatorTypes() {
164-
return this.result.typeInformation.inputFields.filter(f => f.type.kind !== 'LIST' && !f.type.name.endsWith('RelationshipFilterInput'));
170+
return 'any' in this.filters ? 'any' : 'all';
165171
},
166172
},
167173
@@ -178,19 +184,23 @@ export default {
178184
this.filters[this.currentCombineType].push({});
179185
},
180186
181-
filterToFilterRow(filter) {
182-
if (Object.keys(filter).length > 0 && Object.keys(Object.values(filter)[0]).length > 0) {
187+
filterRowFromGraphQLFilter(filter) {
188+
const filterField = this.availableFields.find(field => field.isMatch(filter));
189+
190+
if (filterField) {
183191
return {
184-
field: Object.keys(Object.values(filter)[0])[0],
185-
operator: Object.keys(filter)[0],
186-
value: Object.values(Object.values(filter)[0])[0],
192+
field: filterField,
193+
operator: filterField.getOperatorFromFilter(filter),
194+
value: filterField.getValueFromFilter(filter),
187195
};
188196
}
189197
else {
198+
const field = this.availableFields[0];
199+
190200
return {
191-
field: null,
192-
operator: null,
193-
value: null,
201+
field: field,
202+
operator: 'eq',
203+
value: '',
194204
};
195205
}
196206
},

0 commit comments

Comments
 (0)