From f3cb41a0eeaa82721b55b89874828551300caeb2 Mon Sep 17 00:00:00 2001 From: Erik Vullings Date: Fri, 8 Nov 2024 13:18:11 +0100 Subject: [PATCH] Added barriers, indicators, opportunities and partners to settings page --- packages/gui/src/app.ts | 2 + packages/gui/src/components/settings-page.ts | 66 ++++--- .../src/components/ui/crime-script-editor.ts | 108 ++++++------ .../gui/src/components/ui/multi-select.ts | 163 ------------------ .../src/components/ui/search-select-plugin.ts | 30 ++++ packages/gui/src/models/data-model.ts | 2 +- packages/gui/src/models/forms.ts | 59 +++++-- 7 files changed, 160 insertions(+), 270 deletions(-) delete mode 100644 packages/gui/src/components/ui/multi-select.ts create mode 100644 packages/gui/src/components/ui/search-select-plugin.ts diff --git a/packages/gui/src/app.ts b/packages/gui/src/app.ts index 806e73f..6c23d4a 100644 --- a/packages/gui/src/app.ts +++ b/packages/gui/src/app.ts @@ -8,8 +8,10 @@ import { LANGUAGE, SAVED } from './utils'; import { Languages, i18n } from './services'; import { registerPlugin } from 'mithril-ui-form'; import { SimpleListEditorPlugin } from './components/ui/simple-list-editor'; +import { searchSelectPlugin } from './components/ui/search-select-plugin'; registerPlugin('list', SimpleListEditorPlugin); +registerPlugin('search_select', searchSelectPlugin); document.documentElement.setAttribute('lang', 'en'); diff --git a/packages/gui/src/components/settings-page.ts b/packages/gui/src/components/settings-page.ts index 67363a2..a6d35f0 100644 --- a/packages/gui/src/components/settings-page.ts +++ b/packages/gui/src/components/settings-page.ts @@ -16,8 +16,6 @@ import { Collapsible, FlatButton, Tabs } from 'mithril-materialized'; import { attrForm, AttributeType } from '../models/forms'; import { TextInputWithClear } from './ui/text-input-with-clear'; -type ItemType = 'cast' | 'attribute' | 'location' | 'geolocation' | 'transport' | 'product'; - export const SettingsPage: MeiosisComponent = () => { let edit = false; let storedModel: DataModel; @@ -70,68 +68,68 @@ export const SettingsPage: MeiosisComponent = () => { [ 'attributes', t('ATTRIBUTES'), - 'attribute', + 'attributes', 'build', attributes.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), ], [ 'products', t('PRODUCTS', 2), - 'product', + 'products', 'shopping_bag', products.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), ], [ 'transports', t('TRANSPORTS'), - 'transport', + 'transports', 'directions', transports.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), ], + [ + 'locations', + t('LOCATIONS', 2), + 'locations', + 'warehouse', + locations.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), + ], + [ + 'geoLocations', + t('GEOLOCATIONS', 2), + 'geoLocations', + 'location_on', + geoLocations.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), + ], [ 'opportunities', t('OPPORTUNITIES'), - 'opportunity', + 'opportunities', 'lightbulb', opportunities.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), ], - [ - 'partners', - t('PARTNERS'), - 'partner', - 'handshake', // groups - partners.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), - ], [ 'indicators', t('INDICATORS'), - 'indicator', + 'indicators', 'light_mode', indicators.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), ], + [ + 'partners', + t('PARTNERS'), + 'partners', + 'handshake', // groups + partners.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), + ], [ 'barriers', t('BARRIERS'), - 'barrier', + 'barriers', 'block', barriers.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), ], - [ - 'locations', - t('LOCATIONS', 2), - 'location', - 'warehouse', - locations.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), - ], - [ - 'geoLocations', - t('GEOLOCATIONS', 2), - 'geolocation', - 'location_on', - geoLocations.filter((a) => !labelFilter || (a.label && a.label.toLowerCase().includes(labelFilter))), - ], ] as Array< - [id: AttributeType, label: string, type: ItemType, iconName: string, attrs: Array] + [id: AttributeType, label: string, type: AttributeType, iconName: string, attrs: Array] >; return m( @@ -181,7 +179,7 @@ export const SettingsPage: MeiosisComponent = () => { title: `${attr.length ? `${attr.length} ` : ''}${label}`, vnode: edit ? m(LayoutForm, { - form: attrForm(id, label, attr), + form: attrForm(id, label, type === 'barriers' ? partners : attr, type), obj: model, } as FormAttributes) : m(AttrView, { @@ -202,7 +200,7 @@ export const SettingsPage: MeiosisComponent = () => { const AttrView: FactoryComponent<{ attr: Array; - type: ItemType; + type: AttributeType; iconName?: string; acts: Act[]; crimeScripts: CrimeScript[]; @@ -223,7 +221,7 @@ const AttrView: FactoryComponent<{ if (actIdx < 0) return; const act = acts[actIdx]; [{ ...act }].forEach((phase, phaseIdx) => { - if (type === 'location') { + if (type === 'locations') { if (phase.locationIds) { acc.push([crimeScriptIdx, actIdx, phaseIdx, SearchScore.EXACT_MATCH]); } @@ -234,7 +232,7 @@ const AttrView: FactoryComponent<{ if (cast.includes(c.id)) { acc.push([crimeScriptIdx, actIdx, phaseIdx, SearchScore.EXACT_MATCH]); } - } else if (type === 'attribute') { + } else if (type === 'attributes') { const { attributes = [] } = activity; if (attributes.includes(c.id)) { acc.push([crimeScriptIdx, actIdx, phaseIdx, SearchScore.EXACT_MATCH]); diff --git a/packages/gui/src/components/ui/crime-script-editor.ts b/packages/gui/src/components/ui/crime-script-editor.ts index 3cc054b..3b287b9 100644 --- a/packages/gui/src/components/ui/crime-script-editor.ts +++ b/packages/gui/src/components/ui/crime-script-editor.ts @@ -17,10 +17,9 @@ import { ActivityType, Product, } from '../../models'; -import { FlatButton, Tabs, uniqueId, Select, ISelectOptions } from 'mithril-materialized'; +import { FlatButton, Tabs, uniqueId, Select, ISelectOptions, SearchSelect } from 'mithril-materialized'; import { FormAttributes, LayoutForm, UIForm } from 'mithril-ui-form'; import { labelForm, literatureForm } from '../../models/forms'; -import { MultiSelectDropdown } from '../ui/multi-select'; import { crimeMeasureOptions } from '../../models/situational-crime-prevention'; import { I18N, t } from '../../services/translations'; import { InputOptions, toOptions } from '../../utils'; @@ -192,64 +191,61 @@ export const CrimeScriptEditor: FactoryComponent<{ i18n: I18N, } as FormAttributes>), - curActIds && + curActIds && [ m( '.col.s12', - m('.row', [ - m( - '.col.s12', - m(MultiSelectDropdown, { - items: acts, - selectedIds: curActIds.ids, - label: t('SELECT_ACT_N'), - max: 5, - search: true, - selectAll: false, - listAll: true, - onchange: (selectedIds) => { - crimeScript.stages[curActIdx] = { - id: selectedIds.length > 0 ? selectedIds[0] : '', - ids: selectedIds, - }; - m.redraw(); - }, - }) - ), - curActIds.ids && - curActIds.ids.length > 0 && [ - m(Select, { - key: curAct ? curAct.label : curActIds.id, - label: t('SELECT_ACT'), - className: 'col s6', - initialValue: curActIds.id, - // disabled: curActIds.ids.length === 1, - options: acts.filter((a) => curActIds.ids.includes(a.id)), - onchange: (id) => { - crimeScript.stages[curActIdx].id = id[0]; - }, - } as ISelectOptions), - ], - m(FlatButton, { - label: t('ACT'), + m(SearchSelect, { + key: curAct ? curAct.id : '', + label: t('SELECT_ACT_N'), + options: acts, + initialValue: curActIds.ids, + // max: 5, + // search: true, + // selectAll: false, + // listAll: true, + onchange: (selectedIds) => { + crimeScript.stages[curActIdx] = { + id: selectedIds.length > 0 ? selectedIds[0] : '', + ids: selectedIds, + }; + // m.redraw(); + }, + }) + ), + curActIds.ids && + curActIds.ids.length > 0 && [ + m(Select, { + key: curAct ? curAct.label : curActIds.id, + label: t('SELECT_ACT'), className: 'col s6', - iconName: 'add', - onclick: () => { - const id = uniqueId(); - const newAct = { - id, - label: t('ACT'), - } as Act; - acts.push(newAct); - crimeScript.stages[curActIdx].id = id; - if (crimeScript.stages[curActIdx].ids) { - crimeScript.stages[curActIdx].ids.push(id); - } else { - crimeScript.stages[curActIdx].ids = [id]; - } + initialValue: curActIds.id, + // disabled: curActIds.ids.length === 1, + options: acts.filter((a) => curActIds.ids.includes(a.id)), + onchange: (id) => { + crimeScript.stages[curActIdx].id = id[0]; }, - }), - ]) - ), + } as ISelectOptions), + ], + m(FlatButton, { + label: t('ACT'), + className: 'col s6', + iconName: 'add', + onclick: () => { + const id = uniqueId(); + const newAct = { + id, + label: t('ACT'), + } as Act; + acts.push(newAct); + crimeScript.stages[curActIdx].id = id; + if (crimeScript.stages[curActIdx].ids) { + crimeScript.stages[curActIdx].ids.push(id); + } else { + crimeScript.stages[curActIdx].ids = [id]; + } + }, + }), + ], curAct && [ m('.cur-act', { key: curAct.id }, [ diff --git a/packages/gui/src/components/ui/multi-select.ts b/packages/gui/src/components/ui/multi-select.ts deleted file mode 100644 index 51976c7..0000000 --- a/packages/gui/src/components/ui/multi-select.ts +++ /dev/null @@ -1,163 +0,0 @@ -import m, { FactoryComponent } from 'mithril'; - -export type Item = { - id: string; - label: string; -}; - -export interface MultiSelectAttrs { - items: Array; - selectedIds: Array; - label?: string; - placeholder?: string; - selectAllLabel?: string; - max?: number; - search?: boolean; - selectAll?: boolean; - listAll?: boolean; - onchange?: (ids: string[]) => void; - onselect?: (id: string, label: string) => void; - onunselect?: (id: string, label: string) => void; -} - -export const MultiSelectDropdown: FactoryComponent = () => { - let dropdownRef: HTMLElement | null = null; - // let selectedValues: Set; - let searchQuery = ''; - let dropdownOpen = false; - - const handleDocumentClick = (e: MouseEvent) => { - if (dropdownRef && !dropdownRef.contains(e.target as Node)) { - dropdownOpen = false; - } - }; - - return { - oncreate() { - document.addEventListener('click', handleDocumentClick); - }, - onremove() { - document.removeEventListener('click', handleDocumentClick); - }, - view({ - attrs: { - items, - selectedIds, - label, - placeholder, - selectAllLabel, - max, - search, - selectAll, - listAll, - onchange, - onselect, - onunselect, - }, - }) { - const selectedValues = new Set(selectedIds); - const filteredItems = items.filter((item) => item.label.toLowerCase().includes(searchQuery.toLowerCase())); - const toggleDropdown = () => { - dropdownOpen = !dropdownOpen; - console.log(`Dropdown open: ${dropdownOpen}`); - }; - - const handleSelect = (item: Item) => { - if (selectedValues.has(item.id)) { - selectedValues.delete(item.id); - onunselect?.(item.id, item.label); - } else { - if (!max || selectedValues.size < max) { - selectedValues.add(item.id); - onselect?.(item.id, item.label); - } - } - onchange && onchange(Array.from(selectedValues)); - // m.redraw(); - }; - - const handleSelectAll = () => { - if (selectAll) { - const allSelected = Array.from(selectedValues).length === items.length; - if (allSelected) { - items.forEach((item) => selectedValues.delete(item.id)); - } else { - items.forEach((item) => selectedValues.add(item.id)); - } - // m.redraw(); - } - }; - - return m( - '.multi-select', - { - oncreate: (vnode) => { - dropdownRef = vnode.dom as HTMLElement; - }, - }, - [ - m( - '.multi-select-header', - { - onclick: toggleDropdown, - class: dropdownOpen ? 'multi-select-header-active' : '', - }, - [ - label && m('label', label), - selectedValues.size === 0 - ? m('span.multi-select-header-placeholder', placeholder || 'Select item(s)') - : listAll - ? Array.from(selectedValues).map((id) => - m( - 'span.multi-select-header-option', - { - 'data-id': id, - }, - items.find((item) => item.id === id)?.label - ) - ) - : m('span.multi-select-header-option', `${selectedValues.size} selected`), - max ? m('span.multi-select-header-max', `${selectedValues.size}/${max}`) : null, - ] - ), - dropdownOpen && - m('.multi-select-options', [ - search && - m('input.multi-select-search[type=text][placeholder=Search...]', { - oninput: (e: any) => (searchQuery = e.target.value), - }), - selectAll && - m( - '.multi-select-all', - { - onclick: handleSelectAll, - }, - [ - m('span.multi-select-option-radio', { - class: selectedValues.size === items.length ? 'multi-select-selected' : '', - }), - m('span.multi-select-option-text', selectAllLabel || 'Select all'), - ] - ), - filteredItems.map((item) => - m( - '.multi-select-option', - { - class: selectedValues.has(item.id) ? 'multi-select-selected' : '', - 'data-id': item.id, - onclick: () => handleSelect(item), - }, - [ - m('span.multi-select-option-radio', { - class: selectedValues.has(item.id) ? 'multi-select-selected' : '', - }), - m('span.multi-select-option-text', item.label), - ] - ) - ), - ]), - ] - ); - }, - }; -}; diff --git a/packages/gui/src/components/ui/search-select-plugin.ts b/packages/gui/src/components/ui/search-select-plugin.ts new file mode 100644 index 0000000..c57eedc --- /dev/null +++ b/packages/gui/src/components/ui/search-select-plugin.ts @@ -0,0 +1,30 @@ +import m from 'mithril'; +import { PluginType } from 'mithril-ui-form'; +import { SearchSelect, Option } from 'mithril-materialized'; + +export const searchSelectPlugin: PluginType[]> = () => { + let options: Option[] = []; + let className: string | undefined; + + return { + oninit: ({ attrs: { field } }) => { + const { options: o, className: c } = field; + className = c; + if (o && typeof o !== 'string') { + options = o; + } + }, + view: ({ attrs: { iv, onchange, label } }) => { + return m( + '.multi-select', + { className }, + m(SearchSelect, { + label, + initialValue: iv, + options, + onchange, + }) + ); + }, + }; +}; diff --git a/packages/gui/src/models/data-model.ts b/packages/gui/src/models/data-model.ts index a6e4e62..74d3f50 100644 --- a/packages/gui/src/models/data-model.ts +++ b/packages/gui/src/models/data-model.ts @@ -162,7 +162,7 @@ export type Opportunity = Labeled & Hierarchical; export type Indicator = Labeled & Hierarchical; -export type Barrier = Labeled & Hierarchical; +export type Barrier = Labeled & Hierarchical & { parters: ID[] }; export type Partner = Labeled & Hierarchical; diff --git a/packages/gui/src/models/forms.ts b/packages/gui/src/models/forms.ts index c8fc43c..5cf548c 100644 --- a/packages/gui/src/models/forms.ts +++ b/packages/gui/src/models/forms.ts @@ -3,29 +3,56 @@ import { CrimeScript, Literature, Labeled, Hierarchical, CrimeScriptFilter } fro import { toOptions } from '../utils'; import { t } from '../services'; -export type AttributeType = 'cast' | 'attributes' | 'transports' | 'locations' | 'geoLocations' | 'products'; +export type AttributeType = + | 'cast' + | 'attributes' + | 'transports' + | 'locations' + | 'geoLocations' + | 'products' + | 'opportunities' + | 'indicators' + | 'barriers' + | 'partners'; -export const attrForm = (id: AttributeType, label: string, attr: Labeled[]) => [ +export const attrForm = (id: AttributeType, label: string, attr: Labeled[] = [], attrType: AttributeType) => [ { id, label, className: 'col s12', repeat: true, // pageSize: 100, - type: [ - { id: 'id', type: 'autogenerate', autogenerate: 'id' }, - { id: 'label', type: 'text', className: 'col s12 m4', label: t('NAME') }, - { id: 'synonyms', type: 'tags', className: 'col s12 m8', label: t('SYNONYMS') }, - { - id: 'parents', - type: 'select', - multiple: true, - className: 'col s12', - label: t('CATEGORIES'), - options: attr, - }, - // { id: 'url', type: 'base64', className: 'col s6', label: t('IMAGE') }, - ] as UIForm, + type: ['cast', 'attributes', 'products', 'transports', 'locations', 'geoLocations', 'partners'].includes(attrType) + ? ([ + { id: 'id', type: 'autogenerate', autogenerate: 'id' }, + { id: 'label', type: 'text', className: 'col s12 m4', label: t('NAME') }, + { id: 'synonyms', type: 'tags', className: 'col s12 m8', label: t('SYNONYMS') }, + ['partners', 'locations'].includes(attrType) + ? undefined + : { + id: 'parents', + type: 'search_select', + multiple: true, + className: 'col s12', + label: t('CATEGORIES'), + options: attr.filter(({ label }) => label), + }, + ].filter(Boolean) as UIForm) + : ([ + { id: 'id', type: 'autogenerate', autogenerate: 'id' }, + { id: 'label', type: 'textarea', className: 'col s12', label: t('DESCRIPTION') }, + attrType === 'barriers' + ? { + id: 'partners', + type: 'search_select', + multiple: true, + className: 'col s12', + label: t('PARTNERS'), + options: attr.filter(({ label }) => label), + } + : undefined, + // { id: 'url', type: 'base64', className: 'col s6', label: t('IMAGE') }, + ].filter(Boolean) as UIForm), }, ];