Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/jsonschema-page/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Find exactly what you need:
|-------------------|-----------|-------------|
| Learn the basics | [Documentation](https://github.com/ringcentral/ringcentral-embeddable/jsonschema-page/?path=/docs/jsonschemapage-documentation--docs) | Installation, basic usage |
| Add a text input | [Text Input Widget](https://github.com/ringcentral/ringcentral-embeddable/jsonschema-page/?path=/story/jsonschemapage-form-input-widgets--text-input-widget) | `{ type: 'string', title: 'Name' }` |
| Add scheduling fields | [Scheduling Input Widgets](https://github.com/ringcentral/ringcentral-embeddable/jsonschema-page/?path=/story/jsonschemapage-form-input-widgets--scheduling-input-widgets) | `{ type: 'string', format: 'duration' }` |
| Add a dropdown | [Select Widget](https://github.com/ringcentral/ringcentral-embeddable/jsonschema-page/?path=/story/jsonschemapage-form-input-widgets--select-widget) | `{ enum: ['option1', 'option2'] }` |
| Add a button | [Button Fields](https://github.com/ringcentral/ringcentral-embeddable/jsonschema-page/?path=/story/jsonschemapage-interactive-components--button-fields) | `{ 'ui:field': 'button' }` |
| Show an alert | [Alert Fields](https://github.com/ringcentral/ringcentral-embeddable/jsonschema-page/?path=/story/jsonschemapage-display-components--alert-fields) | `{ 'ui:field': 'alert' }` |
Expand Down Expand Up @@ -137,4 +138,3 @@ npm install @ringcentral/juno @ringcentral/juno-icon react styled-components
---

**Ready to build amazing forms?** Start with the [Documentation](https://github.com/ringcentral/ringcentral-embeddable/jsonschema-page/?path=/docs/jsonschemapage-documentation--docs) or explore the examples above! 🚀

2 changes: 2 additions & 0 deletions packages/jsonschema-page/src/Fields/List.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
Unread,
SettingsBorder,
Warning,
Check,
} from '@ringcentral/juno-icon';

import { ActionMenu } from '../components/ActionMenu';
Expand Down Expand Up @@ -176,6 +177,7 @@ const ICONS_MAP = {
'unread': Unread,
'settings': SettingsBorder,
'warning': Warning,
'check': Check,
};

const DESCRIPTION_COLOR_MAP = {
Expand Down
235 changes: 235 additions & 0 deletions packages/jsonschema-page/src/Widgets/DurationWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import React from 'react';
import {
ariaDescribedByIds,
FormContextType,
labelValue,
RJSFSchema,
StrictRJSFSchema,
WidgetProps,
} from '@rjsf/utils';
import {
RcBox as Box,
RcFormLabel as FormLabel,
RcTextField as TextField,
RcTypography as Typography,
styled,
} from '@ringcentral/juno';

const StyledFormLabel = styled(FormLabel)`
font-size: 0.75rem;
`;

const StyledUnitAdornment = styled(Typography)`
padding-right: 8px;
`;

const StyledTextField = styled(TextField)`
width: 50%;
`;

const DURATION_REGEX = /^P(?=\d|T\d)(?:(\d+)D)?(?:T(?=\d)(?:(\d+)H)?(?:(\d+)M)?)?$/i;

type DurationParts = {
hours: string;
minutes: string;
};

function isFilled(value: string) {
return value.trim() !== '';
}

function normalizePart(value: string) {
const trimmedValue = value.trim();
if (!isFilled(trimmedValue)) {
return null;
}

if (!/^\d+$/.test(trimmedValue)) {
return null;
}

const parsed = Number.parseInt(trimmedValue, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return null;
}

return parsed;
}

function parseDurationValue(value?: string): DurationParts {
if (!value) {
return {
hours: '',
minutes: '',
};
}

const match = value.match(DURATION_REGEX);
if (!match) {
return {
hours: '',
minutes: '',
};
}

const [, days, hours, minutes] = match;
const parsedDays = Number.parseInt(days ?? '0', 10);
const parsedHours = Number.parseInt(hours ?? '0', 10);
const parsedMinutes = Number.parseInt(minutes ?? '0', 10);
const totalHours = parsedDays * 24 + parsedHours;
const hasHourValue = days !== undefined || hours !== undefined;
const hasMinuteValue = minutes !== undefined;

return {
hours: hasHourValue ? `${totalHours}` : '',
minutes: hasMinuteValue ? `${parsedMinutes}` : '',
};
}

function buildDurationValue(hoursValue: string, minutesValue: string) {
const normalizedHours = normalizePart(hoursValue);
const normalizedMinutes = normalizePart(minutesValue);
const hasHours = normalizedHours !== null;
const hasMinutes = normalizedMinutes !== null;

if (!hasHours && !hasMinutes) {
return undefined;
}

const totalMinutes = (normalizedHours ?? 0) * 60 + (normalizedMinutes ?? 0);
const displayHours = Math.floor(totalMinutes / 60);
const displayMinutes = totalMinutes % 60;

const durationParts = [];
if (displayHours > 0) {
durationParts.push(`${displayHours}H`);
}
if (displayMinutes > 0 || durationParts.length === 0) {
durationParts.push(`${displayMinutes}M`);
}

return `PT${durationParts.join('')}`;
}

export default function DurationWidget<
T = any,
S extends StrictRJSFSchema = RJSFSchema,
F extends FormContextType = any
>(props: WidgetProps<T, S, F>) {
const {
id,
label,
hideLabel,
required,
disabled,
readonly,
value,
onChange,
onBlur,
onFocus,
options,
rawErrors = [],
} = props;

const { hours, minutes } = parseDurationValue(typeof value === 'string' ? value : undefined);
const minuteStep = Math.max(Number.parseInt(String(options?.minuteStep ?? 1), 10) || 1, 1);
const hoursLabel = typeof options?.hoursLabel === 'string' ? options.hoursLabel : 'Hours';
const minutesLabel = typeof options?.minutesLabel === 'string' ? options.minutesLabel : 'Minutes';
const hoursUnitLabel = typeof options?.hoursUnitLabel === 'string' ? options.hoursUnitLabel : 'hour';
const minutesUnitLabel = typeof options?.minutesUnitLabel === 'string' ? options.minutesUnitLabel : 'min';
const isDisabled = disabled || readonly;
const describedBy = ariaDescribedByIds<T>(id);

const emitChange = (nextHours: string, nextMinutes: string) => {
onChange(buildDurationValue(nextHours, nextMinutes));
};

const handleBlur = (nextHours: string, nextMinutes: string) => {
onBlur(id, buildDurationValue(nextHours, nextMinutes));
};

const handleFocus = (nextHours: string, nextMinutes: string) => {
onFocus(id, buildDurationValue(nextHours, nextMinutes));
};

return (
<>
{labelValue(
<StyledFormLabel required={required} htmlFor={`${id}__hours`}>
{label || undefined}
</StyledFormLabel>,
hideLabel
)}
<Box display="flex" flexDirection="row" gap={2} flexWrap="wrap">
<StyledTextField
id={`${id}__hours`}
name={`${id}__hours`}
label={undefined}
type="number"
value={hours}
placeholder="0"
disabled={isDisabled}
required={required}
error={rawErrors.length > 0}
onChange={({ target: { value: nextHours } }) => {
emitChange(nextHours, minutes);
}}
onBlur={({ target: { value: nextHours } }) => {
handleBlur(nextHours, minutes);
}}
onFocus={({ target: { value: nextHours } }) => {
handleFocus(nextHours, minutes);
}}
inputProps={{
min: 0,
step: 1,
'aria-label': hoursLabel,
}}
InputProps={{
endAdornment: (
<StyledUnitAdornment variant="caption1" color="neutral.f05">
{hoursUnitLabel}
</StyledUnitAdornment>
),
}}
aria-describedby={describedBy}
clearBtn={false}
/>
<StyledTextField
id={`${id}__minutes`}
name={`${id}__minutes`}
label={undefined}
type="number"
value={minutes}
placeholder="0"
disabled={isDisabled}
required={required}
clearBtn={false}
error={rawErrors.length > 0}
onChange={({ target: { value: nextMinutes } }) => {
emitChange(hours, nextMinutes);
}}
onBlur={({ target: { value: nextMinutes } }) => {
handleBlur(hours, nextMinutes);
}}
onFocus={({ target: { value: nextMinutes } }) => {
handleFocus(hours, nextMinutes);
}}
inputProps={{
min: 0,
step: minuteStep,
'aria-label': minutesLabel,
}}
InputProps={{
endAdornment: (
<StyledUnitAdornment variant="caption1" color="neutral.f05">
{minutesUnitLabel}
</StyledUnitAdornment>
),
}}
aria-describedby={describedBy}
/>
</Box>
</>
);
}
3 changes: 3 additions & 0 deletions packages/jsonschema-page/src/Widgets/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import SelectWidget from './SelectWidget';
import TextareaWidget from './TextareaWidget';
import FileWidget from './FileWidget';
import AutocompleteWidget from './AutocompleteWidget';
import DurationWidget from './DurationWidget';
export const widgets = {
CheckboxWidget,
CheckboxesWidget,
Expand All @@ -17,4 +18,6 @@ export const widgets = {
TextareaWidget,
FileWidget,
AutocompleteWidget,
DurationWidget,
duration: DurationWidget,
} as RegistryWidgetsType<any, RJSFSchema, FormContextType>;
17 changes: 16 additions & 1 deletion packages/jsonschema-page/src/__stories__/Documentation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,21 @@ JSON Schema supports various string formats that automatically provide validatio
"format": "date",
"title": "Date"
},
"time": {
"type": "string",
"format": "time",
"title": "Time"
},
"dateTime": {
"type": "string",
"format": "date-time",
"title": "Date and Time"
},
"duration": {
"type": "string",
"format": "duration",
"title": "Duration"
},
"uri": {
"type": "string",
"format": "uri",
Expand Down Expand Up @@ -654,4 +669,4 @@ For custom fields like buttons, alerts, and typography:
- **[JSON Schema Official Site](https://json-schema.org/)** - Complete JSON Schema specification
- **[JSON Schema Validation](https://json-schema.org/understanding-json-schema/reference/validation.html)** - Validation keywords reference
- **[React JSON Schema Form](https://github.com/rjsf-team/react-jsonschema-form)** - Base library documentation
- **[RingCentral Juno](https://ringcentral.github.io/juno/)** - Design system documentation
- **[RingCentral Juno](https://ringcentral.github.io/juno/)** - Design system documentation
Original file line number Diff line number Diff line change
Expand Up @@ -1142,6 +1142,7 @@ export const ListWithActions: Story = {
{ id: 'read', title: 'Mark as unread', icon: 'read' },
{ id: 'unread', title: 'Mark as read', icon: 'unread' },
{ id: 'settings', title: 'Settings', icon: 'settings' },
{ id: 'check', title: 'Check', icon: 'check' },
],
},
{
Expand Down
2 changes: 1 addition & 1 deletion packages/jsonschema-page/src/__stories__/README.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ Find exactly what you need:
|-------------------|-----------|-------------|
| Learn the basics | [Documentation](/docs/jsonschemapage-documentation--docs) | Installation, basic usage |
| Add a text input | [Text Input Widget](/story/jsonschemapage-form-input-widgets--text-input-widget) | `{ type: 'string', title: 'Name' }` |
| Add scheduling fields | [Scheduling Input Widgets](/story/jsonschemapage-form-input-widgets--scheduling-input-widgets) | `{ type: 'string', format: 'duration' }` |
| Add a dropdown | [Select Widget](/story/jsonschemapage-form-input-widgets--select-widget) | `{ enum: ['option1', 'option2'] }` |
| Add a button | [Button Fields](/story/jsonschemapage-interactive-components--button-fields) | `{ 'ui:field': 'button' }` |
| Show an alert | [Alert Fields](/story/jsonschemapage-display-components--alert-fields) | `{ 'ui:field': 'alert' }` |
Expand Down Expand Up @@ -160,4 +161,3 @@ npm install @ringcentral/juno @ringcentral/juno-icon react styled-components
---

**Ready to build amazing forms?** Start with the [Documentation](/docs/jsonschemapage-documentation--docs) or explore the examples above! 🚀

Loading
Loading