From 01af74192f2a3855823396a357c2d9bbcaf2728b Mon Sep 17 00:00:00 2001 From: Arnaud Vergnet Date: Mon, 6 Oct 2025 16:00:35 +0200 Subject: [PATCH 1/7] feat: add icons list and create screens --- src/modules/RA/Icon/components/IconFields.js | 101 ++++++++++++++++++ .../Icon/components/transparency-pattern.png | Bin 0 -> 570 bytes src/modules/RA/Icon/views/Create.js | 16 +++ src/modules/RA/Icon/views/List.js | 45 ++++++++ src/modules/RA/Icon/views/index.js | 4 + src/modules/RA/ra-modules.js | 23 ++++ 6 files changed, 189 insertions(+) create mode 100644 src/modules/RA/Icon/components/IconFields.js create mode 100644 src/modules/RA/Icon/components/transparency-pattern.png create mode 100644 src/modules/RA/Icon/views/Create.js create mode 100644 src/modules/RA/Icon/views/List.js create mode 100644 src/modules/RA/Icon/views/index.js diff --git a/src/modules/RA/Icon/components/IconFields.js b/src/modules/RA/Icon/components/IconFields.js new file mode 100644 index 000000000..112ad6670 --- /dev/null +++ b/src/modules/RA/Icon/components/IconFields.js @@ -0,0 +1,101 @@ +import React from 'react'; +import { + TextInput, + SimpleForm, + useTranslate, + Labeled, + useInput, +} from 'react-admin'; +import AttachFileIcon from '@material-ui/icons/AttachFile'; +import FormatPaintIcon from '@material-ui/icons/FormatPaint'; +import { connectAuthProvider } from '@terralego/core/modules/Auth'; +import { Box, Button, FormHelperText } from '@material-ui/core'; +import transparency from './transparency-pattern.png'; +import PatternPicker from '../../../../components/react-admin/PatternPicker'; +import { required } from '../../../../utils/react-admin/validate'; + + +const readFile = file => new Promise((resolve, reject) => { + const fr = new FileReader(); + fr.onload = () => { resolve(fr.result); }; + fr.onerror = reject; + fr.readAsDataURL(file); +}); + +function IconsFields (props) { + return ( + + + + + ); +} + +function ImageInput ({ source, label }) { + const translate = useTranslate(); + + + const { + input: { value, onChange }, + meta: { error, submitFailed }, + } = useInput({ source, validate: [required()] }); + + return ( + + + + {value ? ( + + ) : null} + + {' ou '} + } + > + {translate('icon.form.file.compose')} + + + {error && submitFailed ? + {error} : null} + + ); +} + + +export default connectAuthProvider('icon')(IconsFields); diff --git a/src/modules/RA/Icon/components/transparency-pattern.png b/src/modules/RA/Icon/components/transparency-pattern.png new file mode 100644 index 0000000000000000000000000000000000000000..f58f1579ac3e9fe19f48f55c904d087afa773a25 GIT binary patch literal 570 zcmV-A0>%A_P)EX>4Tx04R}tkv&MmKpe$iQ>7vm1v`jz$WWauh>AFB6^c+H)C#RSm|Xe=O&XFE z7e~Rh;NZt%)xpJCR|i)?5c~jfa&%I3krMxx6k5c1aNLh~_a1le0HIM~n$)O(glehxvqHp#<}RSz%wIeCOuCaAr^}rtaLCdnHupFaZJ^8$``U8 ztDLtuYn2*n-IKpCoYz;DxlS{RBo?s*2_h8KP(}qd;)|5Tqat9cCGGtSBr65hAPypV~=$mrD;4RR*=JnRv$LRx*p{`Olz`-Ff zTBPiCpLh3k_V(|YR)0Sa8FGI_iXbEa000SaNLh0L04^f{04^f|c%?sf00007bV*G` z2jmO`5DNru`ww{l000?uMObu0Z*6U5Zgc=ca%Ew3Wn>_CX>@2HM@dakSAh-}0000b zNklIGc$92OwR7@&Et-a85@@2QBOX0P~3lp-#Ft$^ZZW07*qo IM6N<$g6y;B&;S4c literal 0 HcmV?d00001 diff --git a/src/modules/RA/Icon/views/Create.js b/src/modules/RA/Icon/views/Create.js new file mode 100644 index 000000000..706490b78 --- /dev/null +++ b/src/modules/RA/Icon/views/Create.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { Create } from 'react-admin'; + +import DefaultActions from '../../../../components/react-admin/DefaultActions'; +import IconFields from '../components/IconFields'; + +export const IconCreate = props => ( + } + > + + +); + +export default IconCreate; diff --git a/src/modules/RA/Icon/views/List.js b/src/modules/RA/Icon/views/List.js new file mode 100644 index 000000000..913b8b23c --- /dev/null +++ b/src/modules/RA/Icon/views/List.js @@ -0,0 +1,45 @@ +import React from 'react'; +import { + List, + Datagrid, + TextField, + useRecordContext, + TextInput, +} from 'react-admin'; +import CommonBulkActionButtons from '../../../../components/react-admin/CommonBulkActionButtons'; + + +const ImageTextField = ({ source }) => { + const record = useRecordContext(); + return ( + + ); +}; + +const iconFilters = [ + , +]; + +export const IconsList = props => ( + } + filters={iconFilters} + > + + + + + + +); + +export default IconsList; diff --git a/src/modules/RA/Icon/views/index.js b/src/modules/RA/Icon/views/index.js new file mode 100644 index 000000000..59dcd8409 --- /dev/null +++ b/src/modules/RA/Icon/views/index.js @@ -0,0 +1,4 @@ +import create from './Create'; +import list from './List'; + +export default { create, list }; diff --git a/src/modules/RA/ra-modules.js b/src/modules/RA/ra-modules.js index 919c72d38..e8a3d26f3 100644 --- a/src/modules/RA/ra-modules.js +++ b/src/modules/RA/ra-modules.js @@ -5,6 +5,9 @@ import Api from '@terralego/core/modules/Api'; import userViews from './User/views'; import userGroupViews from './UserGroup/views'; +// Icon +import iconViews from './Icon/views'; + // Visu import dataSourceViews from './DataSource/views'; import dataLayerViews from './DataLayer/views'; @@ -31,6 +34,9 @@ export const RES_USER = 'user'; export const RES_USERGROUP = 'usergroup'; export const RES_PERMISSION = 'permissions'; +// Icon +export const RES_ICON = 'icon'; + // Visu export const RES_DATASOURCE = 'datasource'; export const RES_DATALAYER = 'datalayer'; @@ -63,6 +69,13 @@ export const resources = [ endpoint: 'groups', ...userGroupViews, }, + { + name: RES_ICON, + // Let the user edit the icons if they can edit the layers + moduleName: 'DataLayer', + endpoint: 'icon', + ...iconViews, + }, { name: RES_DATASOURCE, moduleName: 'DataSource', @@ -138,6 +151,16 @@ export const config = { beta, })), }, + { + label: 'icon.project', + requiredModule: 'Icon', + items: resources.filter(byModule('Icon')).map(({ name, requiredPermissions, beta }) => ({ + label: `ra.nav.${name}_list`, + href: `/${name}`, + requiredPermissions, + beta, + })), + }, { label: 'datalayer.project', requiredModule: 'DataSource', From 4239a04c5c6e4aee30e2766624ecca3790e9fcbb Mon Sep 17 00:00:00 2001 From: Arnaud Vergnet Date: Mon, 6 Oct 2025 16:01:36 +0200 Subject: [PATCH 2/7] feat: remove data layer icons tab --- .../RA/DataLayer/components/DataLayerForm.js | 11 +- .../DataLayer/components/StyleImageField.js | 139 ------------------ .../components/transparency-pattern.png | Bin 570 -> 0 bytes 3 files changed, 1 insertion(+), 149 deletions(-) delete mode 100644 src/modules/RA/DataLayer/components/StyleImageField.js delete mode 100644 src/modules/RA/DataLayer/components/transparency-pattern.png diff --git a/src/modules/RA/DataLayer/components/DataLayerForm.js b/src/modules/RA/DataLayer/components/DataLayerForm.js index fab4aad1a..e4f01e8e7 100644 --- a/src/modules/RA/DataLayer/components/DataLayerForm.js +++ b/src/modules/RA/DataLayer/components/DataLayerForm.js @@ -1,10 +1,9 @@ import React from 'react'; -import { ArrayInput, FormTab, SimpleFormIterator, TabbedFormTabs } from 'react-admin'; +import { FormTab, TabbedFormTabs } from 'react-admin'; import CustomFormTab from '../../../../components/react-admin/CustomFormTab'; -import StyleImageField from './StyleImageField'; import DefinitionTab from './tabs/DefinitionTab'; import EmbedTab from './tabs/EmbedTab'; import FilterTab from './tabs/FilterTab'; @@ -169,14 +168,6 @@ const DataLayerForm = React.memo(props => { - - - - - - - - new Promise((resolve, reject) => { - const fr = new FileReader(); - fr.onload = () => { resolve(fr.result); }; - fr.onerror = reject; - fr.readAsDataURL(file); -}); - -const isRequired = [required()]; - -const StyleImageField = ({ source }) => { - const translate = useTranslate(); - - return ( - - - {({ input: { value } }) => { - if (value) { - return ( - <> - - - - - - {value} - - - ); - } - - return ( - - - - ); - }} - - - - - {({ input: { value: existingFile } }) => { - if (existingFile) { - return ( - - ); - } - - return ( - - {({ input: { value, onChange } }) => ( - <> - {!value && ( - <> - - - {' ou '} - - } - > - {translate('datalayer.form.style-images.compose')} - - - )} - - - - )} - - ); - }} - - - - ); -}; - -export default React.memo(StyleImageField); diff --git a/src/modules/RA/DataLayer/components/transparency-pattern.png b/src/modules/RA/DataLayer/components/transparency-pattern.png deleted file mode 100644 index f58f1579ac3e9fe19f48f55c904d087afa773a25..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 570 zcmV-A0>%A_P)EX>4Tx04R}tkv&MmKpe$iQ>7vm1v`jz$WWauh>AFB6^c+H)C#RSm|Xe=O&XFE z7e~Rh;NZt%)xpJCR|i)?5c~jfa&%I3krMxx6k5c1aNLh~_a1le0HIM~n$)O(glehxvqHp#<}RSz%wIeCOuCaAr^}rtaLCdnHupFaZJ^8$``U8 ztDLtuYn2*n-IKpCoYz;DxlS{RBo?s*2_h8KP(}qd;)|5Tqat9cCGGtSBr65hAPypV~=$mrD;4RR*=JnRv$LRx*p{`Olz`-Ff zTBPiCpLh3k_V(|YR)0Sa8FGI_iXbEa000SaNLh0L04^f{04^f|c%?sf00007bV*G` z2jmO`5DNru`ww{l000?uMObu0Z*6U5Zgc=ca%Ew3Wn>_CX>@2HM@dakSAh-}0000b zNklIGc$92OwR7@&Et-a85@@2QBOX0P~3lp-#Ft$^ZZW07*qo IM6N<$g6y;B&;S4c From 75db7ebf68f7bece0d23bf3d3a99b973ddc0aaa1 Mon Sep 17 00:00:00 2001 From: Arnaud Vergnet Date: Mon, 6 Oct 2025 16:01:56 +0200 Subject: [PATCH 3/7] feat: fetch icons from api instead of local form data --- src/hooks/useCustomStyleImages.js | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/hooks/useCustomStyleImages.js b/src/hooks/useCustomStyleImages.js index 5cb425b48..01b334b10 100644 --- a/src/hooks/useCustomStyleImages.js +++ b/src/hooks/useCustomStyleImages.js @@ -1,13 +1,17 @@ +import { useGetList } from 'ra-core'; import React from 'react'; -import { useFormState } from 'react-final-form'; +import { RES_ICON } from '../modules/RA/ra-modules'; const useCustomStyleImages = () => { - const { values: { style_images: styleImages } = {} } = useFormState(); + const { data, ids } = useGetList(RES_ICON); const customImages = React.useMemo( - () => styleImages - ?.filter(({ name, slug, file } = {}) => Boolean(name && slug && file)), - [styleImages], + () => { + const styleImages = ids.map(i => data[i]); + return styleImages + ?.filter(({ name, slug, file } = {}) => Boolean(name && slug && file)); + }, + [data, ids], ); return customImages || []; From cfbff7265af67d628611ace9902d404f5556c9fd Mon Sep 17 00:00:00 2001 From: Arnaud Vergnet Date: Mon, 6 Oct 2025 16:02:47 +0200 Subject: [PATCH 4/7] feat: add icon translations --- public/locales/en/translation.json | 69 ++++++++++++++++-------------- public/locales/fr/translation.json | 63 ++++++++++++++------------- 2 files changed, 71 insertions(+), 61 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 0acd39653..d0082b93c 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -388,31 +388,31 @@ "error": "Error" }, "tooltip": { - "times": "Times", - "started": "Started on", - "finished": "Finished on", - "notStarted": "Not started yet", - "notFinished": "Not finished yet", - "lines": { - "singular": "line", - "plural": "lines" - }, - "added": { - "singular": "line added", - "plural": "lines added" - }, - "modified": { - "singular": "line modified", - "plural": "lines modified" - }, - "deleted": { - "singular": "line deleted", - "plural": "lines deleted" - }, - "errors": { - "singular": "error", - "plural": "errors" - } + "times": "Times", + "started": "Started on", + "finished": "Finished on", + "notStarted": "Not started yet", + "notFinished": "Not finished yet", + "lines": { + "singular": "line", + "plural": "lines" + }, + "added": { + "singular": "line added", + "plural": "lines added" + }, + "modified": { + "singular": "line modified", + "plural": "lines modified" + }, + "deleted": { + "singular": "line deleted", + "plural": "lines deleted" + }, + "errors": { + "singular": "error", + "plural": "errors" + } } }, "datalayer": { @@ -425,7 +425,7 @@ "data-source": "Data source", "definition": "Definition", "source-filter": { - "label":"Source filter", + "label": "Source filter", "helper": "You can use an expression to filter the source data. ex: « name == \"john\" and age > 18 »", "error": "Your input can't be parsed, please fix it.", "error-invalid": "Your input is invalid, see error below.", @@ -592,12 +592,6 @@ "no-source": "Please select a source before configuring style", "secondarylabels": "Secondary styles" }, - "style-images": { - "tab": "Icons", - "name": "Name", - "compose": "Compose", - "upload": "Upload" - }, "embed": { "tab": "Embed", "no-embed": "To add an embed, click on", @@ -629,6 +623,16 @@ } } }, + "icon": { + "form": { + "name": "Name", + "file": { + "label": "File", + "compose": "Compose", + "upload": "Upload" + } + } + }, "baseLayer": { "project": "Base map", "nav": { @@ -679,6 +683,7 @@ "nav": { "baselayer_list": "Base layer list", "user_list": "User list", + "icon_list": "Icon list", "usergroup_list": "User group list", "datalayer_list": "Layer list", "datasource_list": "Data source list", diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 791688432..d6603f586 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -440,31 +440,31 @@ "error": "Erreur" }, "tooltip": { - "times": "Temps", - "started": "Commencé le", - "finished": "Finit le", + "times": "Temps", + "started": "Commencé le", + "finished": "Finit le", "notStarted": "Pas encore commencé", "notFinished": "Pas encore fini", - "lines": { - "singular": "ligne", - "plural": "lignes" - }, - "added": { - "singular": "ligne ajoutée", - "plural": "lignes ajoutées" - }, - "modified": { - "singular": "ligne modifiée", - "plural": "lignes modifiées" - }, - "deleted": { - "singular": "ligne supprimée", - "plural": "lignes supprimées" - }, - "errors": { - "singular": "erreur", - "plural": "erreurs" - } + "lines": { + "singular": "ligne", + "plural": "lignes" + }, + "added": { + "singular": "ligne ajoutée", + "plural": "lignes ajoutées" + }, + "modified": { + "singular": "ligne modifiée", + "plural": "lignes modifiées" + }, + "deleted": { + "singular": "ligne supprimée", + "plural": "lignes supprimées" + }, + "errors": { + "singular": "erreur", + "plural": "erreurs" + } } }, "datalayer": { @@ -644,12 +644,6 @@ "no-source": "Veuillez choisir une source de données avant de configurer les styles", "secondarylabels": "Styles secondaires" }, - "style-images": { - "tab": "Icônes", - "name": "Nom", - "compose": "Composer", - "upload": "Envoyer" - }, "embed": { "tab": "Inclusions", "no-embed": "Pour activer les inclusions, cliquer sur", @@ -681,6 +675,16 @@ } } }, + "icon": { + "form": { + "name": "Nom", + "file": { + "label": "Fichier", + "compose": "Composer", + "upload": "Envoyer" + } + } + }, "baseLayer": { "project": "Fond de carte", "nav": { @@ -731,6 +735,7 @@ "nav": { "baselayer_list": "Liste des fonds de carte", "user_list": "Liste des utilisateurs", + "icon_list": "Liste des icônes", "usergroup_list": "Liste des groupes utilisateurs", "datalayer_list": "Liste des couches", "datasource_list": "Liste des sources de données", From ccb2b638cbb1595eca46e56dc13269138ccff85c Mon Sep 17 00:00:00 2001 From: Arnaud Vergnet Date: Mon, 6 Oct 2025 17:28:03 +0200 Subject: [PATCH 5/7] feat: improve pattern picker UX --- public/locales/en/translation.json | 3 +- public/locales/fr/translation.json | 1 + src/components/react-admin/PatternPicker.js | 110 +++++++++++--------- 3 files changed, 66 insertions(+), 48 deletions(-) diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index d0082b93c..0ed9f0990 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -628,6 +628,7 @@ "name": "Name", "file": { "label": "File", + "picker": "Choose an icon", "compose": "Compose", "upload": "Upload" } @@ -720,7 +721,7 @@ "start": "Start", "close": "Close", "submit": "Submit", - "validate": "Vaidate", + "validate": "Validate", "refuse": "Refuse", "unselect": "Unselect" }, diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index d6603f586..2bf0eaa10 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -680,6 +680,7 @@ "name": "Nom", "file": { "label": "Fichier", + "picker": "Choisir une icône", "compose": "Composer", "upload": "Envoyer" } diff --git a/src/components/react-admin/PatternPicker.js b/src/components/react-admin/PatternPicker.js index cc5c33740..3027dfd0a 100644 --- a/src/components/react-admin/PatternPicker.js +++ b/src/components/react-admin/PatternPicker.js @@ -4,7 +4,7 @@ import React from 'react'; import { SketchPicker } from 'react-color'; import tinycolor from 'tinycolor2'; -import { FormControl, Button, MenuItem, Paper, Select } from '@material-ui/core'; +import { FormControl, Button, MenuItem, Paper, Select, InputLabel } from '@material-ui/core'; import { useTranslate } from 'react-admin'; import useCustomStyleImages from '../../hooks/useCustomStyleImages'; @@ -54,15 +54,10 @@ const PatternPicker = ({ React.useEffect( () => { - if (!showPicker && preview) { - onChange(preview); + if (!sourceImage) { + return; } - }, - [showPicker, onChange, preview], - ); - React.useEffect( - () => { const canvas = document.createElement('canvas'); const image = new Image(); image.crossOrigin = 'anonymous'; @@ -103,7 +98,11 @@ const PatternPicker = ({ <> - - )} From 9d3137561be3c190b3559e665f2d8f8bcfc70449 Mon Sep 17 00:00:00 2001 From: Arnaud Vergnet Date: Wed, 15 Oct 2025 10:51:49 +0200 Subject: [PATCH 6/7] feat: add external icons picker --- package-lock.json | 19 +- package.json | 2 +- public/locales/en/translation.json | 12 + public/locales/fr/translation.json | 12 + .../react-admin/IconLibraryPicker.js | 370 ++++++++++++++++++ src/hooks/useFetchIconLibraryIndex.js | 23 ++ src/modules/RA/Icon/components/IconFields.js | 20 +- src/modules/RA/Icon/views/List.js | 56 ++- 8 files changed, 490 insertions(+), 24 deletions(-) create mode 100644 src/components/react-admin/IconLibraryPicker.js create mode 100644 src/hooks/useFetchIconLibraryIndex.js diff --git a/package-lock.json b/package-lock.json index c5e11ec20..141796885 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ "react-sortable-hoc": "^1.11.0", "react-sortable-tree": "^2.8.0", "react-test-renderer": "^16.14.0", - "react-window": "^1.8.9", + "react-window": "^1.8.11", "sass": "^1.58.3", "tinycolor2": "^1.6.0", "unist-util-visit": "^4.1.2", @@ -27459,9 +27459,10 @@ } }, "node_modules/react-window": { - "version": "1.8.9", - "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.9.tgz", - "integrity": "sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==", + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.0.0", "memoize-one": ">=3.1.1 <6" @@ -27470,8 +27471,8 @@ "node": ">8.0.0" }, "peerDependencies": { - "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/reactcss": { @@ -53998,9 +53999,9 @@ } }, "react-window": { - "version": "1.8.9", - "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.9.tgz", - "integrity": "sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q==", + "version": "1.8.11", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz", + "integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==", "requires": { "@babel/runtime": "^7.0.0", "memoize-one": ">=3.1.1 <6" diff --git a/package.json b/package.json index 16fa3f668..40c216c3f 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,7 @@ "react-sortable-hoc": "^1.11.0", "react-sortable-tree": "^2.8.0", "react-test-renderer": "^16.14.0", - "react-window": "^1.8.9", + "react-window": "^1.8.11", "sass": "^1.58.3", "tinycolor2": "^1.6.0", "unist-util-visit": "^4.1.2", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 0ed9f0990..ef0afcaf7 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -630,6 +630,18 @@ "label": "File", "picker": "Choose an icon", "compose": "Compose", + "library": { + "button": "Select from library", + "dialog": { + "title": "Select from an icon library", + "categories": "Categories", + "icons": "Icons", + "selected": "Selected", + "error": "An error occured while fetching the icons", + "errorImage": "An error occured while fetching the image", + "cancel": "Cancel" + } + }, "upload": "Upload" } } diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 2bf0eaa10..bde09371b 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -682,6 +682,18 @@ "label": "Fichier", "picker": "Choisir une icône", "compose": "Composer", + "library": { + "button": "Choisir depuis une bibliothèque", + "dialog": { + "title": "Choisir depuis une bibliothèque", + "categories": "Catégories", + "icons": "Icônes", + "selected": "Sélectionnées", + "error": "Une erreur est survenue lors de la récupération des icônes", + "errorImage": "Une erreur est survenue lors de la récupération de l'image", + "cancel": "Annuler" + } + }, "upload": "Envoyer" } } diff --git a/src/components/react-admin/IconLibraryPicker.js b/src/components/react-admin/IconLibraryPicker.js new file mode 100644 index 000000000..192cdb246 --- /dev/null +++ b/src/components/react-admin/IconLibraryPicker.js @@ -0,0 +1,370 @@ +import { + Avatar, + Box, + Button, + Card, + CardActionArea, + CardContent, + Checkbox, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + IconButton, + InputAdornment, + List, + ListItem, + ListItemIcon, + ListItemText, + Snackbar, + TextField, + Typography, +} from '@material-ui/core'; +import React, { useEffect } from 'react'; +import AppsIcon from '@material-ui/icons/Apps'; +import ChevronRightIcon from '@material-ui/icons/ChevronRight'; +import { Alert } from '@material-ui/lab'; +import { FixedSizeGrid } from 'react-window'; +import { useTranslate } from 'ra-core'; +import SearchIcon from '@material-ui/icons/Search'; +import BackspaceOutlinedIcon from '@material-ui/icons/BackspaceOutlined'; +import ArrowBackIcon from '@material-ui/icons/ArrowBack'; +import useFetchIconLibraryIndex, { ICONS_ROOT } from '../../hooks/useFetchIconLibraryIndex'; + +const LIST_HEIGHT = 400; +const ITEM_SIZE = 128; +const ICONS_COLUMNS = 4; + +function IconLibraryPicker ({ + onChange = () => {}, + disabled, + ...props +}) { + const translate = useTranslate(); + const [showPicker, setShowPicker] = React.useState(false); + const [selectedLibrary, setSelectedLibrary] = React.useState(); + + const libraryIndex = useFetchIconLibraryIndex(showPicker); + + const library = libraryIndex.data && libraryIndex.data.length === 1 + ? libraryIndex.data[0] : selectedLibrary; + + const content = library ? + ( + 1 + ? () => setSelectedLibrary(undefined) : undefined + } + onChange={v => { + setShowPicker(false); + onChange(v); + }} + /> + ) : ( + setSelectedLibrary(value)} + /> + ); + + return ( + <> + + + + + ); +} + +function IconLibraryContent ({ libraryIndex, onSelectLibrary }) { + const translate = useTranslate(); + + if (libraryIndex.status === 'idle') { + return null; + } + if (libraryIndex.status === 'loading') { + return ; + } + if (libraryIndex.status === 'error') { + return {translate('icon.form.file.library.dialog.error')}; + } + + return ( + + {libraryIndex.data.map(item => ( + onSelectLibrary(item)}> + + + + + + ))} + + ); +} + +async function convertImageToBase64 (imageUrl) { + const response = await fetch(imageUrl); + + if (response.status < 200 || response.status >= 300) { + throw Error(`Could not fetch image '${imageUrl}': error code ${response.status}`); + } + + const blob = await response.blob(); + const reader = new FileReader(); + return new Promise((resolve, reject) => { + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +function RowComponent ({ + columnIndex, + rowIndex, + style, + data, +}) { + const { selectedLibrary, iconsIndex, onChange, onError } = data; + const index = rowIndex * ICONS_COLUMNS + columnIndex; + const item = iconsIndex[index]; + if (!item) { + return null; + } + + const imageUrl = `${ICONS_ROOT}/${selectedLibrary.id}/${item.name}.png`; + return ( + + + { + try { + const result = await convertImageToBase64(imageUrl); + onChange({ value: result, name: item.name, source: selectedLibrary.id }); + } catch (e) { + onError(e); + } + }} + > + + + + + {item.name} + + + + + ); +} + +function isStringMatchingSearchTerms (str, searchTerms) { + return searchTerms.every(term => str.includes(term)); +} + +function getFilteredData (data, search, selectedCategories) { + const searchTerms = search.split(' '); + return data.filter( + item => + ( + selectedCategories.length === 0 + || selectedCategories.some(s => item.tags.some(t => t === s)) + ) + && ( + isStringMatchingSearchTerms(item.name, searchTerms) + || item.aliases.some(alias => isStringMatchingSearchTerms(alias, searchTerms)) + || item.tags.some(tag => isStringMatchingSearchTerms(tag, searchTerms)) + ), + ); +} + +function CategoriesList ({ categories, selectedCategories, onClick, onReset }) { + const translate = useTranslate(); + return ( + + + { translate('icon.form.file.library.dialog.categories')} + + + + {categories.map(c => ( + onClick(c)} + > + + {c} + + ))} + + + + {selectedCategories.length} {translate('icon.form.file.library.dialog.selected')} + {selectedCategories.length > 0 ? ( + + + + ) : null} + + + ); +} + +function IconListContent ({ selectedLibrary, onBack, onChange }) { + const translate = useTranslate(); + + const [iconsIndex, setIconsIndex] = React.useState({ status: 'idle' }); + const [search, setSearch] = React.useState(''); + const [error, setError] = React.useState(); + const [selectedCategories, setSelectedCategories] = React.useState([]); + + useEffect(() => { + setIconsIndex({ + status: 'loading', + }); + fetch(`${ICONS_ROOT}/${selectedLibrary.id}/icons.json`) + .then(r => r.json()) + .then(result => setIconsIndex({ status: 'success', data: result })) + .catch(() => setIconsIndex({ status: 'error' })); + }, [selectedLibrary]); + + let content = null; + if (iconsIndex.status === 'loading') { + content = ; + } else if (iconsIndex.status === 'error') { + content = An error occured; + } else if (iconsIndex.status === 'success') { + const filteredData = getFilteredData(iconsIndex.data, search, selectedCategories); + const categories = [...new Set(iconsIndex.data.map(item => item.tags).flat().sort())]; + content = ( + + + { + const newSelectedCategories = [...selectedCategories]; + if (newSelectedCategories.includes(c)) { + newSelectedCategories.splice(newSelectedCategories.indexOf(c), 1); + } else { + newSelectedCategories.push(c); + } + setSelectedCategories(newSelectedCategories); + }} + onReset={() => setSelectedCategories([])} + /> + + + setSearch(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + { + const index = rowIndex * ICONS_COLUMNS + columnIndex; + const item = data.iconsIndex[index]; + return item?.name; + }} + > + {RowComponent} + + + {filteredData.length} {translate('icon.form.file.library.dialog.icons')} + + setError(undefined)} + > + setError(undefined)} + severity="error" + > + {translate('icon.form.file.library.dialog.errorImage')} + + + + + + ); + } + + return ( + + + {onBack ? ( + + + + ) : null} + {selectedLibrary.name} + + + {content} + + ); +} + + +export default IconLibraryPicker; diff --git a/src/hooks/useFetchIconLibraryIndex.js b/src/hooks/useFetchIconLibraryIndex.js new file mode 100644 index 000000000..f13aae47d --- /dev/null +++ b/src/hooks/useFetchIconLibraryIndex.js @@ -0,0 +1,23 @@ +import React from 'react'; + +export const ICONS_ROOT = '/media/icon-libraries'; + +function useFetchIconLibraryIndex (shouldFetch = true) { + const [libraryIndex, setLibraryIndex] = React.useState({ status: 'idle' }); + + React.useEffect(() => { + if (shouldFetch) { + setLibraryIndex({ + status: 'loading', + }); + fetch(`${ICONS_ROOT}/index.json`) + .then(r => r.json()) + .then(result => setLibraryIndex({ status: 'success', data: result })) + .catch(() => setLibraryIndex({ status: 'error' })); + } + }, [shouldFetch]); + + return libraryIndex; +} + +export default useFetchIconLibraryIndex; diff --git a/src/modules/RA/Icon/components/IconFields.js b/src/modules/RA/Icon/components/IconFields.js index 112ad6670..422bee1ec 100644 --- a/src/modules/RA/Icon/components/IconFields.js +++ b/src/modules/RA/Icon/components/IconFields.js @@ -13,6 +13,7 @@ import { Box, Button, FormHelperText } from '@material-ui/core'; import transparency from './transparency-pattern.png'; import PatternPicker from '../../../../components/react-admin/PatternPicker'; import { required } from '../../../../utils/react-admin/validate'; +import IconLibraryPicker from '../../../../components/react-admin/IconLibraryPicker'; const readFile = file => new Promise((resolve, reject) => { @@ -42,6 +43,9 @@ function IconsFields (props) { function ImageInput ({ source, label }) { const translate = useTranslate(); + const { input: { onChange: onNameChange } } = useInput({ source: 'name' }); + const { input: { onChange: onSourceChange } } = useInput({ source: 'source' }); + const { input: { value, onChange }, @@ -78,6 +82,7 @@ function ImageInput ({ source, label }) { if (file) { onChange(await readFile(file)); + onSourceChange(''); } }} hidden @@ -85,11 +90,24 @@ function ImageInput ({ source, label }) { {' ou '} { + onChange(v); + onSourceChange(''); + }} endIcon={} > {translate('icon.form.file.compose')} + {' ou '} + { + onChange(v); + onNameChange(name); + onSourceChange(s); + }} + > + {translate('icon.form.file.library.button')} + {error && submitFailed ? {error} : null} diff --git a/src/modules/RA/Icon/views/List.js b/src/modules/RA/Icon/views/List.js index 913b8b23c..4024c353e 100644 --- a/src/modules/RA/Icon/views/List.js +++ b/src/modules/RA/Icon/views/List.js @@ -5,8 +5,12 @@ import { TextField, useRecordContext, TextInput, + useTranslate, } from 'react-admin'; +import { CircularProgress } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; import CommonBulkActionButtons from '../../../../components/react-admin/CommonBulkActionButtons'; +import useFetchIconLibraryIndex from '../../../../hooks/useFetchIconLibraryIndex'; const ImageTextField = ({ source }) => { @@ -24,22 +28,48 @@ const ImageTextField = ({ source }) => { ); }; +const SourceTextField = ({ source, libraryIndex }) => { + const translate = useTranslate(); + const record = useRecordContext(); + + const id = record && record[source]; + + if (!id || libraryIndex.status === 'idle') { + return null; + } + if (libraryIndex.status === 'loading') { + return ; + } + if (libraryIndex.status === 'error') { + return {translate('ra.page.error')}; + } + + const matchingIndex = libraryIndex.data.find(item => item.id === id); + + return ({matchingIndex ? matchingIndex.name : id}); +}; + const iconFilters = [ , ]; -export const IconsList = props => ( - } - filters={iconFilters} - > - - - - - - -); +export const IconsList = props => { + const libraryIndex = useFetchIconLibraryIndex(); + + return ( + } + filters={iconFilters} + > + + + + + + + + ); +}; export default IconsList; From 15cdafdf5010d34040814612ce5e42a514d8ae7d Mon Sep 17 00:00:00 2001 From: Arnaud Vergnet Date: Thu, 16 Oct 2025 11:29:09 +0200 Subject: [PATCH 7/7] feat: display source as section in icons selector fields --- src/hooks/useStyleImagesOptions.js | 87 +++++++++++++++++++ .../components/tabs/LegendTab/LegendField.js | 42 +-------- .../tabs/StyleTab/Style/WizardIcon.js | 43 +-------- 3 files changed, 91 insertions(+), 81 deletions(-) create mode 100644 src/hooks/useStyleImagesOptions.js diff --git a/src/hooks/useStyleImagesOptions.js b/src/hooks/useStyleImagesOptions.js new file mode 100644 index 000000000..701db9430 --- /dev/null +++ b/src/hooks/useStyleImagesOptions.js @@ -0,0 +1,87 @@ +import { useTranslate } from 'ra-core'; +import React from 'react'; +import { Box } from '@material-ui/core'; +import useSprites from './useSprites'; +import useCustomStyleImages from './useCustomStyleImages'; +import useFetchIconLibraryIndex from './useFetchIconLibraryIndex'; + +function useStyleImagesOptions () { + const translate = useTranslate(); + const defaultSprites = useSprites(); + const customStyleImages = useCustomStyleImages(); + const libraryIndex = useFetchIconLibraryIndex(); + + const customStyleImagesChoices = React.useMemo( + () => { + // Make sure all icons from the same source are one after the other + customStyleImages.sort((a, b) => { + if (a.source !== b.source) { + return a.source > b.source; + } + return a.name > b.name; + }); + let currentSource = null; + const result = []; + result.push(...customStyleImages.map(customImage => { + const items = []; + // The source changed, add the section header + if (customImage.source !== currentSource) { + currentSource = customImage.source; + if (currentSource === '') { + items.push({ + id: 'separator-custom', + name: translate('style-editor.icon.icon-image-custom'), + disabled: true, + }); + } else { + const sourceName = libraryIndex.data + ? libraryIndex.data.find(l => l.id === currentSource)?.name : undefined; + items.push({ + id: `separator-library-${currentSource}`, + name: sourceName ?? currentSource, + disabled: true, + }); + } + } + + items.push(({ + id: customImage.slug, + name: ( + <> + {customImage.name} + + + ), + })); + return items; + }).flat()); + return result; + }, + [customStyleImages, translate, libraryIndex.data], + ); + + const iconChoices = React.useMemo( + () => { + const result = [ + ...customStyleImagesChoices]; + if (defaultSprites.length > 0) { + result.push({ + id: 'separator-native', + name: translate('style-editor.icon.icon-image-native'), + disabled: true, + }); + result.push(...defaultSprites); + } + return result; + }, + [translate, customStyleImagesChoices, defaultSprites], + ); + + return iconChoices; +} + +export default useStyleImagesOptions; diff --git a/src/modules/RA/DataLayer/components/tabs/LegendTab/LegendField.js b/src/modules/RA/DataLayer/components/tabs/LegendTab/LegendField.js index 067a6ccf9..ded544df9 100644 --- a/src/modules/RA/DataLayer/components/tabs/LegendTab/LegendField.js +++ b/src/modules/RA/DataLayer/components/tabs/LegendTab/LegendField.js @@ -13,54 +13,16 @@ import { import Typography from '@material-ui/core/Typography'; import { Field } from 'react-final-form'; import FormLabel from '@material-ui/core/FormLabel'; -import Box from '@material-ui/core/Box'; import Condition from '../../../../../../components/react-admin/Condition'; import ColorPicker from '../../../../../../components/react-admin/ColorPicker'; -import useSprites from '../../../../../../hooks/useSprites'; -import useCustomStyleImages from '../../../../../../hooks/useCustomStyleImages'; +import useStyleImagesOptions from '../../../../../../hooks/useStyleImagesOptions'; const isRequired = [required()]; const LegendItemInput = ({ source, parentSource }) => { const translate = useTranslate(); - const defaultSprites = useSprites(); - const customStyleImages = useCustomStyleImages(); - - const customStyleImagesChoices = React.useMemo( - () => customStyleImages.map(customImage => ({ - id: customImage.slug, - name: ( - <> - {customImage.name} - - - ), - })), - [customStyleImages], - ); - - const iconChoices = React.useMemo( - () => [ - { - id: 'separator-custom', - name: translate('style-editor.icon.icon-image-custom'), - disabled: true, - }, - ...customStyleImagesChoices, - { - id: 'separator-native', - name: translate('style-editor.icon.icon-image-native'), - disabled: true, - }, - ...defaultSprites, - ], - [translate, customStyleImagesChoices, defaultSprites], - ); + const iconChoices = useStyleImagesOptions(); return (
diff --git a/src/modules/RA/DataLayer/components/tabs/StyleTab/Style/WizardIcon.js b/src/modules/RA/DataLayer/components/tabs/StyleTab/Style/WizardIcon.js index e743e0586..074d899e0 100644 --- a/src/modules/RA/DataLayer/components/tabs/StyleTab/Style/WizardIcon.js +++ b/src/modules/RA/DataLayer/components/tabs/StyleTab/Style/WizardIcon.js @@ -1,59 +1,20 @@ import React from 'react'; import { useTranslate, RadioButtonGroupInput } from 'react-admin'; -import { Box } from '@material-ui/core'; import { makeStyles } from '@material-ui/core/styles'; import FormLabel from '@material-ui/core/FormLabel'; import SizeStyleField from './SizeStyleField'; import IconStyleField from './IconStyleField'; -import useSprites from '../../../../../../../hooks/useSprites'; -import useCustomStyleImages from '../../../../../../../hooks/useCustomStyleImages'; - import styles from './styles'; +import useStyleImagesOptions from '../../../../../../../hooks/useStyleImagesOptions'; const useStyles = makeStyles(styles); const WizardIcon = ({ path, fields, getValuesOfProperty }) => { const classes = useStyles(); const translate = useTranslate(); - const defaultSprites = useSprites(); - const customStyleImages = useCustomStyleImages(); - - const customStyleImagesChoices = React.useMemo( - () => customStyleImages.map(customImage => ({ - id: customImage.slug, - name: ( - <> - {customImage.name} - - - ), - })), - [customStyleImages], - ); - - const iconChoices = React.useMemo( - () => [ - { - id: 'separator-custom', - name: translate('style-editor.icon.icon-image-custom'), - disabled: true, - }, - ...customStyleImagesChoices, - { - id: 'separator-native', - name: translate('style-editor.icon.icon-image-native'), - disabled: true, - }, - ...defaultSprites, - ], - [translate, customStyleImagesChoices, defaultSprites], - ); + const iconChoices = useStyleImagesOptions(); return ( <>