Simplify schema.

This commit is contained in:
Dermot Duffy
2022-01-16 13:11:29 -08:00
parent bdc09c5838
commit 6fadc862e3
3 changed files with 167 additions and 177 deletions
+1
View File
@@ -1,4 +1,5 @@
// TODO use the schema default remover code to simplify config schema // TODO use the schema default remover code to simplify config schema
// TODO in fullscreen mode on live view with mixed cameras, center video
// TODO verify README links worked correctly (e.g. basic cameras configuration) // TODO verify README links worked correctly (e.g. basic cameras configuration)
import { import {
+118 -177
View File
@@ -11,6 +11,8 @@ import {
} from 'custom-card-helpers'; } from 'custom-card-helpers';
import { z } from 'zod'; import { z } from 'zod';
import { deepRemoveDefaults } from './zod-util';
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
'frigate-card-editor': LovelaceCardEditor; 'frigate-card-editor': LovelaceCardEditor;
@@ -101,15 +103,13 @@ const customActionSchema = schemaForType<CustomActionConfig>()(
action: z.literal('fire-dom-event'), action: z.literal('fire-dom-event'),
}), }),
); );
const frigateCardCustomActionBaseSchema = customActionSchema.merge( const frigateCardCustomActionBaseSchema = customActionSchema.extend({
z.object({ // Syntactic sugar to avoid 'fire-dom-event' as part of an external API.
// Syntactic sugar to avoid 'fire-dom-event' as part of an external API. action: z
action: z .literal('custom:frigate-card-action')
.literal('custom:frigate-card-action') .transform((): 'fire-dom-event' => 'fire-dom-event')
.transform((): 'fire-dom-event' => 'fire-dom-event') .or(z.literal('fire-dom-event')),
.or(z.literal('fire-dom-event')), });
}),
);
const FRIGATE_CARD_GENERAL_ACTIONS = [ const FRIGATE_CARD_GENERAL_ACTIONS = [
'frigate', 'frigate',
@@ -126,17 +126,13 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
const FRIGATE_CARD_ACTIONS = [...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select'] as const; const FRIGATE_CARD_ACTIONS = [...FRIGATE_CARD_GENERAL_ACTIONS, 'camera_select'] as const;
export type FrigateCardAction = typeof FRIGATE_CARD_ACTIONS[number]; export type FrigateCardAction = typeof FRIGATE_CARD_ACTIONS[number];
const frigateCardGeneralActionSchema = frigateCardCustomActionBaseSchema.merge( const frigateCardGeneralActionSchema = frigateCardCustomActionBaseSchema.extend({
z.object({ frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS),
frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS), });
}), const frigateCardCameraSelectActionSchema = frigateCardCustomActionBaseSchema.extend({
); frigate_card_action: z.literal('camera_select'),
const frigateCardCameraSelectActionSchema = frigateCardCustomActionBaseSchema.merge( camera: z.string(),
z.object({ });
frigate_card_action: z.literal('camera_select'),
camera: z.string(),
}),
);
export const frigateCardCustomActionSchema = z.union([ export const frigateCardCustomActionSchema = z.union([
frigateCardGeneralActionSchema, frigateCardGeneralActionSchema,
frigateCardCameraSelectActionSchema, frigateCardCameraSelectActionSchema,
@@ -169,12 +165,10 @@ const actionsSchema = z.object({
actions: actionBaseSchema.optional(), actions: actionBaseSchema.optional(),
}); });
const elementsBaseSchema = actionBaseSchema.merge( const elementsBaseSchema = actionBaseSchema.extend({
z.object({ style: z.object({}).passthrough().optional(),
style: z.object({}).passthrough().optional(), title: z.string().nullable().optional(),
title: z.string().nullable().optional(), });
}),
);
/** /**
* Picture Element Configuration. * Picture Element Configuration.
@@ -185,68 +179,56 @@ const elementsBaseSchema = actionBaseSchema.merge(
*/ */
// https://www.home-assistant.io/lovelace/picture-elements/#state-badge // https://www.home-assistant.io/lovelace/picture-elements/#state-badge
const stateBadgeIconSchema = elementsBaseSchema.merge( const stateBadgeIconSchema = elementsBaseSchema.extend({
z.object({ type: z.literal('state-badge'),
type: z.literal('state-badge'), entity: z.string(),
entity: z.string(), });
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#state-icon // https://www.home-assistant.io/lovelace/picture-elements/#state-icon
const stateIconSchema = elementsBaseSchema.merge( const stateIconSchema = elementsBaseSchema.extend({
z.object({ type: z.literal('state-icon'),
type: z.literal('state-icon'), entity: z.string(),
entity: z.string(), icon: z.string().optional(),
icon: z.string().optional(), state_color: z.boolean().default(true),
state_color: z.boolean().default(true), });
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#state-label // https://www.home-assistant.io/lovelace/picture-elements/#state-label
const stateLabelSchema = elementsBaseSchema.merge( const stateLabelSchema = elementsBaseSchema.extend({
z.object({ type: z.literal('state-label'),
type: z.literal('state-label'), entity: z.string(),
entity: z.string(), attribute: z.string().optional(),
attribute: z.string().optional(), prefix: z.string().optional(),
prefix: z.string().optional(), suffix: z.string().optional(),
suffix: z.string().optional(), });
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#service-call-button // https://www.home-assistant.io/lovelace/picture-elements/#service-call-button
const serviceCallButtonSchema = elementsBaseSchema.merge( const serviceCallButtonSchema = elementsBaseSchema.extend({
z.object({ type: z.literal('service-button'),
type: z.literal('service-button'), // Title is required for service button.
// Title is required for service button. title: z.string(),
title: z.string(), service: z.string(),
service: z.string(), service_data: z.object({}).passthrough().optional(),
service_data: z.object({}).passthrough().optional(), });
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#icon-element // https://www.home-assistant.io/lovelace/picture-elements/#icon-element
const iconSchema = elementsBaseSchema.merge( const iconSchema = elementsBaseSchema.extend({
z.object({ type: z.literal('icon'),
type: z.literal('icon'), icon: z.string(),
icon: z.string(), entity: z.string().optional(),
entity: z.string().optional(), });
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#image-element // https://www.home-assistant.io/lovelace/picture-elements/#image-element
const imageSchema = elementsBaseSchema.merge( const imageSchema = elementsBaseSchema.extend({
z.object({ type: z.literal('image'),
type: z.literal('image'), entity: z.string().optional(),
entity: z.string().optional(), image: z.string().optional(),
image: z.string().optional(), camera_image: z.string().optional(),
camera_image: z.string().optional(), camera_view: z.string().optional(),
camera_view: z.string().optional(), state_image: z.object({}).passthrough().optional(),
state_image: z.object({}).passthrough().optional(), filter: z.string().optional(),
filter: z.string().optional(), state_filter: z.object({}).passthrough().optional(),
state_filter: z.object({}).passthrough().optional(), aspect_ratio: z.string().optional(),
aspect_ratio: z.string().optional(), });
}),
);
// https://www.home-assistant.io/lovelace/picture-elements/#image-element // https://www.home-assistant.io/lovelace/picture-elements/#image-element
const conditionalSchema = z.object({ const conditionalSchema = z.object({
@@ -318,36 +300,28 @@ export type CameraConfig = z.infer<typeof cameraConfigSchema>;
* Custom Element Types. * Custom Element Types.
*/ */
export const menuIconSchema = iconSchema.merge( export const menuIconSchema = iconSchema.extend({
z.object({ type: z.literal('custom:frigate-card-menu-icon'),
type: z.literal('custom:frigate-card-menu-icon'), });
}),
);
export type MenuIcon = z.infer<typeof menuIconSchema>; export type MenuIcon = z.infer<typeof menuIconSchema>;
export const menuStateIconSchema = stateIconSchema.merge( export const menuStateIconSchema = stateIconSchema.extend({
z.object({ type: z.literal('custom:frigate-card-menu-state-icon'),
type: z.literal('custom:frigate-card-menu-state-icon'), });
}),
);
export type MenuStateIcon = z.infer<typeof menuStateIconSchema>; export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
const menuSubmenuItemSchema = elementsBaseSchema.merge( const menuSubmenuItemSchema = elementsBaseSchema.extend({
z.object({ entity: z.string().optional(),
entity: z.string().optional(), icon: z.string().optional(),
icon: z.string().optional(), state_color: z.boolean().default(true),
state_color: z.boolean().default(true), selected: z.boolean().default(false),
selected: z.boolean().default(false), });
}),
);
export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>; export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>;
export const menuSubmenuSchema = iconSchema.merge( export const menuSubmenuSchema = iconSchema.extend({
z.object({ type: z.literal('custom:frigate-card-menu-submenu'),
type: z.literal('custom:frigate-card-menu-submenu'), items: menuSubmenuItemSchema.array(),
items: menuSubmenuItemSchema.array(), });
}),
);
export type MenuSubmenu = z.infer<typeof menuSubmenuSchema>; export type MenuSubmenu = z.infer<typeof menuSubmenuSchema>;
const frigateCardConditionSchema = z.object({ const frigateCardConditionSchema = z.object({
@@ -492,7 +466,24 @@ export type JSMPEGConfig = z.infer<typeof jsmpegConfigSchema>;
const liveNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({ const liveNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({
// Live cannot show thumbnails, remove that option. // Live cannot show thumbnails, remove that option.
style: z.enum(['none', 'chevrons', 'icons']), style: z
.enum(['none', 'chevrons', 'icons'])
.default(liveConfigDefault.controls.next_previous.style),
size: nextPreviousControlConfigSchema.shape.size.default(
liveConfigDefault.controls.next_previous.size,
),
});
const liveThumbnailControlConfigSchema = thumbnailsControlSchema.extend({
mode: thumbnailsControlSchema.shape.mode.default(
liveConfigDefault.controls.thumbnails.mode,
),
size: thumbnailsControlSchema.shape.size.default(
liveConfigDefault.controls.thumbnails.size,
),
media: z
.enum(['clips', 'snapshots'])
.default(liveConfigDefault.controls.thumbnails.media),
}); });
const liveOverridableConfigSchema = z const liveOverridableConfigSchema = z
@@ -501,50 +492,19 @@ const liveOverridableConfigSchema = z
jsmpeg: jsmpegConfigSchema, jsmpeg: jsmpegConfigSchema,
controls: z controls: z
.object({ .object({
next_previous: liveNextPreviousControlConfigSchema.optional(), next_previous: liveNextPreviousControlConfigSchema.default(
thumbnails: thumbnailsControlSchema liveConfigDefault.controls.next_previous,
.merge( ),
z.object({ thumbnails: liveThumbnailControlConfigSchema.default(
media: z.enum(['clips', 'snapshots']), liveConfigDefault.controls.thumbnails,
}), ),
)
.optional(),
}) })
.optional(), .default(liveConfigDefault.controls),
}) })
.merge(actionsSchema); .merge(actionsSchema);
const liveConfigSchema = liveOverridableConfigSchema const liveConfigSchema = liveOverridableConfigSchema
.extend({ .extend({
// Replace attributes from the overridable schema with those with defaults.
controls: z
.object({
next_previous: liveNextPreviousControlConfigSchema
.extend({
size: liveNextPreviousControlConfigSchema.shape.size.default(
liveConfigDefault.controls.next_previous.size,
),
style: liveNextPreviousControlConfigSchema.shape.style.default(
liveConfigDefault.controls.next_previous.style,
),
})
.default(liveConfigDefault.controls.next_previous),
thumbnails: thumbnailsControlSchema
.extend({
mode: thumbnailsControlSchema.shape.mode.default(
liveConfigDefault.controls.thumbnails.mode,
),
size: thumbnailsControlSchema.shape.size.default(
liveConfigDefault.controls.thumbnails.size,
),
media: z
.enum(['clips', 'snapshots'])
.default(liveConfigDefault.controls.thumbnails.media),
})
.default(liveConfigDefault.controls.thumbnails),
})
.default(liveConfigDefault.controls),
// Non-overrideable parameters. // Non-overrideable parameters.
preload: z.boolean().default(liveConfigDefault.preload), preload: z.boolean().default(liveConfigDefault.preload),
lazy_load: z.boolean().default(liveConfigDefault.lazy_load), lazy_load: z.boolean().default(liveConfigDefault.lazy_load),
@@ -572,27 +532,9 @@ const menuConfigDefault = {
button_size: '40px', button_size: '40px',
}; };
const menuOverridableConfigSchema = z.object({ const menuConfigSchema = z
mode: z.enum(FRIGATE_MENU_MODES).optional(), .object({
buttons: z mode: z.enum(FRIGATE_MENU_MODES).default(menuConfigDefault.mode),
.object({
frigate: z.boolean().optional(),
cameras: z.boolean().optional(),
live: z.boolean().optional(),
clips: z.boolean().optional(),
snapshots: z.boolean().optional(),
image: z.boolean().optional(),
download: z.boolean().optional(),
frigate_ui: z.boolean().optional(),
fullscreen: z.boolean().optional(),
})
.optional(),
button_size: z.string().optional(),
});
const menuConfigSchema = menuOverridableConfigSchema
.extend({
mode: menuOverridableConfigSchema.shape.mode.default(menuConfigDefault.mode),
buttons: z buttons: z
.object({ .object({
frigate: z.boolean().default(menuConfigDefault.buttons.frigate), frigate: z.boolean().default(menuConfigDefault.buttons.frigate),
@@ -606,9 +548,7 @@ const menuConfigSchema = menuOverridableConfigSchema
fullscreen: z.boolean().default(menuConfigDefault.buttons.fullscreen), fullscreen: z.boolean().default(menuConfigDefault.buttons.fullscreen),
}) })
.default(menuConfigDefault.buttons), .default(menuConfigDefault.buttons),
button_size: menuOverridableConfigSchema.shape.button_size.default( button_size: z.string().default(menuConfigDefault.button_size),
menuConfigDefault.button_size,
)
}) })
.default(menuConfigDefault); .default(menuConfigDefault);
export type MenuConfig = z.infer<typeof menuConfigSchema>; export type MenuConfig = z.infer<typeof menuConfigSchema>;
@@ -631,14 +571,12 @@ const viewerConfigDefault = {
}, },
}, },
}; };
const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.merge( const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({
z.object({ style: z
style: z .enum(['none', 'thumbnails', 'chevrons'])
.enum(['none', 'thumbnails', 'chevrons']) .default(viewerConfigDefault.controls.next_previous.style),
.default(viewerConfigDefault.controls.next_previous.style), size: z.string().default(viewerConfigDefault.controls.next_previous.size),
size: z.string().default(viewerConfigDefault.controls.next_previous.size), });
}),
);
export type ViewerNextPreviousControlConfig = z.infer< export type ViewerNextPreviousControlConfig = z.infer<
typeof viewerNextPreviousControlConfigSchema typeof viewerNextPreviousControlConfigSchema
>; >;
@@ -705,10 +643,13 @@ const dimensionsConfigSchema = z
/** /**
* Configuration overrides * Configuration overrides
*/ */
const overrideConfigurationSchema = z.object({ // Strip all defaults from the override schemas, to ensure values are only what
live: liveOverridableConfigSchema.optional(), // the user has specified.
menu: menuOverridableConfigSchema.optional(), const overrideConfigurationSchema =
}); z.object({
live: deepRemoveDefaults(liveOverridableConfigSchema).optional(),
menu: deepRemoveDefaults(menuConfigSchema).optional(),
});
export type OverrideConfigurationKey = keyof z.infer<typeof overrideConfigurationSchema>; export type OverrideConfigurationKey = keyof z.infer<typeof overrideConfigurationSchema>;
const overridesSchema = z const overridesSchema = z
+48
View File
@@ -0,0 +1,48 @@
import { z } from 'zod';
/**
* Recursively remove defaults from a zod schema.
*
* See: https://github.com/colinhacks/zod/discussions/845#discussioncomment-1936943
*
* @param schema The Zod schema.
* @returns A new Zod schema.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function deepRemoveDefaults<T extends z.ZodTypeAny>(schema: T): any {
if (schema instanceof z.ZodDefault) {
return deepRemoveDefaults(schema.removeDefault());
}
if (schema instanceof z.ZodObject) {
const newShape = {};
for (const key in schema.shape) {
const fieldSchema = schema.shape[key];
newShape[key] = z.ZodOptional.create(deepRemoveDefaults(fieldSchema));
}
return new z.ZodObject({
...schema._def,
shape: () => newShape,
});
}
if (schema instanceof z.ZodArray) {
return z.ZodArray.create(deepRemoveDefaults(schema.element));
}
if (schema instanceof z.ZodOptional) {
return z.ZodOptional.create(deepRemoveDefaults(schema.unwrap()));
}
if (schema instanceof z.ZodNullable) {
return z.ZodNullable.create(deepRemoveDefaults(schema.unwrap()));
}
if (schema instanceof z.ZodTuple) {
return z.ZodTuple.create(
schema.items.map((item: z.ZodTypeAny) => deepRemoveDefaults(item)),
);
}
return schema;
}