diff --git a/apps/server/src/modules/rules/helpers/rule.comparator.service.ts b/apps/server/src/modules/rules/helpers/rule.comparator.service.ts index 749eb358e..6005dba7b 100644 --- a/apps/server/src/modules/rules/helpers/rule.comparator.service.ts +++ b/apps/server/src/modules/rules/helpers/rule.comparator.service.ts @@ -523,9 +523,17 @@ export class RuleComparatorService { } private getSecondValueName(rule: RuleDto): string { - return rule.lastVal - ? this.ruleConstanstService.getValueHumanName(rule.lastVal) - : this.ruleConstanstService.getCustomValueIdentifier(rule.customVal).type; + if (rule.lastVal) { + return this.ruleConstanstService.getValueHumanName(rule.lastVal); + } + // Unary actions (EXISTS / NOT_EXISTS) have neither a second value nor a + // custom value. Guard against it so this diagnostic-only helper never + // throws while logging a skipped comparison (which would abort the run). + if (rule.customVal) { + return this.ruleConstanstService.getCustomValueIdentifier(rule.customVal) + .type; + } + return 'none'; } private handleSectionAction(sectionActionAnd: boolean) { diff --git a/apps/server/src/modules/rules/tests/rule.comparator.service.executeRulesWithData.spec.ts b/apps/server/src/modules/rules/tests/rule.comparator.service.executeRulesWithData.spec.ts index 2e70221b8..a6e89359a 100644 --- a/apps/server/src/modules/rules/tests/rule.comparator.service.executeRulesWithData.spec.ts +++ b/apps/server/src/modules/rules/tests/rule.comparator.service.executeRulesWithData.spec.ts @@ -486,6 +486,52 @@ describe('RuleComparatorService.executeRulesWithData', () => { expect(result.data).toEqual([]); }); + + it('completes the run and logs a skip (not a crash) when a unary rule value is unavailable', async () => { + // getCustomValueIdentifier dereferences customValue.ruleTypeId. A unary + // rule (EXISTS/NOT_EXISTS) carries no customVal, so logging the skipped + // comparison must not reach this helper — otherwise the run threw + // "Cannot read properties of undefined (reading 'ruleTypeId')" and aborted. + ruleConstanstService.getCustomValueIdentifier.mockImplementation( + (customValue: { ruleTypeId: number; value: string }) => ({ + type: ['number', 'date', 'text', 'boolean', 'text list'][ + customValue.ruleTypeId + ], + value: customValue.value, + }), + ); + + const mediaItem = createSingleMedia(); + const rules = [ + createStoredRule(1, { + operator: null, + action: RulePossibility.NOT_EXISTS, + firstVal: [Application.PLEX, 6], + section: 0, + }), + ]; + + mockGetterSequence(undefined); + + const result = await ruleComparatorService.executeRulesWithData( + createRulesDto({ dataType: 'movie', rules }), + [mediaItem], + ); + + // The run completes (the bug aborted via the catch and returned undefined), + expect(result).toBeDefined(); + // the item is not matched, + expect(result.data).toEqual([]); + // and the skip is logged rather than crashing the run. + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining( + 'Skipping rule comparison because a value is unavailable', + ), + ); + expect(logger.log).not.toHaveBeenCalledWith( + expect.stringContaining('Something went wrong'), + ); + }); }); describe('OR sections', () => { diff --git a/apps/ui/styles/globals.css b/apps/ui/styles/globals.css index ac650109b..b6a1462a0 100644 --- a/apps/ui/styles/globals.css +++ b/apps/ui/styles/globals.css @@ -632,84 +632,78 @@ .prose a:hover { color: #8d4c1c; } - .react-select-container { - @apply w-full; - } - - .react-select-container .react-select__control { - @apply rounded-md border border-zinc-500 bg-zinc-700 text-white hover:border-zinc-500; - } - - .react-select-container-dark .react-select__control { - @apply border border-zinc-700 bg-zinc-800; - } - - .react-select-container .react-select__control--is-focused { - @apply rounded-md border border-zinc-500 bg-zinc-700 text-white shadow-sm; - } - - .react-select-container-dark .react-select__control--is-focused { - @apply border-zinc-600 bg-zinc-800; - } - - .react-select-container .react-select__menu { - @apply bg-zinc-700 text-zinc-300; - } - - .react-select-container-dark .react-select__menu { - @apply bg-zinc-800; - } +} - .react-select-container .react-select__option--is-focused { - @apply bg-zinc-600 text-white; - } +/* + react-select's control/menu styles come from emotion, which is unlayered and + therefore wins over @layer components under Tailwind v4. Keep this theme + unlayered (like .prose above) so the dark field styling keeps winning; + otherwise the control falls back to react-select's white default. +*/ +.react-select-container { + @apply w-full; +} - .react-select-container-dark .react-select__option--is-focused { - @apply bg-zinc-700; - } +/* Control mirrors the native field (the input/select rule in this file): + same border, background, radius, text size, and shadow. The :hover variant + matches the base so, like a native field, hovering never changes the border. */ +.react-select-container .react-select__control, +.react-select-container .react-select__control:hover { + @apply rounded-md border border-zinc-500 bg-zinc-700 text-sm text-white shadow-xs; +} - .react-select-container .react-select__indicator-separator { - @apply bg-zinc-500; - } +/* Focus: maintainerr border, no ring (react-select paints a blue box-shadow + ring by default) — mirrors the field's focus:border-maintainerr-600 + ring-0. + The :hover variant keeps the focus colour while hovering. */ +.react-select-container .react-select__control--is-focused, +.react-select-container .react-select__control--is-focused:hover { + @apply border-maintainerr-600 shadow-none; +} - .react-select-container .react-select__indicator { - @apply text-zinc-500; - } +/* Selected value, placeholder, and typed input: same size/weight/colour as a + native field (not bold, muted placeholder). */ +.react-select-container .react-select__single-value, +.react-select-container .react-select__input-container { + @apply text-sm text-white; +} - .react-select-container .react-select__placeholder { - @apply text-zinc-400; - } +.react-select-container .react-select__placeholder { + @apply text-sm text-zinc-400; +} - .react-select-container .react-select__multi-value { - @apply rounded-md border border-zinc-500 bg-zinc-800; - } +/* The inner must stay borderless/ringless, otherwise the forms plugin + gives it its own nested field box. */ +.react-select-container .react-select__input, +.react-select-container .react-select__input input, +.react-select-container .react-select__input input:focus { + @apply border-none shadow-none ring-0; +} - .react-select-container .react-select__multi-value__label { - @apply text-white; - } +/* Muted chevron, no separator (native selects have neither). */ +.react-select-container .react-select__indicator { + @apply text-zinc-400; +} - .react-select-container .react-select__multi-value__remove { - @apply cursor-pointer rounded-r-md hover:bg-red-700 hover:text-red-100; - } +.react-select-container .react-select__indicator-separator { + @apply hidden; +} - .react-select-container .react-select__input { - @apply border-none text-base shadow-xs ring-0; - } +/* Menu is a step darker than the control (bg-zinc-800), matching the native + select dropdown in the rule settings: no border, just a shadow. */ +.react-select-container .react-select__menu { + @apply mt-1 rounded-md bg-zinc-800 text-sm text-white shadow-lg; +} - .react-select-container .react-select__input input:focus { - @apply border-none; - box-shadow: none; - } +.react-select-container .react-select__option { + @apply text-zinc-200; +} - .react-select-container .react-select__input-container { - @apply text-white; - } +.react-select-container .react-select__option--is-focused { + @apply bg-zinc-700 text-white; +} - .react-select-container .react-select__input-container, - .react-select-container .react-select__placeholder, - .react-select-container .react-select__single-value { - @apply font-semibold text-white; - } +.react-select-container .react-select__option--is-selected { + @apply bg-maintainerr-600 text-white; } @layer utilities {