diff --git a/.changeset/rude-lamps-invent.md b/.changeset/rude-lamps-invent.md new file mode 100644 index 0000000..62229a5 --- /dev/null +++ b/.changeset/rude-lamps-invent.md @@ -0,0 +1,5 @@ +--- +"@fedimod/fires-server": patch +--- + +Implement ability to assign labels and internal comment during import diff --git a/components/fires-server/app/controllers/admin/imports_controller.ts b/components/fires-server/app/controllers/admin/imports_controller.ts index c8a01dd..f064e39 100644 --- a/components/fires-server/app/controllers/admin/imports_controller.ts +++ b/components/fires-server/app/controllers/admin/imports_controller.ts @@ -1,4 +1,5 @@ import Dataset from '#models/dataset' +import Label from '#models/label' import { ImportFileService } from '#services/import_file_service' import { importFileValidator, @@ -35,6 +36,7 @@ export default class ImportsController { const data = await request.validateUsing(importFileValidator) const dataset = await Dataset.findOrFail(data.dataset) + const labels = await Label.query().orderBy('deprecatedAt', 'desc').orderBy('id', 'desc') if (!data.file.isValid || !data.file.tmpPath) { session.flash('notification', { @@ -63,6 +65,10 @@ export default class ImportsController { unchanged: results.unchanged, missing: results.missing, defaultType: data.defaultType, + labels: labels.filter((l) => l.deprecatedAt === null).map((label) => label.serialize()), + deprecatedLabels: labels + .filter((l) => l.deprecatedAt !== null) + .map((label) => label.serialize()), }) } diff --git a/components/fires-server/app/validators/admin/imports.ts b/components/fires-server/app/validators/admin/imports.ts index b3b944f..2ee94ef 100644 --- a/components/fires-server/app/validators/admin/imports.ts +++ b/components/fires-server/app/validators/admin/imports.ts @@ -4,6 +4,26 @@ import Label from '#models/label' import vine from '@vinejs/vine' import { entityKeyActor, entityKeyDomain } from '#validators/admin/dataset_change' +const labelsValidator = vine.createRule( + async (value, _options, field) => { + if (!field.isValid) { + return + } + + if (!Array.isArray(value)) { + field.report('Invalid labels value', 'invalid_type', field) + return + } + + const labels = await Label.findMany(value) + if (value.length !== labels.length) { + field.report('Invalid labels', 'invalid', field) + return + } + }, + { isAsync: true } +) + export const importValidator = vine.compile( vine.object({ dataset: vine @@ -59,13 +79,15 @@ export const performImportValidator = vine.compile( type: vine.enum(['recommendation', 'advisory', 'retraction']), recommended_policy: vine.enum(DatasetChange.policies), labels: vine - .array( - vine.string().exists({ - table: Label.table, - column: 'id', - }) - ) + .array(vine.string()) + .parse((value) => { + if (typeof value === 'string') { + return value.split(/,\s*/) + } + return value + }) .distinct() + .use(labelsValidator()) .optional(), comment: vine.string().optional(), entity_kind: vine.enum(DatasetChange.entities), diff --git a/components/fires-server/resources/scripts/admin.js b/components/fires-server/resources/scripts/admin.js index ed15dba..83603c1 100644 --- a/components/fires-server/resources/scripts/admin.js +++ b/components/fires-server/resources/scripts/admin.js @@ -1,5 +1,6 @@ // @ts-nocheck import './lib/copyable' +import './admin/import' function addTranslation() { const template = document.getElementById('new-translation') diff --git a/components/fires-server/resources/scripts/admin/import.js b/components/fires-server/resources/scripts/admin/import.js new file mode 100644 index 0000000..d75c623 --- /dev/null +++ b/components/fires-server/resources/scripts/admin/import.js @@ -0,0 +1,140 @@ +;(() => { + const reviewForm = document.getElementById('import-review') + const manageLabelsDialog = document.getElementById('import-manage-labels') + if (!(reviewForm instanceof HTMLFormElement && manageLabelsDialog instanceof HTMLDialogElement)) { + return + } + + const html = document.documentElement + + const isOpenClass = 'modal-is-open' + + /** + * @type {HTMLDialogElement|null} + */ + let visibleModal = null + + /** + * + * @param {HTMLDialogElement} modal + */ + const openModal = (modal) => { + html.classList.add(isOpenClass) + modal.showModal() + visibleModal = modal + } + + /** + * + * @param {HTMLDialogElement} modal + */ + const closeModal = (modal) => { + handleClose(modal) + modal.close('cancel') + } + /** + * @param {HTMLDialogElement} modal + */ + const handleClose = (modal) => { + html.classList.remove(isOpenClass) + visibleModal = null + } + + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape' && visibleModal) { + closeModal(visibleModal) + } + }) + + document.addEventListener('click', (event) => { + if (!event.target || !(event.target instanceof HTMLButtonElement)) return + + // open modal button + if (!visibleModal && event.target.dataset.row) { + event.preventDefault() + + const row = event.target.dataset.row + const rowComment = event.target.dataset.rowComment + const labelInput = document.getElementById(`${row}-labels`) + const commentInput = document.getElementById(`${row}-comment`) + + if (!(labelInput instanceof HTMLInputElement)) { + return + } + + const labels = labelInput.value.split(',').filter((v) => v !== '') + + manageLabelsDialog.querySelectorAll('input[type="checkbox"]').forEach((input) => { + if (!(input instanceof HTMLInputElement)) return + if (labels.includes(input.value)) { + input.setAttribute('checked', 'checked') + input.checked = true + } else { + input.removeAttribute('checked') + input.checked = false + } + }) + + const commentTextarea = manageLabelsDialog.querySelector('input[name="comment"]') + if (commentTextarea instanceof HTMLInputElement && commentInput instanceof HTMLInputElement) { + commentTextarea.value = commentInput.value ?? '' + } + + const rowCommentSection = manageLabelsDialog.querySelector('#manage-row-comment') + const rowCommentField = manageLabelsDialog.querySelector('#manage-row-comment-text') + if (rowCommentField instanceof HTMLParagraphElement) { + if (!rowComment) { + rowCommentSection?.classList.add('d-hidden') + } else { + rowCommentSection?.classList.remove('d-hidden') + rowCommentField.innerText = rowComment + } + } + + manageLabelsDialog.dataset.row = row + openModal(manageLabelsDialog) + } + + // close modal button + if (visibleModal && event.target.getAttribute('rel') === 'prev') { + closeModal(visibleModal) + } + }) + + manageLabelsDialog.addEventListener('close', (event) => { + handleClose(manageLabelsDialog) + if (!event.currentTarget || !(event.currentTarget instanceof HTMLDialogElement)) return + if (event.currentTarget.returnValue == 'cancel') { + return + } + + const form = manageLabelsDialog.getElementsByTagName('form')[0] + const formData = new FormData(form) + + const comment = formData.get('comment')?.toString() + const labels = Array.from(formData.getAll('labels[]')) + const row = manageLabelsDialog.dataset.row + const labelInput = document.getElementById(`${row}-labels`) + const commentInput = document.getElementById(`${row}-comment`) + + if (!(labelInput instanceof HTMLInputElement) || !(commentInput instanceof HTMLInputElement)) { + return + } + + labelInput.value = labels.join(',') + if (comment) { + commentInput.value = comment + } else { + commentInput.value = '' + } + + console.log(`Set ${row} to ${labelInput.value}`) + }) + + manageLabelsDialog.querySelector('#cancel')?.addEventListener('click', () => { + if (visibleModal === manageLabelsDialog) { + manageLabelsDialog.dataset.row = undefined + closeModal(visibleModal) + } + }) +})() diff --git a/components/fires-server/resources/styles/app.scss b/components/fires-server/resources/styles/app.scss index 6825207..8c3cdcc 100644 --- a/components/fires-server/resources/styles/app.scss +++ b/components/fires-server/resources/styles/app.scss @@ -163,6 +163,10 @@ p.lead { margin-bottom: calc(var(--pico-typography-spacing-vertical) * 2); } +blockquote > p:last-child { + margin-bottom: 0; +} + .d-hidden { display: none; } @@ -200,6 +204,10 @@ button.inline { margin-left: calc(var(--pico-spacing) * 2); } +.mb-0 { + margin-bottom: 0 !important; +} + dl.labels dt { font-size: 1.4rem; font-weight: bold; @@ -313,7 +321,8 @@ input:is([type='file']) { } } -[role='group']:has(+ .form-hint) { +[role='group']:has(+ .form-hint), +:is(h1, h2, h3, h4, h5, h6):has(+ .form-hint) { margin-bottom: 0; & + .form-hint { @@ -543,3 +552,32 @@ select[disabled] { pointer-events: none; cursor: pointer; } + +dialog article { + padding: 0; + + header, + footer { + margin: 0; + padding: var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal); + background: var(--pico-color-slate-850); + border: none; + } + + header { + position: sticky; + top: 0px; + } + + form { + padding: var(--pico-block-spacing-vertical) var(--pico-block-spacing-horizontal); + } + + footer { + position: sticky; + bottom: 0px; + // Negative to account for form > footer + margin: var(--pico-block-spacing-vertical) calc(var(--pico-block-spacing-horizontal) * -1); + margin-bottom: calc(var(--pico-block-spacing-vertical) * -1); + } +} diff --git a/components/fires-server/resources/views/admin/imports/_review_header_row.edge b/components/fires-server/resources/views/admin/imports/_review_header_row.edge index 9a50989..62c2c8d 100644 --- a/components/fires-server/resources/views/admin/imports/_review_header_row.edge +++ b/components/fires-server/resources/views/admin/imports/_review_header_row.edge @@ -2,7 +2,8 @@ Change Type Kind Key - Recommended Policy + Policy + {{-- Edit button --}} @if(retraction) @endif diff --git a/components/fires-server/resources/views/admin/imports/_review_row.edge b/components/fires-server/resources/views/admin/imports/_review_row.edge index 28ceed9..cf7b5d3 100644 --- a/components/fires-server/resources/views/admin/imports/_review_row.edge +++ b/components/fires-server/resources/views/admin/imports/_review_row.edge @@ -1,4 +1,4 @@ -
+
+ +  {{ label.name }} + + @else +

+ You need to create labels first before you can assign them. +

+ @end + + @if(deprecatedLabels.length) +
+ Deprecated Labels +
+
+ @each(label in deprecatedLabels) + + @end +
+ @end + +
+ +
+ This comment is not publicly visible, and purely for your own information. + + + + + + + @endslot @end diff --git a/components/fires-server/resources/views/components/layouts/admin.edge b/components/fires-server/resources/views/components/layouts/admin.edge index 6861d16..69373c1 100644 --- a/components/fires-server/resources/views/components/layouts/admin.edge +++ b/components/fires-server/resources/views/components/layouts/admin.edge @@ -58,5 +58,8 @@ @!component('components/footer')
+ @if ($slots.modals) + {{{ await $slots.modals() }}} + @endif