fixes for various admin issues (#181)

* up max char count for variant name to fix https://github.com/owncast/owncast/issues/1037

* max widthing the line chart canvas size so it scales with the page.
fixes
- https://github.com/owncast/owncast/issues/842
- https://github.com/owncast/owncast/issues/1024

* A fix to make Storage Endpoint URL validation have better feedback.

- give the field a type of "url"
- give the field a pattern to check
- have native browser handle the validation
- if the field is invalid, use :invalid selector to turn the text red on blur.

fixes: https://github.com/owncast/owncast/issues/1000

* a fix for https://github.com/owncast/owncast/issues/874

* - fixes for https://github.com/owncast/owncast/issues/972
Add optional prop to text field to trim() whitespaces from field. Apply logic to mostly url fields.

- move textfield blur if invalid turn red to globaal

* - a fix for bug:  https://github.com/owncast/owncast/issues/998
don't return null if platform name not found because its custom.

- clean up react key problem on socialhandles table

* fix react key issue on Actions table

* fix for https://github.com/owncast/owncast/issues/1008 to display 'other' field when editing an item not in predefined social list

* clean up other potential react key warnings

* Prettified Code!

Co-authored-by: gingervitis <gingervitis@users.noreply.github.com>
This commit is contained in:
gingervitis 2021-05-22 23:27:51 -07:00 committed by GitHub
parent 8d7f11b4ef
commit d0eb1446f3
15 changed files with 132 additions and 43 deletions

View File

@ -13,7 +13,7 @@ import {
OTHER_SOCIAL_HANDLE_OPTION, OTHER_SOCIAL_HANDLE_OPTION,
} from '../../utils/config-constants'; } from '../../utils/config-constants';
import { SocialHandle, UpdateArgs } from '../../types/config-section'; import { SocialHandle, UpdateArgs } from '../../types/config-section';
import isValidUrl from '../../utils/urls'; import isValidUrl, { DEFAULT_TEXTFIELD_URL_PATTERN } from '../../utils/urls';
import TextField from './form-textfield'; import TextField from './form-textfield';
import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses'; import { createInputStatus, STATUS_ERROR, STATUS_SUCCESS } from '../../utils/input-statuses';
import FormStatusIndicator from './form-status-indicator'; import FormStatusIndicator from './form-status-indicator';
@ -62,6 +62,10 @@ export default function EditSocialLinks() {
} }
}; };
const isPredefinedSocial = (platform: string) => {
return availableIconsList.find(item => item.key === platform) || false;
};
const selectedOther = const selectedOther =
modalDataState.platform !== '' && modalDataState.platform !== '' &&
!availableIconsList.find(item => item.key === modalDataState.platform); !availableIconsList.find(item => item.key === modalDataState.platform);
@ -172,9 +176,18 @@ export default function EditSocialLinks() {
key: 'combo', key: 'combo',
render: (data, record) => { render: (data, record) => {
const { platform, url } = record; const { platform, url } = record;
const platformInfo = availableIconsList.find(item => item.key === platform); const platformInfo = isPredefinedSocial(platform);
// custom platform case
if (!platformInfo) { if (!platformInfo) {
return platform; return (
<div className="social-handle-cell">
<p className="option-label">
<strong>{platform}</strong>
<span className="handle-url" title={url}>{url}</span>
</p>
</div>
);
} }
const { icon, platform: platformName } = platformInfo; const { icon, platform: platformName } = platformInfo;
const iconUrl = NEXT_PUBLIC_API_HOST + `${icon.slice(1)}`; const iconUrl = NEXT_PUBLIC_API_HOST + `${icon.slice(1)}`;
@ -201,9 +214,13 @@ export default function EditSocialLinks() {
<Button <Button
size="small" size="small"
onClick={() => { onClick={() => {
const platformInfo = currentSocialHandles[index];
setEditId(index); setEditId(index);
setModalDataState({ ...currentSocialHandles[index] }); setModalDataState({ ...platformInfo });
setDisplayModal(true); setDisplayModal(true);
if (!isPredefinedSocial(platformInfo.platform)) {
setDisplayOther(true);
}
}} }}
> >
Edit Edit
@ -251,7 +268,7 @@ export default function EditSocialLinks() {
className="social-handles-table" className="social-handles-table"
pagination={false} pagination={false}
size="small" size="small"
rowKey={record => record.url} rowKey={record => `${record.platform}-${record.url}`}
columns={socialHandlesColumns} columns={socialHandlesColumns}
dataSource={currentSocialHandles} dataSource={currentSocialHandles}
/> />
@ -278,6 +295,9 @@ export default function EditSocialLinks() {
placeholder={PLACEHOLDERS[modalDataState.platform] || 'Url to page'} placeholder={PLACEHOLDERS[modalDataState.platform] || 'Url to page'}
value={modalDataState.url} value={modalDataState.url}
onChange={handleUrlChange} onChange={handleUrlChange}
useTrim
type="url"
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
/> />
<FormStatusIndicator status={submitStatus} /> <FormStatusIndicator status={submitStatus} />
</div> </div>

View File

@ -41,6 +41,7 @@ export default function TextFieldWithSubmit(props: TextFieldWithSubmitProps) {
apiPath, apiPath,
configPath = '', configPath = '',
initialValue, initialValue,
useTrim,
...textFieldProps // rest of props ...textFieldProps // rest of props
} = props; } = props;
@ -70,7 +71,7 @@ export default function TextFieldWithSubmit(props: TextFieldWithSubmitProps) {
// if field is required but value is empty, or equals initial value, then don't show submit/update button. otherwise clear out any result messaging and display button. // if field is required but value is empty, or equals initial value, then don't show submit/update button. otherwise clear out any result messaging and display button.
const handleChange = ({ fieldName: changedFieldName, value: changedValue }: UpdateArgs) => { const handleChange = ({ fieldName: changedFieldName, value: changedValue }: UpdateArgs) => {
if (onChange) { if (onChange) {
onChange({ fieldName: changedFieldName, value: changedValue }); onChange({ fieldName: changedFieldName, value: useTrim ? changedValue.trim() : changedValue });
} }
}; };

View File

@ -22,11 +22,13 @@ export interface TextFieldProps {
disabled?: boolean; disabled?: boolean;
label?: string; label?: string;
maxLength?: number; maxLength?: number;
pattern?: string;
placeholder?: string; placeholder?: string;
required?: boolean; required?: boolean;
status?: StatusState; status?: StatusState;
tip?: string; tip?: string;
type?: string; type?: string;
useTrim?: boolean;
value?: string | number; value?: string | number;
onBlur?: FieldUpdaterFunc; onBlur?: FieldUpdaterFunc;
onChange?: FieldUpdaterFunc; onChange?: FieldUpdaterFunc;
@ -42,20 +44,21 @@ export default function TextField(props: TextFieldProps) {
onBlur, onBlur,
onChange, onChange,
onPressEnter, onPressEnter,
pattern,
placeholder, placeholder,
required, required,
status, status,
tip, tip,
type, type,
useTrim,
value, value,
} = props; } = props;
// if field is required but value is empty, or equals initial value, then don't show submit/update button. otherwise clear out any result messaging and display button.
const handleChange = (e: any) => { const handleChange = (e: any) => {
const val = type === TEXTFIELD_TYPE_NUMBER ? e : e.target.value;
// if an extra onChange handler was sent in as a prop, let's run that too. // if an extra onChange handler was sent in as a prop, let's run that too.
if (onChange) { if (onChange) {
onChange({ fieldName, value: val }); const val = type === TEXTFIELD_TYPE_NUMBER ? e : e.target.value;
onChange({ fieldName, value: useTrim ? val.trim() : val });
} }
}; };
@ -100,6 +103,7 @@ export default function TextField(props: TextFieldProps) {
} else if (type === TEXTFIELD_TYPE_URL) { } else if (type === TEXTFIELD_TYPE_URL) {
fieldProps = { fieldProps = {
type: 'url', type: 'url',
pattern,
}; };
} }
@ -114,6 +118,7 @@ export default function TextField(props: TextFieldProps) {
required, required,
[`status-${statusType}`]: status, [`status-${statusType}`]: status,
}); });
return ( return (
<div className={containerClass}> <div className={containerClass}>
{label ? ( {label ? (
@ -130,7 +135,7 @@ export default function TextField(props: TextFieldProps) {
id={fieldId} id={fieldId}
className={`field ${className} ${fieldId}`} className={`field ${className} ${fieldId}`}
{...fieldProps} {...fieldProps}
allowClear {...(type !== TEXTFIELD_TYPE_NUMBER && { allowClear: true })}
placeholder={placeholder} placeholder={placeholder}
maxLength={maxLength} maxLength={maxLength}
onChange={handleChange} onChange={handleChange}

View File

@ -120,7 +120,7 @@ export default function CodecSelector() {
<Title level={3} className="section-title"> <Title level={3} className="section-title">
Video Codec Video Codec
</Title> </Title>
<p className="description"> <div className="description">
If you have access to specific hardware with the drivers and software installed for them, If you have access to specific hardware with the drivers and software installed for them,
you may be able to improve your video encoding performance. you may be able to improve your video encoding performance.
<p> <p>
@ -133,7 +133,7 @@ export default function CodecSelector() {
unplayable. unplayable.
</a> </a>
</p> </p>
</p> </div>
<div className="segment-slider-container"> <div className="segment-slider-container">
<Popconfirm <Popconfirm
title={`Are you sure you want to change your video codec to ${pendingSaveCodec} and understand what this means?`} title={`Are you sure you want to change your video codec to ${pendingSaveCodec} and understand what this means?`}

View File

@ -153,7 +153,7 @@ export default function VideoVariantForm({
<p className="selected-value-note">{cpuUsageNote()}</p> <p className="selected-value-note">{cpuUsageNote()}</p>
</div> </div>
<p className="read-more-subtext"> <p className="read-more-subtext">
This could mean GPU or CPU usage depending on your server environment. {' '} This could mean GPU or CPU usage depending on your server environment.{' '}
<a <a
href="https://owncast.online/docs/video/?source=admin#cpu-usage" href="https://owncast.online/docs/video/?source=admin#cpu-usage"
target="_blank" target="_blank"

View File

@ -4,7 +4,7 @@ import React, { useState, useEffect, useContext } from 'react';
import { Table, Space, Button, Modal, Checkbox, Input, Typography } from 'antd'; import { Table, Space, Button, Modal, Checkbox, Input, Typography } from 'antd';
import { ServerStatusContext } from '../utils/server-status-context'; import { ServerStatusContext } from '../utils/server-status-context';
import { DeleteOutlined } from '@ant-design/icons'; import { DeleteOutlined } from '@ant-design/icons';
import isValidUrl from '../utils/urls'; import isValidUrl, { DEFAULT_TEXTFIELD_URL_PATTERN } from '../utils/urls';
import FormStatusIndicator from '../components/config/form-status-indicator'; import FormStatusIndicator from '../components/config/form-status-indicator';
import { import {
createInputStatus, createInputStatus,
@ -41,12 +41,12 @@ function NewActionModal(props: Props) {
function save() { function save() {
onOk(actionUrl, actionTitle, actionDescription, actionIcon, actionColor, openExternally); onOk(actionUrl, actionTitle, actionDescription, actionIcon, actionColor, openExternally);
setActionUrl('') setActionUrl('');
setActionTitle('') setActionTitle('');
setActionDescription('') setActionDescription('');
setActionIcon('') setActionIcon('');
setActionColor('') setActionColor('');
setOpenExternally(false) setOpenExternally(false);
} }
function canSave(): Boolean { function canSave(): Boolean {
@ -91,7 +91,9 @@ function NewActionModal(props: Props) {
value={actionUrl} value={actionUrl}
required required
placeholder="https://myserver.com/action (required)" placeholder="https://myserver.com/action (required)"
onChange={input => setActionUrl(input.currentTarget.value)} onChange={input => setActionUrl(input.currentTarget.value.trim())}
type="url"
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
/> />
</p> </p>
<p> <p>
@ -184,7 +186,7 @@ export default function Actions() {
dataIndex: 'icon', dataIndex: 'icon',
key: 'icon', key: 'icon',
render: (url: string) => { render: (url: string) => {
return url ? <img style={{width: '2vw'}} src={url} /> : null; return url ? <img style={{ width: '2vw' }} src={url} /> : null;
}, },
}, },
{ {
@ -289,11 +291,22 @@ export default function Actions() {
</Paragraph> </Paragraph>
<Paragraph> <Paragraph>
Read more about how to use actions, with examples, at{' '} Read more about how to use actions, with examples, at{' '}
<a href="https://owncast.online/thirdparty/?source=admin" target="_blank" <a
rel="noopener noreferrer">our documentation</a>. href="https://owncast.online/thirdparty/?source=admin"
target="_blank"
rel="noopener noreferrer"
>
our documentation
</a>
.
</Paragraph> </Paragraph>
<Table rowKey="id" columns={columns} dataSource={actions} pagination={false} /> <Table
rowKey={record => `${record.title}-${record.url}`}
columns={columns}
dataSource={actions}
pagination={false}
/>
<br /> <br />
<Button type="primary" onClick={showCreateModal}> <Button type="primary" onClick={showCreateModal}>
Create New Action Create New Action

View File

@ -37,7 +37,7 @@ export default function ConfigVideoSettings() {
<VideoLatency /> <VideoLatency />
</div> </div>
<Collapse className="advanced-settings"> <Collapse className="advanced-settings codec-module">
<Panel header="Advanced Settings" key="1"> <Panel header="Advanced Settings" key="1">
<div className="form-module variants-table-module"> <div className="form-module variants-table-module">
<VideoCodecSelector /> <VideoCodecSelector />

View File

@ -75,7 +75,7 @@ export default function HardwareInfo() {
<Col> <Col>
<StatisticItem <StatisticItem
title={series[0].name} title={series[0].name}
value={`${currentCPUUsage}`} value={`${currentCPUUsage || 0}`}
prefix={<LaptopOutlined style={{ color: series[0].color }} />} prefix={<LaptopOutlined style={{ color: series[0].color }} />}
color={series[0].color} color={series[0].color}
progress progress
@ -85,7 +85,7 @@ export default function HardwareInfo() {
<Col> <Col>
<StatisticItem <StatisticItem
title={series[1].name} title={series[1].name}
value={`${currentRamUsage}`} value={`${currentRamUsage || 0}`}
prefix={<BulbOutlined style={{ color: series[1].color }} />} prefix={<BulbOutlined style={{ color: series[1].color }} />}
color={series[1].color} color={series[1].color}
progress progress
@ -95,7 +95,7 @@ export default function HardwareInfo() {
<Col> <Col>
<StatisticItem <StatisticItem
title={series[2].name} title={series[2].name}
value={`${currentDiskUsage}`} value={`${currentDiskUsage || 0}`}
prefix={<SaveOutlined style={{ color: series[2].color }} />} prefix={<SaveOutlined style={{ color: series[2].color }} />}
color={series[2].color} color={series[2].color}
progress progress

View File

@ -205,8 +205,8 @@ export default function Help() {
<Title level={2}>Common tasks</Title> <Title level={2}>Common tasks</Title>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{questions.map(question => ( {questions.map(question => (
<Col xs={24} lg={12}> <Col xs={24} lg={12} key={question.title}>
<Card key={question.title}> <Card>
<Meta avatar={question.icon} title={question.title} description={question.content} /> <Meta avatar={question.icon} title={question.title} description={question.content} />
</Card> </Card>
</Col> </Col>
@ -216,8 +216,8 @@ export default function Help() {
<Title level={2}>Other</Title> <Title level={2}>Other</Title>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{otherResources.map(question => ( {otherResources.map(question => (
<Col xs={24} lg={12}> <Col xs={24} lg={12} key={question.title}>
<Card key={question.title}> <Card>
<Meta avatar={question.icon} title={question.title} description={question.content} /> <Meta avatar={question.icon} title={question.title} description={question.content} />
</Card> </Card>
</Col> </Col>

View File

@ -23,7 +23,15 @@ function AssetTable(assets) {
}, },
]; ];
return <Table dataSource={data} columns={columns} rowKey="id" size="large" pagination={false} />; return (
<Table
dataSource={data}
columns={columns}
rowKey={record => record.id}
size="large"
pagination={false}
/>
);
} }
export default function Logs() { export default function Logs() {

View File

@ -13,7 +13,7 @@ import {
Col, Col,
} from 'antd'; } from 'antd';
import { DeleteOutlined } from '@ant-design/icons'; import { DeleteOutlined } from '@ant-design/icons';
import isValidUrl from '../utils/urls'; import isValidUrl, { DEFAULT_TEXTFIELD_URL_PATTERN } from '../utils/urls';
import { fetchData, DELETE_WEBHOOK, CREATE_WEBHOOK, WEBHOOKS } from '../utils/apis'; import { fetchData, DELETE_WEBHOOK, CREATE_WEBHOOK, WEBHOOKS } from '../utils/apis';
@ -86,7 +86,11 @@ function NewWebhookModal(props: Props) {
}; };
const checkboxes = events.map(function (singleEvent) { const checkboxes = events.map(function (singleEvent) {
return (<Col span={8} key={singleEvent.value}><Checkbox value={singleEvent.value}>{singleEvent.label}</Checkbox></Col>) return (
<Col span={8} key={singleEvent.value}>
<Checkbox value={singleEvent.value}>{singleEvent.label}</Checkbox>
</Col>
);
}); });
return ( return (
@ -101,15 +105,15 @@ function NewWebhookModal(props: Props) {
<Input <Input
value={webhookUrl} value={webhookUrl}
placeholder="https://myserver.com/webhook" placeholder="https://myserver.com/webhook"
onChange={input => setWebhookUrl(input.currentTarget.value)} onChange={input => setWebhookUrl(input.currentTarget.value.trim())}
type="url"
pattern={DEFAULT_TEXTFIELD_URL_PATTERN}
/> />
</div> </div>
<p>Select the events that will be sent to this webhook.</p> <p>Select the events that will be sent to this webhook.</p>
<Checkbox.Group style={{ width: '100%' }} value={selectedEvents} onChange={onChange}> <Checkbox.Group style={{ width: '100%' }} value={selectedEvents} onChange={onChange}>
<Row> <Row>{checkboxes}</Row>
{checkboxes}
</Row>
</Checkbox.Group> </Checkbox.Group>
<p> <p>
<Button type="primary" onClick={selectAll}> <Button type="primary" onClick={selectAll}>
@ -225,7 +229,12 @@ export default function Webhooks() {
. .
</Paragraph> </Paragraph>
<Table rowKey="id" columns={columns} dataSource={webhooks} pagination={false} /> <Table
rowKey={record => record.id}
columns={columns}
dataSource={webhooks}
pagination={false}
/>
<br /> <br />
<Button type="primary" onClick={showCreateModal}> <Button type="primary" onClick={showCreateModal}>
Create Webhook Create Webhook

View File

@ -62,3 +62,8 @@
.read-more-subtext { .read-more-subtext {
font-size: 0.8rem; font-size: 0.8rem;
} }
.codec-module {
.ant-collapse-content-active {
background-color: var(--white-15);
}
}

View File

@ -30,6 +30,7 @@ a {
p, p,
p.description, p.description,
.description,
.ant-typography { .ant-typography {
font-weight: 300; font-weight: 300;
margin: 1em 0; margin: 1em 0;
@ -65,6 +66,10 @@ strong {
margin: 2em auto; margin: 2em auto;
padding: 1em; padding: 1em;
border: 1px solid var(--gray-dark); border: 1px solid var(--gray-dark);
canvas {
max-width: 100%;
}
} }
.form-module { .form-module {
@ -106,3 +111,12 @@ strong {
font-size: 0.92em; font-size: 0.92em;
} }
} }
input {
&:not(:focus) {
&:invalid {
color: var(--ant-error);
}
}
}

View File

@ -1,6 +1,8 @@
// DEFAULT VALUES // DEFAULT VALUES
import { fetchData, SERVER_CONFIG_UPDATE_URL } from './apis'; import { fetchData, SERVER_CONFIG_UPDATE_URL } from './apis';
import { ApiPostArgs, VideoVariant, SocialHandle } from '../types/config-section'; import { ApiPostArgs, VideoVariant, SocialHandle } from '../types/config-section';
import { TEXTFIELD_TYPE_URL } from '../components/config/form-textfield';
import { DEFAULT_TEXTFIELD_URL_PATTERN } from './urls';
export const TEXT_MAXLENGTH = 255; export const TEXT_MAXLENGTH = 255;
@ -131,6 +133,9 @@ export const TEXTFIELD_PROPS_INSTANCE_URL = {
placeholder: 'https://owncast.mysite.com', placeholder: 'https://owncast.mysite.com',
label: 'Server URL', label: 'Server URL',
tip: 'The full url to your Owncast server.', tip: 'The full url to your Owncast server.',
type: TEXTFIELD_TYPE_URL,
pattern: DEFAULT_TEXTFIELD_URL_PATTERN,
useTrim: true,
}; };
// MISC FIELDS // MISC FIELDS
export const FIELD_PROPS_TAGS = { export const FIELD_PROPS_TAGS = {
@ -247,7 +252,7 @@ export const VIDEO_BITRATE_DEFAULTS = {
export const VIDEO_NAME_DEFAULTS = { export const VIDEO_NAME_DEFAULTS = {
fieldName: 'name', fieldName: 'name',
label: 'Name', label: 'Name',
maxLength: 12, maxLength: 15,
placeholder: 'HD or Low', placeholder: 'HD or Low',
tip: 'Human-readable name for for displaying in the player.', tip: 'Human-readable name for for displaying in the player.',
}; };
@ -313,7 +318,10 @@ export const S3_TEXT_FIELDS_INFO = {
label: 'Endpoint', label: 'Endpoint',
maxLength: 255, maxLength: 255,
placeholder: 'https://your.s3.provider.endpoint.com', placeholder: 'https://your.s3.provider.endpoint.com',
tip: 'The full URL endpoint your storage provider gave you.', tip: 'The full URL (with "https://") endpoint from your storage provider.',
useTrim: true,
type: TEXTFIELD_TYPE_URL,
pattern: DEFAULT_TEXTFIELD_URL_PATTERN,
}, },
region: { region: {
fieldName: 'region', fieldName: 'region',
@ -336,5 +344,8 @@ export const S3_TEXT_FIELDS_INFO = {
placeholder: 'http://cdn.ss3.provider.endpoint.com', placeholder: 'http://cdn.ss3.provider.endpoint.com',
tip: tip:
'Optional URL that content should be accessed from instead of the default. Used with CDNs and specific storage providers. Generally not required.', 'Optional URL that content should be accessed from instead of the default. Used with CDNs and specific storage providers. Generally not required.',
type: TEXTFIELD_TYPE_URL,
pattern: DEFAULT_TEXTFIELD_URL_PATTERN,
useTrim: true,
}, },
}; };

View File

@ -1,3 +1,6 @@
// to use with <input type="url"> fields, as the default pattern only checks for `:`,
export const DEFAULT_TEXTFIELD_URL_PATTERN = 'https?://.*';
export default function isValidUrl(url: string): boolean { export default function isValidUrl(url: string): boolean {
const validProtocols = ['http:', 'https:']; const validProtocols = ['http:', 'https:'];