diff --git a/client-app/src/admin/tests/viewmanager/ViewManagerTest.scss b/client-app/src/admin/tests/viewmanager/ViewManagerTest.scss index 038d016ba..3ec10d041 100644 --- a/client-app/src/admin/tests/viewmanager/ViewManagerTest.scss +++ b/client-app/src/admin/tests/viewmanager/ViewManagerTest.scss @@ -9,9 +9,9 @@ } } - .xh-form-field-switch-input { + .xh-form-field--switch-input { margin-left: 4px; - .xh-form-field-label { + .xh-form-field__label { font-size: 0.8em; } } diff --git a/client-app/src/apps/contact.ts b/client-app/src/apps/contact.ts index 1e256035b..99dc1edee 100644 --- a/client-app/src/apps/contact.ts +++ b/client-app/src/apps/contact.ts @@ -8,7 +8,7 @@ import {AuthModel} from '../core/AuthModel'; XH.renderApp({ clientAppCode: 'contact', - clientAppName: 'XH Contact', + clientAppName: 'XH Contacts', componentClass: AppComponent, modelClass: AppModel, containerClass: AppContainer, diff --git a/client-app/src/desktop/tabs/forms/FormPanel.tsx b/client-app/src/desktop/tabs/forms/FormPanel.tsx index 81bbb23eb..a64ad3255 100644 --- a/client-app/src/desktop/tabs/forms/FormPanel.tsx +++ b/client-app/src/desktop/tabs/forms/FormPanel.tsx @@ -1,5 +1,5 @@ import {form} from '@xh/hoist/cmp/form'; -import {div, filler, hbox, hframe, span, vbox} from '@xh/hoist/cmp/layout'; +import {box, div, filler, hbox, hframe, span, vbox} from '@xh/hoist/cmp/layout'; import {creates, hoistCmp} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; import {formField} from '@xh/hoist/desktop/cmp/form'; @@ -59,7 +59,6 @@ export const formPanel = hoistCmp.factory({ const formContent = hoistCmp.factory(({model}) => panel({ - flex: 1, item: form({ fieldDefaults: { inline: model.inline, @@ -72,23 +71,14 @@ const formContent = hoistCmp.factory(({model}) => className: 'tb-form-panel__inner-scroll', items: [ hbox({ - flex: 'none', + gap: 10, items: [ - vbox({ - flex: 1, - marginRight: 30, - items: [ - hbox( - formField({field: 'firstName', item: textInput()}), - formField({field: 'lastName', item: textInput()}) - ), - region(), - email(), - tags() - ] + div({ + style: {width: '50%'}, + items: [firstAndLastNames(), email(), region(), tags()] }), - vbox({ - flex: 1, + div({ + style: {width: '50%'}, items: [ startAndEndDate(), reasonForLeaving(), @@ -107,6 +97,22 @@ const formContent = hoistCmp.factory(({model}) => }) ); +const firstAndLastNames = hoistCmp.factory(({model}) => { + return box({ + flexDirection: model.inline ? 'column' : 'row', + items: [ + formField({ + field: 'firstName', + item: textInput() + }), + formField({ + field: 'lastName', + item: textInput() + }) + ] + }); +}); + const email = hoistCmp.factory(() => formField({ field: 'email', @@ -137,27 +143,21 @@ const tags = hoistCmp.factory(() => }) ); -const startAndEndDate = hoistCmp.factory(() => - hbox( - formField({ - field: 'startDate', - flex: 1, - inline: false, // always print labels on top (override form-level inline) - item: dateInput({ - valueType: 'localDate' - }) - }), - formField({ - field: 'endDate', - flex: 1, - inline: false, - item: dateInput({ - valueType: 'localDate', - enableClear: true +const startAndEndDate = hoistCmp.factory(({model}) => { + return box({ + flexDirection: model.inline ? 'column' : 'row', + items: [ + formField({ + field: 'startDate', + item: dateInput({valueType: 'localDate', width: 150}) + }), + formField({ + field: 'endDate', + item: dateInput({valueType: 'localDate', width: 150, enableClear: true}) }) - }) - ) -); + ] + }); +}); const reasonForLeaving = hoistCmp.factory(() => formField({ @@ -168,22 +168,24 @@ const reasonForLeaving = hoistCmp.factory(() => }) ); -const managerAndYearsExperience = hoistCmp.factory(({model}) => - hbox({ +const managerAndYearsExperience = hoistCmp.factory(({model}) => { + return box({ + flexDirection: model.inline ? 'column' : 'row', items: [ formField({ field: 'isManager', label: 'Manager?', + width: 100, + flex: 'none', item: checkbox() }), formField({ field: 'yearsExperience', item: numberInput({width: 50}) }) - ], - alignItems: model.inline ? 'center' : 'top' - }) -); + ] + }); +}); const notes = hoistCmp.factory(() => formField({ diff --git a/client-app/src/desktop/tabs/forms/FormPanelModel.ts b/client-app/src/desktop/tabs/forms/FormPanelModel.ts index b43932c9e..7c4d9b76d 100644 --- a/client-app/src/desktop/tabs/forms/FormPanelModel.ts +++ b/client-app/src/desktop/tabs/forms/FormPanelModel.ts @@ -1,5 +1,6 @@ -import {HoistModel, managed, TaskObserver, XH} from '@xh/hoist/core'; import {FormModel} from '@xh/hoist/cmp/form'; +import {pre, vbox} from '@xh/hoist/cmp/layout'; +import {HoistModel, managed, TaskObserver, XH} from '@xh/hoist/core'; import { constrainAll, dateIs, @@ -9,11 +10,10 @@ import { stringExcludes, validEmail } from '@xh/hoist/data'; -import {pre, vbox} from '@xh/hoist/cmp/layout'; +import {Icon} from '@xh/hoist/icon'; import {bindable, makeObservable} from '@xh/hoist/mobx'; import {LocalDate} from '@xh/hoist/utils/datetime'; -import {Icon} from '@xh/hoist/icon'; -import {filter, isEmpty, isNil} from 'lodash'; +import {filter, isEmpty} from 'lodash'; export class FormPanelModel extends HoistModel { @managed @@ -55,14 +55,31 @@ export class FormPanelModel extends HoistModel { name: 'yearsExperience', rules: [ numberIs({min: 0, max: 100}), + ({value}) => + value > 50 + ? { + severity: 'info', + message: 'You have extensive experience!' + } + : null, { when: (f, {isManager}) => isManager, check: [ required, - ({value}) => - isNil(value) || value < 10 - ? 'Managerial positions require at least 10 years of experience.' - : null + ({value}) => { + if (value < 10) { + return { + severity: 'error', + message: '10+ years required for managers.' + }; + } + if (value < 15) { + return { + severity: 'warning', + message: '15+ years recommended for managers.' + }; + } + } ] } ] @@ -91,7 +108,16 @@ export class FormPanelModel extends HoistModel { }, { name: 'region', - rules: [required] + rules: [ + required, + ({value}) => + ['London', 'Montreal'].includes(value) + ? { + severity: 'warning', + message: 'Region is outside primary operating areas.' + } + : null + ] }, { name: 'tags', diff --git a/client-app/src/desktop/tabs/forms/InputsPanel.scss b/client-app/src/desktop/tabs/forms/InputsPanel.scss index d269c2f3f..e1d0fc20e 100644 --- a/client-app/src/desktop/tabs/forms/InputsPanel.scss +++ b/client-app/src/desktop/tabs/forms/InputsPanel.scss @@ -33,7 +33,7 @@ text-overflow: ellipsis; } - .xh-form-field .xh-form-field-label { + .xh-form-field .xh-form-field__label { font-size: var(--xh-font-size-large-px); margin-bottom: var(--xh-pad-half-px); } diff --git a/client-app/src/desktop/tabs/grids/InlineEditingPanelModel.ts b/client-app/src/desktop/tabs/grids/InlineEditingPanelModel.ts index 3ac4761a1..c0e5d6782 100644 --- a/client-app/src/desktop/tabs/grids/InlineEditingPanelModel.ts +++ b/client-app/src/desktop/tabs/grids/InlineEditingPanelModel.ts @@ -172,9 +172,14 @@ export class InlineEditingPanelModel extends HoistModel { when: (f, {category}) => category === 'US', check: async ({value}) => { if (this.asyncValidation) await wait(1000); - return isNil(value) || value < 10 - ? 'Records where `category` is "US" require `amount` of 10 or greater.' - : null; + if (isNil(value) || value < 10) { + return 'Records where `category` is "US" require `amount` of 10 or greater.'; + } else if (value > 50) { + return { + severity: 'warning', + message: 'Amounts over 50 may require additional approval.' + }; + } } } ] @@ -182,7 +187,13 @@ export class InlineEditingPanelModel extends HoistModel { { name: 'date', type: 'localDate', - rules: [dateIs({min: LocalDate.today().startOfYear(), max: 'today'})] + rules: [ + dateIs({min: LocalDate.today().startOfYear(), max: 'today'}), + ({value}) => + value && !value.isWeekday + ? {severity: 'info', message: 'Date falls on a weekend.'} + : null + ] }, { name: 'restricted', diff --git a/client-app/src/desktop/tabs/other/formats/Formats.scss b/client-app/src/desktop/tabs/other/formats/Formats.scss index d0e7c388b..b54b28ece 100644 --- a/client-app/src/desktop/tabs/other/formats/Formats.scss +++ b/client-app/src/desktop/tabs/other/formats/Formats.scss @@ -38,7 +38,7 @@ } } - .xh-form-field-label { + .xh-form-field__label { width: 200px; font-family: var(--xh-font-family-mono); } diff --git a/client-app/src/examples/contact/DirectoryPanel.ts b/client-app/src/examples/contact/DirectoryPanel.ts index c27fdc6d6..c90a005d0 100644 --- a/client-app/src/examples/contact/DirectoryPanel.ts +++ b/client-app/src/examples/contact/DirectoryPanel.ts @@ -69,12 +69,14 @@ const tbar = hoistCmp.factory(({model}) => { items: [ button({ text: 'Details', + icon: Icon.list(), value: 'grid', width: 80 }), button({ text: 'Faces', value: 'tiles', + icon: Icon.userCircle(), width: 80 }) ] diff --git a/client-app/src/examples/contact/DirectoryPanelModel.ts b/client-app/src/examples/contact/DirectoryPanelModel.ts index 643d271e0..f14a246ed 100644 --- a/client-app/src/examples/contact/DirectoryPanelModel.ts +++ b/client-app/src/examples/contact/DirectoryPanelModel.ts @@ -1,8 +1,8 @@ -import {HoistModel, managed, persist, XH} from '@xh/hoist/core'; +import {HoistModel, LoadSpec, managed, persist, XH} from '@xh/hoist/core'; import {action, bindable, makeObservable, observable, runInAction} from '@xh/hoist/mobx'; import {div, hbox} from '@xh/hoist/cmp/layout'; import {GridModel} from '@xh/hoist/cmp/grid'; -import {withFilterByField, withFilterByKey} from '@xh/hoist/data'; +import {StoreRecord, withFilterByField, withFilterByKey} from '@xh/hoist/data'; import {isEmpty, uniq, without} from 'lodash'; import {PERSIST_APP} from './AppModel'; @@ -50,20 +50,20 @@ export class DirectoryPanelModel extends HoistModel { const gridModel = (this.gridModel = this.createGridModel()); this.detailsPanelModel = new DetailsPanelModel(this); - this.addReaction({ - track: () => gridModel.selectedRecord, - run: rec => this.detailsPanelModel.setCurrentRecord(rec) - }); - - this.addReaction({ - track: () => this.locationFilter, - run: () => this.updateLocationFilter() - }); - - this.addReaction({ - track: () => this.tagFilters, - run: () => this.updateTagFilter() - }); + this.addReaction( + { + track: () => gridModel.selectedRecord, + run: rec => this.detailsPanelModel.setCurrentRecord(rec) + }, + { + track: () => this.locationFilter, + run: () => this.updateLocationFilter() + }, + { + track: () => this.tagFilters, + run: () => this.updateTagFilter() + } + ); } async updateContactAsync(id, data) { @@ -71,7 +71,7 @@ export class DirectoryPanelModel extends HoistModel { await this.loadAsync(); } - toggleFavorite(record) { + toggleFavorite(record: StoreRecord) { XH.contactService.toggleFavorite(record.id); // Update store directly, to avoid more heavyweight full reload. this.gridModel.store.modifyRecords({id: record.id, isFavorite: !record.data.isFavorite}); @@ -80,19 +80,22 @@ export class DirectoryPanelModel extends HoistModel { //------------------------ // Implementation //------------------------ - override async doLoadAsync(loadSpec) { + override async doLoadAsync(loadSpec: LoadSpec) { const {gridModel} = this; try { const contacts = await XH.contactService.getContactsAsync(); + if (loadSpec.isStale) return; + runInAction(() => { this.tagList = uniq(contacts.flatMap(it => it.tags ?? [])).sort() as string[]; this.locationList = uniq(contacts.map(it => it.location)).sort() as string[]; }); gridModel.loadData(contacts); - gridModel.preSelectFirstAsync(); + await gridModel.preSelectFirstAsync(); } catch (e) { + if (loadSpec.isStale) return; XH.handleException(e); } } diff --git a/client-app/src/examples/contact/details/DetailsPanel.scss b/client-app/src/examples/contact/details/DetailsPanel.scss index c40569c89..345597217 100644 --- a/client-app/src/examples/contact/details/DetailsPanel.scss +++ b/client-app/src/examples/contact/details/DetailsPanel.scss @@ -1,4 +1,8 @@ .tb-contact-details-panel { + --xh-form-field-label-text-transform: uppercase; + --xh-form-field-label-font-size: 0.8em; + --xh-form-field-label-color: var(--xh-text-color-muted); + // Sync header height with DirectoryPanel toolbar. .xh-panel-header { height: 41px; @@ -27,7 +31,7 @@ } } - .xh-form-field-label { + .xh-form-field__label { font-weight: bold; } } diff --git a/client-app/src/examples/contact/details/DetailsPanel.ts b/client-app/src/examples/contact/details/DetailsPanel.ts index 42db1983c..1251da407 100644 --- a/client-app/src/examples/contact/details/DetailsPanel.ts +++ b/client-app/src/examples/contact/details/DetailsPanel.ts @@ -1,5 +1,5 @@ import {form} from '@xh/hoist/cmp/form'; -import {box, div, filler, img, p, placeholder} from '@xh/hoist/cmp/layout'; +import {box, div, filler, hspacer, img, p, placeholder} from '@xh/hoist/cmp/layout'; import {hoistCmp, uses, XH} from '@xh/hoist/core'; import {button} from '@xh/hoist/desktop/cmp/button'; import {formField} from '@xh/hoist/desktop/cmp/form'; @@ -33,7 +33,7 @@ export const detailsPanel = hoistCmp.factory({ className, item: currentRecord ? contactProfile() - : placeholder('Select a contact to view their details.'), + : placeholder(Icon.userCircle(), 'Select a contact to view their details.'), bbar: bbar() }); } @@ -70,13 +70,14 @@ const bbar = hoistCmp.factory(({model}) => { if (!currentRecord) return null; return toolbar( + favoriteButton({omit: isEditing}), + filler(), button({ text: 'Cancel', omit: !isEditing, onClick: () => model.cancelEdit() }), - favoriteButton({omit: isEditing}), - filler(), + hspacer(5), editButton({omit: !XH.getUser().isHoistAdmin}) ); }); @@ -132,9 +133,9 @@ const tagsField = hoistCmp.factory(({model}) => const favoriteButton = hoistCmp.factory(({model}) => { const {isFavorite} = model.currentRecord.data; return button({ - text: isFavorite ? 'Remove Favorite' : 'Make Favorite', + text: isFavorite ? 'Remove Favorite' : 'Add Favorite', icon: Icon.favorite({ - color: isFavorite ? 'gold' : null, + className: isFavorite ? 'xh-orange' : null, prefix: isFavorite ? 'fas' : 'far' }), width: 150, @@ -147,8 +148,10 @@ const editButton = hoistCmp.factory(({model}) => { const {isEditing} = model; return button({ text: isEditing ? 'Save Changes' : 'Edit Contact', - intent: isEditing ? 'primary' : null, + icon: isEditing ? Icon.check() : Icon.edit(), + intent: isEditing ? 'success' : 'primary', minimal: !isEditing, + outlined: !isEditing, onClick: () => model.toggleEditAsync() }); }); diff --git a/client-app/src/mobile/form/FormPageModel.ts b/client-app/src/mobile/form/FormPageModel.ts index 8dd1ff97b..331462b1a 100644 --- a/client-app/src/mobile/form/FormPageModel.ts +++ b/client-app/src/mobile/form/FormPageModel.ts @@ -20,9 +20,24 @@ export class FormPageModel extends HoistModel { {name: 'name', rules: [required, lengthIs({min: 8})]}, {name: 'customer', rules: [required]}, {name: 'movie', rules: [required]}, - {name: 'salary'}, + { + name: 'salary', + rules: [ + ({value}) => + value < 10_000 ? {severity: 'warning', message: 'Salary seems low.'} : null + ] + }, {name: 'percentage'}, - {name: 'date', rules: [required]}, + { + name: 'date', + rules: [ + required, + ({value}) => + value && !value.isWeekday + ? {severity: 'info', message: 'Selected date falls on a weekend.'} + : null + ] + }, {name: 'enabled'}, {name: 'buttonGroup', initialValue: 'button2'}, {name: 'notes'}, diff --git a/client-app/yarn.lock b/client-app/yarn.lock index 723892226..eaa4184d8 100644 --- a/client-app/yarn.lock +++ b/client-app/yarn.lock @@ -1841,9 +1841,9 @@ integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== "@types/node@*": - version "25.0.6" - resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.6.tgz#5ca3c46f2b256b59128f433426e42d464765dab1" - integrity sha512-NNu0sjyNxpoiW3YuVFfNz7mxSQ+S4X2G28uqg2s+CzoqoQjLPsWSbsFFyztIAqt2vb8kfEAsJNepMGPTxFDx3Q== + version "25.0.8" + resolved "https://registry.yarnpkg.com/@types/node/-/node-25.0.8.tgz#e54e00f94fe1db2497b3e42d292b8376a2678c8d" + integrity sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg== dependencies: undici-types "~7.16.0" @@ -2284,9 +2284,9 @@ webpackbar "~7.0.0" "@xh/hoist@^80.0.0-SNAPSHOT": - version "80.0.0-SNAPSHOT.1768251023007" - resolved "https://registry.yarnpkg.com/@xh/hoist/-/hoist-80.0.0-SNAPSHOT.1768251023007.tgz#1e278c3c658f2fc8718c9ea1e3b9b2cace67bb22" - integrity sha512-XZdtioPFGtOPVcWDnyrJsRR+reNJvXMRF+69KkgeDJTo3IK3wdiABz3aEqdRBHRdBY0qGTiXNTf1tp1SZEDPJQ== + version "80.0.0-SNAPSHOT.1768360784265" + resolved "https://registry.yarnpkg.com/@xh/hoist/-/hoist-80.0.0-SNAPSHOT.1768360784265.tgz#8136bdabc62161ea346597f19d1183c4130f1f75" + integrity sha512-Kz78RZMZuRL//BoGCIv6wpsRQ11pqwaPPm4eIaAibwkFx3vFKRV+BY8USqjHk/vAsA3lZTuIhKVjMsRZN3jtkw== dependencies: "@auth0/auth0-spa-js" "~2.9.1" "@azure/msal-browser" "~4.26.2" @@ -8329,9 +8329,9 @@ ua-is-frozen@^0.1.2: integrity sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw== ua-parser-js@~2.0.4: - version "2.0.7" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-2.0.7.tgz#0f22e3f8430cce9c43463b8603e54b45cbae68d5" - integrity sha512-CFdHVHr+6YfbktNZegH3qbYvYgC7nRNEUm2tk7nSFXSODUu4tDBpaFpP1jdXBUOKKwapVlWRfTtS8bCPzsQ47w== + version "2.0.8" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-2.0.8.tgz#4f87d94d164c79a104cf089f85aea810ca3dfcce" + integrity sha512-BdnBM5waFormdrOFBU+cA90R689V0tWUWlIG2i30UXxElHjuCu5+dOV2Etw3547jcQ/yaLtPm9wrqIuOY2bSJg== dependencies: detect-europe-js "^0.1.2" is-standalone-pwa "^0.1.1"