Skip to content

Commit

Permalink
Add support for the file selector
Browse files Browse the repository at this point in the history
  • Loading branch information
balloob committed Aug 14, 2022
1 parent 38607a6 commit 130bdbf
Show file tree
Hide file tree
Showing 7 changed files with 261 additions and 0 deletions.
102 changes: 102 additions & 0 deletions src/components/ha-chip-picker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { mdiClose, mdiPlus } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import "./ha-chip-set";
import "./ha-chip";
import "./ha-svg-icon";
import { ensureArray } from "../common/ensure-array";

interface Chip {
icon: string;
label: string;
}

interface AddChipCallback {
label: string;
callback: () => void;
}

@customElement("ha-chip-picker")
export class HaChipPicker extends LitElement {
@property() public chips?: Chip | Chip[];

@property() public onRemove?: (chip: Chip) => void;

@property() public addChip?: AddChipCallback | AddChipCallback[];

@property({ type: Boolean, reflect: true }) public disabled = false;

@property() public helper?: string;

protected render() {
const chips: TemplateResult[] = [];

if (this.chips) {
const hasTrailingIcon = this.onRemove !== undefined;
for (const chip of ensureArray(this.chips)) {
chips.push(html`
<ha-chip hasIcon .hasTrailingIcon=${hasTrailingIcon}>
<ha-svg-icon .path=${chip.icon} slot="icon"></ha-svg-icon>
${chip.label}
${this.onRemove
? html`
<ha-svg-icon
class="action-icon"
.path=${mdiClose}
slot="trailing-icon"
@click=${
// eslint-disable-next-line lit/no-template-arrow
this.disabled ? undefined : () => this.onRemove!(chip)
}
></ha-svg-icon>
`
: ""}
</ha-chip>
`);
}
}

if (this.addChip) {
for (const addChip of ensureArray(this.addChip)) {
chips.push(html`
<ha-chip
@click=${
// eslint-disable-next-line lit/no-template-arrow
this.disabled ? undefined : () => addChip.callback()
}
hasIcon
>
<ha-svg-icon
class="action-icon"
.path=${mdiPlus}
slot="icon"
></ha-svg-icon>
${addChip.label}
</ha-chip>
`);
}
}

return html`
<ha-chip-set>${chips}</ha-chip-set>
${this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: ""}
`;
}

static get styles(): CSSResultGroup {
return css`
:host([disabled]) .action-icon {
cursor: default;
opacity: 0.5;
}
`;
}
}

declare global {
interface HTMLElementTagNameMap {
"ha-chip-picker": HaChipPicker;
}
}
1 change: 1 addition & 0 deletions src/components/ha-form/compute-initial-ha-form-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const computeInitialHaFormData = (
"text" in selector ||
"addon" in selector ||
"attribute" in selector ||
"file" in selector ||
"icon" in selector ||
"theme" in selector
) {
Expand Down
125 changes: 125 additions & 0 deletions src/components/ha-selector/ha-selector-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { mdiFile } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { LocalizeFunc } from "../../common/translations/localize";
import { removeFile, uploadFile } from "../../data/file_upload";
import { FileSelector } from "../../data/selector";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { HomeAssistant } from "../../types";
import "../ha-chip-picker";

@customElement("ha-selector-file")
export class HaFileSelector extends LitElement {
@property() public hass!: HomeAssistant;

@property() public selector!: FileSelector;

@property() public value?: string;

@property() public label?: string;

@property() public helper?: string;

@property({ type: Boolean, reflect: true }) public disabled = false;

@property({ type: Boolean }) public required = true;

@state() private _filename?: { fileId: string; name: string };

@state() private _busy = false;

private _getChip = memoizeOne((filename: string | undefined) => ({
label: filename || "unknown file",
icon: mdiFile,
}));

private _addChip = memoizeOne((localize: LocalizeFunc) => ({
// Temp
label: localize("ui.components.selectors.file.pick_file"),
callback: () => this._uploadFile(),
}));

protected render() {
return html`
<ha-chip-picker
.chips=${this.value ? this._getChip(this._filename?.name) : undefined}
.disabled=${this.disabled || this._busy}
.addChip=${this.value ? undefined : this._addChip(this.hass.localize)}
.onRemove=${this.value ? this._removeFile : undefined}
.helper=${this.helper}
></ha-chip-picker>
`;
}

protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (
changedProps.has("value") &&
this._filename &&
this.value !== this._filename.fileId
) {
this._filename = undefined;
}
}

private _uploadFile() {
const input = document.createElement("input");
input.type = "file";
input.addEventListener(
"change",
async () => {
this._busy = true;

const file = input.files![0];
document.body.removeChild(input);

try {
const fileId = await uploadFile(this.hass, file);
this._filename = { fileId, name: file.name };
fireEvent(this, "value-changed", { value: fileId });
} catch (err: any) {
showAlertDialog(this, {
text: this.hass.localize(
"ui.components.selectors.file.upload_failed",
{
reason: err.message || err,
}
),
});
} finally {
this._busy = false;
}
},
{ once: true }
);
// https://stackoverflow.com/questions/47664777/javascript-file-input-onchange-not-working-ios-safari-only
input.style.display = "none";
document.body.append(input);
input.click();
}

private _removeFile = async () => {
this._busy = true;
try {
await removeFile(this.hass, this.value!);
} catch (err) {
// Not ideal if removal fails, but will be cleaned up later
} finally {
this._busy = false;
}
this._filename = undefined;
fireEvent(this, "value-changed", { value: "" });
};

static get styles(): CSSResultGroup {
return css``;
}
}

declare global {
interface HTMLElementTagNameMap {
"ha-selector-file": HaFileSelector;
}
}
1 change: 1 addition & 0 deletions src/components/ha-selector/ha-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import "./ha-selector-datetime";
import "./ha-selector-device";
import "./ha-selector-duration";
import "./ha-selector-entity";
import "./ha-selector-file";
import "./ha-selector-number";
import "./ha-selector-object";
import "./ha-selector-select";
Expand Down
22 changes: 22 additions & 0 deletions src/data/file_upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { HomeAssistant } from "../types";

export const uploadFile = async (hass: HomeAssistant, file: File) => {
const fd = new FormData();
fd.append("file", file);
const resp = await hass.fetchWithAuth("/api/file_upload", {
method: "POST",
body: fd,
});
if (resp.status === 413) {
throw new Error(`Uploaded file is too large (${file.name})`);
} else if (resp.status !== 200) {
throw new Error("Unknown error");
}
const data = await resp.json();
return data.file_id;
};

export const removeFile = async (hass: HomeAssistant, file_id: string) =>
hass.callApi("DELETE", "file_upload", {
file_id,
});
6 changes: 6 additions & 0 deletions src/data/selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type Selector =
| DeviceSelector
| DurationSelector
| EntitySelector
| FileSelector
| IconSelector
| LocationSelector
| MediaSelector
Expand Down Expand Up @@ -120,6 +121,11 @@ export interface EntitySelector {
};
}

export interface FileSelector {
// eslint-disable-next-line @typescript-eslint/ban-types
file: {};
}

export interface IconSelector {
icon: {
placeholder?: string;
Expand Down
4 changes: 4 additions & 0 deletions src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,10 @@
"manual": "Manually enter Media ID",
"media_content_id": "Media content ID",
"media_content_type": "Media content type"
},
"file": {
"pick_file": "Select file",
"upload_failed": "Failed to upload file: {reason}"
}
},
"logbook": {
Expand Down

0 comments on commit 130bdbf

Please sign in to comment.