feat: import and export element (E6)
- golem15-seo-transfer: export by dataset with a Blob download, the import file read in the browser and refused over 60 KB, the preview table (refused first, 200 rows, scrolling wrapper), Apply gated on refused and empty previews, a stale preview kept with the message - en and pl copy; assets/dist rebuilt
This commit is contained in:
@@ -5,6 +5,7 @@ import { ROBOTSTESTER_TAG, SeoRobotsTesterElement } from './robotstester'
|
||||
import { ROBOTSTXT_TAG, SeoRobotsTxtElement } from './robotstxt'
|
||||
import { SERP_TAG, SeoSerpElement } from './serp'
|
||||
import { SeoTextElement, TEXT_TAG } from './text'
|
||||
import { SeoTransferElement, TRANSFER_TAG } from './transfer'
|
||||
|
||||
// The admin SPA loads this script once on every admin page (the SEO field
|
||||
// types list it in Scripts); defining a tag twice would throw, so a second
|
||||
@@ -16,6 +17,7 @@ const elements: Array<[string, CustomElementConstructor]> = [
|
||||
[IMAGE_TAG, SeoImageElement],
|
||||
[ROBOTSTXT_TAG, SeoRobotsTxtElement],
|
||||
[ROBOTSTESTER_TAG, SeoRobotsTesterElement],
|
||||
[TRANSFER_TAG, SeoTransferElement],
|
||||
]
|
||||
for (const [tag, ctor] of elements) {
|
||||
if (!customElements.get(tag)) {
|
||||
@@ -35,5 +37,7 @@ export {
|
||||
SeoRobotsTxtElement,
|
||||
SeoSerpElement,
|
||||
SeoTextElement,
|
||||
SeoTransferElement,
|
||||
TEXT_TAG,
|
||||
TRANSFER_TAG,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Element copy of the SEO field elements (UI-SPEC copywriting E1, E2, E3,
|
||||
// E4 and E5, plus E7 for the social image), chosen by the ui-locale
|
||||
// attribute. Plural
|
||||
// Element copy of the SEO field elements (UI-SPEC copywriting E1 to E6,
|
||||
// plus E7 for the social image), chosen by the ui-locale attribute. Plural
|
||||
// texts are CLDR maps; placeholders use :name.
|
||||
|
||||
/** A CLDR plural map: one/other in English, one/few/many/other in Polish. */
|
||||
@@ -101,6 +100,37 @@ export interface Labels {
|
||||
unsaved: string
|
||||
stagingNote: string
|
||||
urlRequired: string
|
||||
// E6
|
||||
exportHeading: string
|
||||
importHeading: string
|
||||
dataset: string
|
||||
dsRedirects: string
|
||||
dsRules: string
|
||||
dsRecords: string
|
||||
download: string
|
||||
chooseFile: string
|
||||
noFile: string
|
||||
summary: string
|
||||
bNew: string
|
||||
bChanged: string
|
||||
bUnchanged: string
|
||||
bRefused: string
|
||||
bWarning: string
|
||||
colLine: string
|
||||
colChange: string
|
||||
colAddress: string
|
||||
colField: string
|
||||
colBefore: string
|
||||
colAfter: string
|
||||
colNote: string
|
||||
rowCap: string
|
||||
refusedBlocks: string
|
||||
nothing: string
|
||||
discard: string
|
||||
fileTooLarge: string
|
||||
readFailed: string
|
||||
openHistory: string
|
||||
applyCount: Plural
|
||||
}
|
||||
|
||||
const en: Labels = {
|
||||
@@ -187,6 +217,36 @@ const en: Labels = {
|
||||
unsaved: 'Tests the text in the editor and the AI bot switches, including unsaved changes.',
|
||||
stagingNote: 'On this environment every address is blocked. The result shows the production behaviour.',
|
||||
urlRequired: 'Enter an address starting with /.',
|
||||
exportHeading: 'Export',
|
||||
importHeading: 'Import',
|
||||
dataset: 'Data',
|
||||
dsRedirects: 'Redirects',
|
||||
dsRules: 'URL rules',
|
||||
dsRecords: 'SEO of records',
|
||||
download: 'Download CSV',
|
||||
chooseFile: 'Choose CSV file',
|
||||
noFile: 'No file chosen',
|
||||
summary: ':new new, :changed changed, :unchanged unchanged, :refused refused',
|
||||
bNew: 'New',
|
||||
bChanged: 'Changed',
|
||||
bUnchanged: 'Unchanged',
|
||||
bRefused: 'Refused',
|
||||
bWarning: 'Warning',
|
||||
colLine: 'Line',
|
||||
colChange: 'Change',
|
||||
colAddress: 'Address',
|
||||
colField: 'Field',
|
||||
colBefore: 'Before',
|
||||
colAfter: 'After',
|
||||
colNote: 'Note',
|
||||
rowCap: 'Showing the first 200 of :count rows. All rows are applied.',
|
||||
refusedBlocks: 'Fix the refused rows in the file and preview it again. Nothing is applied while a row is refused.',
|
||||
nothing: 'The file changes nothing.',
|
||||
discard: 'Discard preview',
|
||||
fileTooLarge: 'This file is larger than :limit. Split it into smaller files, or import it through MCP.',
|
||||
readFailed: 'The file could not be read.',
|
||||
openHistory: 'Open History',
|
||||
applyCount: { one: 'Apply :count change', other: 'Apply :count changes' },
|
||||
}
|
||||
|
||||
const pl: Labels = {
|
||||
@@ -283,6 +343,36 @@ const pl: Labels = {
|
||||
unsaved: 'Sprawdza tekst w edytorze i przełączniki botów AI, także niezapisane zmiany.',
|
||||
stagingNote: 'W tym środowisku każdy adres jest zablokowany. Wynik pokazuje zachowanie na produkcji.',
|
||||
urlRequired: 'Wpisz adres zaczynający się od /.',
|
||||
exportHeading: 'Eksport',
|
||||
importHeading: 'Import',
|
||||
dataset: 'Dane',
|
||||
dsRedirects: 'Przekierowania',
|
||||
dsRules: 'Reguły adresów',
|
||||
dsRecords: 'SEO rekordów',
|
||||
download: 'Pobierz CSV',
|
||||
chooseFile: 'Wybierz plik CSV',
|
||||
noFile: 'Nie wybrano pliku',
|
||||
summary: 'Nowe: :new, zmienione: :changed, bez zmian: :unchanged, odrzucone: :refused',
|
||||
bNew: 'Nowy',
|
||||
bChanged: 'Zmieniony',
|
||||
bUnchanged: 'Bez zmian',
|
||||
bRefused: 'Odrzucony',
|
||||
bWarning: 'Ostrzeżenie',
|
||||
colLine: 'Wiersz',
|
||||
colChange: 'Zmiana',
|
||||
colAddress: 'Adres',
|
||||
colField: 'Pole',
|
||||
colBefore: 'Przed',
|
||||
colAfter: 'Po',
|
||||
colNote: 'Uwagi',
|
||||
rowCap: 'Pokazano pierwsze 200 z :count wierszy. Zastosowane zostaną wszystkie.',
|
||||
refusedBlocks: 'Popraw odrzucone wiersze w pliku i ponownie wyświetl podgląd. Nic nie zostanie zastosowane, dopóki jakiś wiersz jest odrzucony.',
|
||||
nothing: 'Plik niczego nie zmienia.',
|
||||
discard: 'Odrzuć podgląd',
|
||||
fileTooLarge: 'Ten plik jest większy niż :limit. Podziel go na mniejsze pliki lub zaimportuj przez MCP.',
|
||||
readFailed: 'Nie udało się odczytać pliku.',
|
||||
openHistory: 'Otwórz Historię zmian',
|
||||
applyCount: { one: 'Zastosuj :count zmianę', few: 'Zastosuj :count zmiany', many: 'Zastosuj :count zmian', other: 'Zastosuj :count zmiany' },
|
||||
}
|
||||
|
||||
const dictionaries: Record<string, Labels> = { en, pl }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* SEO field elements (UI-SPEC C4, C5, C6, C10, C14). Light DOM, every rule
|
||||
/* SEO field elements (UI-SPEC C4, C5, C6, C10, C13, C14). Light DOM, every rule
|
||||
scoped under its element tag, colours from the public --c-* variables
|
||||
only (except the E-1 mimic palette inside golem15-seo-serp .serp-card). */
|
||||
|
||||
@@ -7,7 +7,8 @@ golem15-seo-robots,
|
||||
golem15-seo-serp,
|
||||
golem15-seo-image,
|
||||
golem15-seo-robotstxt,
|
||||
golem15-seo-robotstester {
|
||||
golem15-seo-robotstester,
|
||||
golem15-seo-transfer {
|
||||
display: block;
|
||||
color: var(--c-text);
|
||||
font-size: 14px;
|
||||
@@ -623,6 +624,190 @@ golem15-seo-robotstester .seo-robotstester__note {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* E6 golem15-seo-transfer */
|
||||
golem15-seo-transfer .seo-transfer__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__section--import {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--c-border);
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__heading {
|
||||
margin: 0;
|
||||
color: var(--c-text);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__label {
|
||||
color: var(--c-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__select {
|
||||
box-sizing: border-box;
|
||||
min-width: 240px;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
transition: border-color 150ms, box-shadow 150ms;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__select:focus {
|
||||
outline: none;
|
||||
border-color: var(--c-primary);
|
||||
box-shadow: 0 0 0 3px var(--c-ring);
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__button {
|
||||
height: 42px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: 10px;
|
||||
background: var(--c-surface);
|
||||
color: var(--c-text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__button--ghost {
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__button--primary {
|
||||
border-color: var(--c-primary);
|
||||
background: var(--c-primary);
|
||||
color: var(--c-on-primary, var(--c-surface));
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--c-ring);
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__filename {
|
||||
align-self: center;
|
||||
color: var(--c-text);
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__filename--none {
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__error {
|
||||
margin: 0;
|
||||
color: var(--c-danger);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__note,
|
||||
golem15-seo-transfer .seo-transfer__done {
|
||||
margin: 0;
|
||||
color: var(--c-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__link {
|
||||
color: var(--c-primary);
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__summary {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__table-wrap {
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__mono {
|
||||
font-family: 'DM Mono', ui-monospace, monospace;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
margin-right: 4px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__badge--new {
|
||||
background: var(--c-ok-bg);
|
||||
color: var(--c-ok-text);
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__badge--changed,
|
||||
golem15-seo-transfer .seo-transfer__badge--warning {
|
||||
background: var(--c-sel);
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__badge--unchanged {
|
||||
background: var(--c-subtle);
|
||||
color: var(--c-muted);
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__badge--refused {
|
||||
background: var(--c-danger-soft);
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
golem15-seo-transfer .seo-transfer__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
golem15-seo-robots .seo-robots__panel {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -637,6 +822,7 @@ golem15-seo-robotstester .seo-robotstester__note {
|
||||
golem15-seo-robotstxt .seo-robotstxt__control,
|
||||
golem15-seo-robotstester .seo-robotstester__input,
|
||||
golem15-seo-robotstester .seo-robotstester__select,
|
||||
golem15-seo-transfer .seo-transfer__select,
|
||||
golem15-seo-text .seo-text__control,
|
||||
golem15-seo-robots .seo-robots__segment,
|
||||
golem15-seo-serp .seo-serp__segment {
|
||||
|
||||
382
assets/src/elements/transfer.ts
Normal file
382
assets/src/elements/transfer.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { el, emit, fmt, labelsFor, num, plural, uid, uiLocaleOf, type Labels } from './labels'
|
||||
|
||||
export const TRANSFER_TAG = 'golem15-seo-transfer'
|
||||
|
||||
/** The largest file the screen previews (UI-SPEC Q-8 assumption). */
|
||||
export const IMPORT_LIMIT = 60 * 1024
|
||||
|
||||
/** The preview rows the screen shows. */
|
||||
export const ROW_CAP = 200
|
||||
|
||||
type Dataset = 'redirects' | 'rules' | 'records'
|
||||
|
||||
/** One preview row of the seo_transfer action. */
|
||||
export interface PreviewRow {
|
||||
line: number
|
||||
change: 'new' | 'changed' | 'unchanged' | 'refused'
|
||||
address: string
|
||||
field: string
|
||||
before: string
|
||||
after: string
|
||||
note: string
|
||||
warning?: boolean
|
||||
}
|
||||
|
||||
/** The preview answer. */
|
||||
export interface PreviewData {
|
||||
op: 'preview'
|
||||
token: string
|
||||
dataset: Dataset
|
||||
summary: { new: number; changed: number; unchanged: number; refused: number; warning: number }
|
||||
rows: PreviewRow[]
|
||||
total: number
|
||||
count: number
|
||||
}
|
||||
|
||||
/** Every answer of the action: an op's data or an op's refusal. */
|
||||
type Answer =
|
||||
| { op: 'export'; filename: string; csv: string; error?: undefined }
|
||||
| (PreviewData & { error?: undefined })
|
||||
| { op: 'apply'; applied: boolean; count: number; batch_id: string; error?: undefined }
|
||||
| { op: 'export' | 'preview' | 'apply'; error: string; message: string }
|
||||
|
||||
type Payload =
|
||||
| { op: 'export'; dataset: Dataset }
|
||||
| { op: 'preview'; dataset: Dataset; csv: string }
|
||||
| { op: 'apply'; token: string }
|
||||
|
||||
/** The apply button's state: why it is disabled, if it is. */
|
||||
export function applyState(p: PreviewData): 'refused' | 'nothing' | 'ready' {
|
||||
if (p.summary.refused > 0) {
|
||||
return 'refused'
|
||||
}
|
||||
return p.count > 0 ? 'ready' : 'nothing'
|
||||
}
|
||||
|
||||
/** The rows the table shows: refused first, at most ROW_CAP. */
|
||||
export function shownRows(rows: PreviewRow[]): PreviewRow[] {
|
||||
const refused = rows.filter((r) => r.change === 'refused')
|
||||
const others = rows.filter((r) => r.change !== 'refused')
|
||||
return [...refused, ...others].slice(0, ROW_CAP)
|
||||
}
|
||||
|
||||
/** Saves text as a file through a Blob link. */
|
||||
function download(filename: string, text: string): void {
|
||||
const blob = new Blob([text], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
a.rel = 'noopener'
|
||||
document.body.append(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0)
|
||||
}
|
||||
|
||||
/** The History list of the admin, next to this screen's route. */
|
||||
function historyHref(): string {
|
||||
const path = window.location.pathname
|
||||
return path.replace(/\/transfer(\/.*)?$/, '/history')
|
||||
}
|
||||
|
||||
/**
|
||||
* E6 golem15-seo-transfer (UI-SPEC C13): an export section (dataset and
|
||||
* Download CSV) and an import section (dataset, the file, Preview import,
|
||||
* the preview table and Apply). Every server call is the seo_transfer
|
||||
* action through the widget; the file is read in the browser and refused
|
||||
* over 60 KB before the action is called. Apply is enabled only after a
|
||||
* fresh preview with no refused row and at least one change; a stale
|
||||
* preview keeps the table and shows the server's message above the
|
||||
* actions.
|
||||
*/
|
||||
export class SeoTransferElement extends HTMLElement {
|
||||
static observedAttributes = ['busy', 'state', 'ui-locale', 'disabled', 'label', 'busy-label']
|
||||
|
||||
private connected = false
|
||||
private pending: Payload['op'] | null = null
|
||||
private exportDataset: Dataset = 'redirects'
|
||||
private importDataset: Dataset = 'redirects'
|
||||
private fileName = ''
|
||||
private csv = ''
|
||||
private fileError = ''
|
||||
private importError = ''
|
||||
private exportError = ''
|
||||
private applyError = ''
|
||||
private preview: PreviewData | null = null
|
||||
private applied = false
|
||||
|
||||
private readonly onResult = (ev: Event): void => {
|
||||
const detail = (ev as CustomEvent).detail as { data?: unknown } | undefined
|
||||
const data = detail?.data as Answer | undefined
|
||||
this.pending = null
|
||||
if (!data) {
|
||||
this.render()
|
||||
return
|
||||
}
|
||||
if (data.error !== undefined) {
|
||||
if (data.op === 'export') {
|
||||
this.exportError = data.message
|
||||
} else if (data.op === 'preview') {
|
||||
this.importError = data.message
|
||||
this.preview = null
|
||||
} else {
|
||||
this.applyError = data.message
|
||||
}
|
||||
} else if (data.op === 'export') {
|
||||
this.exportError = ''
|
||||
download(data.filename, data.csv)
|
||||
} else if (data.op === 'preview') {
|
||||
this.importError = ''
|
||||
this.applyError = ''
|
||||
this.applied = false
|
||||
this.preview = data
|
||||
} else if (data.op === 'apply') {
|
||||
this.preview = null
|
||||
this.applyError = ''
|
||||
this.applied = true
|
||||
}
|
||||
this.render()
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
this.connected = true
|
||||
this.addEventListener('summer-result', this.onResult)
|
||||
this.render()
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
this.connected = false
|
||||
this.removeEventListener('summer-result', this.onResult)
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
|
||||
if (!this.connected) {
|
||||
return
|
||||
}
|
||||
if (name === 'state' && value === 'error') {
|
||||
this.pending = null
|
||||
}
|
||||
if (name === 'busy' && value === null) {
|
||||
this.pending = null
|
||||
}
|
||||
this.render()
|
||||
}
|
||||
|
||||
private labels(): Labels {
|
||||
return labelsFor(uiLocaleOf(this))
|
||||
}
|
||||
|
||||
private request(payload: Payload): void {
|
||||
if (this.hasAttribute('disabled') || this.hasAttribute('busy') || this.pending) {
|
||||
return
|
||||
}
|
||||
this.pending = payload.op
|
||||
emit(this, 'summer-action', { payload })
|
||||
this.render()
|
||||
}
|
||||
|
||||
private busyLabel(op: Payload['op'], idle: string): string {
|
||||
if (this.pending === op) {
|
||||
return this.getAttribute('busy-label') || idle
|
||||
}
|
||||
return idle
|
||||
}
|
||||
|
||||
private datasetSelect(labels: Labels, value: Dataset, onChange: (v: Dataset) => void): HTMLDivElement {
|
||||
const wrap = el('div', 'seo-transfer__field')
|
||||
const id = uid('seo-transfer-dataset')
|
||||
const label = el('label', 'seo-transfer__label', labels.dataset)
|
||||
label.htmlFor = id
|
||||
const select = el('select', 'seo-transfer__select')
|
||||
select.id = id
|
||||
const options: Array<[Dataset, string]> = [
|
||||
['redirects', labels.dsRedirects],
|
||||
['rules', labels.dsRules],
|
||||
['records', labels.dsRecords],
|
||||
]
|
||||
for (const [v, text] of options) {
|
||||
const option = el('option', '', text)
|
||||
option.value = v
|
||||
select.append(option)
|
||||
}
|
||||
select.value = value
|
||||
select.disabled = this.hasAttribute('disabled')
|
||||
select.addEventListener('change', () => onChange(select.value as Dataset))
|
||||
wrap.append(label, select)
|
||||
return wrap
|
||||
}
|
||||
|
||||
private button(text: string, kind: 'outline' | 'ghost' | 'primary', onClick: () => void): HTMLButtonElement {
|
||||
const b = el('button', 'seo-transfer__button seo-transfer__button--' + kind, text)
|
||||
b.type = 'button'
|
||||
b.addEventListener('click', onClick)
|
||||
return b
|
||||
}
|
||||
|
||||
private message(text: string, className: string): HTMLParagraphElement {
|
||||
const p = el('p', className, text)
|
||||
p.setAttribute('role', 'status')
|
||||
return p
|
||||
}
|
||||
|
||||
private async chooseFile(input: HTMLInputElement): Promise<void> {
|
||||
const file = input.files?.[0]
|
||||
this.preview = null
|
||||
this.applied = false
|
||||
this.importError = ''
|
||||
this.applyError = ''
|
||||
this.csv = ''
|
||||
if (!file) {
|
||||
this.fileName = ''
|
||||
this.fileError = ''
|
||||
this.render()
|
||||
return
|
||||
}
|
||||
this.fileName = file.name
|
||||
if (file.size > IMPORT_LIMIT) {
|
||||
this.fileError = fmt(this.labels().fileTooLarge, { limit: num(IMPORT_LIMIT / 1024, uiLocaleOf(this)) + ' KB' })
|
||||
this.render()
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.csv = await file.text()
|
||||
this.fileError = ''
|
||||
} catch {
|
||||
this.fileError = this.labels().readFailed
|
||||
}
|
||||
this.render()
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const labels = this.labels()
|
||||
const locale = uiLocaleOf(this)
|
||||
const disabled = this.hasAttribute('disabled')
|
||||
const busy = this.hasAttribute('busy') || this.pending !== null
|
||||
const actionLabel = this.getAttribute('label') || labels.chooseFile
|
||||
|
||||
// Export.
|
||||
const exportSection = el('section', 'seo-transfer__section')
|
||||
exportSection.append(el('h3', 'seo-transfer__heading', labels.exportHeading))
|
||||
const exportRow = el('div', 'seo-transfer__row')
|
||||
exportRow.append(this.datasetSelect(labels, this.exportDataset, (v) => (this.exportDataset = v)))
|
||||
const downloadButton = this.button(this.busyLabel('export', labels.download), 'outline', () => this.request({ op: 'export', dataset: this.exportDataset }))
|
||||
downloadButton.disabled = busy
|
||||
downloadButton.hidden = disabled
|
||||
exportRow.append(downloadButton)
|
||||
exportSection.append(exportRow)
|
||||
if (this.exportError) {
|
||||
exportSection.append(this.message(this.exportError, 'seo-transfer__error'))
|
||||
}
|
||||
|
||||
// Import.
|
||||
const importSection = el('section', 'seo-transfer__section seo-transfer__section--import')
|
||||
importSection.append(el('h3', 'seo-transfer__heading', labels.importHeading))
|
||||
const importRow = el('div', 'seo-transfer__row')
|
||||
importRow.append(
|
||||
this.datasetSelect(labels, this.importDataset, (v) => {
|
||||
this.importDataset = v
|
||||
this.preview = null
|
||||
this.render()
|
||||
}),
|
||||
)
|
||||
const input = el('input', 'seo-transfer__file')
|
||||
input.type = 'file'
|
||||
input.accept = '.csv,text/csv'
|
||||
input.hidden = true
|
||||
input.addEventListener('change', () => void this.chooseFile(input))
|
||||
const choose = this.button(labels.chooseFile, 'outline', () => input.click())
|
||||
choose.hidden = disabled
|
||||
choose.disabled = busy
|
||||
const name = el('span', this.fileName ? 'seo-transfer__filename' : 'seo-transfer__filename seo-transfer__filename--none', this.fileName || labels.noFile)
|
||||
const previewButton = this.button(this.busyLabel('preview', actionLabel), 'outline', () =>
|
||||
this.request({ op: 'preview', dataset: this.importDataset, csv: this.csv }),
|
||||
)
|
||||
previewButton.disabled = busy || this.csv === '' || this.fileError !== ''
|
||||
previewButton.hidden = disabled
|
||||
importRow.append(input, choose, name, previewButton)
|
||||
importSection.append(importRow)
|
||||
if (this.fileError || this.importError) {
|
||||
importSection.append(this.message(this.fileError || this.importError, 'seo-transfer__error'))
|
||||
}
|
||||
if (this.applied) {
|
||||
const done = el('p', 'seo-transfer__done')
|
||||
const link = el('a', 'seo-transfer__link', labels.openHistory)
|
||||
link.href = historyHref()
|
||||
done.append(link)
|
||||
importSection.append(done)
|
||||
}
|
||||
if (this.preview) {
|
||||
importSection.append(this.renderPreview(labels, locale, busy, disabled))
|
||||
}
|
||||
this.replaceChildren(exportSection, importSection)
|
||||
}
|
||||
|
||||
private renderPreview(labels: Labels, locale: string, busy: boolean, disabled: boolean): HTMLDivElement {
|
||||
const p = this.preview as PreviewData
|
||||
const box = el('div', 'seo-transfer__preview')
|
||||
const s = p.summary
|
||||
box.append(
|
||||
this.message(
|
||||
fmt(labels.summary, { new: num(s.new, locale), changed: num(s.changed, locale), unchanged: num(s.unchanged, locale), refused: num(s.refused, locale) }),
|
||||
'seo-transfer__summary',
|
||||
),
|
||||
)
|
||||
const wrap = el('div', 'summer-table-wrap seo-transfer__table-wrap')
|
||||
const table = el('table', 'summer-table seo-transfer__table')
|
||||
const head = el('tr')
|
||||
for (const text of [labels.colLine, labels.colChange, labels.colAddress, labels.colField, labels.colBefore, labels.colAfter, labels.colNote]) {
|
||||
head.append(el('th', '', text))
|
||||
}
|
||||
const thead = el('thead')
|
||||
thead.append(head)
|
||||
const tbody = el('tbody')
|
||||
const badge: Record<PreviewRow['change'], string> = { new: labels.bNew, changed: labels.bChanged, unchanged: labels.bUnchanged, refused: labels.bRefused }
|
||||
for (const row of shownRows(p.rows)) {
|
||||
const tr = el('tr', 'seo-transfer__tr--' + row.change)
|
||||
tr.append(el('td', 'seo-transfer__num', num(row.line, locale)))
|
||||
const change = el('td')
|
||||
change.append(el('span', 'seo-transfer__badge seo-transfer__badge--' + row.change, badge[row.change]))
|
||||
if (row.warning) {
|
||||
change.append(el('span', 'seo-transfer__badge seo-transfer__badge--warning', labels.bWarning))
|
||||
}
|
||||
tr.append(change)
|
||||
tr.append(el('td', 'seo-transfer__mono', row.address))
|
||||
tr.append(el('td', '', row.field))
|
||||
tr.append(el('td', '', row.before))
|
||||
tr.append(el('td', '', row.after))
|
||||
tr.append(el('td', '', row.note))
|
||||
tbody.append(tr)
|
||||
}
|
||||
table.append(thead, tbody)
|
||||
wrap.append(table)
|
||||
box.append(wrap)
|
||||
if (p.total > ROW_CAP || p.rows.length > ROW_CAP) {
|
||||
box.append(el('p', 'seo-transfer__note', fmt(labels.rowCap, { count: num(Math.max(p.total, p.rows.length), locale) })))
|
||||
}
|
||||
const state = applyState(p)
|
||||
if (state === 'refused') {
|
||||
box.append(this.message(labels.refusedBlocks, 'seo-transfer__error'))
|
||||
} else if (state === 'nothing') {
|
||||
box.append(this.message(labels.nothing, 'seo-transfer__note'))
|
||||
}
|
||||
if (this.applyError) {
|
||||
box.append(this.message(this.applyError, 'seo-transfer__error'))
|
||||
}
|
||||
const actions = el('div', 'seo-transfer__actions')
|
||||
const discard = this.button(labels.discard, 'ghost', () => {
|
||||
this.preview = null
|
||||
this.applyError = ''
|
||||
this.render()
|
||||
})
|
||||
discard.disabled = busy
|
||||
const apply = this.button(this.busyLabel('apply', plural(labels.applyCount, p.count, locale)), 'primary', () =>
|
||||
this.request({ op: 'apply', token: p.token }),
|
||||
)
|
||||
apply.disabled = busy || state !== 'ready'
|
||||
actions.append(discard, apply)
|
||||
actions.hidden = disabled
|
||||
box.append(actions)
|
||||
return box
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user