diff --git a/packages/experiments-realm/address.gts b/packages/experiments-realm/address.gts index 0cee00fa53..d640b9c18b 100644 --- a/packages/experiments-realm/address.gts +++ b/packages/experiments-realm/address.gts @@ -7,7 +7,7 @@ import { import StringField from 'https://cardstack.com/base/string'; import { CountryField } from './country'; import MapPinIcon from '@cardstack/boxel-icons/map-pin'; -import { EntityDisplay } from './components/entity-display'; +import EntityDisplayWithIcon from './components/entity-icon-display'; function getAddressRows( addressLine1: string | undefined, @@ -39,14 +39,11 @@ class Atom extends Component<typeof Address> { ); } <template> - <EntityDisplay> - <:title> - {{this.label}} - </:title> - <:thumbnail> + <EntityDisplayWithIcon @title={{this.label}}> + <:icon> <MapPinIcon /> - </:thumbnail> - </EntityDisplay> + </:icon> + </EntityDisplayWithIcon> </template> } diff --git a/packages/experiments-realm/components/activity-card.gts b/packages/experiments-realm/components/activity-card.gts index 76222830ea..b5277ff520 100644 --- a/packages/experiments-realm/components/activity-card.gts +++ b/packages/experiments-realm/components/activity-card.gts @@ -1,5 +1,5 @@ import GlimmerComponent from '@glimmer/component'; -import { EntityDisplay } from './entity-display'; +import EntityDisplayWithThumbnail from './entity-thumbnail-display'; interface ActivityCardArgs { Blocks: { @@ -17,7 +17,7 @@ export default class ActivityCard extends GlimmerComponent<ActivityCardArgs> { <article class='activity-card' ...attributes> <header class='activity-card-header'> <div class='activity-card-title-desc-group'> - <EntityDisplay> + <EntityDisplayWithThumbnail> <:title> <span class='activity-card-title'> {{yield to='title'}} @@ -28,7 +28,7 @@ export default class ActivityCard extends GlimmerComponent<ActivityCardArgs> { {{yield to='thumbnail'}} </span> </:thumbnail> - </EntityDisplay> + </EntityDisplayWithThumbnail> {{#if (has-block 'description')}} <p class='activity-card-description'> diff --git a/packages/experiments-realm/components/contact-row.gts b/packages/experiments-realm/components/contact-row.gts index 92b8f3e4c2..db8545180b 100644 --- a/packages/experiments-realm/components/contact-row.gts +++ b/packages/experiments-realm/components/contact-row.gts @@ -1,5 +1,5 @@ import { Avatar, Pill } from '@cardstack/boxel-ui/components'; -import { EntityDisplay } from './entity-display'; +import EntityDisplayWithThumbnail from './entity-thumbnail-display'; import GlimmerComponent from '@glimmer/component'; interface ContactRowArgs { @@ -15,10 +15,7 @@ interface ContactRowArgs { export class ContactRow extends GlimmerComponent<ContactRowArgs> { <template> - <EntityDisplay> - <:title> - {{@name}} - </:title> + <EntityDisplayWithThumbnail @title={{@name}}> <:thumbnail> <Avatar @userID={{@userID}} @@ -35,7 +32,7 @@ export class ContactRow extends GlimmerComponent<ContactRowArgs> { </Pill> {{/if}} </:tag> - </EntityDisplay> + </EntityDisplayWithThumbnail> <style scoped> .avatar { --profile-avatar-icon-size: 20px; diff --git a/packages/experiments-realm/components/entity-display.gts b/packages/experiments-realm/components/entity-display.gts deleted file mode 100644 index 24aea981d9..0000000000 --- a/packages/experiments-realm/components/entity-display.gts +++ /dev/null @@ -1,93 +0,0 @@ -import GlimmerComponent from '@glimmer/component'; -interface EntityDisplayArgs { - Args: { - center?: boolean; - underline?: boolean; - }; - Blocks: { - title: []; - thumbnail: []; - tag: []; - content: []; - }; - Element: HTMLElement; -} - -export class EntityDisplay extends GlimmerComponent<EntityDisplayArgs> { - get shouldAlignCenter() { - return this.args.center; - } - - get shouldUnderlineText() { - return this.args.underline; - } - - <template> - <div - class='entity-display {{if this.shouldAlignCenter "center"}}' - ...attributes - > - <div class='entity-thumbnail'>{{yield to='thumbnail'}}</div> - - <div class='entity-info'> - <div class='entity-name-tag'> - <span class='entity-name {{if this.shouldUnderlineText "underline"}}'> - {{yield to='title'}} - </span> - - {{yield to='tag'}} - </div> - - <div class='entity-content'> - {{yield to='content'}} - </div> - </div> - </div> - <style scoped> - .entity-display { - display: inline-flex; - align-items: start; - gap: var(--entity-display-gap, var(--boxel-sp-xs)); - } - .entity-display.center { - align-items: center; - } - .entity-thumbnail { - width: var(--entity-display-thumbnail-size, var(--boxel-icon-sm)); - height: calc( - var(--entity-display-thumbnail-size, var(--boxel-icon-sm)) - 2px - ); - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - color: var(--entity-display-thumbnail-color, var(--boxel-600)); - } - .entity-info { - display: flex; - flex-direction: column; - gap: var(--entity-display-info-gap, var(--boxel-sp-xxxs)); - } - .entity-name-tag { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--entity-display-name-tag-gap, var(--boxel-sp-xxxs)); - } - .entity-name { - word-break: break-word; - } - .entity-name.underline { - text-decoration: underline; - } - .entity-content { - margin: 0; - font-size: var( - --entity-display-content-font-size, - var(--boxel-font-size-sm) - ); - color: var(--entity-display-content-color, var(--boxel-400)); - } - </style> - </template> -} diff --git a/packages/experiments-realm/components/entity-icon-display.gts b/packages/experiments-realm/components/entity-icon-display.gts new file mode 100644 index 0000000000..73cc4142d1 --- /dev/null +++ b/packages/experiments-realm/components/entity-icon-display.gts @@ -0,0 +1,126 @@ +import GlimmerComponent from '@glimmer/component'; +import { concat } from '@ember/helper'; + +interface EntityDisplayWithIconArgs { + Args: { + title?: string; //prefer using args.title if the title is just a string + center?: boolean; + underline?: boolean; + }; + Blocks: { + title?: []; //we can choose use this to pass instead of using args.title if the title block HTML is complex + icon?: []; + tag?: []; + content?: []; + }; + Element: HTMLElement; +} + +export default class EntityDisplayWithIcon extends GlimmerComponent<EntityDisplayWithIconArgs> { + get shouldAlignCenter() { + return this.args.center; + } + + get shouldUnderlineText() { + return this.args.underline; + } + + <template> + <div + class={{concat + 'entity-icon-display' + (if this.shouldAlignCenter ' center') + }} + ...attributes + > + {{#if (has-block 'icon')}} + <aside class='entity-icon'> + {{yield to='icon'}} + </aside> + {{/if}} + + <div class='entity-info'> + {{! Title and tag }} + <div class='entity-title-tag-container'> + {{! this guard clause is already priotize yield to 'title' instead of using args.title if both are provided}} + {{#if (has-block 'title')}} + {{yield to='title'}} + {{else if @title}} + <span + class={{concat + 'entity-title' + (if this.shouldUnderlineText ' underline') + }} + > + {{@title}} + </span> + {{/if}} + + {{#if (has-block 'tag')}} + {{yield to='tag'}} + {{/if}} + </div> + + {{! Extra Content }} + {{#if (has-block 'content')}} + <div class='entity-content'> + {{yield to='content'}} + </div> + {{/if}} + </div> + + </div> + <style scoped> + .entity-icon-display { + --icon-size: var(--entity-display-icon-size, var(--boxel-icon-sm)); + --title-font-size: var( + --entity-display-title-font-size, + var(--boxel-font-size-sm) + ); + --content-font-size: var( + --entity-display-content-font-size, + var(--boxel-font-size-xs) + ); + --content-color: var(--entity-display-content-color, var(--boxel-400)); + --content-gap: var(--entity-display-content-gap, var(--boxel-sp-xxxs)); + display: flex; + align-items: flex-start; + gap: var(--content-gap); + } + .entity-icon-display.center { + align-items: center; + } + .entity-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--icon-size); + height: var(--icon-size); + } + .entity-info { + display: flex; + flex-direction: column; + gap: var(--content-gap); + } + .entity-title-tag-container { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--content-gap); + } + .entity-title { + word-break: break-word; + font-size: var(--title-font-size); + } + .entity-title.underline { + text-decoration: underline; + } + .entity-content { + margin: 0; + font-size: var(--content-font-size); + color: var(--content-color); + } + </style> + </template> +} diff --git a/packages/experiments-realm/components/entity-thumbnail-display.gts b/packages/experiments-realm/components/entity-thumbnail-display.gts new file mode 100644 index 0000000000..26aac840e9 --- /dev/null +++ b/packages/experiments-realm/components/entity-thumbnail-display.gts @@ -0,0 +1,130 @@ +import GlimmerComponent from '@glimmer/component'; +import { concat } from '@ember/helper'; + +interface EntityDisplayWithThumbnailArgs { + Args: { + title?: string; //prefer using args.title if the title is just a string + center?: boolean; + underline?: boolean; + }; + Blocks: { + title?: []; //we can choose use this to pass instead of using args.title if the title block HTML is complex + thumbnail?: []; + tag?: []; + content?: []; + }; + Element: HTMLElement; +} + +export default class EntityDisplayWithThumbnail extends GlimmerComponent<EntityDisplayWithThumbnailArgs> { + get shouldAlignCenter() { + return this.args.center; + } + + get shouldUnderlineText() { + return this.args.underline; + } + + <template> + <div + class={{concat + 'entity-thumbnail-display' + (if this.shouldAlignCenter ' center') + }} + ...attributes + > + {{#if (has-block 'thumbnail')}} + <aside class='entity-thumbnail'> + {{yield to='thumbnail'}} + </aside> + {{/if}} + + <div class='entity-info'> + {{! Title and tag }} + <div class='entity-title-tag-container'> + {{! this guard clause is already priotize yield to 'title' instead of using args.title if both are provided}} + {{#if (has-block 'title')}} + {{yield to='title'}} + {{else if @title}} + <span + class={{concat + 'entity-title' + (if this.shouldUnderlineText ' underline') + }} + > + {{@title}} + </span> + {{/if}} + + {{#if (has-block 'tag')}} + {{yield to='tag'}} + {{/if}} + </div> + + {{! Extra Content }} + {{#if (has-block 'content')}} + <div class='entity-content'> + {{yield to='content'}} + </div> + {{/if}} + </div> + + </div> + <style scoped> + .entity-thumbnail-display { + --thumbnail-size: var( + --entity-display-thumbnail-size, + var(--boxel-icon-sm) + ); + --title-font-size: var( + --entity-display-title-font-size, + var(--boxel-font-size-sm) + ); + --content-font-size: var( + --entity-display-content-font-size, + var(--boxel-font-size-xs) + ); + --content-color: var(--entity-display-content-color, var(--boxel-400)); + --content-gap: var(--entity-display-content-gap, var(--boxel-sp-xxxs)); + display: flex; + align-items: flex-start; + gap: var(--content-gap); + } + .entity-thumbnail-display.center { + align-items: center; + } + .entity-thumbnail { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--thumbnail-size); + height: var(--thumbnail-size); + overflow: hidden; + } + .entity-info { + display: flex; + flex-direction: column; + gap: var(--content-gap); + } + .entity-title-tag-container { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--content-gap); + } + .entity-title { + word-break: break-word; + font-size: var(--title-font-size); + } + .entity-title.underline { + text-decoration: underline; + } + .entity-content { + margin: 0; + font-size: var(--content-font-size); + color: var(--content-color); + } + </style> + </template> +} diff --git a/packages/experiments-realm/crm-app.gts b/packages/experiments-realm/crm-app.gts index b3a00c7348..c1bb2eb310 100644 --- a/packages/experiments-realm/crm-app.gts +++ b/packages/experiments-realm/crm-app.gts @@ -23,12 +23,7 @@ import { } from '@cardstack/boxel-ui/components'; import { IconPlus } from '@cardstack/boxel-ui/icons'; import { AppCard, Tab } from './app-card'; -import { - Query, - CardError, - SupportedMimeType, - codeRefWithAbsoluteURL, -} from '@cardstack/runtime-common'; +import { Query, CardError, SupportedMimeType } from '@cardstack/runtime-common'; import ContactIcon from '@cardstack/boxel-icons/contact'; import HeartHandshakeIcon from '@cardstack/boxel-icons/heart-handshake'; import TargetArrowIcon from '@cardstack/boxel-icons/target-arrow'; @@ -181,15 +176,6 @@ class CrmAppTemplate extends Component<typeof AppCard> { return this.activeTab?.tabId ? this.activeTab.tabId.toLowerCase() : ''; } - get activeTabRef() { - if (!this.activeTab?.ref?.name || !this.activeTab.ref.module) { - return; - } - if (!this.currentRealm) { - return; - } - return codeRefWithAbsoluteURL(this.activeTab.ref, this.currentRealm); - } setTabs(tabs: Tab[]) { this.args.model.tabs = tabs ?? []; } @@ -489,7 +475,7 @@ class CrmAppTemplate extends Component<typeof AppCard> { } export class CrmApp extends AppCard { - static displayName = 'Crm App'; + static displayName = 'CRM App'; static prefersWideFormat = true; static headerColor = '#4D3FE8'; static isolated = CrmAppTemplate; diff --git a/packages/experiments-realm/crm/account.gts b/packages/experiments-realm/crm/account.gts index 72dd7b5483..b467468a30 100644 --- a/packages/experiments-realm/crm/account.gts +++ b/packages/experiments-realm/crm/account.gts @@ -26,7 +26,8 @@ import CalendarExclamation from '@cardstack/boxel-icons/calendar-exclamation'; import { LooseGooseyField } from '../loosey-goosey'; import { StatusPill } from '../components/status-pill'; import { Avatar, Pill, BoxelButton } from '@cardstack/boxel-ui/components'; -import { EntityDisplay } from '../components/entity-display'; +import EntityDisplayWithIcon from '../components/entity-icon-display'; +import EntityDisplayWithThumbnail from '../components/entity-thumbnail-display'; import ActivityCard from '../components/activity-card'; import PlusIcon from '@cardstack/boxel-icons/plus'; import PhoneIcon from '@cardstack/boxel-icons/phone'; @@ -283,41 +284,36 @@ class IsolatedTemplate extends Component<typeof Account> { </:description> <:content> <div class='activity-card-group'> - <EntityDisplay> - <:thumbnail> + <EntityDisplayWithIcon @title='Technova'> + <:icon> <SquareUser /> - </:thumbnail> - <:title> - Dmitri Petrov - </:title> + </:icon> <:content> Technova </:content> - </EntityDisplay> - <EntityDisplay> + </EntityDisplayWithIcon> + <EntityDisplayWithThumbnail @title='Rep: Janus Dios'> <:thumbnail> <Avatar @thumbnailURL='https://images.pexels.com/photos/1624229/pexels-photo-1624229.jpeg?auto=compress&cs=tinysrgb&w=300&h=300&dpr=2' + class='avatar' /> </:thumbnail> - <:title> - Rep: Janus Dios - </:title> <:content> Sales Associate </:content> - </EntityDisplay> - <EntityDisplay class='activity-time'> - <:thumbnail> + </EntityDisplayWithThumbnail> + <EntityDisplayWithIcon + class='activity-time' + @title='May 15, 2024' + > + <:icon> <CalendarTime /> - </:thumbnail> - <:title> - May 15, 2024 - </:title> + </:icon> <:content> 3:15pm </:content> - </EntityDisplay> + </EntityDisplayWithIcon> </div> </:content> </ActivityCard> @@ -407,6 +403,11 @@ class IsolatedTemplate extends Component<typeof Account> { color: var(--boxel-color-gray); margin-left: auto; } + .avatar { + --profile-avatar-icon-size: 20px; + --profile-avatar-icon-border: 0px; + flex-shrink: 0; + } @container activities-summary-card (max-width: 447px) { .activity-button-mobile { diff --git a/packages/experiments-realm/crm/company.gts b/packages/experiments-realm/crm/company.gts index fe64a3a1c5..70af9f19ff 100644 --- a/packages/experiments-realm/crm/company.gts +++ b/packages/experiments-realm/crm/company.gts @@ -2,7 +2,7 @@ import StringField from 'https://cardstack.com/base/string'; import NumberField from 'https://cardstack.com/base/number'; import { WebsiteField } from '../website'; import { Address } from '../address'; -import { EntityDisplay } from '../components/entity-display'; +import EntityDisplayWithIcon from '../components/entity-icon-display'; import { Component, @@ -15,14 +15,11 @@ import BuildingIcon from '@cardstack/boxel-icons/building'; class ViewCompanyTemplate extends Component<typeof Company> { <template> <div class='company-group'> - <EntityDisplay @underline={{true}}> - <:title> - {{@model.name}} - </:title> - <:thumbnail> + <EntityDisplayWithIcon @title={{@model.name}} @underline={{true}}> + <:icon> <BuildingIcon /> - </:thumbnail> - </EntityDisplay> + </:icon> + </EntityDisplayWithIcon> </div> </template> } diff --git a/packages/experiments-realm/crm/contact.gts b/packages/experiments-realm/crm/contact.gts index e043c105f0..8d29b1c062 100644 --- a/packages/experiments-realm/crm/contact.gts +++ b/packages/experiments-realm/crm/contact.gts @@ -19,7 +19,7 @@ import Email from '@cardstack/boxel-icons/mail'; import Linkedin from '@cardstack/boxel-icons/linkedin'; import XIcon from '@cardstack/boxel-icons/brand-x'; import { LooseGooseyField } from '../loosey-goosey'; -import { EntityDisplay } from '../components/entity-display'; +import EntityDisplayWithThumbnail from '../components/entity-thumbnail-display'; export class SocialLinkField extends ContactLinkField { static displayName = 'social-link'; @@ -186,7 +186,7 @@ class FittedTemplate extends Component<typeof Contact> { grid-area: avatar-group-container; } .avatar-group-container - :where(.avatar-info .company-group .entity-name-tag) { + :where(.avatar-info .company-group .entity-title-tag-container) { overflow: hidden; text-overflow: ellipsis; display: -webkit-box; @@ -610,10 +610,7 @@ class AtomTemplate extends Component<typeof Contact> { } <template> <div class='contact'> - <EntityDisplay @underline={{true}}> - <:title> - {{this.label}} - </:title> + <EntityDisplayWithThumbnail @title={{this.label}} @underline={{true}}> <:thumbnail> <Avatar @userID={{@model.id}} @@ -623,7 +620,7 @@ class AtomTemplate extends Component<typeof Contact> { class='avatar' /> </:thumbnail> - </EntityDisplay> + </EntityDisplayWithThumbnail> </div> <style scoped> .contact { diff --git a/packages/experiments-realm/crm/deal.gts b/packages/experiments-realm/crm/deal.gts index 89b0ec1c11..cc14183498 100644 --- a/packages/experiments-realm/crm/deal.gts +++ b/packages/experiments-realm/crm/deal.gts @@ -18,7 +18,7 @@ import { BoxelButton, Pill } from '@cardstack/boxel-ui/components'; import Info from '@cardstack/boxel-icons/info'; import AccountHeader from '../components/account-header'; import CrmProgressBar from '../components/crm-progress-bar'; -import { EntityDisplay } from '../components/entity-display'; +import EntityDisplayWithIcon from '../components/entity-icon-display'; import { htmlSafe } from '@ember/template'; import { concat } from '@ember/helper'; import { LooseGooseyField } from '../loosey-goosey'; @@ -55,7 +55,6 @@ class IsolatedTemplate extends Component<typeof Deal> { ); } get primaryContactName() { - console.log(this.args.fields.account?.primaryContact); return this.args.model.account?.primaryContact?.name; } @@ -84,10 +83,6 @@ class IsolatedTemplate extends Component<typeof Deal> { return this.args.model[realmURL]!; } - get realmHref() { - return this.realmURL.href; - } - get realmHrefs() { return [this.realmURL?.href]; } @@ -96,7 +91,7 @@ class IsolatedTemplate extends Component<typeof Deal> { return { filter: { type: { - module: `${this.realmHref}crm/deal`, + module: new URL('./crm/deal', import.meta.url).href, name: 'Deal', }, }, @@ -116,14 +111,11 @@ class IsolatedTemplate extends Component<typeof Deal> { (acc, deal: Deal) => acc + deal.computedValue.amount, 0, ); - nonZeroDeals.map((d) => console.log(d.computedValue.amount)); - console.log('totalDealRevenue', totalDealRevenue); let avgDealSize = totalDealRevenue / nonZeroDeals.length; - console.log('avgDealSize', avgDealSize); + if (this.args.model.computedValue?.amount) { let percentDiff = (this.args.model.computedValue?.amount - avgDealSize) / avgDealSize; - console.log('percentDiff', percentDiff); let positive = percentDiff >= 0 ? true : false; let summary = `${percentDiff.toFixed(2)}% ${ positive ? 'above' : 'below' @@ -294,14 +286,11 @@ class IsolatedTemplate extends Component<typeof Deal> { <footer class='next-steps'> <div class='next-steps-row'> - <EntityDisplay @center={{true}}> - <:title> - Notes - </:title> - <:thumbnail> + <EntityDisplayWithIcon @title='Notes' @center={{true}}> + <:icon> <Info class='info-icon' /> - </:thumbnail> - </EntityDisplay> + </:icon> + </EntityDisplayWithIcon> {{#if @model.document}} <BoxelButton @@ -524,7 +513,7 @@ class IsolatedTemplate extends Component<typeof Deal> { } .info-atom { width: fit-content; - display: inline-block; + display: inline-flex; } .header-icon { width: var(--boxel-icon-sm); @@ -712,7 +701,7 @@ class FittedTemplate extends Component<typeof Deal> { } .info-atom { width: fit-content; - display: inline-block; + display: inline-flex; } /* deal details */ .deal-details { diff --git a/packages/experiments-realm/email.gts b/packages/experiments-realm/email.gts index b4991a0671..3d01fb25a3 100644 --- a/packages/experiments-realm/email.gts +++ b/packages/experiments-realm/email.gts @@ -15,7 +15,7 @@ import MailIcon from '@cardstack/boxel-icons/mail'; import { debounce } from 'lodash'; import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; -import { EntityDisplay } from './components/entity-display'; +import EntityDisplayWithIcon from './components/entity-icon-display'; // We use simple regex here to validate common email formats // This is definitely NOT a full email validation @@ -68,14 +68,11 @@ export class EmailField extends StringField { static atom = class Atom extends Component<typeof EmailField> { <template> {{#if @model}} - <EntityDisplay @underline={{false}}> - <:title> - {{@model}} - </:title> - <:thumbnail> + <EntityDisplayWithIcon @title={{@model}} @underline={{false}}> + <:icon> <MailIcon class='icon' /> - </:thumbnail> - </EntityDisplay> + </:icon> + </EntityDisplayWithIcon> {{/if}} <style scoped> .icon { diff --git a/packages/experiments-realm/phone-number.gts b/packages/experiments-realm/phone-number.gts index 099cb099b2..e186326d94 100644 --- a/packages/experiments-realm/phone-number.gts +++ b/packages/experiments-realm/phone-number.gts @@ -8,7 +8,7 @@ import { import { LooseGooseyField, LooseyGooseyData } from './loosey-goosey'; import { PhoneInput, Pill } from '@cardstack/boxel-ui/components'; import { RadioInput } from '@cardstack/boxel-ui/components'; -import { EntityDisplay } from './components/entity-display'; +import EntityDisplayWithIcon from './components/entity-icon-display'; import { tracked } from '@glimmer/tracking'; import { fn } from '@ember/helper'; import { action } from '@ember/object'; @@ -94,7 +94,7 @@ export class PhoneField extends FieldDef { static atom = class Atom extends Component<typeof PhoneField> { <template> - <EntityDisplay @underline={{false}}> + <EntityDisplayWithIcon @underline={{false}}> <:title> {{#if @model.countryCode}} +{{@model.countryCode}}{{@model.number}} @@ -102,10 +102,10 @@ export class PhoneField extends FieldDef { {{@model.number}} {{/if}} </:title> - <:thumbnail> + <:icon> <PhoneIcon class='icon' /> - </:thumbnail> - </EntityDisplay> + </:icon> + </EntityDisplayWithIcon> <style scoped> .icon { color: var(--boxel-400); @@ -131,7 +131,7 @@ export class ContactPhoneNumber extends FieldDef { static atom = class Atom extends Component<typeof ContactPhoneNumber> { <template> - <EntityDisplay @underline={{false}}> + <EntityDisplayWithIcon @underline={{false}}> <:title> {{#if @model.phoneNumber.countryCode}} +{{@model.phoneNumber.countryCode}}{{@model.phoneNumber.number}} @@ -139,9 +139,9 @@ export class ContactPhoneNumber extends FieldDef { {{@model.phoneNumber.number}} {{/if}} </:title> - <:thumbnail> + <:icon> <PhoneIcon class='icon' /> - </:thumbnail> + </:icon> <:tag> {{#if @model.type.label}} <Pill class='pill-gray'> @@ -149,7 +149,7 @@ export class ContactPhoneNumber extends FieldDef { </Pill> {{/if}} </:tag> - </EntityDisplay> + </EntityDisplayWithIcon> <style scoped> .icon { color: var(--boxel-400); diff --git a/packages/experiments-realm/website.gts b/packages/experiments-realm/website.gts index 2f79e4af5e..06ad9a5ee6 100644 --- a/packages/experiments-realm/website.gts +++ b/packages/experiments-realm/website.gts @@ -1,7 +1,7 @@ import WorldWwwIcon from '@cardstack/boxel-icons/world-www'; import { UrlField } from './url'; import { Component } from 'https://cardstack.com/base/card-api'; -import { EntityDisplay } from './components/entity-display'; +import EntityDisplayWithIcon from './components/entity-icon-display'; const domainWithPath = (urlString: string | null) => { if (!urlString) { @@ -18,14 +18,11 @@ export class WebsiteField extends UrlField { static atom = class Atom extends Component<typeof WebsiteField> { <template> - <EntityDisplay> - <:title> - {{domainWithPath @model}} - </:title> - <:thumbnail> + <EntityDisplayWithIcon @title={{domainWithPath @model}}> + <:icon> <WorldWwwIcon /> - </:thumbnail> - </EntityDisplay> + </:icon> + </EntityDisplayWithIcon> </template> }; diff --git a/packages/host/app/lib/current-run.ts b/packages/host/app/lib/current-run.ts index 5a8ab028ba..e3d2d6f8c3 100644 --- a/packages/host/app/lib/current-run.ts +++ b/packages/host/app/lib/current-run.ts @@ -228,7 +228,7 @@ export class CurrentRun { private async discoverInvalidations( url: URL, - mtimes: LastModifiedTimes, + indexMtimes: LastModifiedTimes, ): Promise<void> { log.debug(`discovering invalidations in dir ${url.href}`); let ignorePatterns = await this.#reader.readFile( @@ -239,35 +239,23 @@ export class CurrentRun { this.#ignoreData[url.href] = ignorePatterns.content; } - let entries = await this.#reader.directoryListing(url); - - for (let { url, kind, lastModified } of entries) { - let innerURL = new URL(url); - if (isIgnored(this.#realmURL, this.ignoreMap, innerURL)) { + let filesystemMtimes = await this.#reader.mtimes(); + for (let [url, lastModified] of Object.entries(filesystemMtimes)) { + if (!url.endsWith('.json') && !hasExecutableExtension(url)) { + // Only allow json and executable files to be invalidated so that we + // don't end up with invalidated files that weren't meant to be indexed + // (images, etc) continue; } - - if (kind === 'directory') { - await this.discoverInvalidations(innerURL, mtimes); - } else { - if (!url.endsWith('.json') && !hasExecutableExtension(url)) { - // Only allow json and executable files to be invalidated so that we don't end up with invalidated files that weren't meant to be indexed (images, etc) - continue; - } - - let indexEntry = mtimes.get(innerURL.href); - if ( - !indexEntry || - indexEntry.type === 'error' || - indexEntry.lastModified == null - ) { - await this.batch.invalidate(innerURL); - continue; - } - - if (lastModified !== indexEntry.lastModified) { - await this.batch.invalidate(innerURL); - } + let indexEntry = indexMtimes.get(url); + + if ( + !indexEntry || + indexEntry.type === 'error' || + indexEntry.lastModified == null || + lastModified !== indexEntry.lastModified + ) { + await this.batch.invalidate(new URL(url)); } } } diff --git a/packages/host/tests/integration/realm-indexing-and-querying-test.gts b/packages/host/tests/integration/realm-indexing-and-querying-test.gts index 717db00fc0..7cba0ce45f 100644 --- a/packages/host/tests/integration/realm-indexing-and-querying-test.gts +++ b/packages/host/tests/integration/realm-indexing-and-querying-test.gts @@ -3401,6 +3401,12 @@ module(`Integration | realm indexing and querying`, function (hooks) { { id: mangoID }, { id: vanGoghID, + firstName: 'Van Gogh', + friends: [ + { + id: hassanID, + }, + ], }, ], }, diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index 084dd8411d..ee57575503 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -1,4 +1,4 @@ -import { writeFileSync, writeJSONSync } from 'fs-extra'; +import { writeFileSync, writeJSONSync, readdirSync, statSync } from 'fs-extra'; import { NodeAdapter } from '../../node-realm'; import { resolve, join } from 'path'; import { @@ -14,14 +14,16 @@ import { maybeHandleScopedCSSRequest, insertPermissions, IndexWriter, - type MatrixConfig, - type QueuePublisher, - type QueueRunner, - type IndexRunner, asExpressions, query, insert, param, + unixTime, + RealmPaths, + type MatrixConfig, + type QueuePublisher, + type QueueRunner, + type IndexRunner, } from '@cardstack/runtime-common'; import { dirSync } from 'tmp'; import { getLocalConfig as getSynapseConfig } from '../../synapse'; @@ -401,6 +403,7 @@ export async function runTestRealmServer({ let testRealmHttpServer = testRealmServer.listen(parseInt(realmURL.port)); await testRealmServer.start(); return { + testRealmDir, testRealm, testRealmServer, testRealmHttpServer, @@ -494,3 +497,28 @@ export async function fetchSubscriptionsByUserId( stripeSubscriptionId: result.stripe_subscription_id, })); } + +export function mtimes( + path: string, + realmURL: URL, +): { [path: string]: number } { + const mtimes: { [path: string]: number } = {}; + let paths = new RealmPaths(realmURL); + + function traverseDir(currentPath: string) { + const entries = readdirSync(currentPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(currentPath, entry.name); + if (entry.isDirectory()) { + traverseDir(fullPath); + } else if (entry.isFile()) { + const stats = statSync(fullPath); + mtimes[paths.fileURL(fullPath.substring(path.length)).href] = unixTime( + stats.mtime.getTime(), + ); + } + } + } + traverseDir(path); + return mtimes; +} diff --git a/packages/realm-server/tests/realm-server-test.ts b/packages/realm-server/tests/realm-server-test.ts index 400fdbd557..f892a1f5ca 100644 --- a/packages/realm-server/tests/realm-server-test.ts +++ b/packages/realm-server/tests/realm-server-test.ts @@ -54,6 +54,7 @@ import { testRealmInfo, insertUser, insertPlan, + mtimes, fetchSubscriptionsByUserId, cleanWhiteSpace, } from './helpers'; @@ -152,6 +153,7 @@ module('Realm Server', function (hooks) { } let testRealm: Realm; + let testRealmPath: string; let testRealmHttpServer: Server; let request: SuperTest<Test>; let dir: DirResult; @@ -173,7 +175,11 @@ module('Realm Server', function (hooks) { copySync(join(__dirname, 'cards'), testRealmDir); } let virtualNetwork = createVirtualNetwork(); - ({ testRealm, testRealmHttpServer } = await runTestRealmServer({ + ({ + testRealm, + testRealmHttpServer, + testRealmDir: testRealmPath, + } = await runTestRealmServer({ virtualNetwork, testRealmDir, realmsRootPath: join(dir.name, 'realm_server_1'), @@ -210,6 +216,50 @@ module('Realm Server', function (hooks) { resetCatalogRealms(); }); + module('mtimes requests', function (hooks) { + setupPermissionedRealm(hooks, { + mary: ['read'], + }); + + test('non read permission GET /_mtimes', async function (assert) { + let response = await request + .get('/_mtimes') + .set('Accept', 'application/vnd.api+json') + .set('Authorization', `Bearer ${createJWT(testRealm, 'not-mary')}`); + + assert.strictEqual(response.status, 403, 'HTTP 403 status'); + }); + + test('read permission GET /_mtimes', async function (assert) { + let expectedMtimes = mtimes(testRealmPath, testRealmURL); + delete expectedMtimes[`${testRealmURL}.realm.json`]; + + let response = await request + .get('/_mtimes') + .set('Accept', 'application/vnd.api+json') + .set( + 'Authorization', + `Bearer ${createJWT(testRealm, 'mary', ['read'])}`, + ); + + assert.strictEqual(response.status, 200, 'HTTP 200 status'); + let json = response.body; + assert.deepEqual( + json, + { + data: { + type: 'mtimes', + id: testRealmHref, + attributes: { + mtimes: expectedMtimes, + }, + }, + }, + 'mtimes response is correct', + ); + }); + }); + module('permissions requests', function (hooks) { setupPermissionedRealm(hooks, { mary: ['read', 'write', 'realm-owner'], diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index f93bf128f2..8d9c6768ad 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -349,6 +349,7 @@ export class Realm { this.patchCard.bind(this), ) .get('/_info', SupportedMimeType.RealmInfo, this.realmInfo.bind(this)) + .get('/_mtimes', SupportedMimeType.Mtimes, this.realmMtimes.bind(this)) .get('/_search', SupportedMimeType.CardJson, this.search.bind(this)) .get( '/_search-prerendered', @@ -1654,6 +1655,57 @@ export class Realm { }); } + private async realmMtimes( + _request: Request, + requestContext: RequestContext, + ): Promise<Response> { + let mtimes: { [path: string]: number } = {}; + let traverse = async (currentPath = '') => { + const entries = this.#adapter.readdir(currentPath); + + for await (const entry of entries) { + let innerPath = join(currentPath, entry.name); + let innerURL = + entry.kind === 'directory' + ? this.paths.directoryURL(innerPath) + : this.paths.fileURL(innerPath); + if (await this.isIgnored(innerURL)) { + continue; + } + if (entry.kind === 'directory') { + await traverse(innerPath); + } else if (entry.kind === 'file') { + let mtime = await this.#adapter.lastModified(innerPath); + if (mtime != null) { + mtimes[innerURL.href] = mtime; + } + } + } + }; + + await traverse(); + + return createResponse({ + body: JSON.stringify( + { + data: { + id: this.url, + type: 'mtimes', + attributes: { + mtimes, + }, + }, + }, + null, + 2, + ), + init: { + headers: { 'content-type': SupportedMimeType.Mtimes }, + }, + requestContext, + }); + } + private async getRealmPermissions( _request: Request, requestContext: RequestContext, diff --git a/packages/runtime-common/router.ts b/packages/runtime-common/router.ts index dbc47276ab..68aa62aacd 100644 --- a/packages/runtime-common/router.ts +++ b/packages/runtime-common/router.ts @@ -21,6 +21,7 @@ export enum SupportedMimeType { CardSource = 'application/vnd.card+source', DirectoryListing = 'application/vnd.api+json', RealmInfo = 'application/vnd.api+json', + Mtimes = 'application/vnd.api+json', Permissions = 'application/vnd.api+json', Session = 'application/json', EventStream = 'text/event-stream', diff --git a/packages/runtime-common/worker.ts b/packages/runtime-common/worker.ts index c93445d571..767bacfbb1 100644 --- a/packages/runtime-common/worker.ts +++ b/packages/runtime-common/worker.ts @@ -15,7 +15,6 @@ import { type QueueRunner, type TextFileRef, type VirtualNetwork, - type Relationship, type ResponseWithNodeStream, } from '.'; import { MatrixClient } from './matrix-client'; @@ -36,11 +35,7 @@ export interface IndexResults { export interface Reader { readFile: (url: URL) => Promise<TextFileRef | undefined>; - directoryListing: ( - url: URL, - ) => Promise< - { kind: 'directory' | 'file'; url: string; lastModified: number | null }[] - >; + mtimes: () => Promise<{ [url: string]: number }>; } export type RunnerRegistration = ( @@ -342,29 +337,20 @@ export function getReader( }; }, - directoryListing: async (url: URL) => { - let response = await _fetch(url, { + mtimes: async () => { + let response = await _fetch(`${realmURL.href}_mtimes`, { headers: { - Accept: SupportedMimeType.DirectoryListing, + Accept: SupportedMimeType.Mtimes, }, }); let { - data: { relationships: _relationships }, - } = await response.json(); - let relationships = _relationships as Record<string, Relationship>; - return Object.values(relationships).map((entry) => - entry.meta!.kind === 'file' - ? { - url: entry.links.related!, - kind: 'file', - lastModified: (entry.meta?.lastModified ?? null) as number | null, - } - : { - url: entry.links.related!, - kind: 'directory', - lastModified: null, - }, - ); + data: { + attributes: { mtimes }, + }, + } = (await response.json()) as { + data: { attributes: { mtimes: { [url: string]: number } } }; + }; + return mtimes; }, }; }