diff --git a/.changesets/11380.md b/.changesets/11380.md
new file mode 100644
index 000000000000..6a92d5802e9b
--- /dev/null
+++ b/.changesets/11380.md
@@ -0,0 +1,3 @@
+- feat(router): Add option to not reset scroll to the top on navigate/link (#11380) by @guitheengineer
+
+You can now do ``navigate(`?id=${id}`, { scroll: false })`` and ```` to not reset the scroll to the top when navigating.
diff --git a/.changesets/11931.md b/.changesets/11931.md
new file mode 100644
index 000000000000..563e8eb4b6c9
--- /dev/null
+++ b/.changesets/11931.md
@@ -0,0 +1,11 @@
+- feat(cli): Support generating sdls for models with only an id and relation (#11931) by @Tobbe
+
+It's now possible to generate SDL files for models that look like this
+
+```prisma
+// This would be seeded with available car brands
+model CarBrand {
+ brand String @id
+ cars Car[]
+}
+```
diff --git a/docs/docs/router.md b/docs/docs/router.md
index 88bfb61a97d6..bffd342b7b9f 100644
--- a/docs/docs/router.md
+++ b/docs/docs/router.md
@@ -614,6 +614,9 @@ const SomePage = () => {
The browser keeps track of the browsing history in a stack. By default when you navigate to a new page a new item is pushed to the history stack. But sometimes you want to replace the top item on the stack instead of appending to the stack. This is how you do that in Redwood: `navigate(routes.home(), { replace: true })`. As you can see you need to pass an options object as the second parameter to `navigate` with the option `replace` set to `true`.
+By default `navigate` will scroll to the top after navigating to a new route (except for hash param changes), we can prevent this behavior by setting the `scroll` option to false:
+`navigate(routes.home(), { scroll: false })`
+
### back
Going back is as easy as using the `back()` function that's exported from the router.
@@ -675,6 +678,9 @@ const SomePage = () =>
In addition to the `to` prop, `` also takes an `options` prop. This is the same as [`navigate()`](#navigate)'s second argument: `navigate(_, { replace: true })`. We can use it to _replace_ the top item of the browser history stack (instead of pushing a new one). This is how you use it to have this effect: ``.
+By default redirect will scroll to the top after navigating to a new route (except for hash param changes), we can prevent this behavior by setting the `scroll` option to false:
+``
+
## Code-splitting
By default, the router will code-split on every Page, creating a separate lazy-loaded bundle for each. When navigating from page to page, the router will wait until the new Page module is loaded before re-rendering, thus preventing the "white-flash" effect.
diff --git a/packages/api-server/package.json b/packages/api-server/package.json
index 2ed7c5bafd60..70148e3a829c 100644
--- a/packages/api-server/package.json
+++ b/packages/api-server/package.json
@@ -29,7 +29,7 @@
"test:watch": "vitest watch"
},
"dependencies": {
- "@fastify/multipart": "8.3.0",
+ "@fastify/multipart": "8.3.1",
"@fastify/url-data": "5.4.0",
"@redwoodjs/context": "workspace:*",
"@redwoodjs/fastify-web": "workspace:*",
diff --git a/packages/cli/src/commands/generate/scaffold/scaffold.js b/packages/cli/src/commands/generate/scaffold/scaffold.js
index ed0beb58da06..11f3fe264d5d 100644
--- a/packages/cli/src/commands/generate/scaffold/scaffold.js
+++ b/packages/cli/src/commands/generate/scaffold/scaffold.js
@@ -45,8 +45,6 @@ import {
builder as serviceBuilder,
} from '../service/service'
-// note a better way to do this is in https://github.com/redwoodjs/redwood/pull/3783/files
-const NON_EDITABLE_COLUMNS = ['id', 'createdAt', 'updatedAt']
// Any assets that should not trigger an overwrite error and require a --force
const SKIPPABLE_ASSETS = ['scaffold.css']
const PACKAGE_SET = 'Set'
@@ -59,12 +57,19 @@ const getIdName = (model) => {
return model.fields.find((field) => field.isId)?.name
}
-const filterAutoGeneratedColumnsForScaffold = (column) => {
+const isAutoGeneratedColumnForScaffold = (column) => {
const autoGeneratedFunctions = ['now', 'autoincrement']
- return !(
+
+ const columnDefaultFunction =
+ typeof column.default === 'object' && 'name' in column.default
+ ? column.default?.name
+ : ''
+
+ return (
column.isId ||
column.isUpdatedAt ||
- autoGeneratedFunctions.includes(column?.default?.name)
+ column.name === 'createdAt' ||
+ autoGeneratedFunctions.includes(columnDefaultFunction)
)
}
@@ -400,11 +405,9 @@ const modelRelatedVariables = (model) => {
isRelationalField,
}
})
- const editableColumns = columns
- .filter((column) => {
- return NON_EDITABLE_COLUMNS.indexOf(column.name) === -1
- })
- .filter(filterAutoGeneratedColumnsForScaffold)
+ const editableColumns = columns.filter(
+ (column) => !isAutoGeneratedColumnForScaffold(column),
+ )
const fieldsToImport = Object.keys(
editableColumns.reduce((accumulator, column) => {
accumulator[column.component] = true
diff --git a/packages/cli/src/commands/generate/sdl/__tests__/__snapshots__/sdl.test.js.snap b/packages/cli/src/commands/generate/sdl/__tests__/__snapshots__/sdl.test.js.snap
index 9459e6f4dd78..7d930f2f3493 100644
--- a/packages/cli/src/commands/generate/sdl/__tests__/__snapshots__/sdl.test.js.snap
+++ b/packages/cli/src/commands/generate/sdl/__tests__/__snapshots__/sdl.test.js.snap
@@ -1572,6 +1572,66 @@ exports[`with graphql documentations > in typescript mode > creates a single wor
"
`;
+exports[`without graphql documentations > in javascript mode > create an sdl file for model with only id and relation 1`] = `
+"export const schema = gql\`
+ type Car {
+ id: Int!
+ brand: String!
+ carBrand: CarBrand!
+ }
+
+ type Query {
+ cars: [Car!]! @requireAuth
+ car(id: Int!): Car @requireAuth
+ }
+
+ input CreateCarInput {
+ brand: String!
+ }
+
+ input UpdateCarInput {
+ brand: String
+ }
+
+ type Mutation {
+ createCar(input: CreateCarInput!): Car! @requireAuth
+ updateCar(id: Int!, input: UpdateCarInput!): Car! @requireAuth
+ deleteCar(id: Int!): Car! @requireAuth
+ }
+\`
+"
+`;
+
+exports[`without graphql documentations > in javascript mode > create an sdl file for model with only id and relation 2`] = `
+"export const schema = gql\`
+ type CarBrand {
+ brand: String!
+ cars: [Car]!
+ }
+
+ type Query {
+ carBrands: [CarBrand!]! @requireAuth
+ carBrand(brand: String!): CarBrand @requireAuth
+ }
+
+ input CreateCarBrandInput {
+ brand: String!
+ }
+
+ input UpdateCarBrandInput {
+ brand: String!
+ }
+
+ type Mutation {
+ createCarBrand(input: CreateCarBrandInput!): CarBrand! @requireAuth
+ updateCarBrand(brand: String!, input: UpdateCarBrandInput!): CarBrand!
+ @requireAuth
+ deleteCarBrand(brand: String!): CarBrand! @requireAuth
+ }
+\`
+"
+`;
+
exports[`without graphql documentations > in javascript mode > creates a multi word sdl file 1`] = `
"export const schema = gql\`
type UserProfile {
@@ -1809,6 +1869,66 @@ exports[`without graphql documentations > in javascript mode > creates a single
"
`;
+exports[`without graphql documentations > in typescript mode > create an sdl file for model with only id and relation 1`] = `
+"export const schema = gql\`
+ type Car {
+ id: Int!
+ brand: String!
+ carBrand: CarBrand!
+ }
+
+ type Query {
+ cars: [Car!]! @requireAuth
+ car(id: Int!): Car @requireAuth
+ }
+
+ input CreateCarInput {
+ brand: String!
+ }
+
+ input UpdateCarInput {
+ brand: String
+ }
+
+ type Mutation {
+ createCar(input: CreateCarInput!): Car! @requireAuth
+ updateCar(id: Int!, input: UpdateCarInput!): Car! @requireAuth
+ deleteCar(id: Int!): Car! @requireAuth
+ }
+\`
+"
+`;
+
+exports[`without graphql documentations > in typescript mode > create an sdl file for model with only id and relation 2`] = `
+"export const schema = gql\`
+ type CarBrand {
+ brand: String!
+ cars: [Car]!
+ }
+
+ type Query {
+ carBrands: [CarBrand!]! @requireAuth
+ carBrand(brand: String!): CarBrand @requireAuth
+ }
+
+ input CreateCarBrandInput {
+ brand: String!
+ }
+
+ input UpdateCarBrandInput {
+ brand: String!
+ }
+
+ type Mutation {
+ createCarBrand(input: CreateCarBrandInput!): CarBrand! @requireAuth
+ updateCarBrand(brand: String!, input: UpdateCarBrandInput!): CarBrand!
+ @requireAuth
+ deleteCarBrand(brand: String!): CarBrand! @requireAuth
+ }
+\`
+"
+`;
+
exports[`without graphql documentations > in typescript mode > creates a multi word sdl file 1`] = `
"export const schema = gql\`
type UserProfile {
diff --git a/packages/cli/src/commands/generate/sdl/__tests__/fixtures/schema.prisma b/packages/cli/src/commands/generate/sdl/__tests__/fixtures/schema.prisma
index d31245c497a1..17c3f85b1406 100644
--- a/packages/cli/src/commands/generate/sdl/__tests__/fixtures/schema.prisma
+++ b/packages/cli/src/commands/generate/sdl/__tests__/fixtures/schema.prisma
@@ -66,6 +66,18 @@ enum Color {
}
model CustomData {
- id Int @id @default(autoincrement())
- data String
+ id Int @id @default(autoincrement())
+ data String
+}
+
+// This would be seeded with available car brands
+model CarBrand {
+ brand String @id
+ cars Car[]
+}
+
+model Car {
+ id Int @id @default(autoincrement())
+ brand String
+ carBrand CarBrand @relation(fields: [brand], references: [brand])
}
diff --git a/packages/cli/src/commands/generate/sdl/__tests__/sdl.test.js b/packages/cli/src/commands/generate/sdl/__tests__/sdl.test.js
index 56c86490b432..cb22722f7926 100644
--- a/packages/cli/src/commands/generate/sdl/__tests__/sdl.test.js
+++ b/packages/cli/src/commands/generate/sdl/__tests__/sdl.test.js
@@ -236,6 +236,37 @@ const itCreatesAnSDLFileWithByteDefinitions = (baseArgs = {}) => {
})
}
+const itCreatesAnSslFileForModelWithOnlyIdAndRelation = (baseArgs = {}) => {
+ test('create an sdl file for model with only id and relation', async () => {
+ const files = {
+ ...(await sdl.files({
+ ...baseArgs,
+ name: 'Car',
+ crud: true,
+ })),
+ ...(await sdl.files({
+ ...baseArgs,
+ name: 'CarBrand',
+ crud: true,
+ })),
+ }
+ const extension = extensionForBaseArgs(baseArgs)
+
+ expect(
+ files[
+ path.normalize(`/path/to/project/api/src/graphql/cars.sdl.${extension}`)
+ ],
+ ).toMatchSnapshot()
+ expect(
+ files[
+ path.normalize(
+ `/path/to/project/api/src/graphql/carBrands.sdl.${extension}`,
+ )
+ ],
+ ).toMatchSnapshot()
+ })
+}
+
describe('without graphql documentations', () => {
describe('in javascript mode', () => {
const baseArgs = { ...getDefaultArgs(sdl.defaults), tests: true }
@@ -249,6 +280,7 @@ describe('without graphql documentations', () => {
itCreatesAnSDLFileWithEnumDefinitions(baseArgs)
itCreatesAnSDLFileWithJsonDefinitions(baseArgs)
itCreatesAnSDLFileWithByteDefinitions(baseArgs)
+ itCreatesAnSslFileForModelWithOnlyIdAndRelation(baseArgs)
})
describe('in typescript mode', () => {
@@ -267,6 +299,7 @@ describe('without graphql documentations', () => {
itCreatesAnSDLFileWithEnumDefinitions(baseArgs)
itCreatesAnSDLFileWithJsonDefinitions(baseArgs)
itCreatesAnSDLFileWithByteDefinitions(baseArgs)
+ itCreatesAnSslFileForModelWithOnlyIdAndRelation(baseArgs)
})
})
diff --git a/packages/cli/src/commands/generate/sdl/sdl.js b/packages/cli/src/commands/generate/sdl/sdl.js
index cd5fb58bfcc8..4d5ded2967d9 100644
--- a/packages/cli/src/commands/generate/sdl/sdl.js
+++ b/packages/cli/src/commands/generate/sdl/sdl.js
@@ -75,11 +75,13 @@ const modelFieldToSDL = ({
Bytes: 'Byte',
}
- const fieldContent = `${field.name}: ${field.isList ? '[' : ''}${
- prismaTypeToGraphqlType[field.type] || field.type
- }${field.isList ? ']' : ''}${
- (field.isRequired && required) | field.isList ? '!' : ''
- }`
+ const gqlType = prismaTypeToGraphqlType[field.type] || field.type
+ const type = field.isList ? `[${gqlType}]` : gqlType
+ // lists and id fields are always required (lists can be empty, that's fine)
+ const isRequired =
+ (field.isRequired && required) || field.isList || field.isId
+ const fieldContent = `${field.name}: ${type}${isRequired ? '!' : ''}`
+
if (docs) {
return addFieldGraphQLComment(field, fieldContent)
} else {
@@ -98,7 +100,8 @@ const inputSDL = (model, required, types = {}, docs = false) => {
.filter((field) => {
const idField = model.fields.find((field) => field.isId)
- if (idField) {
+ // Only ignore the id field if it has a default value
+ if (idField && idField.default) {
ignoredFields.push(idField.name)
}
@@ -162,7 +165,7 @@ const idName = (model, crud) => {
const sdlFromSchemaModel = async (name, crud, docs = false) => {
const model = await getSchema(name)
- // get models for user-defined types referenced
+ // get models for referenced user-defined types
const types = (
await Promise.all(
model.fields
diff --git a/packages/router/src/__tests__/routeScrollReset.test.tsx b/packages/router/src/__tests__/routeScrollReset.test.tsx
index 723a9251b2ae..87926439e526 100644
--- a/packages/router/src/__tests__/routeScrollReset.test.tsx
+++ b/packages/router/src/__tests__/routeScrollReset.test.tsx
@@ -89,4 +89,57 @@ describe('Router scroll reset', () => {
expect(globalThis.scrollTo).not.toHaveBeenCalled()
})
+
+ it('when scroll option is false, does NOT reset on location/path change', async () => {
+ act(() =>
+ navigate(
+ // @ts-expect-error - AvailableRoutes built in project only
+ routes.page2(),
+ {
+ scroll: false,
+ },
+ ),
+ )
+
+ screen.getByText('Page 2')
+
+ expect(globalThis.scrollTo).toHaveBeenCalledTimes(0)
+ })
+
+ it('when scroll option is false, does NOT reset on location/path and queryChange change', async () => {
+ act(() =>
+ navigate(
+ // @ts-expect-error - AvailableRoutes built in project only
+ routes.page2({
+ tab: 'three',
+ }),
+ {
+ scroll: false,
+ },
+ ),
+ )
+
+ screen.getByText('Page 2')
+
+ expect(globalThis.scrollTo).toHaveBeenCalledTimes(0)
+ })
+
+ it('when scroll option is false, does NOT reset scroll on query params (search) change on the same page', async () => {
+ act(() =>
+ // We're staying on page 1, but changing the query params
+ navigate(
+ // @ts-expect-error - AvailableRoutes built in project only
+ routes.page1({
+ queryParam1: 'foo',
+ }),
+ {
+ scroll: false,
+ },
+ ),
+ )
+
+ screen.getByText('Page 1')
+
+ expect(globalThis.scrollTo).toHaveBeenCalledTimes(0)
+ })
})
diff --git a/packages/router/src/history.tsx b/packages/router/src/history.tsx
index ba04716b8ace..bbb542f7dea0 100644
--- a/packages/router/src/history.tsx
+++ b/packages/router/src/history.tsx
@@ -1,8 +1,9 @@
export interface NavigateOptions {
replace?: boolean
+ scroll?: boolean
}
-export type Listener = (ev?: PopStateEvent) => any
+export type Listener = (ev?: PopStateEvent, options?: NavigateOptions) => any
export type BeforeUnloadListener = (ev: BeforeUnloadEvent) => any
export type BlockerCallback = (tx: { retry: () => void }) => void
export type Blocker = { id: string; callback: BlockerCallback }
@@ -19,7 +20,12 @@ const createHistory = () => {
globalThis.addEventListener('popstate', listener)
return listenerId
},
- navigate: (to: string, options?: NavigateOptions) => {
+ navigate: (
+ to: string,
+ options: NavigateOptions = {
+ scroll: true,
+ },
+ ) => {
const performNavigation = () => {
const { pathname, search, hash } = new URL(
globalThis?.location?.origin + to,
@@ -38,7 +44,7 @@ const createHistory = () => {
}
for (const listener of Object.values(listeners)) {
- listener()
+ listener(undefined, options)
}
}
diff --git a/packages/router/src/location.tsx b/packages/router/src/location.tsx
index ce37e79df678..074d48ffcc66 100644
--- a/packages/router/src/location.tsx
+++ b/packages/router/src/location.tsx
@@ -82,12 +82,13 @@ class LocationProvider extends React.Component<
// componentDidMount() is not called during server rendering (aka SSR and
// prerendering)
componentDidMount() {
- this.HISTORY_LISTENER_ID = gHistory.listen(() => {
+ this.HISTORY_LISTENER_ID = gHistory.listen((_, options) => {
const context = this.getContext()
this.setState((lastState) => {
if (
- context?.pathname !== lastState?.context?.pathname ||
- context?.search !== lastState?.context?.search
+ (context?.pathname !== lastState?.context?.pathname ||
+ context?.search !== lastState?.context?.search) &&
+ options?.scroll === true
) {
globalThis?.scrollTo(0, 0)
}
diff --git a/yarn.lock b/yarn.lock
index 7860b3b7dc6f..e2d94a011d46 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3330,7 +3330,7 @@ __metadata:
languageName: node
linkType: hard
-"@fastify/busboy@npm:^2.0.0, @fastify/busboy@npm:^2.1.0":
+"@fastify/busboy@npm:^2.0.0":
version: 2.1.1
resolution: "@fastify/busboy@npm:2.1.1"
checksum: 10c0/6f8027a8cba7f8f7b736718b013f5a38c0476eea67034c94a0d3c375e2b114366ad4419e6a6fa7ffc2ef9c6d3e0435d76dd584a7a1cbac23962fda7650b579e3
@@ -3351,6 +3351,13 @@ __metadata:
languageName: node
linkType: hard
+"@fastify/deepmerge@npm:^2.0.0":
+ version: 2.0.1
+ resolution: "@fastify/deepmerge@npm:2.0.1"
+ checksum: 10c0/043c7e5e028d01b4bdd6b99588e8f82e5b91399d68bcbcf11726c2d058faf6f0fdeecad837dded1e184430938cf29cfc65ae0d0ac4872ee865d32f6a3e86681f
+ languageName: node
+ linkType: hard
+
"@fastify/error@npm:^3.0.0, @fastify/error@npm:^3.3.0, @fastify/error@npm:^3.4.0":
version: 3.4.1
resolution: "@fastify/error@npm:3.4.1"
@@ -3358,6 +3365,13 @@ __metadata:
languageName: node
linkType: hard
+"@fastify/error@npm:^4.0.0":
+ version: 4.0.0
+ resolution: "@fastify/error@npm:4.0.0"
+ checksum: 10c0/074b8a6c350c29a8fc8314298d9457fe0c1ba6e7f160e9ae6ba0e18853f1ec7427d768f966700cbf67a4694f3a9a593c6a23e42ce3ed62e40fecdf8026040d9a
+ languageName: node
+ linkType: hard
+
"@fastify/fast-json-stringify-compiler@npm:^4.3.0":
version: 4.3.0
resolution: "@fastify/fast-json-stringify-compiler@npm:4.3.0"
@@ -3379,17 +3393,17 @@ __metadata:
languageName: node
linkType: hard
-"@fastify/multipart@npm:8.3.0":
- version: 8.3.0
- resolution: "@fastify/multipart@npm:8.3.0"
+"@fastify/multipart@npm:8.3.1":
+ version: 8.3.1
+ resolution: "@fastify/multipart@npm:8.3.1"
dependencies:
- "@fastify/busboy": "npm:^2.1.0"
- "@fastify/deepmerge": "npm:^1.0.0"
- "@fastify/error": "npm:^3.0.0"
+ "@fastify/busboy": "npm:^3.0.0"
+ "@fastify/deepmerge": "npm:^2.0.0"
+ "@fastify/error": "npm:^4.0.0"
fastify-plugin: "npm:^4.0.0"
secure-json-parse: "npm:^2.4.0"
stream-wormhole: "npm:^1.1.0"
- checksum: 10c0/1021675af149435b1e585cfcaf8aba848c3799cbc213c18a0e3d74c6d64d21db27572a99295a8da5263f5562869452234dea2680e83e248456d97b560fb627eb
+ checksum: 10c0/f60beb6b4fa8fba2a66343cd5be58914e5605fe4a49f26f22e189f120afbd1fe1906c363a538da5b361d7257e962570e2a7f0ff4bc42dc61a2e8a118712a55e1
languageName: node
linkType: hard
@@ -7416,7 +7430,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@redwoodjs/api-server@workspace:packages/api-server"
dependencies:
- "@fastify/multipart": "npm:8.3.0"
+ "@fastify/multipart": "npm:8.3.1"
"@fastify/url-data": "npm:5.4.0"
"@redwoodjs/context": "workspace:*"
"@redwoodjs/fastify-web": "workspace:*"