go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/components/text_artifact.ts (about) 1 // Copyright 2020 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 import '@material/mwc-icon'; 16 import { MobxLitElement } from '@adobe/lit-mobx'; 17 import { css, html } from 'lit'; 18 import { customElement } from 'lit/decorators.js'; 19 import { computed, makeObservable, observable } from 'mobx'; 20 import { fromPromise, IPromiseBasedObservable } from 'mobx-utils'; 21 22 import '@/generic_libs/components/dot_spinner'; 23 import { ARTIFACT_LENGTH_LIMIT } from '@/common/constants/test'; 24 import { commonStyles } from '@/common/styles/stylesheets'; 25 import { unwrapObservable } from '@/generic_libs/tools/mobx_utils'; 26 import { toError, urlSetSearchQueryParam } from '@/generic_libs/tools/utils'; 27 28 import { ON_TEST_RESULT_DATA_READY } from '../constants/event'; 29 import { getRawArtifactURLPath } from '../tools/url_utils'; 30 31 export interface TextArtifactEvent { 32 setData: (invName: string, resultName: string) => void; 33 } 34 35 /** 36 * Renders a text artifact. 37 */ 38 @customElement('text-artifact') 39 export class TextArtifactElement extends MobxLitElement { 40 static get properties() { 41 return { 42 artifactId: { 43 attribute: 'artifact-id', 44 type: String, 45 }, 46 invLevel: { 47 attribute: 'inv-level', 48 type: Boolean, 49 }, 50 }; 51 } 52 53 @observable.ref 54 resultName: string | undefined; 55 56 @observable.ref 57 invName: string | undefined; 58 59 @observable.ref _artifactId!: string; 60 @computed get artifactId() { 61 return this._artifactId; 62 } 63 set artifactId(newVal: string) { 64 this._artifactId = newVal; 65 } 66 67 @observable.ref _invLevel = false; 68 @computed get invLevel() { 69 return this._invLevel; 70 } 71 set invLevel(newVal: boolean) { 72 this._invLevel = newVal; 73 } 74 75 @computed 76 private get fetchUrl(): string { 77 if (!this.resultName || !this.invName) { 78 return ''; 79 } 80 81 if (this.invLevel) { 82 return getRawArtifactURLPath( 83 `${this.invName}/artifacts/${this.artifactId}`, 84 ); 85 } 86 87 return getRawArtifactURLPath( 88 `${this.resultName}/artifacts/${this.artifactId}`, 89 ); 90 } 91 92 @computed 93 private get content$(): IPromiseBasedObservable<string> { 94 if (!this.fetchUrl) { 95 return fromPromise(Promise.race([])); 96 } 97 return fromPromise( 98 fetch( 99 urlSetSearchQueryParam(this.fetchUrl, 'n', ARTIFACT_LENGTH_LIMIT), 100 ).then((res) => res.text()), 101 ); 102 } 103 104 @computed 105 private get content() { 106 return unwrapObservable(this.content$, null); 107 } 108 109 eventTimeout: number | undefined; 110 111 constructor() { 112 super(); 113 makeObservable(this); 114 } 115 116 protected createRenderRoot() { 117 return this; 118 } 119 120 connectedCallback(): void { 121 super.connectedCallback(); 122 // This must be done in the connected callback as it needs 123 // to occur when the element is in the dom in order for the 124 // event to bubble up to the parent. 125 // The timeout is needed to ensure that the event is fired after 126 // parent React components are mounted and were able to 127 // register the event handlers 128 this.eventTimeout = window.setTimeout( 129 () => 130 this.dispatchEvent( 131 new CustomEvent<TextArtifactEvent>(ON_TEST_RESULT_DATA_READY, { 132 detail: { 133 setData: (invName, resultName) => { 134 this.resultName = resultName; 135 this.invName = invName; 136 this.requestUpdate(); 137 }, 138 }, 139 // Allows events to bubble up the tree, 140 // which can then be captured by React components. 141 bubbles: true, 142 // Allows the event to traverse shadowroot boundries. 143 composed: true, 144 }), 145 ), 146 0, 147 ); 148 } 149 150 disconnectedCallback(): void { 151 super.disconnectedCallback(); 152 153 clearTimeout(this.eventTimeout); 154 } 155 156 protected render() { 157 try { 158 const label = this._invLevel ? 'Inv-level artifact' : 'Artifact'; 159 160 if (this.content === null) { 161 return html`<div id="load"> 162 Loading <milo-dot-spinner></milo-dot-spinner> 163 </div>`; 164 } 165 166 if (this.content === '') { 167 return html`<div>${label}: <i>${this.artifactId}</i> is empty.</div>`; 168 } 169 170 return html`<pre>${this.content}</pre>`; 171 } catch (e: unknown) { 172 const error = toError(e); 173 return html`<span>An error occured: ${error.message}</span>`; 174 } 175 } 176 177 static styles = [ 178 commonStyles, 179 css` 180 #load { 181 color: var(--active-text-color); 182 } 183 pre { 184 margin: 0; 185 font-size: 12px; 186 white-space: pre-wrap; 187 overflow-wrap: anywhere; 188 } 189 `, 190 ]; 191 }