From d83d734b7582a82474edc3b52fe9d0adfb8ef188 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Wed, 18 Aug 2021 10:49:34 -0700 Subject: [PATCH] (core) Site Switcher and Other Sites Summary: A new section, Other Sites, will now be shown on the All Documents page when: - A user is on a personal site and has access to other team sites. - A user is on a public site with view access only. In addition, a site switcher is now available by clicking the site name in the top-left section of the UI next to the Grist logo. It works much like the switcher in the Account menu. Test Plan: Browser tests. Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2979 --- app/client/models/AppModel.ts | 18 ++++++- app/client/models/HomeModel.ts | 47 +++++++++++----- app/client/ui/AccountWidget.ts | 47 ++++------------ app/client/ui/AppHeader.ts | 97 ++++++++++++++++++++++++++++------ app/client/ui/AppUI.ts | 6 +-- app/client/ui/BillingPage.ts | 4 +- app/client/ui/DocMenu.ts | 43 +++++++++++++++ app/client/ui/DocMenuCss.ts | 22 ++++++++ app/client/ui/WelcomePage.ts | 4 +- app/client/ui/errorPages.ts | 4 +- 10 files changed, 218 insertions(+), 74 deletions(-) diff --git a/app/client/models/AppModel.ts b/app/client/models/AppModel.ts index 3c2784c7..f1bc4c42 100644 --- a/app/client/models/AppModel.ts +++ b/app/client/models/AppModel.ts @@ -8,7 +8,7 @@ import {GristLoadConfig} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; import {LocalPlugin} from 'app/common/plugin'; import {getOrgName, Organization, OrgError, UserAPI, UserAPIImpl} from 'app/common/UserAPI'; -import {Computed, Disposable, Observable, subscribe} from 'grainjs'; +import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; export {reportError} from 'app/client/models/errors'; @@ -29,6 +29,9 @@ export interface TopAppModel { // different parts of the code aren't using different users/orgs while the switch is pending. appObs: Observable; + orgs: Observable; + users: Observable; + // Reinitialize the app. This is called when org or user changes. initialize(): void; @@ -68,6 +71,8 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { public readonly currentSubdomain = Computed.create(this, urlState().state, (use, s) => s.org); public readonly notifier = Notifier.create(this); public readonly appObs = Observable.create(this, null); + public readonly orgs = Observable.create(this, []); + public readonly users = Observable.create(this, []); public readonly plugins: LocalPlugin[] = []; private readonly _gristConfig?: GristLoadConfig; @@ -85,6 +90,8 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { // and the FullUser to use for it (the user may change when switching orgs). this.autoDispose(subscribe(this.currentSubdomain, (use) => this.initialize())); this.plugins = this._gristConfig?.plugins || []; + + this._fetchUsersAndOrgs().catch(reportError); } public initialize(): void { @@ -146,6 +153,15 @@ export class TopAppModelImpl extends Disposable implements TopAppModel { AppModelImpl.create(this.appObs, this, null, null, {error: err.message, status: err.status || 500}); } } + + private async _fetchUsersAndOrgs() { + const data = await this.api.getSessionAll(); + if (this.isDisposed()) { return; } + bundleChanges(() => { + this.users.set(data.users); + this.orgs.set(data.orgs); + }); + } } export class AppModelImpl extends Disposable implements AppModel { diff --git a/app/client/models/HomeModel.ts b/app/client/models/HomeModel.ts index 1700680f..75dcb5de 100644 --- a/app/client/models/HomeModel.ts +++ b/app/client/models/HomeModel.ts @@ -11,7 +11,7 @@ import {IHomePage} from 'app/common/gristUrls'; import {isLongerThan} from 'app/common/gutil'; import {SortPref, UserOrgPrefs, ViewPref} from 'app/common/Prefs'; import * as roles from 'app/common/roles'; -import {Document, Workspace} from 'app/common/UserAPI'; +import {Document, Organization, Workspace} from 'app/common/UserAPI'; import {bundleChanges, Computed, Disposable, Observable, subscribe} from 'grainjs'; import * as moment from 'moment'; import flatten = require('lodash/flatten'); @@ -63,6 +63,10 @@ export interface HomeModel { // List of featured templates from templateWorkspaces. featuredTemplates: Observable; + // List of other sites (orgs) user can access. Only populated on All Documents, and only when + // the current org is a personal org, or the current org is view access only. + otherSites: Observable; + currentSort: Observable; currentView: Observable; importSources: Observable; @@ -118,6 +122,21 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings return sortBy(featuredTemplates, (t) => t.name.toLowerCase()); }); + public readonly otherSites = Computed.create(this, this.currentPage, this.app.topAppModel.orgs, + (_use, page, orgs) => { + if (page !== 'all') { return []; } + + const currentOrg = this._app.currentOrg; + if (!currentOrg) { return []; } + + const isPersonalOrg = currentOrg.owner; + if (!isPersonalOrg && (currentOrg.access !== 'viewers' || !currentOrg.public)) { + return []; + } + + return orgs.filter(org => org.id !== currentOrg.id); + }); + public readonly currentSort: Observable; public readonly currentView: Observable; @@ -255,16 +274,10 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings this.loading.set(true); const currentPage = this.currentPage.get(); const promises = [ - this._fetchWorkspaces(org.id, false).catch(reportError), // workspaces - currentPage === 'trash' ? this._fetchWorkspaces(org.id, true).catch(reportError) : null, // trash - null // templates - ]; - - const shouldFetchTemplates = ['all', 'templates'].includes(currentPage); - if (shouldFetchTemplates) { - const onlyFeatured = currentPage === 'all'; - promises[2] = this._fetchTemplates(onlyFeatured); - } + this._fetchWorkspaces(org.id, false).catch(reportError), + currentPage === 'trash' ? this._fetchWorkspaces(org.id, true).catch(reportError) : null, + this._maybeFetchTemplates(), + ] as const; const promise = Promise.all(promises); if (await isLongerThan(promise, DELAY_BEFORE_SPINNER_MS)) { @@ -327,9 +340,19 @@ export class HomeModelImpl extends Disposable implements HomeModel, ViewSettings ws.name.toLowerCase()]); } - private async _fetchTemplates(onlyFeatured: boolean) { + /** + * Fetches templates if on the Templates or All Documents page. + * + * Only fetches featured (pinned) templates on the All Documents page. + */ + private async _maybeFetchTemplates(): Promise { + const currentPage = this.currentPage.get(); + const shouldFetchTemplates = ['all', 'templates'].includes(currentPage); + if (!shouldFetchTemplates) { return null; } + let templateWss: Workspace[] = []; try { + const onlyFeatured = currentPage === 'all'; templateWss = await this._app.api.getTemplates(onlyFeatured); } catch { // If the org doesn't exist (404), return nothing and don't report error to user. diff --git a/app/client/ui/AccountWidget.ts b/app/client/ui/AccountWidget.ts index cd7383e8..3cf35788 100644 --- a/app/client/ui/AccountWidget.ts +++ b/app/client/ui/AccountWidget.ts @@ -1,5 +1,5 @@ import {loadGristDoc, loadUserManager} from 'app/client/lib/imports'; -import {AppModel, reportError} from 'app/client/models/AppModel'; +import {AppModel} from 'app/client/models/AppModel'; import {DocPageModel} from 'app/client/models/DocPageModel'; import {getLoginOrSignupUrl, getLoginUrl, getLogoutUrl, urlState} from 'app/client/models/gristUrlState'; import {showProfileModal} from 'app/client/ui/ProfileDialog'; @@ -13,22 +13,17 @@ import {commonUrls} from 'app/common/gristUrls'; import {FullUser} from 'app/common/LoginSessionAPI'; import * as roles from 'app/common/roles'; import {getOrgName, Organization, SUPPORT_EMAIL} from 'app/common/UserAPI'; -import {bundleChanges, Disposable, dom, DomElementArg, Observable, styled} from 'grainjs'; +import {Disposable, dom, DomElementArg, styled} from 'grainjs'; import {cssMenuItem} from 'popweasel'; +import {cssOrgCheckmark, cssOrgSelected} from 'app/client/ui/AppHeader'; /** * Render the user-icon that opens the account menu. When no user is logged in, render a Sign-in * button instead. */ export class AccountWidget extends Disposable { - private _users = Observable.create(this, []); - private _orgs = Observable.create(this, []); - constructor(private _appModel: AppModel, private _docPageModel?: DocPageModel) { super(); - // We initialize users and orgs asynchronously when we create the menu, so it will *probably* be - // available by the time the user opens it. Even if not, we do not delay the opening of the menu. - this._fetchUsersAndOrgs().catch(reportError); } public buildDom() { @@ -47,16 +42,6 @@ export class AccountWidget extends Disposable { ); } - private async _fetchUsersAndOrgs() { - if (!this._appModel.topAppModel.isSingleOrg) { - const data = await this._appModel.api.getSessionAll(); - if (this.isDisposed()) { return; } - bundleChanges(() => { - this._users.set(data.users); - this._orgs.set(data.orgs); - }); - } - } /** * Renders the content of the account menu, with a list of available orgs, settings, and sign-out. @@ -104,6 +89,9 @@ export class AccountWidget extends Disposable { ]; } + const users = this._appModel.topAppModel.users; + const orgs = this._appModel.topAppModel.orgs; + return [ cssUserInfo( createUserImage(user, 'large'), @@ -141,8 +129,8 @@ export class AccountWidget extends Disposable { // org-listing UI below. this._appModel.topAppModel.isSingleOrg ? [] : [ menuDivider(), - menuSubHeader(dom.text((use) => use(this._users).length > 1 ? 'Switch Accounts' : 'Accounts')), - dom.forEach(this._users, (_user) => { + menuSubHeader(dom.text((use) => use(users).length > 1 ? 'Switch Accounts' : 'Accounts')), + dom.forEach(users, (_user) => { if (_user.id === user.id) { return null; } return menuItem(() => this._switchAccount(_user), cssSmallIconWrap(createUserImage(_user, 'small')), @@ -154,11 +142,11 @@ export class AccountWidget extends Disposable { menuItemLink({href: getLogoutUrl()}, "Sign Out", testId('dm-log-out')), - dom.maybe((use) => use(this._orgs).length > 0, () => [ + dom.maybe((use) => use(orgs).length > 0, () => [ menuDivider(), menuSubHeader('Switch Sites'), ]), - dom.forEach(this._orgs, (org) => + dom.forEach(orgs, (org) => menuItemLink(urlState().setLinkUrl({org: org.domain || undefined}), cssOrgSelected.cls('', this._appModel.currentOrg ? org.id === this._appModel.currentOrg.id : false), getOrgName(org), @@ -231,21 +219,6 @@ const cssOtherEmail = styled('div', ` } `); -const cssOrgSelected = styled('div', ` - background-color: ${colors.dark}; - color: ${colors.light}; -`); - -const cssOrgCheckmark = styled(icon, ` - flex: none; - margin-left: 16px; - --icon-color: ${colors.light}; - display: none; - .${cssOrgSelected.className} > & { - display: block; - } -`); - const cssCheckmark = styled(icon, ` flex: none; margin-left: 16px; diff --git a/app/client/ui/AppHeader.ts b/app/client/ui/AppHeader.ts index f671007b..f959e33f 100644 --- a/app/client/ui/AppHeader.ts +++ b/app/client/ui/AppHeader.ts @@ -1,23 +1,75 @@ import {urlState} from 'app/client/models/gristUrlState'; -import {getTheme, ProductFlavor} from 'app/client/ui/CustomThemes'; +import {getTheme} from 'app/client/ui/CustomThemes'; import {cssLeftPane} from 'app/client/ui/PagePanels'; import {colors, testId, vars} from 'app/client/ui2018/cssVars'; import * as version from 'app/common/version'; -import {BindableValue, dom, styled} from "grainjs"; +import {BindableValue, Disposable, dom, styled} from "grainjs"; +import {menu, menuDivider, menuItemLink, menuSubHeader} from 'app/client/ui2018/menus'; +import {getOrgName} from 'app/common/UserAPI'; +import {AppModel} from 'app/client/models/AppModel'; +import {icon} from 'app/client/ui2018/icons'; -export function appHeader(orgName: BindableValue, productFlavor: ProductFlavor) { - const theme = getTheme(productFlavor); - return cssAppHeader( - urlState().setLinkUrl({}), - cssAppHeader.cls('-widelogo', theme.wideLogo || false), - // Show version when hovering over the application icon. - cssAppLogo({title: `Ver ${version.version} (${version.gitcommit})`}), - cssOrgName(dom.text(orgName)), - testId('dm-org'), - ); + +export class AppHeader extends Disposable { + constructor(private _orgName: BindableValue, private _appModel: AppModel) { + super(); + } + + public buildDom() { + const theme = getTheme(this._appModel.topAppModel.productFlavor); + + return cssAppHeader( + cssAppHeader.cls('-widelogo', theme.wideLogo || false), + // Show version when hovering over the application icon. + cssAppLogo( + {title: `Ver ${version.version} (${version.gitcommit})`}, + urlState().setLinkUrl({}), + testId('dm-logo') + ), + cssOrg( + cssOrgName(dom.text(this._orgName)), + this._orgName && cssDropdownIcon('Dropdown'), + menu(() => this._makeOrgMenu(), {placement: 'bottom-start'}), + testId('dm-org'), + ), + ); + } + + private _makeOrgMenu() { + const orgs = this._appModel.topAppModel.orgs; + + return [ + menuItemLink(urlState().setLinkUrl({}), 'Go to Home Page', testId('orgmenu-home-page')), + menuDivider(), + menuSubHeader('Switch Sites'), + dom.forEach(orgs, (org) => + menuItemLink(urlState().setLinkUrl({org: org.domain || undefined}), + cssOrgSelected.cls('', this._appModel.currentOrg ? org.id === this._appModel.currentOrg.id : false), + getOrgName(org), + cssOrgCheckmark('Tick', testId('orgmenu-org-tick')), + testId('orgmenu-org'), + ) + ), + ]; + } } -const cssAppHeader = styled('a', ` +export const cssOrgSelected = styled('div', ` + background-color: ${colors.dark}; + color: ${colors.light}; +`); + +export const cssOrgCheckmark = styled(icon, ` + flex: none; + margin-left: 16px; + --icon-color: ${colors.light}; + display: none; + .${cssOrgSelected.className} > & { + display: block; + } +`); + +const cssAppHeader = styled('div', ` display: flex; width: 100%; height: 100%; @@ -29,7 +81,7 @@ const cssAppHeader = styled('a', ` } `); -const cssAppLogo = styled('div', ` +const cssAppLogo = styled('a', ` flex: none; height: 48px; width: 48px; @@ -49,8 +101,23 @@ const cssAppLogo = styled('div', ` } `); +const cssDropdownIcon = styled(icon, ` + flex-shrink: 0; + margin-right: 8px; +`); + +const cssOrg = styled('div', ` + display: flex; + flex-grow: 1; + align-items: center; + max-width: calc(100% - 48px); + cursor: pointer; + height: 100%; +`); + const cssOrgName = styled('div', ` - padding: 0px 16px; + padding-left: 16px; + padding-right: 8px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/app/client/ui/AppUI.ts b/app/client/ui/AppUI.ts index ee53d3d5..f9dc7a56 100644 --- a/app/client/ui/AppUI.ts +++ b/app/client/ui/AppUI.ts @@ -5,7 +5,7 @@ import {AppModel, TopAppModel} from 'app/client/models/AppModel'; import {DocPageModelImpl} from 'app/client/models/DocPageModel'; import {HomeModelImpl} from 'app/client/models/HomeModel'; import {App} from 'app/client/ui/App'; -import {appHeader} from 'app/client/ui/AppHeader'; +import {AppHeader} from 'app/client/ui/AppHeader'; import {createBottomBarDoc} from 'app/client/ui/BottomBar'; import {createDocMenu} from 'app/client/ui/DocMenu'; import {createForbiddenPage, createNotFoundPage, createOtherErrorPage} from 'app/client/ui/errorPages'; @@ -96,7 +96,7 @@ function pagePanelsHome(owner: IDisposableOwner, appModel: AppModel, app: App) { panelWidth: Observable.create(owner, 240), panelOpen: leftPanelOpen, hideOpener: true, - header: appHeader(appModel.currentOrgName, appModel.topAppModel.productFlavor), + header: dom.create(AppHeader, appModel.currentOrgName, appModel), content: createHomeLeftPane(leftPanelOpen, pageModel), }, headerMain: createTopBarHome(appModel), @@ -136,7 +136,7 @@ function pagePanelsDoc(owner: IDisposableOwner, appModel: AppModel, appObj: App) leftPanel: { panelWidth: leftPanelWidth, panelOpen: leftPanelOpen, - header: appHeader(appModel.currentOrgName || pageModel.currentOrgName, appModel.topAppModel.productFlavor), + header: dom.create(AppHeader, appModel.currentOrgName || pageModel.currentOrgName, appModel), content: pageModel.createLeftPane(leftPanelOpen), }, rightPanel: { diff --git a/app/client/ui/BillingPage.ts b/app/client/ui/BillingPage.ts index e0cf07ee..4d1410f6 100644 --- a/app/client/ui/BillingPage.ts +++ b/app/client/ui/BillingPage.ts @@ -2,7 +2,7 @@ import {beaconOpenMessage} from 'app/client/lib/helpScout'; import {AppModel, reportError} from 'app/client/models/AppModel'; import {BillingModel, BillingModelImpl, ISubscriptionModel} from 'app/client/models/BillingModel'; import {getLoginUrl, getMainOrgUrl, urlState} from 'app/client/models/gristUrlState'; -import {appHeader} from 'app/client/ui/AppHeader'; +import {AppHeader} from 'app/client/ui/AppHeader'; import {BillingForm, IFormData} from 'app/client/ui/BillingForm'; import * as css from 'app/client/ui/BillingPageCss'; import {BillingPlanManagers} from 'app/client/ui/BillingPlanManagers'; @@ -64,7 +64,7 @@ export class BillingPage extends Disposable { panelWidth: Observable.create(this, 240), panelOpen, hideOpener: true, - header: appHeader(this._appModel.currentOrgName, this._appModel.topAppModel.productFlavor), + header: dom.create(AppHeader, this._appModel.currentOrgName, this._appModel), content: leftPanelBasic(this._appModel, panelOpen), }, headerMain: this._createTopBarBilling(), diff --git a/app/client/ui/DocMenu.ts b/app/client/ui/DocMenu.ts index 683d7c38..fe9b8a1b 100644 --- a/app/client/ui/DocMenu.ts +++ b/app/client/ui/DocMenu.ts @@ -84,6 +84,7 @@ function createLoadedDocMenu(home: HomeModel) { ]), dom.maybe(home.available, () => [ + buildOtherSites(home), (showIntro && page === 'all' ? null : css.docListHeader( @@ -232,6 +233,48 @@ function buildAllTemplates(home: HomeModel, templateWorkspaces: Observable { + if (sites.length === 0) { return null; } + + const hideOtherSitesObs = Observable.create(null, false); + return css.otherSitesBlock( + dom.autoDispose(hideOtherSitesObs), + css.otherSitesHeader( + 'Other Sites', + dom.domComputed(hideOtherSitesObs, (collapsed) => + collapsed ? css.otherSitesHeaderIcon('Expand') : css.otherSitesHeaderIcon('Collapse') + ), + dom.on('click', () => hideOtherSitesObs.set(!hideOtherSitesObs.get())), + testId('other-sites-header'), + ), + dom.maybe((use) => !use(hideOtherSitesObs), () => { + const onPersonalSite = Boolean(home.app.currentOrg?.owner); + const siteName = onPersonalSite ? 'your personal site' : `the ${home.app.currentOrgName} site`; + return [ + dom('div', + `You are on ${siteName}. You also have access to the following sites:`, + testId('other-sites-message') + ), + css.otherSitesButtons( + dom.forEach(sites, s => + css.siteButton( + s.name, + urlState().setLinkUrl({org: s.domain ?? undefined}), + testId('other-sites-button') + ) + ), + testId('other-sites-buttons') + ) + ]; + }) + ); + }); +} + /** * Build the widget for selecting sort and view mode options. * If hideSort is true, will hide the sort dropdown: it has no effect on the list of examples, so diff --git a/app/client/ui/DocMenuCss.ts b/app/client/ui/DocMenuCss.ts index db56c962..2f396373 100644 --- a/app/client/ui/DocMenuCss.ts +++ b/app/client/ui/DocMenuCss.ts @@ -2,6 +2,7 @@ import {transientInput} from 'app/client/ui/transientInput'; import {colors, mediaSmall, vars} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {styled} from 'grainjs'; +import {bigBasicButton} from 'app/client/ui2018/buttons'; // The "&:after" clause forces some padding below all docs. export const docList = styled('div', ` @@ -40,6 +41,8 @@ export const featuredTemplatesHeader = styled(docListHeader, ` align-items: center; `); +export const otherSitesHeader = templatesHeader; + export const docBlock = styled('div', ` max-width: 550px; min-width: 300px; @@ -54,6 +57,23 @@ export const templatesDocBlock = styled(docBlock, ` margin-top: 32px; `); +export const otherSitesBlock = styled('div', ` + margin-bottom: 32px; +`); + +export const otherSitesButtons = styled('div', ` + display: flex; + flex-wrap: wrap; + padding-bottom: 16px; + margin-top: 16px; + margin-bottom: 28px; + gap: 16px; +`); + +export const siteButton = styled(bigBasicButton, ` + flex: 0 0 auto; +`); + export const docHeaderIconDark = styled(icon, ` margin-right: 8px; margin-top: -3px; @@ -74,6 +94,8 @@ export const templatesHeaderIcon = styled(docHeaderIcon, ` height: 24px; `); +export const otherSitesHeaderIcon = templatesHeaderIcon; + const docBlockHeader = ` display: flex; align-items: center; diff --git a/app/client/ui/WelcomePage.ts b/app/client/ui/WelcomePage.ts index 50bd2d68..1303bedc 100644 --- a/app/client/ui/WelcomePage.ts +++ b/app/client/ui/WelcomePage.ts @@ -4,7 +4,7 @@ import { submitForm } from "app/client/lib/uploads"; import { AppModel, reportError } from "app/client/models/AppModel"; import { getLoginUrl, getSignupUrl, urlState } from "app/client/models/gristUrlState"; import { AccountWidget } from "app/client/ui/AccountWidget"; -import { appHeader } from 'app/client/ui/AppHeader'; +import { AppHeader } from 'app/client/ui/AppHeader'; import * as BillingPageCss from "app/client/ui/BillingPageCss"; import * as forms from "app/client/ui/forms"; import { pagePanels } from "app/client/ui/PagePanels"; @@ -73,7 +73,7 @@ export class WelcomePage extends Disposable { panelWidth: Observable.create(this, 240), panelOpen: Observable.create(this, false), hideOpener: true, - header: appHeader('', this._appModel.topAppModel.productFlavor), + header: dom.create(AppHeader, '', this._appModel), content: null, }, headerMain: [cssFlexSpace(), dom.create(AccountWidget, this._appModel)], diff --git a/app/client/ui/errorPages.ts b/app/client/ui/errorPages.ts index f9313553..ea66d1a9 100644 --- a/app/client/ui/errorPages.ts +++ b/app/client/ui/errorPages.ts @@ -1,6 +1,6 @@ import {AppModel} from 'app/client/models/AppModel'; import {getLoginUrl, getMainOrgUrl, urlState} from 'app/client/models/gristUrlState'; -import {appHeader} from 'app/client/ui/AppHeader'; +import {AppHeader} from 'app/client/ui/AppHeader'; import {leftPanelBasic} from 'app/client/ui/LeftPanelCommon'; import {pagePanels} from 'app/client/ui/PagePanels'; import {createTopBarHome} from 'app/client/ui/TopBar'; @@ -105,7 +105,7 @@ function pagePanelsError(appModel: AppModel, header: string, content: DomElement panelWidth: observable(240), panelOpen, hideOpener: true, - header: appHeader(appModel.currentOrgName, appModel.topAppModel.productFlavor), + header: dom.create(AppHeader, appModel.currentOrgName, appModel), content: leftPanelBasic(appModel, panelOpen), }, headerMain: createTopBarHome(appModel),