I have a field in react-final-form where the user enters a date. For the internal value this gets normalized to YYYY-MM-DD but the user may enter it as DD.MM.YYYY.
For valid data this is all fine, I can use parse to normalize and format to convert back.
However, if a user enters garbage, there's not much I can do in parse... I ended up doing this awful hack which works, but I wonder if there's a cleaner way that allows me to separate the parsed data that will be fed into the form values, and the data that will be used to display the component and validate the user input.
const formatDate = (value) => {
// console.log(`format: ${value}`);
if (!value) {
return '';
}
// something invalid => keep as-is
if (value.startsWith('INVALID:')) {
return value.substr(8);
}
// we have a valid value => format using our preferred display format
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
return `${m[3]}.${m[2]}.${m[1]}`;
};
const parseDate = (value) => {
if (!value) {
return undefined;
}
// console.log(`parse: ${value}`);
const m = value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (m) {
return `${m[3]}-${m[2]}-${m[1]}`;
}
return 'INVALID:' + value;
};
const validateDate = (value) => {
// console.log(`validate: ${value}`);
if (value && value.startsWith('INVALID:')) {
return 'Invalid date';
}
};
<Field
name="date"
component="input"
type="text"
format={formatDate}
parse={parseDate}
validate={validateDate}
placeholder="dd.mm.yyyy"
/>
Here's an executable codesandbox: https://codesandbox.io/s/react-final-form-format-on-blur-example-forked-5oowz?file=/src/index.js
Note: I'm NOT looking for date pickers or similar widgets that would rely on the field not being directly editable.
Another kind of field where the current behavior feels a bit lacking is for number inputs:
If i parse them to an actual number, I can no longer distinguish null/empty because the field is empty (valid) or because the field contains garbage (invalid)
I can kind of work around this if he field is required (empty is invalid as well), but otherwise I'd once again need a hack like above...
You may keep both raw and parsed strings as a value for the field:
const formatDate = (value) => {
// console.log(`format: ${value}`);
if (!value) {
return "";
}
// something invalid => keep as-is
if (!value.parsed) {
return value.raw;
}
// we have a valid value => format using our preferred display format
const m = value.parsed.match(/^(\d{4})-(\d{2})-(\d{2})$/);
return `${m[3]}.${m[2]}.${m[1]}`;
};
const parseDate = (value) => {
if (!value) {
return undefined;
}
// console.log(`parse: ${value}`);
const m = value.match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (m) {
return { parsed: `${m[3]}-${m[2]}-${m[1]}`, raw: value };
}
return { parsed: null, raw: value };
};
const validateDate = (value) => {
// console.log(`validate: ${value}`);
if (value && !value.parsed) {
return "Invalid date";
}
};
So the value of the field is actually an object of shape { raw:string, parsed:string}. When parsed is empty means the date is invalid.
Related
We are seeing an issue with users unable to access our production and PPE apps via LinkedIn sign in. The redirection is not happening to specified redirect URL once users provides user name and password. The network trace shows login is successful but not going to redirect URL. This has been working last 4 years or so and suddenly started failing in both environments from yesterday.
Bummer. Something went wrong
We tried verifying the network trace and a support case is raised to LinkedIn with recording. Finally we are redirected to raise the issue here.
I had the same issue and found that it was caused by using JSON.stringify to "overload" the state parameter with other parameters. In my case, I add other parameters in the following way:
providerCfg.auth_params.state = JSON.stringify({
state: providerCfg.auth_params.state,
redirectPageUrl,
redirectParams,
userTypeBit,
isLogin
})
const authUrl = new URL(providerCfg.auth_url)
Object.entries(providerCfg.auth_params).forEach(([key, val]) => {
authUrl.searchParams.append(key, encodeURIComponent(val))
})
return buildURL(providerCfg.auth_url, providerCfg.auth_params)
When I removed the call to JSON.stringify and just passed in a state parameter, the oauth flow worked correctly. Obviously, the other parameters that I passed in were important so I created my own functions to serialize and deserialize the values. The code below works well for anything other than deeply nested objects. You will need to update the metaDataCfg based on your own requirements.
const META_STRING_DELIMITER = '|'
const serializeBasicObject = (targetObj) => {
if (!targetObj) {
return ''
}
return Object.entries(targetObj).reduce((objString, [key, val]) => {
const param = `${key}=${val || ''}`
if (!objString.length) {
return param
}
return `${objString}${META_STRING_DELIMITER}${param}`
}, '')
}
const deserializeBasicObject = (targetStr) => {
if (!targetStr) {
return ''
}
const keyValPairs = targetStr.split(META_STRING_DELIMITER)
return keyValPairs.reduce((targetObj, keyValPair) => {
const splitIdx = keyValPair.indexOf('=')
const key = keyValPair.slice(0, splitIdx)
targetObj[key] = keyValPair.slice(splitIdx + 1, keyValPair.length)
return targetObj
}, {})
}
const metaDataCfg = {
state: {},
redirectPageUrl: {},
redirectParams: {
serialize: serializeBasicObject,
deserialize: deserializeBasicObject
},
userTypeBit: { deserialize: Number },
isLogin: { deserialize: dataUtil.getBoolean }
}
const getMetaString = (metaData) => {
return Object.entries(metaDataCfg).reduce((metaString, [metaDataKey, cfg]) => {
const val = (cfg.serialize) ? cfg.serialize(metaData[metaDataKey]) : metaData[metaDataKey]
const param = `${metaDataKey}=${dataUtil.isNil(val) ? '' : val}`
if (!metaString.length) {
return param
}
return `${metaString}${META_STRING_DELIMITER}${param}`
}, '')
}
export const getDataFromMetaString = (metaString) => {
const params = metaString.split(META_STRING_DELIMITER)
const data = params.reduce((metaData, param) => {
const splitIdx = param.indexOf('=')
const key = param.slice(0, splitIdx)
let val = param.slice(splitIdx + 1, param.length)
if (dataUtil.isNil(val) || !val.length) {
return metaData
}
const deserializer = metaDataCfg[key].deserialize
if (deserializer && val) {
val = deserializer(val)
}
metaData[key] = val
return metaData
}, {})
return data
}
In my Angular application I need to swap from momentjs to dayjs.
Because I am using material I have to replace moment-date-adapter with a dayjs-date-adapter, so I write my own date adapter but I can't understand how momentjs can parse a date like 12122020 without any separator (you can see it in action here).
I try to achieve it by setting this MatDateFormats, with an array of dateinput.
But I don't know if it is the best solution because I don't see it in moment-date-adapter
MatDateFormats = {
parse: {
dateInput: ['D/M/YYYY', 'DMYYYY'],
},
display: {
dateInput: 'DD/MM/YYYY',
monthYearLabel: 'MMMM YYYY',
dateA11yLabel: 'DD/MM/YYYY',
monthYearA11yLabel: 'MMMM YYYY',
}
}
This is my dayjs-date-adapter
export interface DayJsDateAdapterOptions {
/**
* Turns the use of utc dates on or off.
* Changing this will change how Angular Material components like DatePicker output dates.
* {#default false}
*/
useUtc?: boolean;
}
/** InjectionToken for Dayjs date adapter to configure options. */
export const MAT_DAYJS_DATE_ADAPTER_OPTIONS = new InjectionToken<DayJsDateAdapterOptions>(
'MAT_DAYJS_DATE_ADAPTER_OPTIONS', {
providedIn: 'root',
factory: MAT_DAYJS_DATE_ADAPTER_OPTIONS_FACTORY
});
export function MAT_DAYJS_DATE_ADAPTER_OPTIONS_FACTORY(): DayJsDateAdapterOptions {
return {
useUtc: false
};
}
/** Creates an array and fills it with values. */
function range<T>(length: number, valueFunction: (index: number) => T): T[] {
const valuesArray = Array(length);
for (let i = 0; i < length; i++) {
valuesArray[i] = valueFunction(i);
}
return valuesArray;
}
/** Adapts Dayjs Dates for use with Angular Material. */
export class DayjsDateAdapter extends DateAdapter<Dayjs> {
private localeData: {
firstDayOfWeek: number,
longMonths: string[],
shortMonths: string[],
dates: string[],
longDaysOfWeek: string[],
shortDaysOfWeek: string[],
narrowDaysOfWeek: string[]
};
constructor(#Optional() #Inject(MAT_DATE_LOCALE) public dateLocale: string,
#Optional() #Inject(MAT_DAYJS_DATE_ADAPTER_OPTIONS) private options?:
DayJsDateAdapterOptions) {
super();
this.initializeParser(dateLocale);
}
private get shouldUseUtc(): boolean {
const {useUtc}: DayJsDateAdapterOptions = this.options || {};
return !!useUtc;
}
// TODO: Implement
setLocale(locale: string) {
super.setLocale(locale);
const dayJsLocaleData = this.dayJs().localeData();
this.localeData = {
firstDayOfWeek: dayJsLocaleData.firstDayOfWeek(),
longMonths: dayJsLocaleData.months(),
shortMonths: dayJsLocaleData.monthsShort(),
dates: range(31, (i) => this.createDate(2017, 0, i + 1).format('D')),
longDaysOfWeek: range(7, (i) => this.dayJs().set('day', i).format('dddd')),
shortDaysOfWeek: dayJsLocaleData.weekdaysShort(),
narrowDaysOfWeek: dayJsLocaleData.weekdaysMin(),
};
}
getYear(date: Dayjs): number {
return this.dayJs(date).year();
}
getMonth(date: Dayjs): number {
return this.dayJs(date).month();
}
getDate(date: Dayjs): number {
return this.dayJs(date).date();
}
getDayOfWeek(date: Dayjs): number {
return this.dayJs(date).day();
}
getMonthNames(style: 'long' | 'short' | 'narrow'): string[] {
return style === 'long' ? this.localeData.longMonths : this.localeData.shortMonths;
}
getDateNames(): string[] {
return this.localeData.dates;
}
getDayOfWeekNames(style: 'long' | 'short' | 'narrow'): string[] {
if (style === 'long') {
return this.localeData.longDaysOfWeek;
}
if (style === 'short') {
return this.localeData.shortDaysOfWeek;
}
return this.localeData.narrowDaysOfWeek;
}
getYearName(date: Dayjs): string {
return this.dayJs(date).format('YYYY');
}
getFirstDayOfWeek(): number {
return this.localeData.firstDayOfWeek;
}
getNumDaysInMonth(date: Dayjs): number {
return this.dayJs(date).daysInMonth();
}
clone(date: Dayjs): Dayjs {
return date.clone();
}
createDate(year: number, month: number, date: number): Dayjs {
const returnDayjs = this.dayJs()
.set('year', year)
.set('month', month)
.set('date', date);
return returnDayjs;
}
today(): Dayjs {
return this.dayJs();
}
parse(value: any, parseFormat: string): Dayjs | null {
if (value && typeof value === 'string') {
return this.dayJs(value, parseFormat, this.locale);
}
return value ? this.dayJs(value).locale(this.locale) : null;
}
format(date: Dayjs, displayFormat: string): string {
if (!this.isValid(date)) {
throw Error('DayjsDateAdapter: Cannot format invalid date.');
}
return date.locale(this.locale).format(displayFormat);
}
addCalendarYears(date: Dayjs, years: number): Dayjs {
return date.add(years, 'year');
}
addCalendarMonths(date: Dayjs, months: number): Dayjs {
return date.add(months, 'month');
}
addCalendarDays(date: Dayjs, days: number): Dayjs {
return date.add(days, 'day');
}
toIso8601(date: Dayjs): string {
return date.toISOString();
}
deserialize(value: any): Dayjs | null {
let date;
if (value instanceof Date) {
date = this.dayJs(value);
} else if (this.isDateInstance(value)) {
// NOTE: assumes that cloning also sets the correct locale.
return this.clone(value);
}
if (typeof value === 'string') {
if (!value) {
return null;
}
date = this.dayJs(value).toISOString();
}
if (date && this.isValid(date)) {
return this.dayJs(date);
}
return super.deserialize(value);
}
isDateInstance(obj: any): boolean {
return dayjs.isDayjs(obj);
}
isValid(date: Dayjs): boolean {
return this.dayJs(date).isValid();
}
invalid(): Dayjs {
return this.dayJs(null);
}
private dayJs(input?: any, format?: string, locale?: string): Dayjs {
if (!this.shouldUseUtc) {
return dayjs(input, format, locale, false);
}
return dayjs(input, {format, locale, utc: this.shouldUseUtc}, locale, false).utc();
}
private initializeParser(dateLocale: string) {
if (this.shouldUseUtc) {
dayjs.extend(utc);
}
dayjs.extend(LocalizedFormat);
dayjs.extend(customParseFormat);
dayjs.extend(localeData);
}
}
The dateInput that you use in the parse property of MatDateFormats is used in the parse function of your dayjs-date-adapter. Right now you supply an array as dateInput, but your function expects a string. Dayjs (unlike moment) cannot handle an array of formats. If you want to use an array, to support multiple formats, you must figure out which format of the array to use in your parse function. The easiest way to do this is probably just to loop over your possible formats and return the dayjs object if it is valid.
Something like this (note I have not tested this):
parse(value: any, parseFormats: string[]): Dayjs | null {
if (value && typeof value === 'string') {
parseFormats.forEach(parseFormat => {
const parsed = this.dayJs(value, parseFormat, this.locale);
if (parsed.isValid()) {
return parsed;
}
}
// return an invalid object if it could not be parsed with the supplied formats
return this.dayJs(null);
}
return value ? this.dayJs(value).locale(this.locale) : null;
}
Note in my own adapter I altered the private dayJs function a little bit, because providing locale also in the format options gave me some weird behavior. I didn't need the utc options, so I ended up using:
private dayJs(input?: any, format?: string, locale?: string): Dayjs {
return dayjs(input, format, locale);
}
An alternative to the approach above would be to just supply 1 dateInput (like : dateInput: 'D/M/YYYY'). And then make the parse function a little bit more flexible. I ended up with this:
parse(value: any, parseFormat: string): Dayjs | null {
if (value && typeof value === 'string') {
const longDateFormat = dayjs().localeData().longDateFormat(parseFormat); // MM/DD/YYY or DD-MM-YYYY, etc.
// return this.dayJs(value, longDateFormat);
let parsed = this.dayJs(value, longDateFormat, this.locale);
if (parsed.isValid()) {
// string value is exactly like long date format
return parsed;
}
const alphaNumericRegex = /[\W_]+/;
if (!alphaNumericRegex.test(value)) {
// if string contains no non-word characters and no _
// user might have typed 24012020 or 01242020
// strip long date format of non-word characters and take only the length of the value so we get DDMMYYYY or DDMM etc
const format = longDateFormat.replace(/[\W_]+/g, '').substr(0, value.length);
parsed = this.dayJs(value, format, this.locale);
if (parsed.isValid()) {
return parsed;
}
}
const userDelimiter = alphaNumericRegex.exec(value) ? alphaNumericRegex.exec(value)![0] : '';
const localeDelimiter = alphaNumericRegex.exec(longDateFormat) ? alphaNumericRegex.exec(longDateFormat)![0] : '';
const parts = value.split(userDelimiter);
const formatParts = longDateFormat.split(localeDelimiter);
if (parts.length <= formatParts.length && parts.length < 4) {
// right now this only works for days, months, and years, if time should be supported this should be altered
let newFormat = '';
parts.forEach((part, index) => {
// get the format in the length of the part, so if a the date is supplied 1-1-19 this should result in D-M-YY
// note, this will not work if really weird input is supplied, but that's okay
newFormat += formatParts[index].substr(0, part.length);
if (index < parts.length - 1) {
newFormat += userDelimiter;
}
});
parsed = this.dayJs(value, newFormat);
if (parsed.isValid()) {
return parsed;
}
}
// not able to parse anything sensible, return something invalid so input can be corrected
return this.dayJs(null);
}
return value ? this.dayJs(value).locale(this.locale) : null;
}
If you only want to support number only inputs (like 28082021) beside your specified input, you need the if statement with !alphaNumericRegex.test(value). This piece of code takes out any delimiters (like - or /) from your formatting string and also makes sure string with only days or days and months are supported (28 or 2808 for example). It will use the current month and year to fill up the missing values. If you only want to support full day-month-year strings you can omit the .substr part.
The piece of code below this if statement causes different types of user input to be supported, like 28-08-2021, 28/08/2021, 28 08 2021, 28-08-21, 28/08 etc..
I'm sure it won't work for every language, but it works for the most used userinputs in my language (dutch).
Hope this helps someone who has been struggling with this as well!
Angular UI Bootstrap changed the way of what the datepicker expects as ng-model in some version after 1.13.0. Before it was fine to give it an ISO date string, now it wants a Date object.
I consume ISO date strings from my API though, so I have to
convert them into Date objects before giving it to the datepicker and
convert them back to an ISO date string when storing it.
In the past I used a directive like this:
function DateObjectDirective() {
const directive = {
restrict: "A",
require: ["ngModel"],
link(scope, element, attributes, controllers) {
const ngModel = controllers[0];
ngModel.$formatters.unshift(value => {
let output = null;
if(value) {
output = moment(value).toDate();
}
return output;
});
ngModel.$parsers.unshift(value => {
let output = null;
if(value) {
output = moment(value).format();
}
return output;
});
},
};
return directive;
}
This no longer works though, as the following error is reported:
this.activeDate.getFullYear is not a function
My guess is that the datepicker still uses the string as reference. Is there any other way I can convert before giving my data to the datepicker?
I found out that the directive I posted does indeed still work. The only problem was the order in which AngularJS evaluated the directives.
For example:
<input ng-model="someDateString" uib-datepicker-popup="yyyy-MM-dd" woo-date-object>
In my case, woo-date-object was always evaluated before uib-datepicker-popup. The result was that the datepicker has always pushed its own formatter on top of ngModel.$formatters, thus eliminating the possibility for me to intervene.
The solution is to give the own directive a higher priority. UI's datepicker doesn't have one set, so anything above 0 (which is the default) works:
{
restrict: "A",
require: "ngModel",
priority: 9999,
link(scope, element, attributes, ngModel) {
ngModel.$formatters.push(value => {
let output = new Date();
if(value) { output = moment(value).toDate(); }
return output;
});
ngModel.$parsers.push(value => {
let output = null;
if(value) { output = moment(value).format(); }
return output;
});
},
}
I'm using select2 with Bootstrap 3.
Now I would like to know whether it is possible to display all optgroup items if the search matches the optgroup name while still being able to search for items as well. If this is possible, how can I do it?
The above answers don't seem to work out of the box with Select2 4.0 so if you're hunting for that, check this out: https://github.com/select2/select2/issues/3034
(Use the function like this: $("#example").select2({matcher: modelMatcher});)
function modelMatcher (params, data) {
data.parentText = data.parentText || "";
// Always return the object if there is nothing to compare
if ($.trim(params.term) === '') {
return data;
}
// Do a recursive check for options with children
if (data.children && data.children.length > 0) {
// Clone the data object if there are children
// This is required as we modify the object to remove any non-matches
var match = $.extend(true, {}, data);
// Check each child of the option
for (var c = data.children.length - 1; c >= 0; c--) {
var child = data.children[c];
child.parentText += data.parentText + " " + data.text;
var matches = modelMatcher(params, child);
// If there wasn't a match, remove the object in the array
if (matches == null) {
match.children.splice(c, 1);
}
}
// If any children matched, return the new object
if (match.children.length > 0) {
return match;
}
// If there were no matching children, check just the plain object
return modelMatcher(params, match);
}
// If the typed-in term matches the text of this term, or the text from any
// parent term, then it's a match.
var original = (data.parentText + ' ' + data.text).toUpperCase();
var term = params.term.toUpperCase();
// Check if the text contains the term
if (original.indexOf(term) > -1) {
return data;
}
// If it doesn't contain the term, don't return anything
return null;
}
Actually found the solution by modifying the matcher opt
$("#myselect").select2({
matcher: function(term, text, opt){
return text.toUpperCase().indexOf(term.toUpperCase())>=0 || opt.parent("optgroup").attr("label").toUpperCase().indexOf(term.toUpperCase())>=0
}
});
Under the premise that the label attribute has been set in each optgroup.
Found a solution from select2/issues/3034
Tested with select2 v.4
$("select").select2({
matcher(params, data) {
const originalMatcher = $.fn.select2.defaults.defaults.matcher;
const result = originalMatcher(params, data);
if (
result &&
data.children &&
result.children &&
data.children.length
) {
if (
data.children.length !== result.children.length &&
data.text.toLowerCase().includes(params.term.toLowerCase())
) {
result.children = data.children;
}
return result;
}
return null;
},
});
A few minor changes to people suggested code, less repetitive and copes when there are no parent optgroups:
$('select').select2({
matcher: function(term, text, opt){
var matcher = opt.parent('select').select2.defaults.matcher;
return matcher(term, text) || (opt.parent('optgroup').length && matcher(term, opt.parent('optgroup').attr("label")));
}
});
I run the code below and I got an error without any stack trace.
My code:
typedef Check<T>(T value, [onError(T value)]);
main () {
List<Check> checks = [
(str) => str != null,
(str) => !str.isEmpty
];
Check<String> doCheck = (String value, [onError(String)]) {
checks.forEach((Check check) {
if (?onError) {
check(value, onError);
} else {
check(value);
}
});
};
doCheck("10");
}
And, the error I got.
file:///..()../sample.dart': Error: line 11 pos 12: formal parameter name expected
if (?onError) {
I want to get onError as an optional parameter in doCheck function, and pass this parameter to other functions in checks.
I confirmed to forward an optional parameter to 'one' function...
Is this one of restrictions to optional parameters?
I would say it is a bug (see issue 8007). To work around it, you have to use a temporary variable :
typedef Check<T>(T value, [onError(T value)]);
main () {
List<Check> checks = [
(str) => str != null,
(str) => !str.isEmpty
];
Check<String> doCheck = (String value, [onError(String)]) {
final isOnErrorPresent = ?onError;
checks.forEach((Check check) {
if (isOnErrorPresent) {
check(value, onError);
} else {
check(value);
}
});
};
doCheck("10");
}