UNPKG

@c8y/ngx-components

Version:

Angular modules for Cumulocity IoT applications

370 lines (363 loc) 26.9 kB
import * as i0 from '@angular/core'; import { Injectable, inject, ViewChild, Input, Component } from '@angular/core'; import { gettext } from '@c8y/ngx-components/gettext'; import * as i1 from '@c8y/ngx-components'; import { C8yValidators, FormGroupComponent, DropAreaComponent, EmptyStateComponent, IconDirective, C8yTranslatePipe, MarkdownToHtmlPipe } from '@c8y/ngx-components'; import * as i1$1 from '@angular/forms'; import { Validators, ReactiveFormsModule, FormsModule } from '@angular/forms'; import * as i2 from '@c8y/client'; import * as i3 from '@ngx-translate/core'; import { WidgetConfigService } from '@c8y/ngx-components/context-dashboard'; import { AsyncPipe } from '@angular/common'; import { BehaviorSubject, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { EditorComponent } from '@c8y/ngx-components/editor'; const MarkdownSourceType = { WRITE: 'writeMarkdown', UPLOAD: 'uploadBinary', URL: 'uploadUrl' }; class MarkdownWidgetService { constructor(fileService, inventory, binary, alert, translate, fetchClient, appStateService) { this.fileService = fileService; this.inventory = inventory; this.binary = binary; this.alert = alert; this.translate = translate; this.fetchClient = fetchClient; this.appStateService = appStateService; this.headers = { 'Content-Type': 'text/markdown' }; } /** * Retrieves a markdown file from the inventory by its binary ID. * @param markdownBinaryId - The ID of the binary managed object. * @returns The file if found, otherwise null. */ async getFile(markdownBinaryId) { if (!markdownBinaryId) { return null; } try { const { data: markdownBinaryMo } = await this.inventory.detail(markdownBinaryId); const file = await this.fileService.getFile(markdownBinaryMo); return file; } catch (e) { const text = this.translate.instant(gettext('Unable to retrieve binary with ID: {{ markdownBinaryId }}'), { markdownBinaryId }); this.alert.danger(text, e?.data); } return null; } /** * Uploads a markdown file to the inventory as a binary. * @param file - The file to upload. * @returns The ID of the created binary managed object. */ async uploadFile(file) { const { data: mo } = await this.binary.create(file); return mo.id; } /** * Fetches markdown content from a URL. * For internal URLs (e.g., `/readme.md`), uses FetchClient with the app's context path. * For external URLs, uses XMLHttpRequest to avoid CORS issues with auth headers. * @param url - The URL to fetch content from. * @returns The markdown content as a string, or empty string on error. */ async getContentFromUrl(url) { if (url?.toLowerCase() === '/readme.md') { return this.getContentFromInternalUrl(url); } return this.getContentFromExternalUrl(url); } async getContentFromInternalUrl(url) { try { const contextPath = this.appStateService.state.app.contextPath; const options = { method: 'GET', headers: this.headers }; const response = await this.fetchClient.fetch(`/apps/${contextPath}${url}`, options); if (response.status === 200) { return response.text(); } } catch { // ignore error, return empty string } return ''; } getContentFromExternalUrl(url) { return new Promise(resolve => { const req = new XMLHttpRequest(); req.onreadystatechange = () => { if (req.readyState === 4) { if (req.status === 200) { resolve(req.response); } else { resolve(''); } } }; req.onerror = () => resolve(''); req.open('GET', url); req.responseType = 'text'; req.setRequestHeader('Accept', 'text/html'); req.send(); }); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: MarkdownWidgetService, deps: [{ token: i1.FilesService }, { token: i2.InventoryService }, { token: i2.InventoryBinaryService }, { token: i1.AlertService }, { token: i3.TranslateService }, { token: i2.FetchClient }, { token: i1.AppStateService }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: MarkdownWidgetService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: MarkdownWidgetService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i1.FilesService }, { type: i2.InventoryService }, { type: i2.InventoryBinaryService }, { type: i1.AlertService }, { type: i3.TranslateService }, { type: i2.FetchClient }, { type: i1.AppStateService }] }); class MarkdownWidgetConfigComponent { set markdownPreviewTemplate(template) { this.widgetConfigService.setPreview(template || null); } constructor(formBuilder, form, alert, markdownService) { this.formBuilder = formBuilder; this.form = form; this.alert = alert; this.markdownService = markdownService; this.MarkdownSourceType = MarkdownSourceType; this.uploadChoice = MarkdownSourceType.URL; this.previewMarkdown$ = new BehaviorSubject(''); this.editorContent = ''; this.widgetConfigService = inject(WidgetConfigService); this.destroy$ = new Subject(); } async onBeforeSave(config) { if (this.uploadChoice === MarkdownSourceType.WRITE) { Object.assign(config, { markdownContent: this.editorContent, contentUrl: null, markdownBinaryId: null }); return true; } if (this.formGroup.invalid) { return false; } if (this.uploadChoice === MarkdownSourceType.URL) { Object.assign(config, { contentUrl: this.formGroup.value.contentUrl, markdownBinaryId: null, markdownContent: null }); return true; } const fileFromForm = this.getFileFromFormValue(this.formGroup.value); if (fileFromForm && fileFromForm !== this.fileFromConfig) { try { const markdownBinaryId = await this.markdownService.uploadFile(fileFromForm); Object.assign(config, { markdownBinaryId, contentUrl: null, markdownContent: null }); return true; } catch (e) { this.alert.danger(gettext('Unable to upload Markdown file.'), e?.data); return false; } } if (!fileFromForm) { Object.assign(config, { contentUrl: '/readme.md', markdownBinaryId: null, markdownContent: null }); } return true; } async ngOnInit() { // Determine initial mode from config if (this.config.markdownContent) { this.uploadChoice = MarkdownSourceType.WRITE; } else if (this.config.markdownBinaryId) { this.uploadChoice = MarkdownSourceType.UPLOAD; } this.initForm(); // Load initial content based on mode switch (this.uploadChoice) { case MarkdownSourceType.WRITE: this.editorContent = this.config.markdownContent; this.previewMarkdown$.next(this.config.markdownContent); break; case MarkdownSourceType.UPLOAD: this.fileFromConfig = await this.markdownService.getFile(this.config.markdownBinaryId); this.formGroup.patchValue({ droppedFile: [{ file: this.fileFromConfig, name: this.fileFromConfig.name }] }); await this.updatePreviewFromFile(this.fileFromConfig); break; case MarkdownSourceType.URL: if (this.config.contentUrl) { this.previewMarkdown$.next(await this.markdownService.getContentFromUrl(this.config.contentUrl)); } break; } this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(async (value) => { await this.updatePreview(value); }); } ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } onChange(value) { this.uploadChoice = value; // Ensure dropped file has 'name' property for drop area to display filename const droppedFile = this.formGroup.value.droppedFile; if (value === MarkdownSourceType.UPLOAD && droppedFile?.[0]?.file) { const normalizedFile = droppedFile.map(item => ({ ...item, name: item.name || item.file?.name })); this.formGroup.controls['droppedFile'].setValue(normalizedFile); } this.formGroup.updateValueAndValidity(); } onEditorChange(content) { this.editorContent = content; this.previewMarkdown$.next(content); } async updatePreview(formValue) { const choice = formValue.uploadChoice || this.uploadChoice; switch (choice) { case MarkdownSourceType.WRITE: if (!this.editorContent) { this.editorContent = this.previewMarkdown$.getValue(); } this.previewMarkdown$.next(this.editorContent); break; case MarkdownSourceType.UPLOAD: const file = this.getFileFromFormValue(formValue); file ? await this.updatePreviewFromFile(file) : this.previewMarkdown$.next(''); break; case MarkdownSourceType.URL: if (formValue.contentUrl) { this.previewMarkdown$.next(await this.markdownService.getContentFromUrl(formValue.contentUrl)); } else { this.previewMarkdown$.next(''); } break; } } async updatePreviewFromFile(file) { try { this.previewMarkdown$.next(await file.text()); } catch { this.previewMarkdown$.next(''); } } getFileFromFormValue(formValue) { const binary = formValue?.droppedFile || []; return binary[0]?.file || null; } initForm() { this.formGroup = this.formBuilder.group({ contentUrl: this.formBuilder.nonNullable.control('', [Validators.maxLength(2000)]), droppedFile: this.formBuilder.control(null, [ Validators.minLength(1), Validators.maxLength(1), C8yValidators.filesValidator({ maximumFileSizeInKb: 1000 }) ]), uploadChoice: this.formBuilder.nonNullable.control(this.uploadChoice) }, { validators: this.requireValidSource() }); this.form.form.addControl('config', this.formGroup); this.formGroup.patchValue(this.config); } requireValidSource() { return (control) => { const url = control.get('contentUrl'); const droppedFile = control.get('droppedFile'); // Write mode - always valid, editor content handled separately if (this.uploadChoice === MarkdownSourceType.WRITE) { url.setErrors(null); droppedFile.setErrors(null); return null; } // Clear required errors on inactive controls, set on active if empty if (this.uploadChoice === MarkdownSourceType.URL) { this.clearRequiredError(droppedFile); if (!url.value) { url.setErrors({ ...url.errors, required: true }); return { required: true }; } this.clearRequiredError(url); } if (this.uploadChoice === MarkdownSourceType.UPLOAD) { this.clearRequiredError(url); if (!droppedFile.value) { droppedFile.setErrors({ ...droppedFile.errors, required: true }); return { required: true }; } this.clearRequiredError(droppedFile); } return null; }; } clearRequiredError(control) { if (control?.errors?.required) { const { required: _, ...rest } = control.errors; control.setErrors(Object.keys(rest).length ? rest : null); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: MarkdownWidgetConfigComponent, deps: [{ token: i1$1.FormBuilder }, { token: i1$1.NgForm }, { token: i1.AlertService }, { token: MarkdownWidgetService }], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.19", type: MarkdownWidgetConfigComponent, isStandalone: true, selector: "c8y-markdown-widget-config", inputs: { config: "config" }, viewQueries: [{ propertyName: "markdownPreviewTemplate", first: true, predicate: ["markdownPreview"], descendants: true }], ngImport: i0, template: "<fieldset class=\"c8y-fieldset\">\n <legend>{{ 'Source' | translate }}</legend>\n <form [formGroup]=\"formGroup\">\n <div class=\"form-group\">\n <label\n class=\"c8y-radio radio-inline\"\n title=\"{{ 'Write Markdown' | translate }}\"\n >\n <input\n name=\"uploadChoice\"\n type=\"radio\"\n [value]=\"MarkdownSourceType.WRITE\"\n formControlName=\"uploadChoice\"\n (change)=\"onChange(MarkdownSourceType.WRITE)\"\n />\n <span></span>\n <span>{{ 'Write Markdown' | translate }}</span>\n </label>\n <label\n class=\"c8y-radio radio-inline m-l-8\"\n title=\"{{ 'Upload a binary' | translate }}\"\n >\n <input\n name=\"uploadChoice\"\n type=\"radio\"\n [value]=\"MarkdownSourceType.UPLOAD\"\n formControlName=\"uploadChoice\"\n (change)=\"onChange(MarkdownSourceType.UPLOAD)\"\n />\n <span></span>\n <span>{{ 'Upload a binary' | translate }}</span>\n </label>\n <label\n class=\"c8y-radio radio-inline m-l-8\"\n title=\"{{ 'Provide a file path' | translate }}\"\n >\n <input\n name=\"uploadChoice\"\n type=\"radio\"\n [value]=\"MarkdownSourceType.URL\"\n formControlName=\"uploadChoice\"\n (change)=\"onChange(MarkdownSourceType.URL)\"\n />\n <span></span>\n <span>{{ 'Provide a file path' | translate }}</span>\n </label>\n </div>\n @switch (uploadChoice) {\n @case (MarkdownSourceType.WRITE) {\n <c8y-editor\n class=\"d-block\"\n style=\"height: 300px\"\n [ngModel]=\"editorContent\"\n (ngModelChange)=\"onEditorChange($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [editorOptions]=\"{\n language: 'markdown',\n tabSize: 2,\n insertSpaces: true,\n minimap: { enabled: false },\n wordWrap: 'on'\n }\"\n ></c8y-editor>\n }\n @case (MarkdownSourceType.UPLOAD) {\n <c8y-form-group class=\"m-b-24\">\n <c8y-drop-area\n class=\"drop-area-sm\"\n [title]=\"'Drop file or click to browse' | translate\"\n formControlName=\"droppedFile\"\n [maxAllowedFiles]=\"1\"\n [accept]=\"'md'\"\n ></c8y-drop-area>\n </c8y-form-group>\n }\n @case (MarkdownSourceType.URL) {\n <c8y-form-group class=\"m-b-24\">\n <div class=\"input-group\">\n <span class=\"input-group-addon\">\n <i c8yIcon=\"globe\"></i>\n </span>\n <input\n class=\"form-control\"\n placeholder=\"{{ 'e.g.' | translate }} http://example.com/binary.zip\"\n type=\"text\"\n formControlName=\"contentUrl\"\n />\n </div>\n </c8y-form-group>\n }\n }\n </form>\n</fieldset>\n\n<ng-template #markdownPreview>\n @if (previewMarkdown$ | async; as previewMarkdown) {\n <div\n class=\"p-16 p-t-0 markdown-content fit-h overflow-auto\"\n [innerHTML]=\"previewMarkdown | markdownToHtml | async\"\n ></div>\n } @else {\n <div class=\"fit-h d-flex d-col j-c-center a-i-center\">\n <c8y-ui-empty-state\n [icon]=\"'file-text'\"\n [title]=\"'No content to preview' | translate\"\n [subtitle]=\"\n 'Write Markdown, upload a file, or provide a URL to see the preview' | translate\n \"\n [horizontal]=\"false\"\n ></c8y-ui-empty-state>\n </div>\n }\n</ng-template>\n", dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1$1NgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1$1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$1.RadioControlValueAccessor, selector: "input[type=radio][formControlName],input[type=radio][formControl],input[type=radio][ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1$1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1$1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "component", type: FormGroupComponent, selector: "c8y-form-group", inputs: ["hasError", "hasWarning", "hasSuccess", "novalidation", "status"] }, { kind: "component", type: DropAreaComponent, selector: "c8y-drop-area", inputs: ["formControl", "title", "message", "icon", "loadingMessage", "forceHideList", "alwaysShow", "clickToOpen", "loading", "progress", "maxAllowedFiles", "files", "maxFileSizeInMegaBytes", "accept"], outputs: ["dropped"] }, { kind: "component", type: EmptyStateComponent, selector: "c8y-ui-empty-state", inputs: ["icon", "title", "subtitle", "horizontal"] }, { kind: "directive", type: IconDirective, selector: "[c8yIcon]", inputs: ["c8yIcon"] }, { kind: "component", type: EditorComponent, selector: "c8y-editor", inputs: ["editorOptions", "theme"], outputs: ["editorInit"] }, { kind: "pipe", type: AsyncPipe, name: "async" }, { kind: "pipe", type: C8yTranslatePipe, name: "translate" }, { kind: "pipe", type: MarkdownToHtmlPipe, name: "markdownToHtml" }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: MarkdownWidgetConfigComponent, decorators: [{ type: Component, args: [{ selector: 'c8y-markdown-widget-config', standalone: true, imports: [ ReactiveFormsModule, FormsModule, AsyncPipe, C8yTranslatePipe, MarkdownToHtmlPipe, FormGroupComponent, DropAreaComponent, EmptyStateComponent, IconDirective, EditorComponent ], template: "<fieldset class=\"c8y-fieldset\">\n <legend>{{ 'Source' | translate }}</legend>\n <form [formGroup]=\"formGroup\">\n <div class=\"form-group\">\n <label\n class=\"c8y-radio radio-inline\"\n title=\"{{ 'Write Markdown' | translate }}\"\n >\n <input\n name=\"uploadChoice\"\n type=\"radio\"\n [value]=\"MarkdownSourceType.WRITE\"\n formControlName=\"uploadChoice\"\n (change)=\"onChange(MarkdownSourceType.WRITE)\"\n />\n <span></span>\n <span>{{ 'Write Markdown' | translate }}</span>\n </label>\n <label\n class=\"c8y-radio radio-inline m-l-8\"\n title=\"{{ 'Upload a binary' | translate }}\"\n >\n <input\n name=\"uploadChoice\"\n type=\"radio\"\n [value]=\"MarkdownSourceType.UPLOAD\"\n formControlName=\"uploadChoice\"\n (change)=\"onChange(MarkdownSourceType.UPLOAD)\"\n />\n <span></span>\n <span>{{ 'Upload a binary' | translate }}</span>\n </label>\n <label\n class=\"c8y-radio radio-inline m-l-8\"\n title=\"{{ 'Provide a file path' | translate }}\"\n >\n <input\n name=\"uploadChoice\"\n type=\"radio\"\n [value]=\"MarkdownSourceType.URL\"\n formControlName=\"uploadChoice\"\n (change)=\"onChange(MarkdownSourceType.URL)\"\n />\n <span></span>\n <span>{{ 'Provide a file path' | translate }}</span>\n </label>\n </div>\n @switch (uploadChoice) {\n @case (MarkdownSourceType.WRITE) {\n <c8y-editor\n class=\"d-block\"\n style=\"height: 300px\"\n [ngModel]=\"editorContent\"\n (ngModelChange)=\"onEditorChange($event)\"\n [ngModelOptions]=\"{ standalone: true }\"\n [editorOptions]=\"{\n language: 'markdown',\n tabSize: 2,\n insertSpaces: true,\n minimap: { enabled: false },\n wordWrap: 'on'\n }\"\n ></c8y-editor>\n }\n @case (MarkdownSourceType.UPLOAD) {\n <c8y-form-group class=\"m-b-24\">\n <c8y-drop-area\n class=\"drop-area-sm\"\n [title]=\"'Drop file or click to browse' | translate\"\n formControlName=\"droppedFile\"\n [maxAllowedFiles]=\"1\"\n [accept]=\"'md'\"\n ></c8y-drop-area>\n </c8y-form-group>\n }\n @case (MarkdownSourceType.URL) {\n <c8y-form-group class=\"m-b-24\">\n <div class=\"input-group\">\n <span class=\"input-group-addon\">\n <i c8yIcon=\"globe\"></i>\n </span>\n <input\n class=\"form-control\"\n placeholder=\"{{ 'e.g.' | translate }} http://example.com/binary.zip\"\n type=\"text\"\n formControlName=\"contentUrl\"\n />\n </div>\n </c8y-form-group>\n }\n }\n </form>\n</fieldset>\n\n<ng-template #markdownPreview>\n @if (previewMarkdown$ | async; as previewMarkdown) {\n <div\n class=\"p-16 p-t-0 markdown-content fit-h overflow-auto\"\n [innerHTML]=\"previewMarkdown | markdownToHtml | async\"\n ></div>\n } @else {\n <div class=\"fit-h d-flex d-col j-c-center a-i-center\">\n <c8y-ui-empty-state\n [icon]=\"'file-text'\"\n [title]=\"'No content to preview' | translate\"\n [subtitle]=\"\n 'Write Markdown, upload a file, or provide a URL to see the preview' | translate\n \"\n [horizontal]=\"false\"\n ></c8y-ui-empty-state>\n </div>\n }\n</ng-template>\n" }] }], ctorParameters: () => [{ type: i1$1.FormBuilder }, { type: i1$1.NgForm }, { type: i1.AlertService }, { type: MarkdownWidgetService }], propDecorators: { config: [{ type: Input }], markdownPreviewTemplate: [{ type: ViewChild, args: ['markdownPreview'] }] } }); class MarkdownWidgetViewComponent { constructor(markdownWidgetService) { this.markdownWidgetService = markdownWidgetService; } async ngOnInit() { if (this.config.markdownContent) { this.markdown = this.config.markdownContent; } else if (this.config.markdownBinaryId) { const file = await this.markdownWidgetService.getFile(this.config.markdownBinaryId); this.markdown = await file?.text(); } else if (this.config.contentUrl) { this.markdown = await this.markdownWidgetService.getContentFromUrl(this.config.contentUrl); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: MarkdownWidgetViewComponent, deps: [{ token: MarkdownWidgetService }], target: i0.ɵɵFactoryTarget.Component }); } static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.19", type: MarkdownWidgetViewComponent, isStandalone: true, selector: "c8y-markdown-widget-view", inputs: { config: "config" }, ngImport: i0, template: "<div id=\"helpContent\" class=\"p-16 p-t-0 markdown-content\" [innerHTML]=\"markdown | markdownToHtml | async\"></div>\n", dependencies: [{ kind: "pipe", type: MarkdownToHtmlPipe, name: "markdownToHtml" }, { kind: "pipe", type: AsyncPipe, name: "async" }] }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.19", ngImport: i0, type: MarkdownWidgetViewComponent, decorators: [{ type: Component, args: [{ selector: 'c8y-markdown-widget-view', standalone: true, imports: [MarkdownToHtmlPipe, AsyncPipe], template: "<div id=\"helpContent\" class=\"p-16 p-t-0 markdown-content\" [innerHTML]=\"markdown | markdownToHtml | async\"></div>\n" }] }], ctorParameters: () => [{ type: MarkdownWidgetService }], propDecorators: { config: [{ type: Input }] } }); /** * Generated bundle index. Do not edit. */ export { MarkdownSourceType, MarkdownWidgetConfigComponent, MarkdownWidgetService, MarkdownWidgetViewComponent }; //# sourceMappingURL=c8y-ngx-components-widgets-implementations-markdown.mjs.map