Skip to content

Commit

Permalink
feat(icons): icon list and edit (#2819)
Browse files Browse the repository at this point in the history
* feat(icons): custom icons

* fix: default key to filename

* fix: upload icons and pagination

* fix: fix button width

* refactor: cleanup

* fix: hide hidden upload field

* refactor: move uploadiconfield to separate component

* feat: icon list

* fix: functional icon-list

* fix: implement update icon

* fix: functional icons edit and list

* style: cleanup

* fix(icons): fix list pagination

* fix(icon): fix infinite loop on img-load error

* refactor: cleanup

* fix: translation
  • Loading branch information
Birkbjo committed Mar 21, 2024
1 parent 6bd243f commit 05e677a
Show file tree
Hide file tree
Showing 20 changed files with 863 additions and 17 deletions.
4 changes: 2 additions & 2 deletions src/EditModel/form-rules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,8 @@ function noneOfOperator(value, list) {
return list.indexOf(value) < 0;
}

function predicateOperator(value, predicate) {
return predicate(value)
function predicateOperator(value, predicate, fieldConfig) {
return predicate(value, fieldConfig)
}

function isPointOperator(value) {
Expand Down
35 changes: 34 additions & 1 deletion src/EditModel/objectActions.js
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ objectActions.getObjectOfTypeByIdAndClone
});

// Standard save handler
const specialSaveHandlers = ['legendSet', 'dataSet', 'organisationUnit', 'programRule', 'programRuleVariable'];
const specialSaveHandlers = ['legendSet', 'dataSet', 'organisationUnit', 'programRule', 'programRuleVariable', 'icon'];
objectActions.saveObject
.filter(({ data }) => !specialSaveHandlers.includes(data.modelType))
.subscribe((action) => {
Expand Down Expand Up @@ -401,6 +401,39 @@ objectActions.saveObject
});
});

// Icon save handler - calls modelDefinition.save instead of model.save to shortcircuit validation
objectActions.saveObject
.filter(({ data }) => data.modelType === 'icon')
.subscribe((action) => {
const isDirty = modelToEditStore.getState().isDirty();
const iconModel = modelToEditStore.getState();

const errorHandler = (error) => {
if(typeof error === 'string') {
action.error(error)
}
if(error.message) {
action.error(error.message);
} else {
action.error(error)
}
};

const successHandler = () => {
if (!isDirty) {
action.complete('no_changes_to_be_saved');
} else {
action.complete('success');
}
};

return iconModel.modelDefinition.save(iconModel)
.then(successHandler)
.catch(errorHandler)
}, (e) => {
log.error(e);
});

objectActions.update.subscribe((action) => {
const { fieldName, value } = action.data;
const modelToEdit = modelToEditStore.getState();
Expand Down
2 changes: 1 addition & 1 deletion src/List/List.component.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ class List extends Component {
case 'edit':
return model.modelDefinition.name !== 'locale' && model.access.write;
case 'clone':
return !['dataSet', 'program', 'locale', 'sqlView', 'optionSet'].includes(model.modelDefinition.name) &&
return !['dataSet', 'program', 'locale', 'sqlView', 'optionSet', 'icon'].includes(model.modelDefinition.name) &&
model.access.write;
case 'translate':
return model.access.read && model.modelDefinition.identifiableObject && model.modelDefinition.name !== 'sqlView';
Expand Down
8 changes: 8 additions & 0 deletions src/List/listValueRenderers.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,11 @@ addValueRenderer(
({ columnName, valueType }) => columnName === 'formType' && valueType === 'CONSTANT',
renderNothingWhenValueIsNotAString(({ value }) => (<Translate>{value.toLowerCase()}</Translate>))
);

addValueRenderer(
({ columnName, valueType, value }) => {
return columnName === 'icon' && valueType === 'URL' && value.endsWith('/icon')
},
({ value }) => <img width={36} src={value} alt={'icon'} />
)

170 changes: 170 additions & 0 deletions src/config/custom-models/icon/IconModelCollection.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { isArray, checkType } from 'd2/lib/lib/check';
import { throwError } from 'd2/lib/lib/utils';
import Model from 'd2/lib/model/Model';
import ModelDefinition from 'd2/lib/model/ModelDefinition';
import Pager from 'd2/lib/pager/Pager';

function throwIfContainsOtherThanModelObjects(values) {
if (values && values[Symbol.iterator]) {
const toCheck = [...values];
toCheck.forEach((value) => {
if (!(value instanceof Model)) {
throwError('Values of a ModelCollection must be instances of Model');
}
});
}
}

/* This is needed because ModelCollection throws if ID is not valid - icons do not have ids (we use icon.key)
We also cannot extend ModelCollection, because the throw is called in the constructor, and we would need to call super()
So it's the exact same class, just that the constructor doesn't check for valid Uuid*/

/**
* Collection of `Model` objects that can be interacted upon. Can contain a pager object to easily navigate
* pages within the system.
*
* @memberof module:model
*/
class IconModelCollection {
/**
* @constructor
*
* @param {ModelDefinition} modelDefinition The `ModelDefinition` that this collection is for. This defines the type of models that
* are allowed to be added to the collection.
* @param {Model[]} values Initial values that should be added to the collection.
* @param {Object} pagerData Object with pager data. This object contains data that will be put into the `Pager` instance.
*
* @description
*
* Creates a new `ModelCollection` object based on the passed `modelDefinition`. Additionally values can be added by passing
* `Model` objects in the `values` parameter. The collection also exposes a pager object which can be used to navigate through
* the pages in the collection. For more information see the `Pager` class.
*/
constructor(modelDefinition, values, pagerData) {
checkType(modelDefinition, ModelDefinition);
/**
* @property {ModelDefinition} modelDefinition The `ModelDefinition` that this collection is for. This defines the type of models that
* are allowed to be added to the collection.
*/
this.modelDefinition = modelDefinition;

/**
* @property {Pager} pager Pager object that is created from the pagerData that was passed when the collection was constructed. If no pager data was present
* the pager will have default values.
*/
this.pager = new Pager(pagerData, modelDefinition);

// We can not extend the Map object right away in v8 contexts.
this.valuesContainerMap = new Map();
this[Symbol.iterator] = this.valuesContainerMap[Symbol.iterator].bind(this.valuesContainerMap);

throwIfContainsOtherThanModelObjects(values);
// THIS IS THE ONLY CHANGE
// throwIfContainsModelWithoutUid(values);

// Add the values separately as not all Iterators return the same values
if (isArray(values)) {
values.forEach(value => this.valuesContainerMap.set(value.id, value));
}
}

/**
* @property {Number} size The number of Model objects that are in the collection.
*
* @description
* Contains the number of Model objects that are in this collection. If the collection is a collection with a pager. This
* does not take into account all the items in the database. Therefore when a pager is present on the collection
* the size will return the items on that page. To get the total number of items consult the pager.
*/
get size() {
return this.valuesContainerMap.size;
}

/**
* Adds a Model instance to the collection. The model is checked if it is a correct instance of `Model` and if it has
* a valid id. A valid id is a uid string of 11 alphanumeric characters.
*
* @param {Model} value Model instance to add to the collection.
* @returns {ModelCollection} Returns itself for chaining purposes.
*
* @throws {Error} When the passed value is not an instance of `Model`
* @throws {Error} Throws error when the passed value does not have a valid id.
*/
add(value) {
throwIfContainsOtherThanModelObjects([value]);
//throwIfContainsModelWithoutUid([value]);

this.set(value.id, value);
return this;
}

/**
* If working with the Map type object is inconvenient this method can be used to return the values
* of the collection as an Array object.
*
* @returns {Array} Returns the values of the collection as an array.
*/
toArray() {
const resultArray = [];

this.forEach((model) => {
resultArray.push(model);
});

return resultArray;
}

static create(modelDefinition, values, pagerData) {
return new IconModelCollection(modelDefinition, values, pagerData);
}

static throwIfContainsOtherThanModelObjects(value) {
return throwIfContainsOtherThanModelObjects(value);
}


/**
* Clear the collection and remove all it's values.
*
* @returns {this} Returns itself for chaining purposes;
*/
// TODO: Reset the pager?
clear() {
return this.valuesContainerMap.clear.call(this.valuesContainerMap);
}

delete(...args) {
return this.valuesContainerMap.delete.call(this.valuesContainerMap, ...args);
}

entries() {
return this.valuesContainerMap.entries.call(this.valuesContainerMap);
}

// FIXME: This calls the forEach function with the values Map and not with the ModelCollection as the third argument
forEach(...args) {
return this.valuesContainerMap.forEach.call(this.valuesContainerMap, ...args);
}

get(...args) {
return this.valuesContainerMap.get.call(this.valuesContainerMap, ...args);
}

has(...args) {
return this.valuesContainerMap.has.call(this.valuesContainerMap, ...args);
}

keys() {
return this.valuesContainerMap.keys.call(this.valuesContainerMap);
}

set(...args) {
return this.valuesContainerMap.set.call(this.valuesContainerMap, ...args);
}

values() {
return this.valuesContainerMap.values.call(this.valuesContainerMap);
}
}

export default IconModelCollection;
95 changes: 95 additions & 0 deletions src/config/custom-models/icon/IconModelDefinition.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import ModelDefinition from 'd2/lib/model/ModelDefinition';
import IconModelCollection from './IconModelCollection.js';
import { uploadIcon } from '../../../forms/form-fields/helpers/IconPickerDialog/uploadIcon.js';

export default class IconModelDefinition extends ModelDefinition {
iconToModel(iconData) {
// apparently there's no restriction for icon access, so just hardcode it
const access = {
read: true,
update: true,
externalize: false,
delete: true,
write: true,
manage: true,
};
return this.create({
...iconData,
access,
name: iconData.key,
displayName: iconData.key,
id: iconData.key,
user: iconData.createdBy,
icon: iconData.href,
});
}
get(id) {
// id is actually key here
return this.api.get(`icons/${id}`).then(icon => this.iconToModel(icon));
}

list(listParams = {}) {
const nameFilter = this.filters.filters.find(
({ propertyName }) => propertyName === 'identifiable'
);
const search = nameFilter && nameFilter.filterValue;
const params = {
fields:
'key,description,custom,created,lastUpdated,createdBy[id,displayName,name],fileResource,href',
type: 'custom',
...(search && { search }),
...(listParams.page && { page: listParams.page }),
};

return this.api
.get('icons', params)
.then(response => ({
...response,
icons: response.icons.map(icon => this.iconToModel(icon)),
}))
.then(response => {
// icons API doesnt have nextPage and prevPage...
// we only need these to be defined for it to work
const pager = {
...response.pager,
nextPage:
response.pager.page < response.pager.pageCount ||
undefined,
prevPage: response.pager.page === 1 && undefined,
};
const collection = IconModelCollection.create(
this,
response.icons,
pager
);
return collection;
});
}

async save(model) {
// href is not set before after creation
const isUpdate = model.href;

const baseSaveObject = {
description: model.description,
keywords: model.keywords,
};

if (isUpdate) {
return this.api.update(`icons/${model.key}`, baseSaveObject);
}

if (model.file) {
return uploadIcon(model.file, {
...baseSaveObject,
key: model.key,
});
}

return Promise.reject('Choose a file to upload');
}

delete(model) {
return this.api.delete(`icons/${model.id}`);
}
}

0 comments on commit 05e677a

Please sign in to comment.