Skip to content

Commit 38f9be2

Browse files
authored
Merge pull request #681 from nasa/harmony-1978
Harmony 1978 - Add/create label from jobs page of workflow ui
2 parents b453d9c + 36ce14b commit 38f9be2

File tree

16 files changed

+297
-94
lines changed

16 files changed

+297
-94
lines changed

services/harmony/app/frontends/labels.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { keysToLowerCase } from '../util/object';
1414
* @param req - The request sent by the client
1515
* @param res - The response to send to the client
1616
* @param next - The next function in the call chain
17-
* @returns Resolves when the request is complete
17+
* @returns Resolves when the request is complete, returning labels on success
1818
*/
1919
export async function addJobLabels(
2020
req: HarmonyRequest, res: Response, next: NextFunction,
@@ -28,7 +28,7 @@ export async function addJobLabels(
2828
});
2929

3030
res.status(201);
31-
res.send('OK');
31+
res.send({ labels: req.body.label });
3232
} catch (e) {
3333
req.context.logger.error(e);
3434
next(e);

services/harmony/app/frontends/workflow-ui.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,21 +102,27 @@ function parseQuery( /* eslint-disable @typescript-eslint/no-explicit-any */
102102
tableQuery.allowUsers = !(requestQuery.disallowuser === 'on');
103103
tableQuery.allowProviders = !(requestQuery.disallowprovider === 'on');
104104
const selectedOptions: { field: string, dbValue: string, value: string }[] = JSON.parse(requestQuery.tablefilter);
105+
105106
const validStatusSelections = selectedOptions
106107
.filter(option => option.field === 'status' && Object.values<string>(statusEnum).includes(option.dbValue));
107108
const statusValues = validStatusSelections.map(option => option.dbValue);
109+
108110
const validServiceSelections = selectedOptions
109111
.filter(option => option.field === 'service' && serviceNames.includes(option.dbValue));
110112
const serviceValues = validServiceSelections.map(option => option.dbValue);
113+
111114
const validUserSelections = selectedOptions
112115
.filter(option => isAdminAccess && /^user: [A-Za-z0-9\.\_]{4,30}$/.test(option.value));
113116
const userValues = validUserSelections.map(option => option.value.split('user: ')[1]);
117+
114118
const validLabelSelections = selectedOptions
115119
.filter(option => /^label: .{1,100}$/.test(option.value));
116-
const labelValues = validLabelSelections.map(option => option.value.split('label: ')[1].toLowerCase());
120+
const labelValues = validLabelSelections.map(option => option.dbValue || option.value.split('label: ')[1].toLowerCase());
121+
117122
const validProviderSelections = selectedOptions
118123
.filter(option => /^provider: [A-Za-z0-9_]{1,100}$/.test(option.value));
119124
const providerValues = validProviderSelections.map(option => option.value.split('provider: ')[1].toLowerCase());
125+
120126
if ((statusValues.length + serviceValues.length + userValues.length + providerValues.length) > maxFilters) {
121127
throw new RequestValidationError(`Maximum amount of filters (${maxFilters}) was exceeded.`);
122128
}
@@ -209,6 +215,9 @@ function jobRenderingFunctions(logger: Logger, requestQuery: Record<string, any>
209215
return this.request;
210216
}
211217
},
218+
jobLabels(): string {
219+
return JSON.stringify(this.labels || []);
220+
},
212221
jobLabelsDisplay(): string {
213222
return this.labels.map((label) => {
214223
const labelText = truncateString(label, 30);

services/harmony/app/middleware/label.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,6 @@ export default async function handleLabelParameter(
2323
// If 'label' exists, convert it to an array (if not already) and assign it to 'label' in the body
2424
if (label) {
2525
const labels = parseMultiValueParameter(label);
26-
for (const lbl of labels) {
27-
if (lbl.indexOf(',') > -1) {
28-
res.status(400);
29-
res.send('Labels cannot contain commas');
30-
return;
31-
}
32-
}
3326
const normalizedLabels = labels.map(normalizeLabel);
3427
for (const lbl of normalizedLabels) {
3528
if (lbl === '') {

services/harmony/app/models/job.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -499,11 +499,11 @@ export class Job extends DBRecord implements JobRecord {
499499
includeLabels = false,
500500
): Promise<{ data: Job[]; pagination: ILengthAwarePagination }> {
501501
let query;
502-
502+
const labelDelimiter = '*&%$#'; // can't use comma because some labels contain commas
503503
if (includeLabels) {
504504
if (constraints.labels) { // Requesting to limit the jobs based on the provided labels
505505
query = tx(Job.table)
506-
.select(`${Job.table}.*`, tx.raw(`STRING_AGG(${LABELS_TABLE}.value, ',' order by value) AS label_values`))
506+
.select(`${Job.table}.*`, tx.raw(`STRING_AGG(${LABELS_TABLE}.value, '${labelDelimiter}' order by value) AS label_values`))
507507
.leftOuterJoin(`${JOBS_LABELS_TABLE}`, `${Job.table}.jobID`, '=', `${JOBS_LABELS_TABLE}.job_id`)
508508
.leftOuterJoin(`${LABELS_TABLE}`, `${JOBS_LABELS_TABLE}.label_id`, '=', `${LABELS_TABLE}.id`)
509509
// Subquery that filters to get the list of jobIDs that match any of the provided labels
@@ -524,7 +524,7 @@ export class Job extends DBRecord implements JobRecord {
524524
.modify((queryBuilder) => modifyQuery(queryBuilder, constraints));
525525
} else {
526526
query = tx(Job.table)
527-
.select(`${Job.table}.*`, tx.raw(`STRING_AGG(${LABELS_TABLE}.value, ',' order by value) AS label_values`))
527+
.select(`${Job.table}.*`, tx.raw(`STRING_AGG(${LABELS_TABLE}.value, '${labelDelimiter}' order by value) AS label_values`))
528528
.leftOuterJoin(`${JOBS_LABELS_TABLE}`, `${Job.table}.jobID`, '=', `${JOBS_LABELS_TABLE}.job_id`)
529529
.leftOuterJoin(`${LABELS_TABLE}`, `${JOBS_LABELS_TABLE}.label_id`, '=', `${LABELS_TABLE}.id`)
530530
.where(setTableNameForWhereClauses(Job.table, constraints.where))
@@ -550,7 +550,7 @@ export class Job extends DBRecord implements JobRecord {
550550
const jobs: Job[] = items.data.map((j: JobWithLabels) => {
551551
const job = new Job(j);
552552
if (includeLabels && j.label_values) {
553-
job.labels = j.label_values.split(',');
553+
job.labels = j.label_values.split(labelDelimiter);
554554
} else {
555555
job.labels = [];
556556
}

services/harmony/app/models/label.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ export function checkLabel(label: string): string {
3131
* Trim the whitespace from the beginning/end of a label and convert it to lowercase
3232
*
3333
* @param label - the label to normalize
34-
* @returns - label converted to lowercase with leading/trailing whitespace trimmed and commas removed
34+
* @returns - label converted to lowercase with leading/trailing whitespace trimmed
3535
*/
3636
export function normalizeLabel(label: string): string {
37-
return label.trim().toLowerCase().replaceAll(',', '');
37+
return label.trim().toLowerCase();
3838
}
3939

4040
/**

services/harmony/app/views/workflow-ui/jobs/index.mustache.html

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,16 @@
4141
<!-- job state change links will go here -->
4242
{{^isAdminRoute}}
4343
{{#jobs.length}}
44-
{{#labels.length}}
4544
<li id="label-nav-item" class="nav-item dropstart d-none">
4645
<a id="label-dropdown-a" class="nav-link dropdown-toggle py-0 px-2" data-bs-toggle="dropdown" data-bs-auto-close="outside" href="#" role="button" aria-expanded="false">label</a>
47-
<ul class="dropdown-menu mt-2">
48-
<li class="mx-2 mb-2">
49-
<input type="text" class="form-control" id="label-search" placeholder="label name">
46+
<ul id="label-dropdown-menu" class="dropdown-menu mt-2">
47+
<li class="mx-2">
48+
<input type="text" class="form-control" id="label-search" placeholder="label name" maxlength="255">
5049
</li>
51-
<li id="no-match-li" class="fw-light text-center fs-6" style="display: none;"><i>no matches</i></li>
52-
<li>
50+
<li id="no-match-li" class="fw-light text-center fs-6 mx-2 mt-2" style="display: none;">
51+
<a href="#" id="create-label-link">Create Label</a>
52+
</li>
53+
<li id="labels-li" style="display: none;">
5354
<ul id="labels-list">
5455
{{#labels}}
5556
<li class="label-li"><a class="dropdown-item label-item text-truncate" name="{{.}}" data-value="{{.}}" href="#">{{.}}</a></li>
@@ -58,7 +59,6 @@
5859
</li>
5960
</ul>
6061
</li>
61-
{{/labels.length}}
6262
{{/jobs.length}}
6363
{{/isAdminRoute}}
6464
</ul>

services/harmony/app/views/workflow-ui/request-preview.mustache.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
data-truncated="{{jobRequestIsTruncated}}"></i>
55
<span title="{{jobRequest}}" class="text-muted job-url-text">{{jobRequestDisplay}}</span>
66
</div>
7-
<div class="ml-1" id="job-labels-display-{{jobID}}" data-labels="{{labels}}">
7+
<div class="ml-1" id="job-labels-display-{{jobID}}" data-labels="{{jobLabels}}">
88
{{{jobLabelsDisplay}}}
99
</div>
1010
</div>

services/harmony/public/css/workflow-ui/default.css

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,16 @@
7474

7575
.label-li {
7676
max-width: 200px;
77+
}
78+
79+
#label-dropdown-menu {
80+
max-width: 40vw;
81+
}
82+
83+
#create-label-link {
84+
word-wrap: break-word;
85+
}
86+
87+
.toast, .toast-container {
88+
z-index: 1021;
7789
}

services/harmony/public/js/workflow-ui/jobs/index.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ if (isAdminRoute) {
2626
params.dateKind = document.getElementById('dateKindUpdated').checked ? 'updatedAt' : 'createdAt';
2727
params.sortGranules = document.getElementById('sort-granules').value;
2828

29-
jobsTable.init(params);
29+
const tagInputPromise = jobsTable.init(params);
3030

3131
const jobStatusLinks = new JobsStatusChangeLinks();
3232
jobStatusLinks.init('job-state-links-container', 'job-selected');
@@ -35,5 +35,5 @@ toasts.init();
3535

3636
const labelDropdown = document.getElementById('label-dropdown-a');
3737
if (labelDropdown) {
38-
labels.init();
38+
labels.init(tagInputPromise);
3939
}

services/harmony/public/js/workflow-ui/jobs/jobs-table.js

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* eslint-disable no-param-reassign */
2-
import { formatDates, initCopyHandler } from '../table.js';
2+
import { formatDates, initCopyHandler, trimForDisplay } from '../table.js';
33
import PubSub from '../../pub-sub.js';
44

55
// all of the currently selected job IDs
@@ -9,12 +9,13 @@ let statuses = [];
99

1010
/**
1111
* Build the jobs filter with filter facets like 'status' and 'user'.
12-
* @param {string} currentUser - the current Harmony user
13-
* @param {string[]} services - service names from services.yml
14-
* @param {string[]} providers - array of provider ids
15-
* @param {string[]} labels - known job labels
16-
* @param {boolean} isAdminRoute - whether the current page is /admin/...
17-
* @param {object[]} tableFilter - initial tags that will populate the input
12+
* @param {string} currentUser - the current Harmony user
13+
* @param {string[]} services - service names from services.yml
14+
* @param {string[]} providers - array of provider ids
15+
* @param {string[]} labels - known job labels
16+
* @param {boolean} isAdminRoute - whether the current page is /admin/...
17+
* @param {object[]} tableFilter - initial tags that will populate the input
18+
* @returns the tag input instance
1819
*/
1920
function initFilter(currentUser, services, providers, labels, isAdminRoute, tableFilter) {
2021
const filterInput = document.querySelector('input[name="tableFilter"]');
@@ -33,14 +34,15 @@ function initFilter(currentUser, services, providers, labels, isAdminRoute, tabl
3334
allowedList.push(...serviceList);
3435
const providerList = providers.map((provider) => ({ value: `provider: ${provider}`, dbValue: provider, field: 'provider' }));
3536
allowedList.push(...providerList);
36-
const labelList = labels.map((label) => ({ value: `label: ${label}`, dbValue: label, field: 'label' }));
37+
const labelList = labels.map((label) => ({ value: `label: ${trimForDisplay(label, 30)}`, dbValue: label, field: 'label', searchBy: label }));
3738
allowedList.push(...labelList);
3839
if (isAdminRoute) {
3940
allowedList.push({ value: `user: ${currentUser}`, dbValue: currentUser, field: 'user' });
4041
}
4142
const allowedValues = allowedList.map((t) => t.value);
4243
const tagInput = new Tagify(filterInput, {
4344
whitelist: allowedList,
45+
delimiters: null, // prevent characters like "," from triggering input submission
4446
validate(tag) {
4547
if (allowedValues.includes(tag.value)
4648
|| /^provider: [A-Za-z0-9_]{1,100}$/.test(tag.value)
@@ -60,9 +62,26 @@ function initFilter(currentUser, services, providers, labels, isAdminRoute, tabl
6062
enabled: 0,
6163
closeOnSelect: true,
6264
},
65+
templates: {
66+
tag(tagData) {
67+
return `<tag title="${tagData.dbValue}"
68+
contenteditable='false'
69+
spellcheck='false'
70+
tabIndex="${this.settings.a11y.focusableTags ? 0 : -1}"
71+
class="${this.settings.classNames.tag}"
72+
${this.getAttributes(tagData)}>
73+
<x title='' class="${this.settings.classNames.tagX}" role='button' aria-label='remove tag'></x>
74+
<div>
75+
<span class="${this.settings.classNames.tagText}">${trimForDisplay(tagData.value.split(': ')[1], 20)}</span>
76+
</div>
77+
</tag>`;
78+
},
79+
},
6380
});
6481
const initialTags = JSON.parse(tableFilter);
6582
tagInput.addTags(initialTags);
83+
84+
return tagInput;
6685
}
6786

6887
/**
@@ -231,17 +250,27 @@ const jobsTable = {
231250
* tzOffsetMinutes - offset from UTC
232251
* dateKind - updatedAt or createdAt
233252
* sortGranules - sort the rows ascending ('asc') or descending ('desc')
253+
* @returns the tag input instance
234254
*/
235255
async init(params) {
236256
PubSub.subscribe(
237257
'row-state-change',
238258
async () => loadRows(params),
239259
);
240260
formatDates('.date-td');
241-
initFilter(params.currentUser, params.services, params.providers, params.labels, params.isAdminRoute, params.tableFilter);
261+
const tagInput = initFilter(
262+
params.currentUser,
263+
params.services,
264+
params.providers,
265+
params.labels,
266+
params.isAdminRoute,
267+
params.tableFilter,
268+
);
242269
initCopyHandler('.copy-request');
243270
initSelectHandler('.select-job');
244271
initSelectAllHandler();
272+
273+
return tagInput;
245274
},
246275

247276
/**

0 commit comments

Comments
 (0)