-
Notifications
You must be signed in to change notification settings - Fork 9
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Constraint/required validation UI #164
Conversation
🦋 Changeset detectedLatest commit: 82d35d0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
7608de9
to
0ef31bb
Compare
3810dd5
to
d631f03
Compare
d631f03
to
91db812
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great to see this in action! Overall it's a great first step on one of the remaining bits of essential functionality for many use cases.
I've added a fair bit of commentary, much of it around approach. While I'd like to spend some time thinking more deeply about the questions I've raised in those comments, I'd generally prioritize them lower (and happy to defer to future work) than a few areas of UX which feel need some improvement.
To my mind, I'd prioritize those (highest to lowest):
- Reducing the difference between conditions for first- and subsequent-display of input validation messages. I'm not totally sure the suggestion I made is the best/only way to achieve that, but my experience before/after the suggested change is that it's drastically easier to understand what happens in various cases I've tried (both in terms of initial form definition, and various usage and state flows).
- Some refinement of the form-wide sticky banner for narrow screens. As I mentioned inline, a single-breakpoint to switch from 100% to 70% width would be a great start. I'm happy to consider more specific improvements as well.
- I do think it'd be good now to anticipate app translations, with some preliminary structure for referencing static/templated user-facing text. This could be as simple as importing those from a common module.
I have several concerns about scrolling (and DOM scroll APIs) as a mechanism for in-form navigation. I think the most immediately pressing aspect there is also the one with the most immediate UX impact: focus. I'd be happy to defer the rest of the scrolling related concerns, but it's worth thinking about whether there's a quick win in terms of jump to first validation error -> focus field with first validation error. I think the rest may come into play more as we address other view modes, other aspects of in-form navigation, etc. But if we can reasonably bring the issue of focus into scope, it'll be good for a variety of accessibility/usability reasons.
@@ -47,7 +52,7 @@ const setSelectNValue = (values: SelectItem[]) => { | |||
outline: 1px solid var(--surface-300); | |||
border-radius: 10px; | |||
padding: 15px; | |||
margin: 20px 0; | |||
margin: 20px 0 0 0; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know if it's best to put this here, it's more of a holistic concern. I'd like to think about leaning more heavily on flex/grid to handle spacing concerns like this. Using margin
is notoriously problematic for a ton of reasons. I've had this concern in numerous places while I look through this PR, but it really stands out here because this is pretty much a perfect example of why margin
is a poor fit for spacing when other options are available. I think I know why it is changing here, but I would never in a million years be able to look back at a git blame and make the connection. I really only make the connection now because I'm trying very hard to keep all of the cross-component coupling in my head at once.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will handle this with #173 (comment)
86e6bba
to
6236b20
Compare
* Instead of using CSS for sharing the state between components, I have used provide/inject method. * Using input and blur events to determine when to show validation message * Created a separate validation message component and consumed it in components for ControlNodes instead of having it in FormQuestion component
12b1401
to
a2d4ec8
Compare
…abel only checkboxes
23855a6
to
2d6f7cb
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Getting really close! I left a few suggestions inline. Feel free to take them or leave them now, but I'd strongly encourage the ones related to scrolling and mocking.
I would like to see a changeset before merge. I'd be happy to add these for you when they're missing, if that's what you'd prefer. Otherwise I'll suggest that I've found the GitHub bot provides a helpful reminder.
These are the aspects I think are worth addressing or considering now.
I'll add a separate comment with a variety of other notes I took during this review pass, which we can file in one or more issues for followup as appropriate.
} | ||
else{ | ||
submitPressed.value = true; | ||
window.scrollTo(0,0); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we still want this...
window.scrollTo(0,0); | |
document.scrollingElement?.scrollTo(0, 0); |
Both because it's more flexible for host applications, and because it doesn't seem to produce error logs in tests.
if (!Element.prototype.scrollIntoView) { | ||
// eslint-disable-next-line @typescript-eslint/no-empty-function | ||
Element.prototype.scrollIntoView = () => {}; | ||
} | ||
if (!HTMLElement.prototype.showPopover) { | ||
HTMLElement.prototype.showPopover = function () { | ||
this.style.display = 'block'; | ||
}; | ||
} | ||
if (!HTMLElement.prototype.hidePopover) { | ||
HTMLElement.prototype.hidePopover = function () { | ||
this.style.display = 'none'; | ||
}; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know there's a lot here, but it's worth considering. There are a few good candidates for moving to somewhere shared/reusable, but we can start here for now.
Main goals:
- Clean up any mutation of global scope/prototypes
- Use mocking facilities consistently
- Generalize enough that we can apply similar techniques without a lot of extra fuss in the future
if (!Element.prototype.scrollIntoView) { | |
// eslint-disable-next-line @typescript-eslint/no-empty-function | |
Element.prototype.scrollIntoView = () => {}; | |
} | |
if (!HTMLElement.prototype.showPopover) { | |
HTMLElement.prototype.showPopover = function () { | |
this.style.display = 'block'; | |
}; | |
} | |
if (!HTMLElement.prototype.hidePopover) { | |
HTMLElement.prototype.hidePopover = function () { | |
this.style.display = 'none'; | |
}; | |
} | |
// TODO: how the heck is `undefined` a key of anything?! | |
type StringKeyOf<T> = Extract<keyof T, string>; | |
type StringKeyOfDocument = StringKeyOf<Document>; | |
// prettier-ignore | |
type DocumentPropertyName = { | |
[PropertyName in StringKeyOfDocument]: | |
Document[PropertyName] extends AnyFunction | |
? never | |
: PropertyName; | |
}[StringKeyOfDocument]; | |
let documentKeysAdded: StringKeyOfDocument[] = []; | |
const mockDocumentGetter = <PropertyName extends DocumentPropertyName>( | |
propertyName: PropertyName, | |
mockImplementation: () => Document[PropertyName] | |
) => { | |
if (propertyName in document) { | |
return vi.spyOn(document, propertyName, 'get').mockImplementation(mockImplementation); | |
} | |
documentKeysAdded.push(propertyName); | |
const mock = vi.fn(mockImplementation); | |
Object.defineProperty(document, propertyName, { | |
get: mock, | |
configurable: true, | |
}); | |
return mock; | |
}; | |
type StringKeyOfHTMLElement = StringKeyOf<HTMLElement>; | |
// prettier-ignore | |
type ElementMethodName = { | |
[PropertyName in StringKeyOfHTMLElement]: | |
HTMLElement[PropertyName] extends AnyFunction | |
? PropertyName | |
: never; | |
}[keyof HTMLElement]; | |
type ElementMethodMock<MethodName extends ElementMethodName> = ( | |
this: HTMLElement, | |
...args: Parameters<HTMLElement[MethodName]> | |
) => ReturnType<HTMLElement[MethodName]>; | |
let elementKeysAdded: StringKeyOfHTMLElement[] = []; | |
const mockElementPrototypeMethod = <MethodName extends ElementMethodName>( | |
methodName: MethodName, | |
mockImplementation: ElementMethodMock<MethodName> | |
) => { | |
if (methodName in HTMLElement.prototype) { | |
return vi.spyOn(HTMLElement.prototype, methodName).mockImplementation(function (...args) { | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any | |
return mockImplementation.apply(this as HTMLElement, args as any); | |
}); | |
} | |
elementKeysAdded.push(methodName); | |
const mock = vi.fn(mockImplementation); | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any | |
HTMLElement.prototype[methodName] = mock as any; | |
return mock; | |
}; | |
beforeEach(() => { | |
documentKeysAdded = []; | |
elementKeysAdded = []; | |
// Not necessary if we scroll `document.scrollingElement` instead. | |
// Including to show how it'd be done if not. | |
vi.stubGlobal('scrollTo', () => { | |
/* Do nothing, unless we test this behavior. */ | |
}); | |
mockDocumentGetter('scrollingElement', () => { | |
return document.documentElement; | |
}); | |
mockElementPrototypeMethod('showPopover', function () { | |
this.style.display = 'block'; | |
}); | |
mockElementPrototypeMethod('hidePopover', function () { | |
this.style.display = 'none'; | |
}); | |
}); | |
afterEach(() => { | |
documentKeysAdded.forEach((propertyName) => { | |
delete document[propertyName]; | |
}); | |
elementKeysAdded.forEach((methodName) => { | |
delete HTMLElement.prototype[methodName]; | |
}); | |
vi.restoreAllMocks(); | |
vi.unstubAllGlobals(); | |
}); |
(You'll also need these import changes:)
- import { describe, expect, it } from 'vitest';
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+ import type { AnyFunction } from '@getodk/common/types/helpers.d.ts';
// Assert validation banner is visible and question container is highlighted again | ||
expect(component.get('.form-error-message').isVisible()).toBe(true); | ||
expect(component.get('.question-container').classes().includes('highlight')).toBe(true); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is at least 2 tests. Fine for now, but probably worth breaking up when we revisit the programmatic in-form navigation aspect.
(Sorry if any formatting is off here, or anything is confusing. These were notes I was scrawling in Notes.app while I reviewed, and may not have clarified or Markdown-ified everything sufficiently.) Nice to have in follow-up PR(s)Vue conventions
Validation
Styles
Component structure/naming
Interactivity
|
and split the OdkWebForm test into two
Closes #130
Changes:
dirty
class to the control when its value is changed.