mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 20:03:52 +00:00
feat: UI access for custom views (#2020)
* style: consistent casing on menu * refactor: bundle demo from code * feat: allow uploading custom views
This commit is contained in:
@@ -7,6 +7,7 @@ export const APP_SERVER_PORT = ['appServerPort'];
|
||||
export const APP_VERSION = ['appVersion'];
|
||||
export const AUTOMATION = ['automation'];
|
||||
export const CUSTOM_FIELDS = ['customFields'];
|
||||
export const CUSTOM_VIEWS = ['customViews'];
|
||||
export const PROJECT_DATA = ['project'];
|
||||
export const PROJECT_LIST = ['projectList'];
|
||||
export const PROJECT_RUNDOWNS = ['projectRundowns'];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import axios from 'axios';
|
||||
import { type CustomViewsListResponse, type MessageResponse } from 'ontime-types';
|
||||
|
||||
import { apiEntryUrl } from './constants';
|
||||
import type { RequestOptions } from './requestOptions';
|
||||
import { axiosConfig } from './requestTimeouts';
|
||||
import { downloadBlob } from './utils';
|
||||
|
||||
const customViewsPath = `${apiEntryUrl}/custom-views`;
|
||||
|
||||
export async function getCustomViews(options?: RequestOptions): Promise<CustomViewsListResponse> {
|
||||
const response =
|
||||
await axios.get<CustomViewsListResponse>(customViewsPath, {
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeout ?? axiosConfig.shortTimeout,
|
||||
})
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function uploadCustomView(slug: string, file: File, options?: RequestOptions): Promise<MessageResponse> {
|
||||
const formData = new FormData();
|
||||
formData.append('indexHtml', file);
|
||||
|
||||
return (
|
||||
await axios.post<MessageResponse>(`${customViewsPath}/${encodeURIComponent(slug)}/upload`, formData, {
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeout ?? axiosConfig.longTimeout,
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
export async function downloadCustomView(slug: string, options?: RequestOptions): Promise<void> {
|
||||
const response = await axios.get(`${customViewsPath}/${encodeURIComponent(slug)}/download`, {
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeout ?? axiosConfig.longTimeout,
|
||||
responseType: 'blob',
|
||||
});
|
||||
|
||||
downloadBlob(response.data, `${slug}-index.html`);
|
||||
}
|
||||
|
||||
export async function restoreDemoView(options?: RequestOptions): Promise<MessageResponse> {
|
||||
return (
|
||||
await axios.post<MessageResponse>(`${customViewsPath}/restore-demo`, null, {
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeout ?? axiosConfig.longTimeout,
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
export async function deleteCustomView(slug: string, options?: RequestOptions): Promise<void> {
|
||||
await axios.delete(`${customViewsPath}/${encodeURIComponent(slug)}`, {
|
||||
signal: options?.signal,
|
||||
timeout: options?.timeout ?? axiosConfig.longTimeout,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { type CustomViewsListResponse } from 'ontime-types';
|
||||
|
||||
import { queryRefetchIntervalSlow } from '../../ontimeConfig';
|
||||
import { CUSTOM_VIEWS } from '../api/constants';
|
||||
import { getCustomViews } from '../api/customViews';
|
||||
|
||||
const placeholderCustomViews: CustomViewsListResponse = {
|
||||
views: [],
|
||||
};
|
||||
|
||||
export default function useCustomViews() {
|
||||
const { data, status, refetch } = useQuery({
|
||||
queryKey: CUSTOM_VIEWS,
|
||||
queryFn: ({ signal }) => getCustomViews({ signal }),
|
||||
placeholderData: (previousData, _previousQuery) => previousData,
|
||||
refetchInterval: queryRefetchIntervalSlow,
|
||||
});
|
||||
|
||||
return { data: data ?? placeholderCustomViews, status, refetch };
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ChangeEvent, FormEvent, useMemo, useRef, useState } from 'react';
|
||||
import { IoCloudUploadOutline } from 'react-icons/io5';
|
||||
|
||||
import { uploadCustomView } from '../../../../common/api/customViews';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Input from '../../../../common/components/input/input/Input';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { getFileError, getSlugError, getViewUrl, maxUploadLabel } from './customViews.utils';
|
||||
|
||||
import style from './CustomViews.module.scss';
|
||||
|
||||
interface CustomViewFormProps {
|
||||
onComplete: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CustomViewForm({ onComplete, onClose }: CustomViewFormProps) {
|
||||
const [slug, setSlug] = useState('');
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [slugDirty, setSlugDirty] = useState(false);
|
||||
const [fileDirty, setFileDirty] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const normalisedSlug = useMemo(() => slug.trim().toLowerCase(), [slug]);
|
||||
const previewUrl = getViewUrl(normalisedSlug);
|
||||
const slugError = useMemo(() => getSlugError(normalisedSlug), [normalisedSlug]);
|
||||
const fileError = useMemo(() => getFileError(selectedFile), [selectedFile]);
|
||||
const canUpload = Boolean(normalisedSlug && selectedFile) && !slugError && !fileError && !isUploading;
|
||||
|
||||
const handleSelectFile = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
setSelectedFile(event.target.files?.[0] ?? null);
|
||||
setFileDirty(true);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleUpload = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!selectedFile || slugError || fileError) return;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setError(null);
|
||||
await uploadCustomView(normalisedSlug, selectedFile);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
setError(maybeAxiosError(err));
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Indent as='form' onSubmit={handleUpload} className={style.uploadForm}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
type='file'
|
||||
onChange={handleSelectFile}
|
||||
accept='.html,text/html'
|
||||
/>
|
||||
|
||||
<div className={style.step}>
|
||||
<div className={style.stepTitle}>1. Choose a name</div>
|
||||
<Panel.Description>Name</Panel.Description>
|
||||
<Input
|
||||
value={slug}
|
||||
onChange={(event) => {
|
||||
setSlug(event.target.value);
|
||||
setSlugDirty(true);
|
||||
}}
|
||||
placeholder='my-view'
|
||||
aria-label='Custom view name'
|
||||
autoCapitalize='off'
|
||||
autoComplete='off'
|
||||
fluid
|
||||
/>
|
||||
<Panel.Description>
|
||||
Use lowercase letters, numbers, and dashes. Example: <Panel.Highlight>my-view</Panel.Highlight>
|
||||
</Panel.Description>
|
||||
<Panel.Description>
|
||||
Preview URL: <Panel.Highlight>{previewUrl}</Panel.Highlight>
|
||||
</Panel.Description>
|
||||
{slugDirty && slugError && <Panel.Error>{slugError}</Panel.Error>}
|
||||
</div>
|
||||
|
||||
<div className={style.step}>
|
||||
<div className={style.stepTitle}>2. Select index.html</div>
|
||||
<Panel.Description>Upload file</Panel.Description>
|
||||
<Panel.InlineElements wrap='wrap' className={style.filePicker}>
|
||||
<Button onClick={() => fileInputRef.current?.click()}>
|
||||
{selectedFile ? 'Replace index.html' : 'Choose index.html'}
|
||||
</Button>
|
||||
<span className={style.fileName}>
|
||||
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : 'No file selected'}
|
||||
</span>
|
||||
</Panel.InlineElements>
|
||||
<Panel.Description>Accepted: index.html only, maximum {maxUploadLabel}.</Panel.Description>
|
||||
{fileDirty && fileError && <Panel.Error>{fileError}</Panel.Error>}
|
||||
</div>
|
||||
|
||||
{error && <Panel.Error>{error}</Panel.Error>}
|
||||
|
||||
<Panel.InlineElements align='end'>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant='primary' type='submit' loading={isUploading} disabled={!canUpload}>
|
||||
Upload view <IoCloudUploadOutline />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.Indent>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.uploadForm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stepTitle {
|
||||
font-weight: 600;
|
||||
color: $gray-200;
|
||||
}
|
||||
|
||||
.filePicker {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
.fileName {
|
||||
color: $gray-300;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
.missing {
|
||||
color: $warning-orange;
|
||||
}
|
||||
|
||||
.urlCell {
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $gray-300;
|
||||
user-select: all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actionsHeader {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actionsCell {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.actionsGroup {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from 'react';
|
||||
import { IoAdd } from 'react-icons/io5';
|
||||
|
||||
import { restoreDemoView } from '../../../../common/api/customViews';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import Button from '../../../../common/components/buttons/Button';
|
||||
import Info from '../../../../common/components/info/Info';
|
||||
import ExternalLink from '../../../../common/components/link/external-link/ExternalLink';
|
||||
import useCustomViews from '../../../../common/hooks-query/useCustomViews';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import CustomViewForm from './CustomViewForm';
|
||||
import CustomViewsList from './CustomViewsList';
|
||||
import { customViewsDocs } from './customViews.utils';
|
||||
|
||||
export default function CustomViews() {
|
||||
const { data, refetch, status } = useCustomViews();
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const hasDemoView = data.views.some((view) => view.slug === 'demo');
|
||||
|
||||
const handleUploadComplete = async () => {
|
||||
setIsUploadOpen(false);
|
||||
await refetch();
|
||||
};
|
||||
|
||||
const handleRestoreDemo = async () => {
|
||||
try {
|
||||
setActionError(null);
|
||||
await restoreDemoView();
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
setActionError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel.Section>
|
||||
<Panel.Card>
|
||||
<Panel.SubHeader>
|
||||
Custom views
|
||||
<Panel.InlineElements>
|
||||
<Button variant='ghosted' onClick={handleRestoreDemo} disabled={hasDemoView}>
|
||||
Restore demo
|
||||
</Button>
|
||||
<Button onClick={() => setIsUploadOpen(true)} disabled={isUploadOpen}>
|
||||
New <IoAdd />
|
||||
</Button>
|
||||
</Panel.InlineElements>
|
||||
</Panel.SubHeader>
|
||||
<Panel.Divider />
|
||||
|
||||
<Panel.Section>
|
||||
<Info>
|
||||
Upload one <strong>index.html</strong> per view to <strong>/external/<name>/</strong>.
|
||||
<br />
|
||||
External imports are not allowed, include all assets inside the html file.
|
||||
<ExternalLink href={customViewsDocs}>See the docs</ExternalLink>
|
||||
</Info>
|
||||
</Panel.Section>
|
||||
|
||||
<Panel.Section>
|
||||
<Panel.Loader isLoading={status === 'pending'} />
|
||||
|
||||
{isUploadOpen && <CustomViewForm onComplete={handleUploadComplete} onClose={() => setIsUploadOpen(false)} />}
|
||||
|
||||
{actionError && <Panel.Error>{actionError}</Panel.Error>}
|
||||
|
||||
<CustomViewsList
|
||||
views={data.views}
|
||||
onOpenUpload={() => setIsUploadOpen(true)}
|
||||
onMutate={() => refetch()}
|
||||
onError={setActionError}
|
||||
/>
|
||||
|
||||
</Panel.Section>
|
||||
</Panel.Card>
|
||||
</Panel.Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { CustomViewSummary } from 'ontime-types';
|
||||
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import CustomViewsListItem from './CustomViewsListItem';
|
||||
|
||||
import style from './CustomViews.module.scss';
|
||||
|
||||
interface CustomViewsListProps {
|
||||
views: CustomViewSummary[];
|
||||
onOpenUpload: () => void;
|
||||
onMutate: () => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export default function CustomViewsList({ views, onOpenUpload, onMutate, onError }: CustomViewsListProps) {
|
||||
return (
|
||||
<Panel.Table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>URL</th>
|
||||
<th className={style.actionsHeader} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{views.length === 0 && <Panel.TableEmpty handleClick={onOpenUpload} label='No custom views yet' />}
|
||||
{views.map((view, index) => (
|
||||
<CustomViewsListItem
|
||||
key={view.slug}
|
||||
slug={view.slug}
|
||||
index={index}
|
||||
onMutate={onMutate}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</Panel.Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { IoDownloadOutline, IoOpenOutline, IoTrash } from 'react-icons/io5';
|
||||
|
||||
import { deleteCustomView, downloadCustomView } from '../../../../common/api/customViews';
|
||||
import { maybeAxiosError } from '../../../../common/api/utils';
|
||||
import IconButton from '../../../../common/components/buttons/IconButton';
|
||||
import { handleLinks } from '../../../../common/utils/linkUtils';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import { getViewUrl } from './customViews.utils';
|
||||
|
||||
import style from './CustomViews.module.scss';
|
||||
|
||||
interface CustomViewsListItemProps {
|
||||
slug: string;
|
||||
index: number;
|
||||
onMutate: () => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
export default function CustomViewsListItem({ slug, index, onMutate, onError }: CustomViewsListItemProps) {
|
||||
const handlePreview = () => {
|
||||
handleLinks(`external/${encodeURIComponent(slug)}/`);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
await downloadCustomView(slug);
|
||||
} catch (error) {
|
||||
onError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await deleteCustomView(slug);
|
||||
onMutate();
|
||||
} catch (error) {
|
||||
onError(maybeAxiosError(error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>{slug}</td>
|
||||
<td className={style.urlCell}>{getViewUrl(slug)}</td>
|
||||
<td className={style.actionsCell}>
|
||||
<Panel.InlineElements relation='inner' align='end' className={style.actionsGroup}>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
onClick={handlePreview}
|
||||
aria-label='Preview custom view'
|
||||
data-testid={`custom-view__preview_${index}`}
|
||||
>
|
||||
<IoOpenOutline />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant='ghosted-white'
|
||||
onClick={handleDownload}
|
||||
aria-label='Download custom view'
|
||||
data-testid={`custom-view__download_${index}`}
|
||||
>
|
||||
<IoDownloadOutline />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant='ghosted-destructive'
|
||||
onClick={handleDelete}
|
||||
aria-label='Delete custom view'
|
||||
data-testid={`custom-view__delete_${index}`}
|
||||
>
|
||||
<IoTrash />
|
||||
</IconButton>
|
||||
</Panel.InlineElements>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { baseURI, serverURL } from '../../../../externals';
|
||||
|
||||
export const customViewsDocs = 'https://docs.getontime.no/features/custom-views/';
|
||||
const customViewSlugPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const maxUploadBytes = 4_000_000;
|
||||
export const maxUploadLabel = `${maxUploadBytes / 1_000_000}MB`;
|
||||
|
||||
export function getSlugError(slug: string): string | null {
|
||||
if (!slug) {
|
||||
return 'Name is required.';
|
||||
}
|
||||
if (!customViewSlugPattern.test(slug)) {
|
||||
return 'Use lowercase letters, numbers, and dashes only.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getFileError(file: File | null): string | null {
|
||||
if (!file) {
|
||||
return 'index.html is required.';
|
||||
}
|
||||
if (file.name.toLowerCase() !== 'index.html') {
|
||||
return 'Only index.html uploads are supported.';
|
||||
}
|
||||
if (file.size === 0) {
|
||||
return 'Uploaded file is empty.';
|
||||
}
|
||||
if (file.size > maxUploadBytes) {
|
||||
return `File size limit (${maxUploadLabel}) exceeded.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getViewUrl(slug: string): string {
|
||||
const url = new URL(serverURL);
|
||||
const path = slug ? `external/${encodeURIComponent(slug)}/` : 'external/';
|
||||
url.pathname = baseURI ? `${baseURI}/${path}` : `/${path}`;
|
||||
return url.toString();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import useScrollIntoView from '../../../../common/hooks/useScrollIntoView';
|
||||
import { isDocker } from '../../../../externals';
|
||||
import type { PanelBaseProps } from '../../panel-list/PanelList';
|
||||
import * as Panel from '../../panel-utils/PanelUtils';
|
||||
import CustomViews from '../manage-panel/CustomViews';
|
||||
import GeneralSettings from './GeneralSettings';
|
||||
import ProjectData from './ProjectData';
|
||||
import ServerPortSettings from './ServerPortSettings';
|
||||
@@ -10,8 +11,9 @@ import ViewSettings from './ViewSettings';
|
||||
export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
const dataRef = useScrollIntoView<HTMLDivElement>('data', location);
|
||||
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
|
||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
|
||||
const customViewsRef = useScrollIntoView<HTMLDivElement>('custom-views', location);
|
||||
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -25,6 +27,9 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
|
||||
<div ref={viewRef}>
|
||||
<ViewSettings />
|
||||
</div>
|
||||
<div ref={customViewsRef}>
|
||||
<CustomViews />
|
||||
</div>
|
||||
{!isDocker && (
|
||||
<div ref={portRef}>
|
||||
<ServerPortSettings />
|
||||
|
||||
@@ -19,7 +19,8 @@ const staticOptions = [
|
||||
{ id: 'settings__data', label: 'Project data' },
|
||||
{ id: 'settings__general', label: 'General settings' },
|
||||
{ id: 'settings__view', label: 'View settings' },
|
||||
{ id: 'settings__port', label: 'Server Port' },
|
||||
{ id: 'settings__custom-views', label: 'Custom views' },
|
||||
{ id: 'settings__port', label: 'Server port' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user