diff --git a/declarations.d.ts b/declarations.d.ts index c6593da..14f6120 100644 --- a/declarations.d.ts +++ b/declarations.d.ts @@ -1 +1,2 @@ /// +/// diff --git a/package-lock.json b/package-lock.json index 75793a2..7b5e172 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "source-pane", - "version": "3.1.1-4", + "version": "3.1.1-5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "source-pane", - "version": "3.1.1-4", + "version": "3.1.1-5", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.10.0", @@ -33,6 +33,7 @@ "vitest-axe": "^0.1.0" }, "devDependencies": { + "@iconify-json/lucide": "^1.2.116", "@types/mime-types": "^3.0.1", "@types/node": "^22.20.0", "@vitest/coverage-v8": "^4.0.18", @@ -2311,9 +2312,9 @@ } }, "node_modules/@digitalbazaar/http-client/node_modules/undici": { - "version": "6.27.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz", - "integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==", + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -2655,6 +2656,16 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@iconify-json/lucide": { + "version": "1.2.119", + "resolved": "https://registry.npmjs.org/@iconify-json/lucide/-/lucide-1.2.119.tgz", + "integrity": "sha512-KbrC7fT5wPshV1KLRM0k91poP0OH4eKEVywEOH0FCczKb+qth8Pcq1Hy9kS2tXeF52s23qzLnG8AJsExPSupeA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@iconify/types": "*" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", @@ -4189,9 +4200,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", - "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==", + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.3.tgz", + "integrity": "sha512-sbT0Ui/CZwyAyy7icT1Gw5P1LKRlFaHwaF6tDCW5YHq2X5SeeZFphBuIagopSfwSSZq3sQcbmEL072yphxm7ew==", "dev": true, "license": "Apache-2.0", "bin": { @@ -4212,15 +4223,15 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", + "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { @@ -4823,9 +4834,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.395", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", - "integrity": "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==", + "version": "1.5.396", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz", + "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==", "dev": true, "license": "ISC" }, @@ -8049,9 +8060,9 @@ } }, "node_modules/postcss": { - "version": "8.5.22", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", - "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -9420,9 +9431,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "devOptional": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 9bcfb70..0f32f6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "source-pane", - "version": "3.1.1-4", + "version": "3.1.1-5", "description": "Solid-compatible Panes: Source editor", "main": "dist/index.cjs.js", "exports": { @@ -55,6 +55,7 @@ }, "homepage": "https://github.com/SolidOS/source-pane", "devDependencies": { + "@iconify-json/lucide": "^1.2.116", "@types/mime-types": "^3.0.1", "@types/node": "^22.20.0", "@vitest/coverage-v8": "^4.0.18", diff --git a/src/Header.ts b/src/Header.ts deleted file mode 100644 index 7c6cb3d..0000000 --- a/src/Header.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { html } from 'lit' -import { ref } from 'lit/directives/ref.js' -import { NamedNode, LiveStore } from 'rdflib' -import { widgets, icons } from 'solid-ui' -import { SourcePaneState } from './types' -import type SourceEditorCard from './components/source-editor-card/SourceEditorCard' - -/* This we will use in the header ticket, didn't want to lose the code */ -export function canEditSource (subject: NamedNode, sourcePaneState: SourcePaneState) { - const { allowed } = sourcePaneState - return !subject.uri.endsWith('/') && (!allowed || allowed.includes('PUT')) -} - -function mountButton (host: HTMLElement, button: HTMLElement) { - host.replaceChildren(button) -} - -export function renderHeader (store: LiveStore, subject: NamedNode, sourcePaneState: SourcePaneState) { - - function setEditable (sourcePaneState: SourcePaneState) { - if (sourcePaneState.broken) return - const editorCard = document.querySelector('solid-panes-source-editor-card') as SourceEditorCard | null - editorCard?.updateEditingState(true) - editorCard?.setReadOnly(false) - editorCard?.focusEditor() - } - - return html` -
- { - if (host instanceof HTMLElement) { - mountButton(host, createEditButton(host.ownerDocument, () => setEditable(sourcePaneState))) - } - })}> -
- ` -} - -export function createEditButton (dom: Document, onClick?: EventListener) { - const myEditButton = widgets.button( - dom, - icons.iconBase + 'noun_253504.svg', - 'Edit' - ) - myEditButton.classList.add('sourcePaneEditButton') - if (onClick) { - myEditButton.addEventListener('click', onClick) - } - return myEditButton -} diff --git a/src/StatusSection.css b/src/StatusSection.css index f71b3a6..36adee4 100644 --- a/src/StatusSection.css +++ b/src/StatusSection.css @@ -6,14 +6,46 @@ .sourcePaneStatus[data-visible='true'] { display: block; + margin: 0; + padding: 12px; + background: var(--dialog-error-background, var(--pink-500, #ffdde6)); + border-radius: 5px; + border: 1px solid var(--dialog-error-border, var(--pink-700, #ff98b3)); +} + +#statusMessageArea { + display: flex; + align-items: center; + gap: 6px; } .error-text { margin: 0; - padding: 12px; - background-color: #fde8e8; - border: 1px solid #f8b4b4; - border-radius: 4px; - color: #9b1c1c; + padding: 10px 12px; + background-color: transparent; + border: 0; + border-radius: 0; + color: var(--solid-ui-color-error, #b00020); font-weight: 500; + flex: 1; +} + +.statusCloseButton { + flex: 0 0 auto; + margin-top: 0; + align-self: center; + width: 28px; + height: 28px; + border: 0; + border-radius: 5px; + background: transparent; + color: var(--solid-ui-color-error, #b00020); + font-weight: 600; + line-height: 1; + padding: 0; + cursor: pointer; +} + +.statusCloseButton:hover { + background: color-mix(in srgb, var(--solid-ui-color-error, #b00020) 10%, transparent); } diff --git a/src/StatusSection.ts b/src/StatusSection.ts index 1a96e81..1fede1b 100644 --- a/src/StatusSection.ts +++ b/src/StatusSection.ts @@ -39,7 +39,7 @@ export function getStatusSection(){ ` diff --git a/src/components/header/SourceHeader.styles.css b/src/components/header/SourceHeader.styles.css new file mode 100644 index 0000000..d75d59f --- /dev/null +++ b/src/components/header/SourceHeader.styles.css @@ -0,0 +1,12 @@ +:host { + header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 6px; + flex-wrap: nowrap; + width: 100%; + text-align: right; + padding: 15px; + } +} diff --git a/src/components/header/SourceHeader.ts b/src/components/header/SourceHeader.ts new file mode 100644 index 0000000..f3aee2a --- /dev/null +++ b/src/components/header/SourceHeader.ts @@ -0,0 +1,29 @@ +import { WebComponent } from 'solid-ui' +import { customElement } from 'lit/decorators.js' +import { html } from 'lit' +import 'solid-ui/components/button' +import { consume } from '@lit/context' +import { sourceContext, SourceContext } from '../../primitives/context' +import '~icons/lucide/share-2' +import '~icons/lucide/pencil' +import '~icons/lucide/ellipsis-vertical' +import styles from './SourceHeader.styles.css' +import './SourceHeaderSummary' +import './SourceHeaderControls' + +@customElement('source-pane-source-header') +export default class SourceHeader extends WebComponent { + static styles = styles + + @consume({ context: sourceContext, subscribe: true }) + accessor sourceContext: SourceContext = undefined as unknown as SourceContext + + render () { + return html` +
+ + +
+ ` + } +} diff --git a/src/components/header/SourceHeaderControls.styles.css b/src/components/header/SourceHeaderControls.styles.css new file mode 100644 index 0000000..c2316f9 --- /dev/null +++ b/src/components/header/SourceHeaderControls.styles.css @@ -0,0 +1,28 @@ +:host { + div { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 6px; + flex: 0 0 auto; + text-align: right; + } + + /* generative ai */ + div > * { + flex: 0 1 auto; + } + + @media (max-width: 900px) { + div { + padding-right: 3px; + } + } + + @media (max-width: 600px) { + div { + justify-content: flex-start; + gap: 5px; + } + } +} diff --git a/src/components/header/SourceHeaderControls.ts b/src/components/header/SourceHeaderControls.ts new file mode 100644 index 0000000..b82fc07 --- /dev/null +++ b/src/components/header/SourceHeaderControls.ts @@ -0,0 +1,49 @@ +import { WebComponent } from 'solid-ui' +import { customElement } from 'lit/decorators.js' +import { html } from 'lit' +import 'solid-ui/components/button' +import { consume } from '@lit/context' +import { sourceContext, SourceContext } from '../../primitives/context' +import '~icons/lucide/share-2' +import '~icons/lucide/pencil' +import '~icons/lucide/ellipsis-vertical' +import styles from './SourceHeaderControls.styles.css' +import './SourceHeaderSummary' + +@customElement('source-pane-source-header-controls') +export default class SourceHeaderControls extends WebComponent { + static styles = styles + + @consume({ context: sourceContext, subscribe: true }) + accessor sourceContext: SourceContext = undefined as unknown as SourceContext + + /* private canEditSource (subject: NamedNode, sourcePaneState: SourcePaneState) { + const { allowed } = sourcePaneState + return !subject.uri.endsWith('/') && (!allowed || allowed.includes('PUT')) + } */ + + private setEditable() { + const sourcePaneState = this.sourceContext?.sourcePaneState + const { canEdit } = this.sourceContext?.headerMetadata ?? { canEdit: false } + const subject = this.sourceContext?.subject + if (!sourcePaneState || !canEdit || sourcePaneState.broken || (subject && subject.endsWith('/'))) return + + this.sourceContext?.setEditing?.() + } + + render () { + return html` +
+ + + + this.setEditable()}> + + + + + +
+ ` + } +} diff --git a/src/components/header/SourceHeaderSummary.styles.css b/src/components/header/SourceHeaderSummary.styles.css new file mode 100644 index 0000000..98ea911 --- /dev/null +++ b/src/components/header/SourceHeaderSummary.styles.css @@ -0,0 +1,82 @@ + :host { + .source-pane-header-summary { + display: flex; + justify-content: flex-start; + align-items: center; + gap: 6px; + flex-wrap: wrap; + flex: 1 1 auto; + min-width: 0; + text-align: left; + } + + h1 { + color: var(--solid-ui-color-gray-700, #364153); + font-size: var(--solid-ui-font-size-2xl, 1.5rem); + font-weight: 500; + } + + .source-pane-icon { + display: flex; + width: 40px; + height: 40px; + padding: 7.5px 7px 6.5px 7px; + justify-content: center; + align-items: center; + aspect-ratio: 1/1; + border-radius: 100px; + background: var(--slate-600, #45556C); + } + + p { + display: flex; + align-items: center; + gap: 5px; + color: var(--solid-ui-gray-500, #6A7282); + font-size: var(--solid-ui-font-size-xs, .75rem); + font-weight: 500; + } + + .source-pane-public { + display: flex; + padding: 2px 5px; + justify-content: center; + align-items: center; + gap: 3px; + color: var(--blue-400, #51A2FF); + border-radius: 5px; + background: var(--blue-50, #EFF6FF); + } + + icon-lucide-globe { + stroke-width: 1.021px; + stroke: var(--blue-400, #51A2FF); + } + + .source-pane-private { + display: flex; + padding: 2px 5px; + justify-content: center; + align-items: center; + gap: 3px; + color: var(--yellow-600, #D08700); + border-radius: 5px; + background: var(--yellow-100, #FEF9C2); + } + + icon-lucide-lock-keyhole { + stroke-width: 1.021px; + stroke: var(--yellow-600, #D08700); + } + + icon-lucide-globe, + icon-lucide-lock-keyhole { + width: 0.875rem; + height: 0.875rem; + } + + icon-lucide-arrow-left { + width: 1.125rem; + height: 1.125rem; + } +} \ No newline at end of file diff --git a/src/components/header/SourceHeaderSummary.ts b/src/components/header/SourceHeaderSummary.ts new file mode 100644 index 0000000..54d80aa --- /dev/null +++ b/src/components/header/SourceHeaderSummary.ts @@ -0,0 +1,67 @@ +import { utils, WebComponent } from 'solid-ui' +import 'solid-ui/components/button' +import { customElement } from 'lit/decorators.js' +import { html } from 'lit' +import { consume } from '@lit/context' +import { sym } from 'rdflib' +import { sourceContext, SourceContext } from '../../primitives/context' +import '~icons/lucide/globe' +import '~icons/lucide/lock-keyhole' +import '~icons/lucide/arrow-left' +import styles from './SourceHeaderSummary.styles.css' +import { sourcePaneIcon } from '../../icons/sourcePaneIcon' + +@customElement('source-pane-source-header-summary') +export default class SourceHeaderSummary extends WebComponent { + static styles = styles + + @consume({ context: sourceContext, subscribe: true }) + accessor sourceContext: SourceContext = undefined as unknown as SourceContext + + private formatModifiedDate (modified: string | undefined) { + if (!modified) return '' + + const date = new Date(modified) + if (Number.isNaN(date.getTime())) return modified + + const parts = new Intl.DateTimeFormat('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true + }).formatToParts(date) + + const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find(part => part.type === type)?.value ?? '' + const day = getPart('day') + const month = getPart('month') + const year = getPart('year') + const hour = getPart('hour') + const minute = getPart('minute') + const dayPeriod = getPart('dayPeriod').toUpperCase() + + return `${day} ${month}, ${year} at ${hour}:${minute} ${dayPeriod}` + } + + render () { + const subject = this.sourceContext?.subject ? sym(this.sourceContext.subject) : undefined + const label = subject ? utils.label(subject) : '' + const modified = this.formatModifiedDate(this.sourceContext?.headerMetadata?.modified) + const isPublic = this.sourceContext?.headerMetadata?.isPublic ?? false + + return html` +
+ + + + ${sourcePaneIcon} +
+

${label}

+

${modified} ${isPublic ? html` Public` : html` Private`}

+
+
+ ` + } +} diff --git a/src/components/source-editor-card/SourceEditorCard.styles.css b/src/components/source-editor-card/SourceEditorCard.styles.css index 2d48d8c..59b48b8 100644 --- a/src/components/source-editor-card/SourceEditorCard.styles.css +++ b/src/components/source-editor-card/SourceEditorCard.styles.css @@ -8,6 +8,7 @@ flex-direction: column; width: 100%; align-items: stretch; + padding: 0 40px; } .sourcePaneEditor { diff --git a/src/components/source-editor-card/SourceEditorCard.ts b/src/components/source-editor-card/SourceEditorCard.ts index b833f33..10d7a10 100644 --- a/src/components/source-editor-card/SourceEditorCard.ts +++ b/src/components/source-editor-card/SourceEditorCard.ts @@ -2,26 +2,28 @@ import { html, nothing } from 'lit' import { consume } from '@lit/context' import { customElement, query, state } from 'lit/decorators.js' import { NamedNode, parse, serialize } from 'rdflib' +import { WebComponent } from 'solid-ui' +import 'solid-ui/components/button' import type { SourceEditor } from './SourceEditor' -import { applyResponseHeaders, checkSyntax, fetchContentAndMetadata, getResponseHeaders, happy } from '../../helpers' +import { checkSyntax, happy } from '../../helpers' import styles from './SourceEditorCard.styles.css' -import { WebComponent } from 'solid-ui' import { getStatusSection } from '../../StatusSection' -import 'solid-ui/components/button' import { compactable } from '../../compactableFormats' import { sourceContext, SourceContext } from '../../primitives/context' +import { getResponseMetadata } from '../../resourceLoader' -@customElement('solid-panes-source-editor-card') +@customElement('source-pane-source-editor-card') export default class SourceEditorCard extends WebComponent { static styles = styles private _editor?: SourceEditor private _originalContent?: string private _dirtyState = false - private _editingState = false + private _initializing = false @state() accessor _editorReady = false + @query('.sourcePaneEditor') accessor _editorMount: HTMLDivElement | null = null @@ -63,8 +65,7 @@ export default class SourceEditorCard extends WebComponent { } updateEditingState(editing: boolean) { - if (this._editingState === editing) return - this._editingState = editing + if (this.sourceContext?.sourcePaneState.editing === editing) return this.sourceContext?.updateSourcePaneState('editing', editing) } @@ -76,18 +77,19 @@ export default class SourceEditorCard extends WebComponent { } private async _initializeEditor () { - if (this._editor) return + if (this._editor || this._initializing) return + this._initializing = true const sourcePaneEditor = this._editorMount const sourceContext = this.sourceContext if (!sourcePaneEditor || !sourceContext) { + this._initializing = false return } try { const { SourceEditor } = await import('./SourceEditor') - const { content, metadata } = await fetchContentAndMetadata(sourceContext.context.session.store as any, new NamedNode(sourceContext.subject), sourceContext.sourcePaneState as any) - this._originalContent = content + this._originalContent = sourceContext.originalContent this._editor = new SourceEditor() - await this._editor.initialize(sourcePaneEditor, content, metadata.contentType, 'dark', dirty => { + await this._editor.initialize(sourcePaneEditor, sourceContext.originalContent ?? '', sourceContext.editorMetadata.contentType, 'dark', dirty => { this.updateDirtyState(dirty) }) this._editorReady = true @@ -95,6 +97,8 @@ export default class SourceEditorCard extends WebComponent { } catch (err) { const { showError } = getStatusSection() showError('Error fetching content: ' + err) + } finally { + this._initializing = false } } @@ -110,7 +114,7 @@ export default class SourceEditorCard extends WebComponent { } this._editorReady = false this._dirtyState = false - this._editingState = false + this._initializing = false } private cancelHandler () { @@ -128,10 +132,9 @@ export default class SourceEditorCard extends WebComponent { const store = sourceContext.context.session.store as any const subject = new NamedNode(sourceContext.subject) - const sourcePaneState = sourceContext.sourcePaneState const fetcher = store.fetcher const data = this.getEditor()?.getValue() ?? '' - const { contentType, eTag } = sourceContext.sourcePaneState + const { contentType, eTag } = sourceContext.editorMetadata if (!checkSyntax(store, subject as any, data, contentType, subject as any)) { const { showError } = getStatusSection() showError('Syntax error: fix the document before saving.') @@ -147,7 +150,8 @@ export default class SourceEditorCard extends WebComponent { try { const response = await fetcher.webOperation('HEAD', subject.uri) // , defaultFetchHeaders()) if (!happy(response, 'HEAD')) return - applyResponseHeaders(sourcePaneState as any, getResponseHeaders(store, subject as any, response)) + const metadata = getResponseMetadata(store, subject as any, response) + sourceContext.updateMetadata(metadata) this._resetEditorState() } catch (err) { throw err @@ -161,7 +165,7 @@ export default class SourceEditorCard extends WebComponent { private prettyHandler () { const sourceContext = this._requireSourceContext() - const { contentType } = sourceContext.sourcePaneState + const { contentType } = sourceContext.editorMetadata const compactContentType = contentType?.split(';')[0] const { showError } = getStatusSection() const store = sourceContext.context.session.store as any @@ -181,29 +185,39 @@ export default class SourceEditorCard extends WebComponent { } } } + + private renderFooter() { + const sourceContext = this._requireSourceContext() + const compactContentType = sourceContext.editorMetadata.contentType?.split(';')[0] + const showPrettyButton = !sourceContext.sourcePaneState.editing && !!compactContentType && compactable[compactContentType] + + if (sourceContext.sourcePaneState.editing) { + return html` +
+ Cancel + Save Changes +
+ ` + } + + if (showPrettyButton) { + return html` +
+ Prettify +
+ ` + } + + return nothing + } render() { - const sourceContext = this._requireSourceContext() const sectionClass = this._editorReady ? 'sourcePaneCard' : 'sourcePaneCard sourcePaneCardLoading' - const compactContentType = sourceContext.sourcePaneState.contentType?.split(';')[0] - const showPrettyButton = !this._editingState && !!compactContentType && compactable[compactContentType] - const prettyButton = showPrettyButton - ? html` - Prettify - ` - : nothing return html`
-
- ${this._editingState - ? html` - Cancel - Save Changes - ` - : prettyButton} -
+ ${this.renderFooter()}
` } diff --git a/src/components/source-provider/SourceProvider.styles.css b/src/components/source-provider/SourceProvider.styles.css new file mode 100644 index 0000000..67637b0 --- /dev/null +++ b/src/components/source-provider/SourceProvider.styles.css @@ -0,0 +1,12 @@ +:host { + display: flex; + flex-direction: column; + width: 100%; + align-self: stretch; + box-sizing: border-box; + padding-bottom: 39px; +} + +source-pane-source-header { + margin-bottom: 23px; +} diff --git a/src/components/source-provider/SourceProvider.ts b/src/components/source-provider/SourceProvider.ts index 6d1fc37..975cc71 100644 --- a/src/components/source-provider/SourceProvider.ts +++ b/src/components/source-provider/SourceProvider.ts @@ -1,27 +1,67 @@ import { html, type PropertyValues } from 'lit' import { provide } from '@lit/context' -import { customElement, property } from 'lit/decorators.js' +import { customElement, property, query, state } from 'lit/decorators.js' import { NamedNode } from 'rdflib' import { DataBrowserContext } from 'pane-registry' -import { renderHeader } from '../../Header' import { getStatusSection } from '../../StatusSection' import { WebComponent } from 'solid-ui' import { sourceContext, SourceContext } from '../../primitives/context' -import { SourcePaneState } from '../../types' - +import { SourcePaneState, HeaderMetadata, EditorMetadata, ResourceMetadata } from '../../types' +import type SourceEditorCard from '../source-editor-card/SourceEditorCard' +import '../header/SourceHeader' +import { fetchContentAndMetadata } from '../../resourceLoader' +import styles from './SourceProvider.styles.css' void import('../source-editor-card/SourceEditorCard').then(() => undefined) -const defaultSourcePaneState: SourcePaneState = { - broken: false, - dirty: false, - editing: false, - allowed: undefined, - contentType: undefined, - eTag: undefined +function createDefaultSourcePaneState(): SourcePaneState { + return { + broken: false, + dirty: false, + editing: false + } +} + +function createDefaultHeaderMetadata(): HeaderMetadata { + return { + canEdit: false, + isPublic: false, + modified: undefined + } +} + +function createDefaultEditorMetadata(): EditorMetadata { + return { + contentType: undefined, + eTag: undefined + } } +function createSourceContextValue(input: { + context: DataBrowserContext | undefined + subject: NamedNode | undefined + originalContent: string | undefined + sourcePaneState: SourcePaneState + headerMetadata: HeaderMetadata + editorMetadata: EditorMetadata + updateSourcePaneState: SourceProvider['updateSourcePaneState'] + updateMetadata: SourceProvider['updateEditorMetadata'] + setEditing: SourceProvider['setEditing'] +}): SourceContext { + return { + context: input.context as DataBrowserContext, + subject: input.subject?.uri ?? '', + originalContent: input.originalContent, + sourcePaneState: input.sourcePaneState, + headerMetadata: input.headerMetadata, + editorMetadata: input.editorMetadata, + updateSourcePaneState: input.updateSourcePaneState, + updateMetadata: input.updateMetadata, + setEditing: input.setEditing, + } +} @customElement('source-pane-source-provider') export default class SourceProvider extends WebComponent { + static styles = styles @property({ attribute: false }) accessor context: DataBrowserContext | undefined = undefined @@ -29,38 +69,69 @@ export default class SourceProvider extends WebComponent { @property({ attribute: false }) accessor subject: NamedNode | undefined = undefined - @property({ attribute: false }) - accessor sourcePaneState: SourcePaneState = defaultSourcePaneState + @state() + accessor originalContent: string | undefined = undefined - @provide({ context: sourceContext }) - accessor sourceContextValue: SourceContext = { - context: undefined as unknown as DataBrowserContext, - subject: '', - sourcePaneState: defaultSourcePaneState, - updateSourcePaneState: () => {}, - } + @state() + accessor dataLoaded = false - // need this while we are using document.querySelector - // and rendering plain HTML children. Can remove later when all - // code is refactored to use context and components. - createRenderRoot () { - return this - } + @state() + accessor sourcePaneState: SourcePaneState = createDefaultSourcePaneState() - private _requireContext () { - if (!this.context) { - throw new Error('The element is missing the required `context` property.') - } + @state() + accessor headerMetadata: HeaderMetadata = createDefaultHeaderMetadata() + + @state() + accessor editorMetadata: EditorMetadata = createDefaultEditorMetadata() - return this.context - } - private _requireSubject () { - if (!this.subject) { - throw new Error('The element is missing the required `subject` property.') + @provide({ context: sourceContext }) + accessor sourceContextValue: SourceContext = createSourceContextValue({ + context: undefined, + subject: undefined, + originalContent: undefined, + sourcePaneState: createDefaultSourcePaneState(), + headerMetadata: createDefaultHeaderMetadata(), + editorMetadata: createDefaultEditorMetadata(), + updateSourcePaneState: () => {}, + updateMetadata: () => {}, + setEditing: () => {}, + }) + + @query('source-pane-source-editor-card') + private accessor editorCard: SourceEditorCard | null = null + + private loadContentAndMetadata = async () => { + try { + if (!this.context) { + throw new Error('The element is missing the required `context` property.') + } + if (!this.subject) { + throw new Error('The element is missing the required `subject` property.') + } + + const { content, metadata } = await fetchContentAndMetadata(this.context.session.store as any, this.subject) + this.originalContent = content + this.updateEditorMetadata(metadata) + this.dataLoaded = true + } catch (error: any) { + const { showError } = getStatusSection() + showError(error.message) } + } - return this.subject + private refreshSourceContextValue() { + this.sourceContextValue = createSourceContextValue({ + context: this.context, + subject: this.subject, + originalContent: this.originalContent, + sourcePaneState: this.sourcePaneState, + headerMetadata: this.headerMetadata, + editorMetadata: this.editorMetadata, + updateSourcePaneState: this.updateSourcePaneState, + updateMetadata: this.updateEditorMetadata, + setEditing: this.setEditing, + }) } updateSourcePaneState = (key: K, value: SourcePaneState[K]) => { @@ -70,30 +141,46 @@ export default class SourceProvider extends WebComponent { } } - protected override willUpdate (changedProperties: PropertyValues) { - super.willUpdate(changedProperties) + updateEditorMetadata = (metadata: ResourceMetadata) => { + this.editorMetadata = { + contentType: metadata.contentType, + eTag: metadata.eTag + } + this.headerMetadata = { + canEdit: metadata.canEdit, + isPublic: metadata.isPublic, + modified: metadata.modified + } + } - const context = this._requireContext() - const subject = this._requireSubject() + setEditing = () => { + this.updateSourcePaneState('editing', true) + this.editorCard?.updateEditingState(true) + this.editorCard?.setReadOnly(false) + this.editorCard?.focusEditor() + } - this.sourceContextValue = { - context, - subject: subject.uri, - sourcePaneState: this.sourcePaneState, - updateSourcePaneState: this.updateSourcePaneState, + protected async firstUpdated() { + await this.loadContentAndMetadata() + } + + protected willUpdate (changedProperties: PropertyValues) { + super.willUpdate(changedProperties) + if (!this.context) { + throw new Error('The element is missing the required `context` property.') } + + this.refreshSourceContextValue() } render() { - const context = this._requireContext() - const subject = this._requireSubject() - const store = context.session.store - const { renderStatusSection } = getStatusSection() - return html` - ${renderHeader(store as any, subject as any, this.sourcePaneState)} - - ${renderStatusSection()} + ${this.dataLoaded + ? html` + + + ` + : html``} ` } } diff --git a/src/helpers.ts b/src/helpers.ts index a4c5385..aa78938 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,8 +1,6 @@ import { LiveStore, NamedNode, parse, serialize } from 'rdflib' import { getStatusSection } from './StatusSection' -import { error, log } from './debug' -import { ns } from 'solid-ui' -import { HttpResourceMetadata, SourcePaneState } from './types' +import { error } from './debug' const parseable: Record = { 'text/n3': true, @@ -131,69 +129,3 @@ export function setControlVisible (button: HTMLElement | null, visible: boolean) button.classList.toggle('sourcePaneControlVisible', visible) button.classList.toggle('sourcePaneControlHidden', !visible) } - -export function applyResponseHeaders (sourcePaneState: SourcePaneState, metadata: HttpResourceMetadata) { - sourcePaneState.contentType = metadata.contentType - sourcePaneState.allowed = metadata.allowed - sourcePaneState.eTag = metadata.eTag -} - -export function getResponseHeaders (store: LiveStore, subject: NamedNode, response: Response): HttpResourceMetadata { - let contentType: string | undefined - let allowed: string | undefined - let eTag: string | undefined - if (response.headers && response.headers.get('content-type')) { - contentType = response.headers.get('content-type')?.split(';')[0] ?? undefined // Should work but headers may be empty - allowed = response.headers.get('allow') ?? undefined // const cts = store.fetcher.getHeader(subject.doc(), 'content-type') - eTag = response.headers.get('etag') ?? undefined - } else { - const reqs = store.each( - null, - store.sym('http://www.w3.org/2007/ont/link#requestedURI'), - subject - ) - reqs.forEach((req: any) => { - const rrr = store.any( - req as any, - store.sym('http://www.w3.org/2007/ont/link#response') - ) - if (rrr && rrr.termType === 'NamedNode') { - contentType = store.anyValue(rrr as any, ns.httph('content-type')) || undefined - allowed = store.anyValue(rrr as any, ns.httph('allow')) || undefined - eTag = store.anyValue(rrr as any, ns.httph('etag')) || undefined - if (!eTag) log('sourcePane: No eTag on GET') - } - }) - } - return { contentType, allowed, eTag } -} - -export async function fetchContentAndMetadata(store: LiveStore, subject: NamedNode, sourcePaneState: SourcePaneState): Promise<{ content: string, metadata: HttpResourceMetadata }> { - const fetcher = store.fetcher - const { showError } = getStatusSection() - - try { - const response = await fetcher.webOperation('GET', subject.uri) - if (!happy(response, 'GET')) { - throw new Error('GET request failed') - } - - const content = (response as Response & { responseText?: string }).responseText - if (content === undefined) { // Defensive https://github.com/linkeddata/rdflib.js/issues/506 - throw new Error('source pane: No text in response object!!') - } - - const metadata = getResponseHeaders(store, subject, response) - if (!metadata.contentType) { - throw new Error('Error: No content-type available!') - } - applyResponseHeaders(sourcePaneState, metadata) - if (!metadata.allowed) { - error('@@@@@@@@@@ No Allow: header from this server') - } - return { content, metadata } - } catch (err: any) { - showError('Error reading file: ' + err) - throw err - } -} diff --git a/src/icons/sourcePaneIcon.ts b/src/icons/sourcePaneIcon.ts new file mode 100644 index 0000000..103c714 --- /dev/null +++ b/src/icons/sourcePaneIcon.ts @@ -0,0 +1,8 @@ +import { html } from 'lit' + +export const sourcePaneIcon = html` + + + + +` diff --git a/src/primitives/context.ts b/src/primitives/context.ts index 3ce007d..ef8a543 100644 --- a/src/primitives/context.ts +++ b/src/primitives/context.ts @@ -1,12 +1,17 @@ import { DataBrowserContext } from 'pane-registry' import { createContext } from '@lit/context' -import { SourcePaneState } from '../types' +import { EditorMetadata, HeaderMetadata, SourcePaneState, ResourceMetadata } from '../types' export interface SourceContext { context: DataBrowserContext, readonly subject: string, + originalContent: string | undefined, sourcePaneState: SourcePaneState, - updateSourcePaneState: (key: K, value: SourcePaneState[K]) => void + headerMetadata: HeaderMetadata, + editorMetadata: EditorMetadata, + updateSourcePaneState: (key: K, value: SourcePaneState[K]) => void, + updateMetadata: (metadata: ResourceMetadata) => void, + setEditing: () => void } export const sourceContext = createContext(Symbol('source')) diff --git a/src/resourceLoader.ts b/src/resourceLoader.ts new file mode 100644 index 0000000..7be6792 --- /dev/null +++ b/src/resourceLoader.ts @@ -0,0 +1,104 @@ +import { ns } from 'solid-ui' +import { log } from './debug' +import { ResourceMetadata } from './types' +import { LiveStore, NamedNode } from 'rdflib' +import { happy } from './helpers' + +function parseWacAllowHeader (headerValue: string | null | undefined) { + const permissions = new Map>() + if (!headerValue) return permissions + + for (const entry of headerValue.split(',')) { + const match = entry.trim().match(/^([A-Za-z]+)\s*=\s*"([^"]*)"$/) + if (match) { + const [, permissionGroup, accessModes] = match + const modes = accessModes.trim().split(/\s+/).filter(Boolean) + permissions.set(permissionGroup.toLowerCase(), new Set(modes.map(mode => mode.toLowerCase()))) + } + } + + return permissions +} + +function deriveAccessFlags (wacAllow: string | null | undefined) { + if (!wacAllow) { + log('deriveAccessFlags: No WAC-Allow header found.') + return { canEdit: false, isPublic: false } + } + + const permissions = parseWacAllowHeader(wacAllow) + const userModes = permissions.get('user') ?? new Set() + const publicModes = permissions.get('public') ?? new Set() + + return { + canEdit: userModes.has('write'), + isPublic: publicModes.has('read') || publicModes.has('write') + } +} + +export function getResponseMetadata (store: LiveStore, subject: NamedNode, response: Response): ResourceMetadata { + let contentType: string | undefined + let canEdit = false + let isPublic = false + let eTag: string | undefined + let modified: string | undefined + + if (response.headers && response.headers.get('content-type')) { + contentType = response.headers.get('content-type')?.split(';')[0] ?? undefined // Should work but headers may be empty + const accessFlags = deriveAccessFlags(response.headers.get('wac-allow')) + + canEdit = accessFlags.canEdit + isPublic = accessFlags.isPublic + eTag = response.headers.get('etag') ?? undefined + modified = store.anyValue(subject as any, ns.dct('modified')) || store.anyValue(subject as any, ns.dc('modified')) || undefined + } else { + const reqs = store.each( + null, + store.sym('http://www.w3.org/2007/ont/link#requestedURI'), + subject + ) + reqs.forEach((req: any) => { + const responseNode = store.any( + req as any, + store.sym('http://www.w3.org/2007/ont/link#response') + ) + if (responseNode && responseNode.termType === 'NamedNode') { + contentType = store.anyValue(responseNode as any, ns.httph('content-type')) || undefined + const wacAllow = (store.anyValue(responseNode as any, ns.httph('wac-allow')) as string | undefined) + || (store.anyValue(responseNode as any, ns.httph('WAC-Allow')) as string | undefined) + const accessFlags = deriveAccessFlags(wacAllow) + canEdit = accessFlags.canEdit + isPublic = accessFlags.isPublic + eTag = store.anyValue(responseNode as any, ns.httph('etag')) || undefined + modified = store.anyValue(subject as any, ns.dct('modified')) || store.anyValue(subject as any, ns.dc('modified')) || undefined + if (!eTag) log('sourcePane: No eTag on GET') + } + }) + } + return { contentType, canEdit, isPublic, eTag, modified } as ResourceMetadata +} + +export async function fetchContentAndMetadata(store: LiveStore, subject: NamedNode): Promise<{ content: string, metadata: ResourceMetadata }> { + const fetcher = store.fetcher + + try { + const response = await fetcher.webOperation('GET', subject.uri) + if (!happy(response, 'GET')) { + throw new Error('GET request failed') + } + + const content = (response as Response & { responseText?: string }).responseText + if (content === undefined) { // Defensive https://github.com/linkeddata/rdflib.js/issues/506 + throw new Error('source pane: No text in response object!!') + } + + const metadata = getResponseMetadata(store, subject, response) + if (!metadata.contentType) { + throw new Error('Error: No content-type available!') + } + return { content, metadata } + } catch (error: any) { + throw new Error(`Error reading file: ${error.message}`) + } + +} diff --git a/src/sourcePane.css b/src/sourcePane.css index 9dc6a3d..9aacd53 100644 --- a/src/sourcePane.css +++ b/src/sourcePane.css @@ -5,40 +5,11 @@ border: 1px solid var(--color-border, var(--gray-200, #E5E7EB)); } -.sourcePaneControls { - display: flex; - justify-content: flex-end; - align-items: center; - gap: 6px; - flex-wrap: wrap; - width: 100%; - text-align: right; - padding: 0 3px 16px 0; -} -/* generative ai */ -.sourcePaneControls > * { - flex: 0 1 auto; -} - -.sourcePaneHeader, .sourcePaneCard, .sourcePaneStatus { width: 100%; } -/* This code is here because currently the shadow DOM isn't -working just yet because I need to implement the header as a web component first -once I do that I can move this to the host css of the source-provider */ -source-pane-source-provider { - display: flex; - flex-direction: column; - width: 100%; - align-self: stretch; - box-sizing: border-box; - padding: 20px 20px 29px; - gap: 23px; -} - .sourcePaneCompactButton { box-sizing: border-box; display: inline-flex; @@ -51,10 +22,6 @@ source-pane-source-provider { } @media (max-width: 900px) { - .sourcePaneControls { - padding-right: 3px; - } - .sourcePaneTextArea { margin: 12px 2px; padding: 12px; @@ -62,11 +29,6 @@ source-pane-source-provider { } @media (max-width: 600px) { - .sourcePaneControls { - justify-content: flex-start; - gap: 5px; - } - .sourcePaneCompactButton { min-width: 0; } @@ -75,11 +37,3 @@ source-pane-source-provider { .sourcePaneTextAreaError { color: var(--solid-ui-color-error, #b00020); } - -.sourcePaneControlVisible { - display: block; -} - -.sourcePaneControlHidden { - display: none; -} diff --git a/src/sourcePane.ts b/src/sourcePane.ts index 8d717b2..da1697e 100644 --- a/src/sourcePane.ts +++ b/src/sourcePane.ts @@ -9,9 +9,9 @@ import { DataBrowserContext, NewPaneOptions } from 'pane-registry' import * as mime from 'mime-types' import { html, render as litRender } from 'lit' import { log } from './debug' +import { getStatusSection } from './StatusSection' import './sourcePane.css' import './components/source-provider/SourceProvider' -import { SourcePaneState } from './types' const pane = { icon: icons.iconBase + 'noun_109873.svg', // noun_109873_51A7F9.svg @@ -84,24 +84,16 @@ const pane = { }, render: function (subject: NamedNode, context: DataBrowserContext) { - const sourcePaneState: SourcePaneState = { - broken: false, - dirty: false, - editing: false, - allowed: undefined, - contentType: undefined, - eTag: undefined - } - + const { renderStatusSection } = getStatusSection() const sourcePane = context.dom.createElement('div') sourcePane.setAttribute('class', 'sourcePane') litRender(html` + ${renderStatusSection()} `, sourcePane) return sourcePane diff --git a/src/types.ts b/src/types.ts index e4ea537..136eaff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,16 @@ -export type HttpResourceMetadata = { +export type ResourceMetadata = { contentType: string | undefined - allowed: string | undefined + canEdit: boolean + isPublic: boolean eTag: string | undefined + modified: string | undefined } +export type HeaderMetadata = Pick +export type EditorMetadata = Pick + export type SourcePaneState = { broken: boolean dirty: boolean editing: boolean -} & HttpResourceMetadata +} diff --git a/test/helpers.test.js b/test/helpers.test.js index 617cbdc..49d9689 100644 --- a/test/helpers.test.js +++ b/test/helpers.test.js @@ -1,8 +1,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { fetchContentAndMetadata, getResponseHeaders } from '../src/helpers.ts' +import { fetchContentAndMetadata, getResponseMetadata } from '../src/resourceLoader.ts' -describe('helpers', () => { +describe('resourceLoader', () => { beforeEach(() => { vi.clearAllMocks() }) @@ -12,19 +12,27 @@ describe('helpers', () => { headers: { get: vi.fn((name) => { if (name === 'content-type') return 'text/turtle; charset=utf-8' - if (name === 'allow') return 'GET,PUT' + if (name === 'wac-allow') return 'user="write", public="read"' if (name === 'etag') return '"abc"' return null }) } } - const store = { each: vi.fn(), any: vi.fn(), anyValue: vi.fn(), sym: vi.fn() } + const store = { + each: vi.fn(), + any: vi.fn(), + anyValue: vi.fn(() => undefined), + sym: vi.fn() + } const subject = { uri: 'https://example.org/profile/card' } - expect(getResponseHeaders(store, subject, response)).toEqual({ + expect(getResponseMetadata(store, subject, response)).toEqual({ contentType: 'text/turtle', - allowed: 'GET,PUT', + canEdit: true, + isPublic: true, eTag: '"abc"' + , + modified: undefined }) }) @@ -34,7 +42,7 @@ describe('helpers', () => { headers: { get: vi.fn((name) => { if (name === 'content-type') return 'text/turtle' - if (name === 'allow') return 'GET,PUT' + if (name === 'wac-allow') return 'user="write", public="read"' if (name === 'etag') return '"abc"' return null }) @@ -47,27 +55,22 @@ describe('helpers', () => { }, each: vi.fn(), any: vi.fn(), - anyValue: vi.fn(), + anyValue: vi.fn(() => undefined), sym: vi.fn() } const subject = { uri: 'https://example.org/profile/card' } - const sourcePaneState = { broken: false, contentType: undefined, allowed: undefined, eTag: undefined } - const result = await fetchContentAndMetadata(store, subject, sourcePaneState) + const result = await fetchContentAndMetadata(store, subject) expect(result).toEqual({ content: '<> a <#Thing>.', metadata: { contentType: 'text/turtle', - allowed: 'GET,PUT', - eTag: '"abc"' + canEdit: true, + isPublic: true, + eTag: '"abc"', + modified: undefined } }) - expect(sourcePaneState).toEqual({ - broken: false, - contentType: 'text/turtle', - allowed: 'GET,PUT', - eTag: '"abc"' - }) }) }) diff --git a/test/sourceEditorCard.test.js b/test/sourceEditorCard.test.js index c50268c..f0e4155 100644 --- a/test/sourceEditorCard.test.js +++ b/test/sourceEditorCard.test.js @@ -2,7 +2,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const fetchContentAndMetadata = vi.fn() -vi.mock('../src/helpers.ts', () => ({ +vi.mock('../src/resourceMetadata.ts', () => ({ fetchContentAndMetadata, })) diff --git a/test/sourcePane.test.js b/test/sourcePane.test.js index f0dcb80..747dc67 100644 --- a/test/sourcePane.test.js +++ b/test/sourcePane.test.js @@ -1,35 +1,27 @@ -import { render } from 'lit' import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' -let renderHeader +let sourcePane beforeAll(async () => { - class MockSourceEditorCard extends HTMLElement { - constructor () { - super() - this.updateEditingState = vi.fn() - this.setReadOnly = vi.fn() - this.focusEditor = vi.fn() - } - } - - if (!globalThis.customElements.get('solid-panes-source-editor-card')) { - globalThis.customElements.define('solid-panes-source-editor-card', MockSourceEditorCard) - } + await import('../src/components/source-provider/SourceProvider.ts') + await import('../src/components/header/SourceHeader.ts') + sourcePane = (await import('../src/sourcePane.ts')).default - const headerModule = await import('../src/Header.ts') - renderHeader = headerModule.renderHeader }) -function renderHeaderIntoDocument (sourcePaneState) { +function renderPaneIntoDocument () { const container = document.createElement('div') document.body.appendChild(container) const store = { findTypeURIs: vi.fn(() => ({})) } const subject = { uri: 'https://janedoe.example/test.ttl' } - render(renderHeader(store, subject, sourcePaneState), container) - - return { container, subject, store } + const context = { + dom: document, + session: { store } + } + const rendered = sourcePane.render(subject, context) + container.appendChild(rendered) + return { container, subject, store, rendered } } describe('source-pane', () => { @@ -38,42 +30,12 @@ describe('source-pane', () => { vi.clearAllMocks() }) - it('renders the header edit control', async () => { - const sourcePaneState = { - broken: false, - editing: false, - readonly: false, - allowed: undefined, - contentType: undefined, - eTag: undefined - } - - const { container } = renderHeaderIntoDocument(sourcePaneState) - await Promise.resolve() - - expect(container.querySelector('header.sourcePaneHeader')).not.toBeNull() - expect(container.querySelector('.sourcePaneEditButton')).not.toBeNull() - }) - - it('activates the editor when edit is clicked', async () => { - const sourcePaneState = { - broken: false, - editing: false, - readonly: false, - allowed: undefined, - contentType: undefined, - eTag: undefined - } - - const { container } = renderHeaderIntoDocument(sourcePaneState) - const editorCard = document.createElement('solid-panes-source-editor-card') - document.body.appendChild(editorCard) - + it('renders the source provider wrapper', async () => { + const { rendered } = renderPaneIntoDocument() await Promise.resolve() - container.querySelector('.sourcePaneEditButton').click() - expect(editorCard.updateEditingState).toHaveBeenCalledWith(true) - expect(editorCard.setReadOnly).toHaveBeenCalledWith(false) - expect(editorCard.focusEditor).toHaveBeenCalled() + expect(rendered).not.toBeNull() + expect(rendered.className).toBe('sourcePane') + expect(rendered.getAttribute('class')).toBe('sourcePane') }) })