diff --git a/client-app/src/apps/contact.ts b/client-app/src/apps/contact.ts index 1e256035b..301b7ef61 100644 --- a/client-app/src/apps/contact.ts +++ b/client-app/src/apps/contact.ts @@ -2,8 +2,8 @@ import '../Bootstrap'; import {XH} from '@xh/hoist/core'; import {AppContainer} from '@xh/hoist/desktop/appcontainer'; -import {AppComponent} from '../examples/contact/AppComponent'; -import {AppModel} from '../examples/contact/AppModel'; +import {AppComponent} from '../examples/contact/desktop/AppComponent'; +import {AppModel} from '../examples/contact/desktop/AppModel'; import {AuthModel} from '../core/AuthModel'; XH.renderApp({ diff --git a/client-app/src/apps/contactMobile.ts b/client-app/src/apps/contactMobile.ts new file mode 100644 index 000000000..f5692245a --- /dev/null +++ b/client-app/src/apps/contactMobile.ts @@ -0,0 +1,19 @@ +import '../Bootstrap'; + +import {XH} from '@xh/hoist/core'; +import {AppContainer} from '@xh/hoist/mobile/appcontainer'; +import {AppComponent} from '../examples/contact/mobile/AppComponent'; +import AppModel from '../examples/contact/mobile/AppModel'; +import {AuthModel} from '../core/AuthModel'; + +XH.renderApp({ + clientAppCode: 'contactMobile', + clientAppName: 'XH Contact Mobile', + componentClass: AppComponent, + modelClass: AppModel, + containerClass: AppContainer, + authModelClass: AuthModel, + isMobileApp: true, + enableLogout: true, + checkAccess: () => true +}); diff --git a/client-app/src/examples/contact/cmp/FavoriteButton.scss b/client-app/src/examples/contact/FavoriteButton.scss similarity index 100% rename from client-app/src/examples/contact/cmp/FavoriteButton.scss rename to client-app/src/examples/contact/FavoriteButton.scss diff --git a/client-app/src/examples/contact/cmp/TileView.scss b/client-app/src/examples/contact/TileView.scss similarity index 100% rename from client-app/src/examples/contact/cmp/TileView.scss rename to client-app/src/examples/contact/TileView.scss diff --git a/client-app/src/examples/contact/AppComponent.ts b/client-app/src/examples/contact/desktop/AppComponent.ts similarity index 62% rename from client-app/src/examples/contact/AppComponent.ts rename to client-app/src/examples/contact/desktop/AppComponent.ts index 8f4889322..53f925f07 100644 --- a/client-app/src/examples/contact/AppComponent.ts +++ b/client-app/src/examples/contact/desktop/AppComponent.ts @@ -2,18 +2,27 @@ import {hoistCmp, uses} from '@xh/hoist/core'; import {appBar} from '@xh/hoist/desktop/cmp/appbar'; import {panel} from '@xh/hoist/desktop/cmp/panel'; import {Icon} from '@xh/hoist/icon'; +import {a} from '@xh/hoist/cmp/layout'; import {AppModel} from './AppModel'; import {directoryPanel} from './DirectoryPanel'; -import '../../core/Toolbox.scss'; +import '../../../core/Toolbox.scss'; export const AppComponent = hoistCmp({ displayName: 'App', model: uses(AppModel), render() { + const domain = window.location.origin; return panel({ tbar: appBar({ icon: Icon.contact({size: '2x', prefix: 'fal'}), + leftItems: [ + a({ + item: 'View contacts for mobile', + href: `${domain}/contactMobile`, + target: '_blank' + }) + ], appMenuButtonProps: {hideLogoutItem: false} }), item: directoryPanel() diff --git a/client-app/src/examples/contact/AppModel.ts b/client-app/src/examples/contact/desktop/AppModel.ts similarity index 60% rename from client-app/src/examples/contact/AppModel.ts rename to client-app/src/examples/contact/desktop/AppModel.ts index c63a82bcb..ced34670b 100644 --- a/client-app/src/examples/contact/AppModel.ts +++ b/client-app/src/examples/contact/desktop/AppModel.ts @@ -1,8 +1,6 @@ import {XH} from '@xh/hoist/core'; -import {ContactService} from './svc/ContactService'; -import {BaseAppModel} from '../../BaseAppModel'; - -export const PERSIST_APP = {prefKey: 'contactAppState'}; +import {ContactService} from '../svc/ContactService'; +import {BaseAppModel} from '../../../BaseAppModel'; export class AppModel extends BaseAppModel { static instance: AppModel; diff --git a/client-app/src/examples/contact/DirectoryPanel.ts b/client-app/src/examples/contact/desktop/DirectoryPanel.ts similarity index 98% rename from client-app/src/examples/contact/DirectoryPanel.ts rename to client-app/src/examples/contact/desktop/DirectoryPanel.ts index c27fdc6d6..975ea22f5 100644 --- a/client-app/src/examples/contact/DirectoryPanel.ts +++ b/client-app/src/examples/contact/desktop/DirectoryPanel.ts @@ -9,8 +9,9 @@ import {toolbar, toolbarSep} from '@xh/hoist/desktop/cmp/toolbar'; import {Icon} from '@xh/hoist/icon'; import {tileView} from './cmp/TileView'; import {detailsPanel} from './details/DetailsPanel'; -import './DirectoryPanel.scss'; + import {DirectoryPanelModel} from './DirectoryPanelModel'; +import '../DirectoryPanel.scss'; export const directoryPanel = hoistCmp.factory({ model: creates(DirectoryPanelModel), diff --git a/client-app/src/examples/contact/DirectoryPanelModel.ts b/client-app/src/examples/contact/desktop/DirectoryPanelModel.ts similarity index 98% rename from client-app/src/examples/contact/DirectoryPanelModel.ts rename to client-app/src/examples/contact/desktop/DirectoryPanelModel.ts index a0734110e..ea429f22a 100644 --- a/client-app/src/examples/contact/DirectoryPanelModel.ts +++ b/client-app/src/examples/contact/desktop/DirectoryPanelModel.ts @@ -5,10 +5,10 @@ import {GridModel} from '@xh/hoist/cmp/grid'; import {withFilterByField, withFilterByKey} from '@xh/hoist/data'; import {isEmpty, uniq, without} from 'lodash'; -import {PERSIST_APP} from './AppModel'; +import {PERSIST_APP} from '../svc/ContactService'; import {favoriteButton} from './cmp/FavoriteButton'; import {DetailsPanelModel} from './details/DetailsPanelModel'; -import {cellPhoneCol, emailCol, locationCol, nameCol, workPhoneCol} from '../../core/columns'; +import {cellPhoneCol, emailCol, locationCol, nameCol, workPhoneCol} from '../../../core/columns'; import {FilterLike} from '@xh/hoist/data/filter/Types'; /** diff --git a/client-app/src/examples/contact/cmp/FavoriteButton.ts b/client-app/src/examples/contact/desktop/cmp/FavoriteButton.ts similarity index 95% rename from client-app/src/examples/contact/cmp/FavoriteButton.ts rename to client-app/src/examples/contact/desktop/cmp/FavoriteButton.ts index f0c5939f6..ba3b3331e 100644 --- a/client-app/src/examples/contact/cmp/FavoriteButton.ts +++ b/client-app/src/examples/contact/desktop/cmp/FavoriteButton.ts @@ -3,8 +3,8 @@ import {Icon} from '@xh/hoist/icon/Icon'; import {consumeEvent} from '@xh/hoist/utils/js'; import {button} from '@xh/hoist/desktop/cmp/button'; -import './FavoriteButton.scss'; import {DirectoryPanelModel} from '../DirectoryPanelModel'; +import '../../FavoriteButton.scss'; export const favoriteButton = hoistCmp.factory(({model, record}) => { const {isFavorite} = record.data; diff --git a/client-app/src/examples/contact/cmp/TileView.ts b/client-app/src/examples/contact/desktop/cmp/TileView.ts similarity index 97% rename from client-app/src/examples/contact/cmp/TileView.ts rename to client-app/src/examples/contact/desktop/cmp/TileView.ts index d7053c0a4..88520da17 100644 --- a/client-app/src/examples/contact/cmp/TileView.ts +++ b/client-app/src/examples/contact/desktop/cmp/TileView.ts @@ -1,8 +1,9 @@ import {div, filler, span, tileFrame, vbox} from '@xh/hoist/cmp/layout'; import {hoistCmp} from '@xh/hoist/core'; import {favoriteButton} from './FavoriteButton'; -import './TileView.scss'; + import {DirectoryPanelModel} from '../DirectoryPanelModel'; +import '../../TileView.scss'; export const tileView = hoistCmp.factory({ render({model}) { diff --git a/client-app/src/examples/contact/details/DetailsPanel.scss b/client-app/src/examples/contact/desktop/details/DetailsPanel.scss similarity index 100% rename from client-app/src/examples/contact/details/DetailsPanel.scss rename to client-app/src/examples/contact/desktop/details/DetailsPanel.scss diff --git a/client-app/src/examples/contact/details/DetailsPanel.ts b/client-app/src/examples/contact/desktop/details/DetailsPanel.ts similarity index 100% rename from client-app/src/examples/contact/details/DetailsPanel.ts rename to client-app/src/examples/contact/desktop/details/DetailsPanel.ts diff --git a/client-app/src/examples/contact/details/DetailsPanelModel.ts b/client-app/src/examples/contact/desktop/details/DetailsPanelModel.ts similarity index 100% rename from client-app/src/examples/contact/details/DetailsPanelModel.ts rename to client-app/src/examples/contact/desktop/details/DetailsPanelModel.ts diff --git a/client-app/src/examples/contact/mobile/AppComponent.ts b/client-app/src/examples/contact/mobile/AppComponent.ts new file mode 100644 index 000000000..ebb298991 --- /dev/null +++ b/client-app/src/examples/contact/mobile/AppComponent.ts @@ -0,0 +1,27 @@ +import {hoistCmp, uses, XH} from '@xh/hoist/core'; +import {appBar} from '@xh/hoist/mobile/cmp/header'; +import {panel} from '@xh/hoist/mobile/cmp/panel'; +import {Icon} from '@xh/hoist/icon'; +import AppModel from './AppModel'; +import {navigator} from '@xh/hoist/mobile/cmp/navigator'; +import '../../../core/Toolbox.scss'; + +export const AppComponent = hoistCmp({ + displayName: 'App', + model: uses(AppModel), + + render() { + return panel({ + tbar: appBar({ + omit: XH.isLandscape, + icon: Icon.boxFull({size: 'lg', prefix: 'fal'}), + hideRefreshButton: false, + appMenuButtonProps: { + hideLogoutItem: false, + hideThemeItem: true + } + }), + item: navigator() + }); + } +}); diff --git a/client-app/src/examples/contact/mobile/AppModel.ts b/client-app/src/examples/contact/mobile/AppModel.ts new file mode 100644 index 000000000..690f3bea0 --- /dev/null +++ b/client-app/src/examples/contact/mobile/AppModel.ts @@ -0,0 +1,99 @@ +import {managed, PlainObject, XH} from '@xh/hoist/core'; +import {ContactService} from '../svc/ContactService'; +import {BaseAppModel} from '../../../BaseAppModel'; +import {NavigatorModel} from '@xh/hoist/mobile/cmp/navigator'; +import directoryPanel from './DirectoryPanel'; +import detailsPanel from './details/DetailsPanel'; +import DetailsPanelModel from './details/DetailsPanelModel'; +import DirectoryPanelModel from './DirectoryPanelModel'; +import {action, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; +import {castArray, isEmpty, uniq} from 'lodash'; + +import { + autoRefreshAppOption, + sizingModeAppOption, + themeAppOption +} from '@xh/hoist/mobile/cmp/appOption'; + +export default class AppModel extends BaseAppModel { + static instance: AppModel; + + @observable.ref tagList: string[] = []; + @observable.ref contacts: PlainObject[] = []; + + @managed directoryPanelModel: DirectoryPanelModel; + @managed detailsPanelModel: DetailsPanelModel; + + @managed + navigatorModel: NavigatorModel = new NavigatorModel({ + track: true, + pages: [ + {id: 'default', content: directoryPanel}, + {id: 'details', content: detailsPanel} + ] + }); + + override getRoutes() { + return [ + { + name: 'default', + path: '/contactMobile', + children: [ + { + name: 'details', + path: '/:id' + } + ] + } + ]; + } + + constructor() { + super(); + makeObservable(this); + this.directoryPanelModel = new DirectoryPanelModel(this); + this.detailsPanelModel = new DetailsPanelModel(this); + } + + async updateContactAsync(id: string, data: PlainObject) { + if (!data || isEmpty(data)) return; + // Mobile select only allows single selections, while desktop allows multiple + // This means we receive an array on the desktop picker, and a single value here. + // Convert single result into an array of one to keep data consistent. + if (data.tags) { + data.tags = castArray(data.tags); + } + await XH.contactService.updateContactAsync(id, data); + await this.refreshAsync(); + } + + @action + async toggleFavorite(id: string) { + await XH.contactService.toggleFavorite(id); + this.contacts = this.contacts.map(contact => + contact.id === id ? {...contact, isFavorite: !contact.isFavorite} : contact + ); + } + + override getAppOptions() { + return [themeAppOption(), sizingModeAppOption(), autoRefreshAppOption()]; + } + + override async initAsync() { + await super.initAsync(); + await XH.installServicesAsync(ContactService); + await this.loadAsync(); + } + + override async doLoadAsync() { + try { + const contacts = await XH.contactService.getContactsAsync(); + runInAction(() => { + this.contacts = contacts; + this.tagList = uniq(contacts.flatMap(it => it.tags ?? [])).sort() as string[]; + }); + } catch (e) { + XH.handleException(e); + } + } +} diff --git a/client-app/src/examples/contact/mobile/DirectoryPanel.ts b/client-app/src/examples/contact/mobile/DirectoryPanel.ts new file mode 100644 index 000000000..07d1b1091 --- /dev/null +++ b/client-app/src/examples/contact/mobile/DirectoryPanel.ts @@ -0,0 +1,72 @@ +import {hoistCmp, uses} from '@xh/hoist/core'; +import {grid} from '@xh/hoist/cmp/grid'; +import {hframe, filler} from '@xh/hoist/cmp/layout'; +import {storeCountLabel, storeFilterField} from '@xh/hoist/cmp/store'; +import {button} from '@xh/hoist/mobile/cmp/button'; +import {buttonGroupInput} from '@xh/hoist/mobile/cmp/input'; +import {panel} from '@xh/hoist/mobile/cmp/panel'; +import {Icon} from '@xh/hoist/icon'; +import {toolbar} from '@xh/hoist/mobile/cmp/toolbar'; + +import tileView from './cmp/TileView'; +import DirectoryPanelModel from './DirectoryPanelModel'; +import '../DirectoryPanel.scss'; + +const directoryPanel = hoistCmp.factory({ + model: uses(DirectoryPanelModel), + + render({model}) { + return panel({ + className: 'tb-directory-panel', + item: hframe( + panel({ + tbar: tbar(), + item: model.displayMode === 'grid' ? grid() : tileView(), + bbar: bbar() + }) + ) + }); + } +}); + +const tbar = hoistCmp.factory(({model}) => { + return toolbar({ + className: 'tb-directory-panel__tbar', + items: [ + storeFilterField({ + leftIcon: Icon.search(), + maxWidth: 400, + minWidth: 200 + }), + filler(), + buttonGroupInput({ + outlined: true, + bind: 'displayMode', + value: model.displayMode, + intent: 'primary', + items: [ + button({ + icon: Icon.list(), + value: 'grid', + width: 50 + }), + button({ + icon: Icon.users(), + value: 'tiles', + width: 50 + }) + ] + }) + ] + }); +}); + +const bbar = hoistCmp.factory(({model}) => { + const { + gridModel: {store} + } = model; + + return toolbar(filler(), storeCountLabel({store, unit: 'contact'})); +}); + +export default directoryPanel; diff --git a/client-app/src/examples/contact/mobile/DirectoryPanelModel.ts b/client-app/src/examples/contact/mobile/DirectoryPanelModel.ts new file mode 100644 index 000000000..3dcedd3e4 --- /dev/null +++ b/client-app/src/examples/contact/mobile/DirectoryPanelModel.ts @@ -0,0 +1,126 @@ +import {HoistModel, managed, persist, XH} from '@xh/hoist/core'; +import {bindable, makeObservable, action} from '@xh/hoist/mobx'; +import {GridModel} from '@xh/hoist/cmp/grid'; +import {div, hbox} from '@xh/hoist/cmp/layout'; +import {nameCol, locationCol} from '../../../core/columns'; +import {isEmpty, uniq} from 'lodash'; + +// Shared from the desktop version +import {PERSIST_APP} from '../svc/ContactService'; + +import AppModel from './AppModel'; + +export default class ContactsPageModel extends HoistModel { + override persistWith = PERSIST_APP; + + @bindable @persist displayMode: 'grid' | 'tiles' = 'tiles'; + + @managed gridModel: GridModel; + @managed appModel: AppModel; + + get records() { + return this.gridModel.store.records; + } + + constructor(appModel: AppModel) { + super(); + makeObservable(this); + + this.gridModel = this.createGridModel(); + this.appModel = appModel; + + this.addReaction( + { + track: () => this.appModel.contacts, + run: data => this.gridModel.loadData(data), + fireImmediately: true + }, + { + track: () => this.gridModel.selectedRecord, + run: rec => this.navigateToSelectedRecord(rec) + }, + { + track: () => XH.routerState, + run: ({path}) => { + if (path === '/contactMobile') this.clearCurrentSelection(); + } + } + ); + } + + navigateToSelectedRecord(record) { + if (!record) return; + XH.appendRoute('details', {id: record.id}); + } + + clearCurrentSelection() { + this.gridModel.clearSelection(); + } + + @action + private toggleTag(tag: string) { + const tagList = this.appModel.tagList ?? []; + this.appModel.tagList = tagList.includes(tag) + ? uniq(tagList.filter(t => t !== tag)) + : [...tagList, tag]; + } + + //------------------------ + // Implementation + //------------------------ + private createGridModel() { + return new GridModel({ + emptyText: 'No matching contacts found.', + selModel: 'single', + groupBy: 'isFavorite', + groupRowRenderer: ({value}) => (value === 'true' ? 'Favorites' : 'XH Engineers'), + groupSortFn: (a, b) => (a < b ? 1 : -1), + store: { + fields: [ + {name: 'isFavorite', type: 'bool'}, + {name: 'profilePicture', type: 'string'}, + {name: 'bio', type: 'string'}, + {name: 'tags', type: 'auto'} + ] + }, + columns: [ + { + field: {name: 'isFavorite', type: 'bool'} + }, + { + ...nameCol, + width: null, + flex: 1 + }, + { + ...locationCol, + width: 150 + }, + {field: {name: 'email', type: 'string'}, hidden: true}, + {field: {name: 'bio', type: 'string'}, hidden: true}, + { + field: 'tags', + width: 400, + renderer: this.tagsRenderer + }, + {field: {name: 'workPhone', type: 'string'}, hidden: true}, + {field: {name: 'cellPhone', type: 'string'}, hidden: true} + ] + }); + } + + private tagsRenderer = v => { + if (isEmpty(v)) return null; + + return hbox({ + className: 'tb-contact-tag-container', + items: v.map(tag => + div({ + className: 'tb-contact-tag', + item: tag, + onClick: () => this.toggleTag(tag) + }) + ) + }); + }; +} diff --git a/client-app/src/examples/contact/mobile/cmp/FavoriteButton.ts b/client-app/src/examples/contact/mobile/cmp/FavoriteButton.ts new file mode 100644 index 000000000..53d056403 --- /dev/null +++ b/client-app/src/examples/contact/mobile/cmp/FavoriteButton.ts @@ -0,0 +1,24 @@ +import {hoistCmp} from '@xh/hoist/core'; +import {Icon} from '@xh/hoist/icon/Icon'; +import {consumeEvent} from '@xh/hoist/utils/js'; +import {button} from '@xh/hoist/mobile/cmp/button'; + +import DirectoryPanelModel from '../DirectoryPanelModel'; +import '../../FavoriteButton.scss'; + +export const favoriteButton = hoistCmp.factory(({model, record}) => { + const {isFavorite} = record.data; + return button({ + className: 'tb-contact-fave-btn', + height: null, + style: {backgroundColor: 'transparent'}, + icon: Icon.favorite({ + color: isFavorite ? 'gold' : null, + prefix: isFavorite ? 'fas' : 'far' + }), + onClick: e => { + consumeEvent(e); + model.appModel.toggleFavorite(record.id); + } + }); +}); diff --git a/client-app/src/examples/contact/mobile/cmp/TileView.ts b/client-app/src/examples/contact/mobile/cmp/TileView.ts new file mode 100644 index 000000000..ae9996c3a --- /dev/null +++ b/client-app/src/examples/contact/mobile/cmp/TileView.ts @@ -0,0 +1,49 @@ +import {hoistCmp} from '@xh/hoist/core'; +import DirectoryPanelModel from '../DirectoryPanelModel'; +import {tileFrame, vbox, div, span, filler} from '@xh/hoist/cmp/layout'; + +import {favoriteButton} from './FavoriteButton'; +import '../../TileView.scss'; + +const tileView = hoistCmp.factory({ + render({model}) { + return tileFrame({ + spacing: 10, + maxTileHeight: 200, + minTileHeight: 150, + maxTileWidth: 200, + minTileWidth: 150, + items: model.records.map(record => tile({record})) + }); + } +}); + +const tile = hoistCmp.factory(({model, record}) => { + const {gridModel} = model; + const isSelected = gridModel.selectedId === record.id; + const {profilePicture, name} = record.data; + + return vbox({ + style: {backgroundImage: `url(${profilePicture})`}, + className: `tb-contact-tile ${isSelected ? 'tb-contact-tile--selected' : ''}`, + items: [ + div({ + className: 'tb-contact-tile__bar', + // This inline style defintion keeps me from needing to fight with + // existing styles when making the mobile button want to center itself + style: {alignItems: 'center'}, + items: [ + favoriteButton({record}), + filler(), + span({ + className: 'tb-contact-tile__name', + item: name + }) + ] + }) + ], + onClick: () => gridModel.selectAsync(record) + }); +}); + +export default tileView; diff --git a/client-app/src/examples/contact/mobile/details/DetailsPanel.ts b/client-app/src/examples/contact/mobile/details/DetailsPanel.ts new file mode 100644 index 000000000..197c46342 --- /dev/null +++ b/client-app/src/examples/contact/mobile/details/DetailsPanel.ts @@ -0,0 +1,147 @@ +import {XH, hoistCmp, uses} from '@xh/hoist/core'; +import {box, div, img, p, filler} from '@xh/hoist/cmp/layout'; +import {formField} from '@xh/hoist/mobile/cmp/form'; +import {form} from '@xh/hoist/cmp/form'; +import {panel} from '@xh/hoist/mobile/cmp/panel'; +import {select, textArea, textInput} from '@xh/hoist/mobile/cmp/input'; +import {button} from '@xh/hoist/mobile/cmp/button'; +import {toolbar} from '@xh/hoist/mobile/cmp/toolbar'; +import {Icon} from '@xh/hoist/icon'; +import {consumeEvent} from '@xh/hoist/utils/js'; +import {isEmpty, get} from 'lodash'; + +import DetailsPanelModel from './DetailsPanelModel'; +import '../../desktop/details/DetailsPanel.scss'; + +const detailsPanel = hoistCmp.factory({ + model: uses(() => DetailsPanelModel), + render() { + return panel({ + className: 'tb-contact-details-panel', + title: '', + item: contactProfile(), + bbar: bbar() + }); + } +}); + +const contactProfile = hoistCmp.factory({ + render() { + return div({ + className: 'tb-contact-details-panel__inner', + items: [ + picture(), + form({ + fieldDefaults: { + inline: true, + labelWidth: 100 + }, + items: [ + textField({field: 'name'}), + textField({field: 'email'}), + textField({field: 'location'}), + textField({field: 'workPhone'}), + textField({field: 'cellPhone'}), + tagsField(), + bioField() + ] + }) + ] + }); + } +}); + +const picture = hoistCmp.factory(({model}) => + img({src: model.currentContact?.profilePicture}) +); + +const bbar = hoistCmp.factory(({model}) => { + const {currentContact, isEditing} = model; + if (!currentContact) return null; + + return toolbar( + button({ + text: 'Cancel', + omit: !isEditing, + onClick: () => model.cancelEdit() + }), + favoriteButton({omit: isEditing}), + filler(), + editButton({omit: !XH.getUser().isHoistAdmin}) + ); +}); + +//-------------- +// FormFields +//-------------- +const textField = hoistCmp.factory(({field}) => + formField({ + field, + readonlyRenderer: val => val ?? '-', + item: textInput() + }) +); + +const bioField = hoistCmp.factory(() => + formField({ + field: 'bio', + label: null, + item: textArea({minHeight: 250}), + readonlyRenderer: val => { + return val ? div(val.split('\n').map(v => p(v))) : '-'; + } + }) +); + +const tagsField = hoistCmp.factory(({model}) => + formField({ + field: 'tags', + item: select({ + enableCreate: true, + options: model.appModel.tagList + }), + readonlyRenderer: tags => { + return isEmpty(tags) + ? 'None (yet)' + : box({ + flexWrap: 'wrap', + items: tags.map(tag => div({className: 'tb-contact-tag', item: tag})) + }); + } + }) +); + +//------------ +// Buttons +//------------ +const favoriteButton = hoistCmp.factory(({model}) => { + const {currentContact} = model; + const isFavorite = get(currentContact, 'isFavorite'); + return button({ + text: 'Favorite', + icon: Icon.favorite({ + color: isFavorite ? 'gold' : null, + prefix: isFavorite ? 'fas' : 'far' + }), + width: 120, + minimal: false, + onClick: e => { + consumeEvent(e); + model.appModel.toggleFavorite(currentContact.id); + } + }); +}); + +const editButton = hoistCmp.factory(({model}) => { + const {isEditing} = model; + + return button({ + icon: isEditing ? Icon.save() : Icon.edit(), + text: isEditing ? 'Save' : 'Edit', + intent: isEditing ? 'primary' : null, + minimal: !isEditing, + onClick: () => model.toggleEditAsync() + }); +}); + +export default detailsPanel; diff --git a/client-app/src/examples/contact/mobile/details/DetailsPanelModel.ts b/client-app/src/examples/contact/mobile/details/DetailsPanelModel.ts new file mode 100644 index 000000000..c4b55dac1 --- /dev/null +++ b/client-app/src/examples/contact/mobile/details/DetailsPanelModel.ts @@ -0,0 +1,77 @@ +import {managed, XH} from '@xh/hoist/core'; +import {HoistModel} from '@xh/hoist/core/model/HoistModel'; +import {FormModel} from '@xh/hoist/cmp/form'; +import {makeObservable} from '@xh/hoist/mobx'; +import {required} from '@xh/hoist/data/validation/constraints'; +import {isNil} from 'lodash'; + +import AppModel from '../AppModel'; + +export default class DetailsPanelModel extends HoistModel { + @managed + formModel: FormModel; + + @managed + appModel: AppModel; + + get isEditing() { + return !this.formModel.readonly; + } + + get currentContact() { + if (XH.routerState?.name !== 'default.details') return null; + return this.appModel.contacts.find(it => it.id === XH.routerState.params.id); + } + + constructor(appModel: AppModel) { + super(); + makeObservable(this); + + this.appModel = appModel; + + this.formModel = new FormModel({ + readonly: true, + fields: [ + {name: 'name', rules: [required]}, + {name: 'email', rules: [required]}, + {name: 'location', rules: [required]}, + {name: 'workPhone'}, + {name: 'cellPhone'}, + {name: 'bio'}, + {name: 'tags'} + ] + }); + + this.addReaction({ + track: () => this.currentContact, + run: contact => { + if (!isNil(contact)) { + this.formModel.init(contact); + } + }, + fireImmediately: true + }); + } + + cancelEdit() { + this.formModel.readonly = true; + this.formModel.init(this.currentContact); + } + + async toggleEditAsync() { + const {formModel, currentContact} = this; + const {readonly, isDirty} = formModel; + + if (readonly || !isDirty) { + formModel.readonly = false; + return; + } + + try { + await this.appModel.updateContactAsync(currentContact.id, formModel.getData(true)); + formModel.readonly = true; + } catch (e) { + XH.handleException(e); + } + } +} diff --git a/client-app/src/examples/contact/svc/ContactService.ts b/client-app/src/examples/contact/svc/ContactService.ts index 20c703d43..c11c212af 100644 --- a/client-app/src/examples/contact/svc/ContactService.ts +++ b/client-app/src/examples/contact/svc/ContactService.ts @@ -2,7 +2,7 @@ import {HoistService, persist, XH} from '@xh/hoist/core'; import {action, observable, makeObservable} from '@xh/hoist/mobx'; import {without} from 'lodash'; -import {PERSIST_APP} from '../AppModel'; +export const PERSIST_APP = {prefKey: 'contactAppState'}; /** * Service to manage fetching and updating contacts. @@ -25,6 +25,7 @@ export class ContactService extends HoistService { async getContactsAsync() { const ret = await XH.fetchJson({url: 'contacts'}); + ret.forEach(it => { it.isFavorite = this.userFaves.includes(it.id); it.profilePicture = `../../public/contact-images/${ @@ -48,10 +49,17 @@ export class ContactService extends HoistService { } @action - toggleFavorite(id) { + toggleFavorite(id: string) { const {userFaves} = this, isFavorite = userFaves.includes(id); - this.userFaves = isFavorite ? without(userFaves, id) : [...userFaves, id]; + const userFavorites = isFavorite ? without(userFaves, id) : [...userFaves, id]; + + XH.setPref(PERSIST_APP.prefKey, { + ...(XH.getPref(PERSIST_APP.prefKey) ?? {}), + userFaves: userFavorites + }); + + this.userFaves = userFavorites; } }