diff --git a/CHANGELOG.md b/CHANGELOG.md index 0647491b3..50c249dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog All notable changes to this project will be documented in this file. +## 0.10 - Jelling +### Added +- Support Multiediting attributes across multiple entities (through ...-menu in entity tree) +### Changed +- Entity tree sorting is now accessible through ...-menu + ## 0.9.14 ### Added - Changelog Viewer diff --git a/app/AttributeValue.php b/app/AttributeValue.php index f5822115d..19bdd39c0 100644 --- a/app/AttributeValue.php +++ b/app/AttributeValue.php @@ -3,6 +3,7 @@ namespace App; use App\Exceptions\InvalidDataException; +use App\Plugins\Map\App\Geodata; use Illuminate\Database\Eloquent\Model; use MStaack\LaravelPostgis\Eloquent\PostgisTrait; use App\Traits\CommentTrait; @@ -10,6 +11,7 @@ use Spatie\Activitylog\LogOptions; use Spatie\Searchable\Searchable; use Spatie\Searchable\SearchResult; +use stdClass; class AttributeValue extends Model implements Searchable { @@ -102,10 +104,102 @@ public function patch($values) { $this->save(); } + public static function getFormattedKeyValue($datatype, $rawValue) : stdClass { + $keyValue = new stdClass(); + + switch($datatype) { + // for primitive types: just save them to the db + case 'stringf': + case 'string': + case 'iconclass': + case 'rism': + $key = 'str_val'; + $val = $rawValue; + break; + case 'double': + $key = 'dbl_val'; + $val = $rawValue; + break; + case 'boolean': + case 'percentage': + case 'integer': + $key = 'int_val'; + $val = $rawValue; + break; + case 'date': + $key = 'dt_val'; + $val = $rawValue; + break; + case 'string-sc': + $key = 'thesaurus_val'; + $val = $rawValue['concept_url']; + break; + case 'string-mc': + $key = 'json_val'; + $thesaurus_urls = []; + foreach($rawValue as $val) { + $thesaurus_urls[] = [ + "concept_url" => $val['concept_url'], + "id" => $val['id'] + ]; + } + $val = json_encode($thesaurus_urls); + break; + case 'epoch': + case 'timeperiod': + case 'dimension': + case 'list': + case 'table': + $key = 'json_val'; + // check for invalid time spans + if($datatype == 'epoch' || $datatype == 'timeperiod') { + $sl = isset($rawValue['startLabel']) ? strtoupper($rawValue['startLabel']) : null; + $el = isset($rawValue['endLabel']) ? strtoupper($rawValue['endLabel']) : null; + $s = $rawValue['start']; + $e = $rawValue['end']; + if( + (isset($s) && !isset($sl)) + || + (isset($e) && !isset($el)) + ) { + throw new InvalidDataException(__('You have to specify if your date is BC or AD.')); + } + if( + ($sl == 'AD' && $el == 'BC') + || + ($sl == 'BC' && $el == 'BC' && $s < $e) + || + ($sl == 'AD' && $el == 'AD' && $s > $e) + ) { + throw new InvalidDataException(__('Start date of a time period must not be after it\'s end date')); + } + } + $val = json_encode($rawValue); + break; + case 'entity': + $key = 'entity_val'; + $val = $rawValue; + break; + case 'entity-mc': + $key = 'json_val'; + $val = json_encode($rawValue); + break; + case 'geography': + $key = 'geography_val'; + $val = Geodata::parseWkt($rawValue); + break; + } + + $keyValue->key = $key; + $keyValue->val = $val; + + return $keyValue; + } + // Does not handle InvalidDataException and AmbiguousValueException in stringToValue method here! // Throws InvalidDataException // Throws AmbiguousValueException - public function setValue($strValue, $type = null, $save = false) { + public function setValueFromRaw($strValue, $type = null, $save = false) { if(!isset($type)) { $type = Attribute::first($this->attribute_id)->datatype; } diff --git a/app/Http/Controllers/EntityController.php b/app/Http/Controllers/EntityController.php index 43c4eff5a..8979dd66c 100644 --- a/app/Http/Controllers/EntityController.php +++ b/app/Http/Controllers/EntityController.php @@ -474,7 +474,7 @@ public function importData(Request $request) { $attrVal->certainty = 100; $attrVal->user_id = $user->id; try { - $setValue = $attrVal->setValue($row[$val], $type); + $setValue = $attrVal->setValueFromRaw($row[$val], $type); if($setValue === null) { continue; } @@ -565,83 +565,14 @@ public function patchAttributes($id, Request $request) { if($op == 'remove') continue; $attr = Attribute::find($aid); - switch($attr->datatype) { - // for primitive types: just save them to the db - case 'stringf': - case 'string': - case 'iconclass': - case 'rism': - $attrval->str_val = $value; - break; - case 'double': - $attrval->dbl_val = $value; - break; - case 'boolean': - case 'percentage': - case 'integer': - $attrval->int_val = $value; - break; - case 'date': - $attrval->dt_val = $value; - break; - case 'string-sc': - $thesaurus_url = $value['concept_url']; - $attrval->thesaurus_val = $thesaurus_url; - break; - case 'string-mc': - $thesaurus_urls = []; - foreach($value as $val) { - $thesaurus_urls[] = [ - "concept_url" => $val['concept_url'], - "id" => $val['id'] - ]; - } - $attrval->json_val = json_encode($thesaurus_urls); - break; - case 'epoch': - case 'timeperiod': - case 'dimension': - case 'list': - case 'table': - // check for invalid time spans - if($attr->datatype == 'epoch' || $attr->datatype == 'timeperiod') { - $sl = isset($value['startLabel']) ? strtoupper($value['startLabel']) : null; - $el = isset($value['endLabel']) ? strtoupper($value['endLabel']) : null; - $s = $value['start']; - $e = $value['end']; - if( - (isset($s) && !isset($sl)) - || - (isset($e) && !isset($el)) - ) { - return response()->json([ - 'error' => __('You have to specify if your date is BC or AD.') - ], 422); - } - if( - ($sl == 'AD' && $el == 'BC') - || - ($sl == 'BC' && $el == 'BC' && $s < $e) - || - ($sl == 'AD' && $el == 'AD' && $s > $e) - ) { - return response()->json([ - 'error' => __('Start date of a time period must not be after it\'s end date') - ], 422); - } - } - $attrval->json_val = json_encode($value); - break; - case 'entity': - $attrval->entity_val = $value; - break; - case 'entity-mc': - $attrval->json_val = json_encode($value); - break; - case 'geography': - $attrval->geography_val = Geodata::parseWkt($value); - break; + try { + $formKeyValue = AttributeValue::getFormattedKeyValue($attr->datatype, $value); + } catch(InvalidDataException $ide) { + return response()->json([ + 'error' => $ide->getMessage(), + ], 422); } + $attrval->{$formKeyValue->col} = $formKeyValue->val; $attrval->user_id = $user->id; $attrval->save(); } @@ -706,6 +637,57 @@ public function patchAttribute($id, $aid, Request $request) { return response()->json($attrValue, 201); } + public function multieditAttributes(Request $request) { + $user = auth()->user(); + if(!$user->can('entity_data_write')) { + return response()->json([ + 'error' => __('You do not have the permission to modify an entity\'s data') + ], 403); + } + + $this->validate($request, [ + 'entity_ids' => 'required|array', + 'entries' => 'required|array', + ]); + + $entities = $request->get('entity_ids'); + $attrValues = $request->get('entries'); + + DB::beginTransaction(); + + foreach($attrValues as $av) { + try { + $attr = Attribute::findOrFail($av['attribute_id']); + } catch(ModelNotFoundException $e) { + DB::rollBack(); + return response()->json([ + 'error' => __('This attribute does not exist'), + ], 400); + } + try { + $formKeyValue = AttributeValue::getFormattedKeyValue($attr->datatype, $av['value']); + } catch(InvalidDataException $ide) { + DB::rollBack(); + return response()->json([ + 'error' => $ide->getMessage(), + ], 422); + } + foreach($entities as $eid) { + AttributeValue::updateOrCreate( + ['entity_id' => $eid, 'attribute_id' => $av['attribute_id']], + [ + $formKeyValue->key => $formKeyValue->val, + 'user_id' => $user->id, + ] + ); + } + } + + DB::commit(); + + return response()->json(null, 204); + } + public function patchName($id, Request $request) { $user = auth()->user(); if(!$user->can('entity_write')) { diff --git a/resources/js/api.js b/resources/js/api.js index 7b37460a3..baeb3b2b5 100644 --- a/resources/js/api.js +++ b/resources/js/api.js @@ -614,6 +614,20 @@ export async function patchAttributes(entityId, data) { ); }; +export async function multieditAttributes(entityIds, entries) { + const data = { + entity_ids: entityIds, + entries: entries, + }; + return $httpQueue.add( + () => http.patch(`/entity/multiedit`, data).then(response => { + return response.data; + }).catch(error => { + throw error; + }) + ); +}; + export async function moveEntity(entityId, parentId = null, rank = null) { const data = { parent_id: parentId, diff --git a/resources/js/bootstrap/font.js b/resources/js/bootstrap/font.js index b4d303315..6ffd63e7d 100644 --- a/resources/js/bootstrap/font.js +++ b/resources/js/bootstrap/font.js @@ -117,6 +117,7 @@ import { faLightbulb, faLink, faList, + faListCheck, faListOl, faListUl, faLongArrowAltDown, @@ -300,6 +301,7 @@ library.add( faLightbulb, faLink, faList, + faListCheck, faListOl, faListUl, faLongArrowAltDown, diff --git a/resources/js/bootstrap/store.js b/resources/js/bootstrap/store.js index 5cf5f6c39..39ec7110b 100644 --- a/resources/js/bootstrap/store.js +++ b/resources/js/bootstrap/store.js @@ -10,6 +10,8 @@ import { fillEntityData, only, slugify, + getIntersectedEntityAttributes, + hasIntersectionWithEntityAttributes, } from '@/helpers/helpers.js'; import { getEntityData, @@ -17,6 +19,17 @@ import { getEntityReferences, } from '@/api.js'; +function updateSelectionTypeIdList(selection) { + const tmpDict = {}; + for(let k in selection) { + const curr = selection[k]; + if(!tmpDict[curr.entity_type_id]) { + tmpDict[curr.entity_type_id] = 1; + } + } + return Object.keys(tmpDict).map(tdk => parseInt(tdk)); +} + export const store = createStore({ modules: { core: { @@ -47,6 +60,9 @@ export const store = createStore({ roles: [], rolePresets: [], tree: [], + treeSelectionMode: false, + treeSelection: {}, + treeSelectionTypeIds: [], user: {}, users: [], version: {}, @@ -402,6 +418,37 @@ export const store = createStore({ sortTree(state, sort) { sortTree(sort.by, sort.dir, state.tree); }, + addToTreeSelection(state, data) { + const addPossible = hasIntersectionWithEntityAttributes(data.value.entity_type_id, state.treeSelectionTypeIds); + if(addPossible || state.treeSelectionTypeIds.length == 0) { + state.treeSelection[data.id] = data.value; + + state.treeSelectionTypeIds = []; + state.treeSelectionTypeIds = updateSelectionTypeIdList(state.treeSelection); + } + }, + removeFromTreeSelection(state, data) { + delete state.treeSelection[data.id]; + + state.treeSelectionTypeIds = []; + state.treeSelectionTypeIds = updateSelectionTypeIdList(state.treeSelection); + }, + toggleTreeSelectionMode(state) { + state.treeSelectionMode = !state.treeSelectionMode; + + if(!state.treeSelectionMode) { + state.treeSelection = {}; + state.treeSelectionTypeIds = []; + } + }, + setTreeSelectionMode(state, data) { + state.treeSelectionMode = data; + + if(!state.treeSelectionMode) { + state.treeSelection = {}; + state.treeSelectionTypeIds = []; + } + }, setPreferences(state, data) { state.preferences = data; }, @@ -544,6 +591,21 @@ export const store = createStore({ sortTree({commit}, sort) { commit('sortTree', sort) }, + addToTreeSelection({commit}, data) { + commit('addToTreeSelection', data); + }, + removeFromTreeSelection({commit}, data) { + commit('removeFromTreeSelection', data); + }, + toggleTreeSelectionMode({commit}) { + commit('toggleTreeSelectionMode'); + }, + setTreeSelectionMode({commit}) { + commit('setTreeSelectionMode', true); + }, + unsetTreeSelectionMode({commit}) { + commit('setTreeSelectionMode', false); + }, setMainViewTab({commit}, data) { commit('setMainViewTab', data); }, @@ -756,6 +818,11 @@ export const store = createStore({ geometryTypes: state => state.geometryTypes, mainView: state => state.mainView, tree: state => state.tree, + treeSelectionMode: state => state.treeSelectionMode, + treeSelection: state => state.treeSelection, + treeSelectionCount: state => Object.keys(state.treeSelection).length, + treeSelectionTypeIds: state => state.treeSelectionTypeIds, + treeSelectionIntersection: state => getIntersectedEntityAttributes(state.treeSelectionTypeIds), preferenceByKey: state => key => state.preferences[key], preferences: state => state.preferences, systemPreferences: state => state.systemPreferences, diff --git a/resources/js/components/DataModelDetailView.vue b/resources/js/components/DataModelDetailView.vue index 0c404100e..3de30f698 100644 --- a/resources/js/components/DataModelDetailView.vue +++ b/resources/js/components/DataModelDetailView.vue @@ -108,7 +108,7 @@ import { useToast } from '@/plugins/toast.js'; import { - defaultAttributeValue, + getInitialAttributeValue, getEntityType, getEntityTypeAttributes, translateConcept, @@ -216,7 +216,9 @@ for(let i=0; i { state.tableColumnValidated = e; }; - const getInitialValue = attribute => { - switch(attribute.type) { - case 'string': - case 'stringf': - case 'iconclass': - case 'rism': - case 'geography': - return ''; - case 'integer': - case 'double': - return 0; - case 'boolean': - return 0; - case 'percentage': - return 50; - case 'serial': - let str = attribute.textContent; - let toRepl = '%d'; - let ctr = "1954"; - if(!str) { - str = 'Find_%05d_Placeholder'; - } - let hasIdentifier = false; - let isSimple = true; - let matches = str.match(/.*(%d).*/); - if(matches && matches[1]) { - hasIdentifier = true; - isSimple = true; - } else { - matches = str.match(/.*(%\d*d).*/); - if(matches && matches[1]) { - hasIdentifier = true; - isSimple = false; - } - } - if(hasIdentifier && !isSimple) { - toRepl = matches[1]; - let pad = parseInt(toRepl.substring(1, toRepl.length-1)); - ctr = ctr.padStart(pad, '0'); - } - return str.replaceAll(toRepl, ctr); - case 'list': - case 'string-mc': - case 'entity-mc': - return []; - case 'date': - return new Date(); - case 'sql': - return t('global.preview_not_available'); - case 'epoch': - case 'dimension': - case 'entity': - case 'string-sc': - case 'table': - return {}; - } - }; // DATA const fakeId = randomId(); @@ -197,7 +142,7 @@ }], values: { [fakeId]: { - value: getInitialValue(state.attribute), + value: getInitialAttributeValue(state.attribute), }, }, }; diff --git a/resources/js/components/modals/attribute/MultiEdit.vue b/resources/js/components/modals/attribute/MultiEdit.vue new file mode 100644 index 000000000..346109767 --- /dev/null +++ b/resources/js/components/modals/attribute/MultiEdit.vue @@ -0,0 +1,135 @@ + + + \ No newline at end of file diff --git a/resources/js/components/tree/Entity.vue b/resources/js/components/tree/Entity.vue index 4b0c253e6..1aeb4ce27 100644 --- a/resources/js/components/tree/Entity.vue +++ b/resources/js/components/tree/Entity.vue @@ -8,65 +8,86 @@