diff --git a/arches_references/arches_references/media/js/viewmodels/reference-select.js b/arches_references/arches_references/media/js/viewmodels/reference-select.js new file mode 100644 index 0000000..01a27a1 --- /dev/null +++ b/arches_references/arches_references/media/js/viewmodels/reference-select.js @@ -0,0 +1,167 @@ +define([ + 'jquery', + 'knockout', + 'knockout-mapping', + 'arches', + 'viewmodels/widget', +], function($, ko, koMapping, arches, WidgetViewModel) { + var NAME_LOOKUP = {}; + var ReferenceSelectViewModel = function(params) { + var self = this; + + params.configKeys = ['placeholder']; + this.multiple = !!ko.unwrap(params.node.config.multiValue); + this.displayName = ko.observable(''); + this.selectionValue = ko.observable([]); // formatted version of this.value that select2 can use + this.activeLanguage = arches.activeLanguage; + + WidgetViewModel.apply(this, [params]); + + this.getPrefLabel = function(labels){ + return koMapping.toJS(labels)?.find( + label => label.language_id === arches.activeLanguage && label.valuetype_id === 'prefLabel' + )?.value || arches.translations.unlabeledItem; + }; + + this.isLabel = function (value) { + return ['prefLabel', 'altLabel'].includes(value.valuetype_id); + }; + + this.displayValue = ko.computed(function() { + const val = self.value(); + let name = null; + if (val) { + name = val.map(item => self.getPrefLabel(item.labels)).join(", "); + } + return name; + }); + + this.valueAndSelectionDiffer = function(value, selection) { + if (!(ko.unwrap(value) instanceof Array)) { + return true; + } + const valueUris = ko.unwrap(value).map(val=>ko.unwrap(val.uri)); + return JSON.stringify(selection) !== JSON.stringify(valueUris); + }; + + this.selectionValue.subscribe(selection => { + if (selection) { + if (!(selection instanceof Array)) { selection = [selection]; } + if (self.valueAndSelectionDiffer(self.value, selection)) { + const newItem = selection.map(uri => { + return { + "labels": NAME_LOOKUP[uri].labels, + "listid": NAME_LOOKUP[uri]["listid"], + "uri": uri + }; + }); + self.value(newItem); + } + } else { + self.value(null); + } + }); + + this.value.subscribe(val => { + if (val?.length) { + self.selectionValue(val.map(item=>ko.unwrap(item.uri))); + } else { + self.selectionValue(null); + } + }); + + this.select2Config = { + value: self.selectionValue, + clickBubble: true, + multiple: this.multiple, + closeOnSelect: true, + placeholder: self.placeholder, + allowClear: true, + ajax: { + url: arches.urls.controlled_list(ko.unwrap(params.node.config.controlledList)), + dataType: 'json', + quietMillis: 250, + data: function(requestParams) { + + return { + flat: true + }; + }, + processResults: function(data) { + const items = data.items; + items.forEach(item => { + item["listid"] = item.id; + item.id = item.uri; + item.disabled = item.guide; + item.labels = item.values.filter(val => self.isLabel(val)); + }); + return { + "results": items, + "pagination": { + "more": false + } + }; + } + }, + templateResult: function(item) { + let indentation = ''; + for (let i = 0; i < item.depth; i++) { + indentation += '    '; + } + + if (item.uri) { + const text = self.getPrefLabel(item.labels) || arches.translations.searching + '...'; + NAME_LOOKUP[item.uri] = {"prefLabel": text, "labels": item.labels, "listid": item.controlled_list_id}; + return indentation + text; + } + }, + templateSelection: function(item) { + if (!item.uri) { // option has a different shape when coming from initSelection vs templateResult + return item.text; + } else { + return NAME_LOOKUP[item.uri]["prefLabel"]; + } + }, + escapeMarkup: function(markup) { return markup; }, + initComplete: false, + initSelection: function(el, callback) { + + const setSelectionData = function(data) { + const valueData = koMapping.toJS(self.value()); + valueData.forEach(function(value) { + NAME_LOOKUP[value.uri] = { + "prefLabel": self.getPrefLabel(value.labels), + "labels": value.labels, + "listid": value.listid + }; + }); + + if(!self.select2Config.initComplete){ + valueData.forEach(function(data) { + const option = new Option( + self.getPrefLabel(data.labels), + data.uri, + true, + true + ); + $(el).append(option); + self.selectionValue().push(data.uri); + }); + self.select2Config.initComplete = true; + } + callback(valueData); + }; + + if (self.value()?.length) { + setSelectionData(); + } else { + callback([]); + } + + } + }; + + }; + + return ReferenceSelectViewModel; +}); \ No newline at end of file diff --git a/arches_references/arches_references/media/js/views/components/datatypes/reference.js b/arches_references/arches_references/media/js/views/components/datatypes/reference.js new file mode 100644 index 0000000..263f1bb --- /dev/null +++ b/arches_references/arches_references/media/js/views/components/datatypes/reference.js @@ -0,0 +1,54 @@ +define([ + 'knockout', + 'arches', + 'js-cookie', + 'templates/views/components/datatypes/reference.htm', + 'views/components/simple-switch', +], function(ko, arches, Cookies, referenceDatatypeTemplate) { + + const viewModel = function(params) { + const self = this; + this.search = params.search; + + if (this.search) { + params.config = ko.observable({ + controlledList:[], + placeholder: arches.translations.selectAnOption, + multiValue: true + }); + } + + this.controlledList = params.config.controlledList; + this.multiValue = params.config.multiValue; + this.controlledLists = ko.observable(); + this.getControlledLists = async function() { + const response = await fetch(arches.urls.controlled_lists, { + method: 'GET', + credentials: 'include', + headers: { + "X-CSRFToken": Cookies.get('csrftoken') + }, + }); + if (response.ok) { + return await response.json(); + } else { + console.error('Failed to fetch controlled lists'); + } + }; + + this.init = async function() { + const lists = await this.getControlledLists(); + this.controlledLists(lists?.controlled_lists); + }; + + this.init(); + }; + + + ko.components.register('reference-datatype-config', { + viewModel: viewModel, + template: referenceDatatypeTemplate, + }); + + return name; +}); \ No newline at end of file diff --git a/arches_references/arches_references/media/js/views/components/plugins/controlled-list-manager.js b/arches_references/arches_references/media/js/views/components/plugins/controlled-list-manager.js new file mode 100644 index 0000000..0e08a37 --- /dev/null +++ b/arches_references/arches_references/media/js/views/components/plugins/controlled-list-manager.js @@ -0,0 +1,14 @@ +import ko from 'knockout'; +import ControlledListManager from '@/arches/plugins/ControlledListManager.vue'; +import createVueApp from 'utils/create-vue-application'; +import ControlledListManagerTemplate from 'templates/views/components/plugins/controlled-list-manager.htm'; + + +ko.components.register('controlled-list-manager', { + viewModel: function() { + createVueApp(ControlledListManager).then((vueApp) => { + vueApp.mount('#controlled-list-manager-mounting-point'); + }); + }, + template: ControlledListManagerTemplate, +}); \ No newline at end of file diff --git a/arches_references/arches_references/media/js/views/components/widgets/reference-select.js b/arches_references/arches_references/media/js/views/components/widgets/reference-select.js new file mode 100644 index 0000000..1e4ec41 --- /dev/null +++ b/arches_references/arches_references/media/js/views/components/widgets/reference-select.js @@ -0,0 +1,15 @@ +define([ + 'knockout', + 'viewmodels/reference-select', + 'templates/views/components/widgets/reference-select.htm', + 'bindings/select2-query', +], function(ko, ReferenceSelectViewModel, referenceSelectTemplate) { + const viewModel = function(params) { + ReferenceSelectViewModel.apply(this, [params]); + }; + + return ko.components.register('reference-select-widget', { + viewModel: viewModel, + template: referenceSelectTemplate, + }); +}); \ No newline at end of file diff --git a/arches_references/arches_references/src/arches-references/api.ts b/arches_references/arches_references/src/arches-references/api.ts new file mode 100644 index 0000000..0177717 --- /dev/null +++ b/arches_references/arches_references/src/arches-references/api.ts @@ -0,0 +1,277 @@ +import arches from "arches"; +import Cookies from "js-cookie"; + +import { makeParentMap, makeSortOrderMap } from "@/controlled-lists/utils.ts"; + +import type { + ControlledList, + ControlledListItem, + ControlledListItemImage, + ControlledListItemImageMetadata, + Value, + NewControlledListItemImageMetadata, + NewOrExistingValue, +} from "@/controlled-lists/types"; + +function getToken() { + const token = Cookies.get("csrftoken"); + if (!token) { + throw new Error("Missing csrftoken"); + } + return token; +} + +export const fetchLists = async () => { + const response = await fetch(arches.urls.controlled_lists); + try { + const parsed = await response.json(); + if (response.ok) { + return parsed; + } + throw new Error(parsed.message); + } catch (error) { + throw new Error((error as Error).message || response.statusText); + } +}; + +export const createList = async (name: string) => { + const response = await fetch(arches.urls.controlled_list_add, { + method: "POST", + headers: { "X-CSRFToken": getToken() }, + body: JSON.stringify({ name }), + }); + try { + const parsed = await response.json(); + if (response.ok) { + return parsed; + } + throw new Error(parsed.message); + } catch (error) { + throw new Error((error as Error).message || response.statusText); + } +}; + +export const createItem = async (item: ControlledListItem) => { + const response = await fetch(arches.urls.controlled_list_item_add, { + method: "POST", + headers: { "X-CSRFToken": getToken() }, + body: JSON.stringify(item), + }); + try { + const parsed = await response.json(); + if (response.ok) { + return parsed; + } + throw new Error(parsed.message); + } catch (error) { + throw new Error((error as Error).message || response.statusText); + } +}; + +export const patchItem = async ( + item: ControlledListItem, + field: "uri" | "guide", +) => { + const response = await fetch(arches.urls.controlled_list_item(item.id), { + method: "PATCH", + headers: { "X-CSRFToken": getToken() }, + body: JSON.stringify({ [field]: item[field] }), + }); + if (response.ok) { + return true; + } + try { + const error = await response.json(); + throw new Error(error.message); + } catch (error) { + throw new Error((error as Error).message || response.statusText); + } +}; + +export const patchList = async ( + list: ControlledList, + field: "name" | "sortorder" | "children", +) => { + let body = {}; + switch (field) { + case "name": + body = { name: list.name }; + break; + case "sortorder": + body = { sortorder_map: makeSortOrderMap(list) }; + break; + case "children": + // Parentage is adjusted on the children themselves. + body = { + parent_map: makeParentMap(list), + sortorder_map: makeSortOrderMap(list), + }; + break; + } + + const response = await fetch(arches.urls.controlled_list(list.id), { + method: "PATCH", + headers: { "X-CSRFToken": getToken() }, + body: JSON.stringify(body), + }); + if (response.ok) { + return true; + } + try { + const error = await response.json(); + throw new Error(error.message); + } catch (error) { + throw new Error((error as Error).message || response.statusText); + } +}; + +export const deleteLists = async (listIds: string[]) => { + const promises = listIds.map((id) => + fetch(arches.urls.controlled_list(id), { + method: "DELETE", + headers: { "X-CSRFToken": getToken() }, + }), + ); + const settled = await Promise.allSettled(promises); + const errors = []; + for (const fulfilled of settled.filter( + (prom) => prom.status === "fulfilled", + )) { + const resp = fulfilled as PromiseFulfilledResult; + if (!resp.value.ok) { + const error = await resp.value.json(); + errors.push(error.message); + } + } + if (errors.length) { + throw new Error(errors.join("|")); + } + return true; +}; + +export const deleteItems = async (itemIds: string[]) => { + const promises = itemIds.map((id) => + fetch(arches.urls.controlled_list_item(id), { + method: "DELETE", + headers: { "X-CSRFToken": getToken() }, + }), + ); + const settled = await Promise.allSettled(promises); + const errors = []; + for (const fulfilled of settled.filter( + (prom) => prom.status === "fulfilled", + )) { + const resp = fulfilled as PromiseFulfilledResult; + if (!resp.value.ok) { + const error = await resp.value.json(); + errors.push(error.message); + } + } + if (errors.length) { + throw new Error(errors.join("|")); + } + return true; +}; + +export const upsertValue = async (value: NewOrExistingValue) => { + const url = value.id + ? arches.urls.controlled_list_item_value(value.id) + : arches.urls.controlled_list_item_value_add; + const method = value.id ? "PUT" : "POST"; + const response = await fetch(url, { + method, + headers: { "X-CSRFToken": getToken() }, + body: JSON.stringify(value), + }); + try { + const parsed = await response.json(); + if (response.ok) { + return parsed; + } + throw new Error(parsed.message); + } catch (error) { + throw new Error((error as Error).message || response.statusText); + } +}; + +export const deleteValue = async (value: Value) => { + const response = await fetch( + arches.urls.controlled_list_item_value(value.id), + { + method: "DELETE", + headers: { "X-CSRFToken": getToken() }, + }, + ); + if (response.ok) { + return true; + } + try { + const error = await response.json(); + throw new Error(error.message); + } catch (error) { + throw new Error((error as Error).message || response.statusText); + } +}; + +export const upsertMetadata = async ( + metadata: NewControlledListItemImageMetadata, +) => { + const url = metadata.id + ? arches.urls.controlled_list_item_image_metadata(metadata.id) + : arches.urls.controlled_list_item_image_metadata_add; + const method = metadata.id ? "PUT" : "POST"; + const response = await fetch(url, { + method, + headers: { "X-CSRFToken": getToken() }, + body: JSON.stringify(metadata), + }); + try { + const parsed = await response.json(); + if (response.ok) { + return parsed; + } + throw new Error(parsed.message); + } catch (error) { + throw new Error((error as Error).message || response.statusText); + } +}; + +export const deleteMetadata = async ( + metadata: ControlledListItemImageMetadata, +) => { + const response = await fetch( + arches.urls.controlled_list_item_image_metadata(metadata.id), + { + method: "DELETE", + headers: { "X-CSRFToken": getToken() }, + }, + ); + if (response.ok) { + return true; + } + try { + const error = await response.json(); + throw new Error(error.message); + } catch (error) { + throw new Error((error as Error).message || response.statusText); + } +}; + +export const deleteImage = async (image: ControlledListItemImage) => { + const response = await fetch( + arches.urls.controlled_list_item_image(image.id), + { + method: "DELETE", + headers: { "X-CSRFToken": getToken() }, + }, + ); + if (response.ok) { + return true; + } + try { + const error = await response.json(); + throw new Error(error.message); + } catch (error) { + throw new Error((error as Error).message || response.statusText); + } +}; diff --git a/arches_references/arches_references/src/arches-references/components/ControlledListMain.vue b/arches_references/arches_references/src/arches-references/components/ControlledListMain.vue new file mode 100644 index 0000000..b5f9af9 --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/ControlledListMain.vue @@ -0,0 +1,101 @@ + + + + + \ No newline at end of file diff --git a/arches_references/arches_references/src/arches-references/components/editor/AddMetadata.vue b/arches_references/arches_references/src/arches-references/components/editor/AddMetadata.vue new file mode 100644 index 0000000..8c26a9e --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/AddMetadata.vue @@ -0,0 +1,99 @@ + + + + + \ No newline at end of file diff --git a/arches_references/arches_references/src/arches-references/components/editor/AddValue.vue b/arches_references/arches_references/src/arches-references/components/editor/AddValue.vue new file mode 100644 index 0000000..5ffe94f --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/AddValue.vue @@ -0,0 +1,130 @@ + + + + + \ No newline at end of file diff --git a/arches_references/arches_references/src/arches-references/components/editor/ImageEditor.vue b/arches_references/arches_references/src/arches-references/components/editor/ImageEditor.vue new file mode 100644 index 0000000..cdbd0fa --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/ImageEditor.vue @@ -0,0 +1,52 @@ + + + \ No newline at end of file diff --git a/arches_references/arches_references/src/arches-references/components/editor/ImageMetadata.vue b/arches_references/arches_references/src/arches-references/components/editor/ImageMetadata.vue new file mode 100644 index 0000000..49c770e --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/ImageMetadata.vue @@ -0,0 +1,427 @@ + + + + + diff --git a/arches_references/arches_references/src/arches-references/components/editor/ItemEditor.vue b/arches_references/arches_references/src/arches-references/components/editor/ItemEditor.vue new file mode 100644 index 0000000..4c4cca4 --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/ItemEditor.vue @@ -0,0 +1,36 @@ + + + diff --git a/arches_references/arches_references/src/arches-references/components/editor/ItemHeader.vue b/arches_references/arches_references/src/arches-references/components/editor/ItemHeader.vue new file mode 100644 index 0000000..3042e0b --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/ItemHeader.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/arches_references/arches_references/src/arches-references/components/editor/ItemImages.vue b/arches_references/arches_references/src/arches-references/components/editor/ItemImages.vue new file mode 100644 index 0000000..90da655 --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/ItemImages.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/arches_references/arches_references/src/arches-references/components/editor/ItemType.vue b/arches_references/arches_references/src/arches-references/components/editor/ItemType.vue new file mode 100644 index 0000000..3ec70ce --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/ItemType.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/arches_references/arches_references/src/arches-references/components/editor/ItemURI.vue b/arches_references/arches_references/src/arches-references/components/editor/ItemURI.vue new file mode 100644 index 0000000..b537f73 --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/ItemURI.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/arches_references/arches_references/src/arches-references/components/editor/ListCharacteristic.vue b/arches_references/arches_references/src/arches-references/components/editor/ListCharacteristic.vue new file mode 100644 index 0000000..eabc839 --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/ListCharacteristic.vue @@ -0,0 +1,160 @@ + + + + + diff --git a/arches_references/arches_references/src/arches-references/components/editor/ListCharacteristics.vue b/arches_references/arches_references/src/arches-references/components/editor/ListCharacteristics.vue new file mode 100644 index 0000000..da824f2 --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/ListCharacteristics.vue @@ -0,0 +1,89 @@ + + + + + diff --git a/arches_references/arches_references/src/arches-references/components/editor/ReferenceNodeLink.vue b/arches_references/arches_references/src/arches-references/components/editor/ReferenceNodeLink.vue new file mode 100644 index 0000000..25a4666 --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/ReferenceNodeLink.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/arches_references/arches_references/src/arches-references/components/editor/ValueEditor.vue b/arches_references/arches_references/src/arches-references/components/editor/ValueEditor.vue new file mode 100644 index 0000000..18c544a --- /dev/null +++ b/arches_references/arches_references/src/arches-references/components/editor/ValueEditor.vue @@ -0,0 +1,450 @@ + + +